""" Gedeelde UI-hulpmiddelen voor app.py en alle pages/*.py. Elke Streamlit-pagina draait als los script, dus gedeelde constanten/functies (kleurenpalet, thema-detectie) horen hier thuis in plaats van gedupliceerd te worden per pagina. """ from __future__ import annotations from datetime import date, datetime, timedelta, timezone import pandas as pd import plotly.graph_objects as go import streamlit as st from nightscout_client import fetch_entries_range, fetch_profile, fetch_treatments_range from profile_data import parse_profile from reporting import ( HIGH, LOW, VERY_HIGH, VERY_LOW, completeness_warning, detect_events, summarize_events, ) MMOL_HOVERTEMPLATE = "%{x|%d-%m %H:%M} · %{y:.1f} mmol/L" # Periode-opties voor de rapportagepagina's (FR1): elke pagina kiest zijn eigen # standaard (90 dagen voor statistiek/TIR, 14 dagen voor recente # glucosegrafieken) via `default_label`. PERIOD_OPTIONS_DAYS = {"14 dagen": 14, "30 dagen": 30, "90 dagen": 90} # Kleuren per rol, licht/donker — categorale kleuren voor identiteit # (glucose/insuline/carbs), status-kleuren (vast, nooit hergebruikt) voor # te laag/te hoog. PALETTE = { "light": { "glucose": "#2a78d6", "insulin": "#1baf7a", "carbs": "#eb6834", "low": "#d03b3b", "very_low": "#d03b3b", "high": "#fab219", "very_high": "#fab219", "band": "rgba(12,163,12,0.08)", "grid": "#e1e0d9", "muted": "#898781", "text": "#52514e", "good": "#0ca30c", # Losstaand van "low"/"high" hierboven (die blijven gedeeld tussen # andere grafieken): de 5-bands TIR-grafiek (gitea #14) heeft juist # zichtbaar onderscheid tussen laag/zeer-laag en hoog/zeer-hoog nodig. "tir_very_low": "#8b1a1a", "tir_low": "#d03b3b", "tir_in_range": "#0ca30c", "tir_high": "#fab219", "tir_very_high": "#c76f0a", "basal": "#7b5ea3", "bolus_food": "#1baf7a", "bolus_correction": "#2a78d6", "bolus_smb": "#898781", "target": "#52514e", }, "dark": { "glucose": "#3987e5", "insulin": "#199e70", "carbs": "#d95926", "low": "#d03b3b", "very_low": "#d03b3b", "high": "#fab219", "very_high": "#fab219", "band": "rgba(12,163,12,0.15)", "grid": "#2c2c2a", "muted": "#898781", "text": "#c3c2b7", "good": "#0ca30c", "tir_very_low": "#a32626", "tir_low": "#d95d5d", "tir_in_range": "#0ca30c", "tir_high": "#fab219", "tir_very_high": "#d9852e", "basal": "#9b7ec2", "bolus_food": "#199e70", "bolus_correction": "#3987e5", "bolus_smb": "#898781", "target": "#c3c2b7", }, } # Klinische drempellijnen (Battelino et al. 2019), gedeeld tussen de AGP- en # Dagoverzicht-pagina's (FR3, FR4 — gitea #15) zodat beide grafieken exact # dezelfde 4 lijnen op dezelfde manier tonen. THRESHOLD_LINES = [ (VERY_LOW, "3.0"), (LOW, "3.9"), (HIGH, "10.0"), (VERY_HIGH, "13.9"), ] def hex_to_rgba(hex_color: str, alpha: float) -> str: """Zet een '#rrggbb'-kleur om naar een 'rgba(r,g,b,a)'-string voor Plotly-fills.""" hex_color = hex_color.lstrip("#") r, g, b = (int(hex_color[i : i + 2], 16) for i in (0, 2, 4)) return f"rgba({r},{g},{b},{alpha})" def get_colors() -> dict: """Geef de kleurenset terug die past bij het actieve Streamlit-thema.""" theme_type = "light" try: theme_type = st.context.theme.type or "light" except AttributeError: pass return PALETTE["dark" if theme_type == "dark" else "light"] def select_period_days(default_label: str) -> tuple[date, date, int]: # pragma: no cover """Periodekiezer voor rapportagepagina's; geeft (start, eind, aantal_dagen) terug. Pure Streamlit-widget-orkestratie (st.radio) — buiten scope van de unit-testdekking net als pages/*.py, zie pyproject.toml se coverage-omit-lijst. """ labels = list(PERIOD_OPTIONS_DAYS.keys()) label = st.radio("Periode", labels, index=labels.index(default_label), horizontal=True) days = PERIOD_OPTIONS_DAYS[label] end = datetime.now(tz=timezone.utc).date() start = end - timedelta(days=days - 1) return start, end, days @st.cache_data(ttl=3600, show_spinner="Data ophalen van Nightscout...") def load_range_data(start: date, end: date): # pragma: no cover """Gecachete Nightscout-fetch voor een vaste periode. Lange TTL (in tegenstelling tot de 60s van de live-pagina): historische data van een afgeronde periode verandert niet. Dunne st.cache_data-wrapper rond al-geteste fetch_entries_range()/ fetch_treatments_range() — buiten scope van de unit-testdekking net als pages/*.py, zie pyproject.toml se coverage-omit-lijst. """ glucose = fetch_entries_range(start, end) treatments = fetch_treatments_range(start, end) return glucose, treatments @st.cache_data(ttl=21600, show_spinner="Profiel ophalen van Nightscout...") def load_profile() -> dict: # pragma: no cover """Gecachete profiel-fetch. Langere TTL dan load_range_data (6 uur i.p.v. 1 uur): een pompprofiel wijzigt zelden, in tegenstelling tot glucose-/ behandeldata van een lopende periode. Dunne st.cache_data-wrapper rond al-geteste fetch_profile()/parse_profile() — buiten scope van de unit-testdekking net als pages/*.py. """ raw = fetch_profile() return parse_profile(raw) def add_threshold_lines(fig: go.Figure, colors: dict, row: int | None = None) -> None: """Teken de 4 klinische drempellijnen (3.0/3.9/10.0/13.9 mmol/L) op een grafiek. Gedeeld door de AGP- en Dagoverzicht-pagina's (gitea #15). `row` wordt doorgegeven aan `add_hline` voor subplot-figuren (Dagoverzicht); voor een los-staande figuur (AGP) blijft dit `None`. """ kwargs = {} if row is None else {"row": row, "col": 1} for value, label in THRESHOLD_LINES: fig.add_hline( y=value, line={"color": colors["muted"], "width": 1, "dash": "dot"}, annotation_text=label, annotation_position="right", annotation_font={"size": 9, "color": colors["muted"]}, **kwargs, ) TIR_BAND_ORDER = ["very_low", "low", "in_range", "high", "very_high"] TIR_BAND_LABELS = { "very_low": "Zeer laag", "low": "Laag", "in_range": "In bereik", "high": "Hoog", "very_high": "Zeer hoog", } def build_tir_stacked_bar_figure(tir: dict, colors: dict) -> go.Figure: """Bouw de 5-bands tijd-in-bereik als één gestapelde staafgrafiek (gitea #14). Eén staaf, geen x-as-label, percentage op de y-as; van onder naar boven "Zeer laag" -> "Zeer hoog" — dezelfde volgorde als de klinische banden. """ fig = go.Figure() for band in TIR_BAND_ORDER: fig.add_trace( go.Bar( x=[""], y=[tir[band]], name=TIR_BAND_LABELS[band], marker_color=colors[f"tir_{band}"], hovertemplate=f"{TIR_BAND_LABELS[band]}: %{{y:.0f}}%", ) ) fig.update_layout( barmode="stack", paper_bgcolor="rgba(0,0,0,0)", plot_bgcolor="rgba(0,0,0,0)", font_color=colors["text"], height=140, margin={"l": 10, "r": 10, "t": 10, "b": 10}, yaxis={"title": "%", "range": [0, 100], "gridcolor": colors["grid"]}, xaxis={"visible": False}, legend={"orientation": "h", "yanchor": "bottom", "y": 1.02, "xanchor": "left", "x": 0}, ) return fig BOLUS_CATEGORY_LABELS = { "food": "Bolus (voeding)", "correction": "Bolus (correctie)", "smb": "SMB", } BOLUS_CATEGORY_COLOR_KEYS = { "food": "bolus_food", "correction": "bolus_correction", "smb": "bolus_smb", } # Tekenvolgorde van bolus-categorieën: SMB eerst (onderop, meestal talrijker en # kleiner), voeding/correctie erbovenop (grotere, gelabelde stippen blijven # zichtbaar in plaats van bedolven te raken onder de SMB-stippen). BOLUS_CATEGORY_DRAW_ORDER = ["smb", "correction", "food"] def add_basal_area_trace( fig: go.Figure, basal: pd.DataFrame, colors: dict, *, row: int | None = None, secondary_y: bool = False, x_col: str = "timestamp", ) -> None: """Basaal-insuline als semi-transparant stapvlak (line_shape='hv'), met alpha zodat onderliggende data zichtbaar blijft. Verwacht kolommen 'rate' en `x_col` (standaard 'timestamp'; de Dagoverzicht-pagina geeft hier 'hour' aan omdat elke dag daar op een gedeelde 0-24u-as staat i.p.v. echte datums — zie reporting.effective_basal_series). Bedoeld voor een secundaire y-as (E/uur past niet op de mmol/L-schaal van de glucoselijn). """ if basal.empty: return kwargs: dict = {"row": row, "col": 1} if row is not None else {} if secondary_y: kwargs["secondary_y"] = True fig.add_trace( go.Scatter( x=basal[x_col], y=basal["rate"], mode="lines", line={"shape": "hv", "color": colors["basal"], "width": 1}, fill="tozeroy", fillcolor=hex_to_rgba(colors["basal"], 0.3), opacity=0.8, name="Basaal", hovertemplate="%{y:.2f} E/uur", ), **kwargs, ) def add_carb_bars_trace( fig: go.Figure, carbs: pd.DataFrame, colors: dict, *, row: int | None = None, secondary_y: bool = False, x_col: str = "timestamp", ) -> None: """Koolhydraten als staafjes op het moment van invoer. Verwacht kolommen 'carbs' en `x_col` (zie add_basal_area_trace voor waarom dit configureerbaar is). Zelfde secundaire as als de basaal-area (allebei geen mmol/L-grootheid), met een eigen kleur en alpha zodat beide onderscheidbaar blijven.""" if carbs.empty: return kwargs: dict = {"row": row, "col": 1} if row is not None else {} if secondary_y: kwargs["secondary_y"] = True fig.add_trace( go.Bar( x=carbs[x_col], y=carbs["carbs"], marker_color=colors["carbs"], opacity=0.55, name="Koolhydraten", hovertemplate="%{y:.0f} g", ), **kwargs, ) def add_bolus_marker_traces( fig: go.Figure, bolus: pd.DataFrame, colors: dict, lane_y: float, *, row: int | None = None, x_col: str = "timestamp", ) -> None: """Eén Scatter-trace per boluscategorie (food/correction/smb), gepositioneerd op een vaste 'lane'-hoogte op de primaire (mmol/L-)as — net als de bestaande insuline/carbs-lanes op de Live-pagina. De hoogte zelf draagt geen kwantitatieve betekenis; de markergrootte (geschaald naar toegediende eenheden) en hoverwaarde wel. Voeding/correctie krijgen een tekstlabel met de dosis; SMB's niet (te talrijk/te klein voor leesbare labels). Verwacht kolommen 'insulin'/'category' en `x_col` (zie reporting.classify_bolus). """ # Datumformattering in de hover werkt alleen op een echte datetime-as; de # Dagoverzicht-pagina geeft hier 'hour' (een kaal getal 0-24) aan, dus # daar laten we het tijdstip weg en toont de hover alleen de dosis. hover_prefix = "%{x|%d-%m %H:%M} · " if x_col == "timestamp" else "" kwargs: dict = {"row": row, "col": 1} if row is not None else {} for category in BOLUS_CATEGORY_DRAW_ORDER: subset = bolus[bolus["category"] == category] if not bolus.empty else bolus if subset.empty: continue show_label = category != "smb" fig.add_trace( go.Scatter( x=subset[x_col], y=[lane_y] * len(subset), mode="markers+text" if show_label else "markers", marker={ "size": (subset["insulin"] * 8).clip(lower=6, upper=30), "color": colors[BOLUS_CATEGORY_COLOR_KEYS[category]], "opacity": 0.75, }, text=[f"{v:.1f}E" for v in subset["insulin"]] if show_label else None, textposition="top center", textfont={"size": 9, "color": colors["text"]}, name=BOLUS_CATEGORY_LABELS[category], customdata=subset["insulin"], hovertemplate=f"{hover_prefix}%{{customdata:.2f}} E", ), **kwargs, ) def add_target_line_trace( fig: go.Figure, target: pd.DataFrame, colors: dict, *, row: int | None = None, x_col: str = "timestamp" ) -> None: """Streefglucose uit het pompprofiel als lijn op de primaire (mmol/L-)as. Verwacht kolommen 'target' en `x_col` (zie reporting.target_glucose_series).""" if target.empty: return kwargs: dict = {"row": row, "col": 1} if row is not None else {} fig.add_trace( go.Scatter( x=target[x_col], y=target["target"], mode="lines", line={"shape": "hv", "color": colors["target"], "width": 1.5, "dash": "dashdot"}, opacity=0.9, name="Streefwaarde (pomp)", hovertemplate="%{y:.1f} mmol/L", ), **kwargs, ) def render_completeness_warning(glucose: pd.DataFrame, start: date, end: date) -> None: # pragma: no cover """Toon reporting.completeness_warning()'s oordeel als st.warning, indien nodig. Gebruikt door elke rapportagepagina, direct na de load_range_data-call. De beoordelingslogica zelf staat in reporting.py (pure functie, wél volledig getest) zodat het PDF-rapport (pdf_export.py) exact dezelfde disclaimer kan hergebruiken. Deze functie is alleen de st.warning-weergave — buiten scope van de unit-testdekking net als pages/*.py. """ message = completeness_warning(glucose, start, end) if message: st.warning(message) def render_event_level( # pragma: no cover glucose: pd.DataFrame, threshold: float, direction: str, level_name: str, period_days: int, colors: dict, ) -> None: """Render de metrics/tabel/tijdstip-histogram voor één hypo-/hyperniveau. Gedeeld tussen de Hypo Events- en Hyper Events-pagina's (FR5/FR6), die elk twee niveaus (Level 1/Level 2) op dezelfde manier tonen. Pure Streamlit-widget-weergave rond al-geteste reporting.detect_events()/ summarize_events() — buiten scope van de unit-testdekking net als pages/*.py. """ events = detect_events(glucose, threshold, direction) summary = summarize_events(events, period_days) st.subheader(level_name) cols = st.columns(4) cols[0].metric("Aantal events", summary["count"]) cols[1].metric("Gem. duur", f"{summary['avg_duration_minutes']:.0f} min") cols[2].metric("Langste event", f"{summary['longest_duration_minutes']:.0f} min") cols[3].metric("Events/week", f"{summary['events_per_week']:.1f}") if events.empty: st.info("Geen events gevonden in deze periode.") return bar_color = colors["low"] if direction == "below" else colors["high"] hours = events["start"].dt.hour hour_counts = hours.value_counts().reindex(range(24), fill_value=0).sort_index() fig = go.Figure(go.Bar(x=list(hour_counts.index), y=hour_counts.to_numpy(), marker_color=bar_color)) fig.update_layout( paper_bgcolor="rgba(0,0,0,0)", plot_bgcolor="rgba(0,0,0,0)", font_color=colors["text"], height=220, margin={"l": 10, "r": 10, "t": 10, "b": 10}, xaxis={"title": "Uur van de dag", "dtick": 2, "gridcolor": colors["grid"]}, yaxis={"title": "Aantal events", "gridcolor": colors["grid"]}, ) st.plotly_chart(fig, width="stretch") display = events.copy() display["start"] = display["start"].dt.strftime("%d-%m-%Y %H:%M") display["end"] = display["end"].dt.strftime("%d-%m-%Y %H:%M") display = display.rename( columns={"start": "Start", "end": "Einde", "duration_minutes": "Duur (min)"} ) st.dataframe(display, width="stretch", hide_index=True)