""" 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_treatments_range from reporting import 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", }, "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", }, } 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]: """Periodekiezer voor rapportagepagina's; geeft (start, eind, aantal_dagen) terug.""" 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): """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. """ glucose = fetch_entries_range(start, end) treatments = fetch_treatments_range(start, end) return glucose, treatments def render_completeness_warning(glucose: pd.DataFrame, start: date, end: date) -> None: """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) zodat het PDF-rapport (pdf_export.py) exact dezelfde disclaimer kan hergebruiken. """ message = completeness_warning(glucose, start, end) if message: st.warning(message) def render_event_level( 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. """ 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)