From 95b3aff31082563d90d216c79ecddacbaf8d05b5 Mon Sep 17 00:00:00 2001 From: willemjan Date: Sun, 6 Sep 2026 14:43:19 +0200 Subject: [PATCH] Updated charts for glucose data and profile chart --- nightscout_client.py | 27 ++++ pages/4_Dagoverzicht.py | 226 ++++++++++++++++++-------------- pages/9_Insulineprofiel.py | 6 +- pdf_export.py | 52 +++++--- tests/test_nightscout_client.py | 44 +++++++ ui_common.py | 27 +++- 6 files changed, 262 insertions(+), 120 deletions(-) diff --git a/nightscout_client.py b/nightscout_client.py index 3f16c9a..03b0d87 100644 --- a/nightscout_client.py +++ b/nightscout_client.py @@ -254,6 +254,33 @@ def fetch_treatments_range(start_date: date, end_date: date) -> pd.DataFrame: return _treatments_since(since, until) +def fetch_entries_for_day(day: date) -> pd.DataFrame: + """Haal glucosemetingen op voor precies één kalenderdag. + + Dunne wrapper om fetch_entries_range() met start=end=day, voor de + dag-navigatie op de Dagoverzicht-pagina: laadt alleen de geselecteerde + dag in plaats van een hele periode, zodat het wisselen tussen dagen snel + blijft. + """ + return fetch_entries_range(day, day) + + +def fetch_treatments_for_day(day: date, lookback_hours: int = 6) -> pd.DataFrame: + """Haal behandelingen op voor één kalenderdag, met een lookback-marge vóór middernacht. + + reporting.effective_basal_series() heeft de starttijd van een Temp Basal + nodig om de basaalsnelheid vroeg in de dag correct te berekenen — ook als + die temp basal al vóór middernacht is gestart en doorloopt. Zonder deze + marge zou de eerste uren van elke los opgevraagde dag ten onrechte het + profieltempo tonen in plaats van een nog actieve temp basal. 6 uur ligt + ruim boven de gebruikelijke temp-basal-duur in gesloten-lus-modus + (doorgaans 30-60 minuten). + """ + since = datetime.combine(day, time.min, tzinfo=timezone.utc) - timedelta(hours=lookback_hours) + until = datetime.combine(day, time.max, tzinfo=timezone.utc) + return _treatments_since(since, until) + + def fetch_profile() -> dict: """Haal het meest recent gebruikte pompprofiel op. diff --git a/pages/4_Dagoverzicht.py b/pages/4_Dagoverzicht.py index 0bc6e5a..8b1d00a 100644 --- a/pages/4_Dagoverzicht.py +++ b/pages/4_Dagoverzicht.py @@ -1,3 +1,5 @@ +from datetime import timedelta + import pandas as pd import plotly.graph_objects as go import requests @@ -7,16 +9,17 @@ from plotly.subplots import make_subplots from auth import require_auth from data_processing import TARGET_HIGH, TARGET_LOW, classify_treatments from nightscout_client import NightscoutConfigError -from reporting import classify_bolus, effective_basal_series, target_glucose_series +from reporting import BOLUS_CATEGORY_SMB, classify_bolus, daily_stats, effective_basal_series, target_glucose_series from ui_common import ( + MMOL_HOVERTEMPLATE, add_basal_area_trace, add_bolus_marker_traces, add_carb_bars_trace, add_target_line_trace, add_threshold_lines, get_colors, + load_day_data, load_profile, - load_range_data, render_completeness_warning, select_period_days, ) @@ -25,17 +28,48 @@ st.set_page_config(page_title="Glucose dashboard — Dagoverzicht", page_icon=" require_auth() st.title("Dagoverzicht") -st.caption("Eén glucosegrafiek per kalenderdag, met basaal/bolus/koolhydraten-overlay (FR4).") +st.caption( + "Eén dag per keer: boven glucose met koolhydraten/bolus-overlay, onder basaal " + "en SMB — apart gehouden omdat één gecombineerde grafiek te veel informatie bevatte." +) -start, end, days = select_period_days(default_label="14 dagen") -if days > 31: - st.info( - "Bij langere periodes wordt de pagina lang en kan laden traag zijn — " - "voor het bekijken van individuele dagen is 14 of 30 dagen prettiger." +# De periode-knop bepaalt alleen het navigeerbare venster (hoe ver terug je kunt +# bladeren), niet welke data wordt opgehaald — dat gebeurt hieronder voor precies +# de geselecteerde dag (load_day_data), zodat het wisselen tussen dagen snel blijft. +window_start, window_end, days = select_period_days(default_label="14 dagen") + +state_key = "dagoverzicht_selected_day" +if state_key not in st.session_state: + st.session_state[state_key] = window_end +if not (window_start <= st.session_state[state_key] <= window_end): + st.session_state[state_key] = window_end + +selected_day = st.session_state[state_key] + +col_prev, col_date, col_next = st.columns([1, 3, 1]) +with col_prev: + if st.button("← Vorige dag", disabled=selected_day <= window_start, width="stretch"): + selected_day = selected_day - timedelta(days=1) + st.session_state[state_key] = selected_day +with col_next: + if st.button("Volgende dag →", disabled=selected_day >= window_end, width="stretch"): + selected_day = selected_day + timedelta(days=1) + st.session_state[state_key] = selected_day +with col_date: + date_options = [window_end - timedelta(days=i) for i in range(days)] + chosen = st.selectbox( + "Datum", + options=date_options, + index=date_options.index(selected_day), + format_func=lambda d: d.strftime("%A %d-%m-%Y"), + label_visibility="collapsed", ) + if chosen != selected_day: + selected_day = chosen + st.session_state[state_key] = selected_day try: - glucose, treatments = load_range_data(start, end) + glucose, treatments = load_day_data(selected_day) profile = load_profile() except NightscoutConfigError as exc: st.error(str(exc)) @@ -45,132 +79,124 @@ except (requests.RequestException, ValueError) as exc: st.stop() if glucose.empty: - st.info("Geen glucosemetingen gevonden in deze periode.") + st.info("Geen glucosemetingen gevonden op deze dag.") st.stop() -render_completeness_warning(glucose, start, end) +render_completeness_warning(glucose, selected_day, selected_day) colors = get_colors() -glucose = glucose.copy() -glucose["date"] = glucose["timestamp"].dt.date -glucose["hour"] = glucose["timestamp"].dt.hour + glucose["timestamp"].dt.minute / 60 +day_start = pd.Timestamp(selected_day) +day_end = day_start + pd.Timedelta(1, unit="D") -carbs_events = classify_treatments(treatments)["carbs"].copy() -if not carbs_events.empty: - carbs_events["date"] = carbs_events["timestamp"].dt.date - carbs_events["hour"] = carbs_events["timestamp"].dt.hour + carbs_events["timestamp"].dt.minute / 60 +# treatments bevat een lookback-marge vóór middernacht (zie +# nightscout_client.fetch_treatments_for_day) zodat een temp basal die al +# vóór deze dag begon toch meetelt — effective_basal_series() clipt dat +# zelf aan [day_start, day_end). Voor de koolhydraten/bolus-overlays willen +# we die marge juist NIET, anders duiken gisteravond se stippen op vandaag op. +day_treatments = treatments[(treatments["timestamp"] >= day_start) & (treatments["timestamp"] < day_end)] -bolus_events = classify_bolus(treatments) -if not bolus_events.empty: - bolus_events["date"] = bolus_events["timestamp"].dt.date - bolus_events["hour"] = bolus_events["timestamp"].dt.hour + bolus_events["timestamp"].dt.minute / 60 +carbs_day = classify_treatments(day_treatments)["carbs"] +bolus_day = classify_bolus(day_treatments) +basal_day = effective_basal_series(profile, treatments, day_start, day_end) +target_day = target_glucose_series(profile, day_start, day_end) def _close_step_to_day_end(df: pd.DataFrame, value_col: str) -> pd.DataFrame: - """Voeg een laatste punt op hour=24 toe (zelfde waarde als het laatste + """Voeg een laatste punt op day_end toe (zelfde waarde als het laatste segment) zodat een stapvlak/-lijn de volle dag doortekent i.p.v. te stoppen bij het laatste breekpunt.""" if df.empty: return df last_value = df.iloc[-1][value_col] - closing_row = pd.DataFrame([{"hour": 24.0, value_col: last_value}]) + closing_row = pd.DataFrame([{"timestamp": day_end, value_col: last_value}]) return pd.concat([df, closing_row], ignore_index=True) -dates = sorted(glucose["date"].unique()) -row_height = 190 -fig = make_subplots( - rows=len(dates), - cols=1, - shared_xaxes=True, - vertical_spacing=min(0.4 / max(len(dates) - 1, 1), 0.05), - specs=[[{"secondary_y": True}]] * len(dates), -) +basal_day = _close_step_to_day_end(basal_day, "rate") +target_day = _close_step_to_day_end(target_day, "target") -for i, day in enumerate(dates, start=1): - day_df = glucose[glucose["date"] == day] - day_start = pd.Timestamp(day) - day_end = day_start + pd.Timedelta(1, unit="D") +def build_day_figure(glucose_day, carbs_day, bolus_day, basal_day, target_day, colors) -> go.Figure: + fig = make_subplots( + rows=2, + cols=1, + shared_xaxes=True, + row_heights=[0.68, 0.32], + vertical_spacing=0.08, + specs=[[{"secondary_y": True}], [{"secondary_y": False}]], + ) + + # Rij 1: glucose + streefwaarde + koolhydraten + voeding-/correctiebolus. fig.add_hrect( - y0=TARGET_LOW, y1=TARGET_HIGH, fillcolor=colors["band"], line_width=0, layer="below", row=i, col=1 - ) - # Handmatige titel-annotatie t.o.v. het domein van déze rij i.p.v. - # make_subplots' subplot_titles (die t.o.v. de hele figuur positioneert en - # daardoor bij veel dagen de gedeelde legenda bovenaan kan overlappen). - fig.add_annotation( - text=day.strftime("%A %d-%m-%Y"), - xref="x domain", - yref="y domain", - x=0.0, - y=1.15, - xanchor="left", - yanchor="bottom", - showarrow=False, - font={"size": 13, "color": colors["text"]}, - row=i, - col=1, + y0=TARGET_LOW, y1=TARGET_HIGH, fillcolor=colors["band"], line_width=0, layer="below", row=1, col=1 ) + add_target_line_trace(fig, target_day, colors, row=1) + add_carb_bars_trace(fig, carbs_day, colors, row=1, secondary_y=True) - basal_day = effective_basal_series(profile, treatments, day_start, day_end) - if not basal_day.empty: - basal_day["hour"] = (basal_day["timestamp"] - day_start).dt.total_seconds() / 3600 - basal_day = _close_step_to_day_end(basal_day, "rate") - add_basal_area_trace(fig, basal_day, colors, row=i, secondary_y=True, x_col="hour") - - target_day = target_glucose_series(profile, day_start, day_end) - if not target_day.empty: - target_day["hour"] = (target_day["timestamp"] - day_start).dt.total_seconds() / 3600 - target_day = _close_step_to_day_end(target_day, "target") - add_target_line_trace(fig, target_day, colors, row=i, x_col="hour") - - add_carb_bars_trace(fig, carbs_events[carbs_events["date"] == day], colors, row=i, secondary_y=True, x_col="hour") - - lane_y = day_df["mmol"].min() - 1.0 if not day_df.empty else TARGET_LOW - 1.0 - add_bolus_marker_traces(fig, bolus_events[bolus_events["date"] == day], colors, lane_y, row=i, x_col="hour") + top_bolus = bolus_day[bolus_day["category"] != BOLUS_CATEGORY_SMB] if not bolus_day.empty else bolus_day + lane_y = glucose_day["mmol"].min() - 1.0 if not glucose_day.empty else TARGET_LOW - 1.0 + add_bolus_marker_traces(fig, top_bolus, colors, lane_y, row=1) fig.add_trace( go.Scatter( - x=day_df["hour"], - y=day_df["mmol"], + x=glucose_day["timestamp"], + y=glucose_day["mmol"], mode="lines", line={"color": colors["glucose"], "width": 2}, name="Glucose", - hovertemplate="%{y:.1f} mmol/L", + hovertemplate=MMOL_HOVERTEMPLATE, ), - row=i, + row=1, col=1, - secondary_y=False, ) - fig.update_yaxes(title_text="mmol/L", gridcolor=colors["grid"], row=i, col=1, secondary_y=False) + add_threshold_lines(fig, colors, row=1) + fig.update_yaxes(title_text="mmol/L", gridcolor=colors["grid"], row=1, col=1, secondary_y=False) + fig.update_yaxes(title_text="Koolhydraten (g)", showgrid=False, row=1, col=1, secondary_y=True) + + # Rij 2: basaal + SMB — apart van rij 1 zodat de glucosegrafiek niet + # overladen raakt met insulinedetails. + add_basal_area_trace(fig, basal_day, colors, row=2) + smb_bolus = bolus_day[bolus_day["category"] == BOLUS_CATEGORY_SMB] if not bolus_day.empty else bolus_day + smb_lane_y = (basal_day["rate"].max() * 1.15) if not basal_day.empty else 1.0 + add_bolus_marker_traces(fig, smb_bolus, colors, smb_lane_y, row=2) fig.update_yaxes( - title_text="Basaal (E/uur) · KH (g)", showgrid=False, row=i, col=1, secondary_y=True + title_text="Basaal (E/uur)", gridcolor=colors["grid"], rangemode="tozero", row=2, col=1 ) - add_threshold_lines(fig, colors, row=i) -# Elke dag herhaalt dezelfde spoornamen (Glucose/Basaal/Koolhydraten/...) — zonder -# deduplicatie zou de legenda 7x "Glucose" etc. tonen. Alleen de eerste keer per -# naam blijft in de legenda staan. -seen_names = set() -for trace in fig.data: - if trace.name in seen_names: - trace.showlegend = False - else: - seen_names.add(trace.name) + fig.update_xaxes(range=[day_start, day_end], dtick=4 * 3600 * 1000, gridcolor=colors["grid"]) + fig.update_layout( + paper_bgcolor="rgba(0,0,0,0)", + plot_bgcolor="rgba(0,0,0,0)", + font_color=colors["text"], + height=560, + showlegend=True, + legend={"orientation": "h", "yanchor": "bottom", "y": 1.02, "xanchor": "left", "x": 0}, + margin={"l": 10, "r": 10, "t": 40, "b": 10}, + ) + return fig -fig.update_xaxes(title_text="Uur van de dag", range=[0, 24], dtick=4, gridcolor=colors["grid"]) -fig.update_layout( - paper_bgcolor="rgba(0,0,0,0)", - plot_bgcolor="rgba(0,0,0,0)", - font_color=colors["text"], - height=row_height * len(dates) + 40, - showlegend=True, - legend={"orientation": "h", "yanchor": "bottom", "y": 1.0, "xanchor": "left", "x": 0}, - margin={"l": 10, "r": 10, "t": 40, "b": 10}, -) + +fig = build_day_figure(glucose, carbs_day, bolus_day, basal_day, target_day, colors) st.plotly_chart(fig, width="stretch") + +stats = daily_stats(glucose, day_treatments, profile, day_start, day_end) +col1, col2, col3, col4 = st.columns(4) +col1.metric( + "Glucose (gem./min/max)", + f"{stats['mean_mmol']:.1f} mmol/L", + delta=f"{stats['min_mmol']:.1f} - {stats['max_mmol']:.1f}", + delta_color="off", +) +col2.metric("Totale insuline", f"{stats['total_insulin']:.1f} E") +col3.metric("Basaal / bolus / SMB", f"{stats['basal_insulin']:.1f} / {stats['bolus_insulin']:.1f} / {stats['smb_insulin']:.1f} E") +col4.metric( + "Hypo's / hyper's", + f"{stats['hypo_count']} / {stats['hyper_count']}", + delta=f"{stats['hypo_duration_minutes']:.0f} / {stats['hyper_duration_minutes']:.0f} min", + delta_color="off", +) + st.caption( - f"{len(dates)} dagen · {len(glucose)} metingen · stippen = bolusinsuline " - "(voeding/correctie/SMB, grootte naar dosis) · vlak = basaal · staafjes = koolhydraten · " - "streepstippellijn = streefwaarde uit pompprofiel" + "Boven: stippen = bolusinsuline (voeding/correctie, grootte naar dosis) · staafjes = koolhydraten · " + "streepstippellijn = streefwaarde uit pompprofiel. Onder: vlak = basaal · stippen = SMB." ) diff --git a/pages/9_Insulineprofiel.py b/pages/9_Insulineprofiel.py index 61604a1..75387cf 100644 --- a/pages/9_Insulineprofiel.py +++ b/pages/9_Insulineprofiel.py @@ -6,7 +6,7 @@ import streamlit as st from auth import require_auth from nightscout_client import NightscoutConfigError from profile_data import segments_table, segments_to_hourly_bars, total_daily_basal -from ui_common import get_colors, load_profile +from ui_common import get_colors, hex_to_rgba, load_profile st.set_page_config(page_title="Glucose dashboard — Insulineprofiel", page_icon="📊", layout="wide") require_auth() @@ -42,8 +42,8 @@ fig = go.Figure( mode="lines", line={"shape": "hv", "color": colors["basal"], "width": 2}, fill="tozeroy", - fillcolor=colors["basal"], - opacity=0.7, + fillcolor=hex_to_rgba(colors["basal"], 0.3), + opacity=0.8, name="Basaal", hovertemplate="%{x:.0f}:00 · %{y:.2f} E/uur", ) diff --git a/pdf_export.py b/pdf_export.py index 8b7f9cd..696b53d 100644 --- a/pdf_export.py +++ b/pdf_export.py @@ -230,7 +230,7 @@ def _insulin_profile_figure_png(profile: dict) -> bytes: if bars: hours = [b["hour"] for b in bars] values = [b["value"] for b in bars] - ax.step(hours, values, where="post", color=_BASAL_COLOR, linewidth=1.5) + ax.step(hours, values, where="post", color=_BASAL_COLOR, linewidth=1.5, alpha=0.8) ax.fill_between(hours, values, step="post", color=_BASAL_COLOR, alpha=0.3) ax.set_xlim(0, 24) ax.set_xticks(range(0, 25, 2)) @@ -261,9 +261,8 @@ def _add_carbs_overlay(ax2, day_treatments: pd.DataFrame, day_start) -> None: ax2.bar(hours, carbs["carbs"], width=0.25, color=_CARBS_COLOR, alpha=0.55, label="Koolhydraten") -def _add_bolus_overlay(ax, day_treatments: pd.DataFrame, day_start, lane_y: float) -> None: - bolus = classify_bolus(day_treatments) - for category in (BOLUS_CATEGORY_SMB, BOLUS_CATEGORY_CORRECTION, BOLUS_CATEGORY_FOOD): +def _add_bolus_overlay(ax, bolus: pd.DataFrame, day_start, lane_y: float, categories: tuple[str, ...]) -> None: + for category in categories: subset = bolus[bolus["category"] == category] if not bolus.empty else bolus if subset.empty: continue @@ -291,35 +290,56 @@ def _add_target_overlay(ax, profile: dict | None, day_start, day_end) -> None: def _daily_figure_png( day_df: pd.DataFrame, day_treatments: pd.DataFrame, profile: dict | None, day_label: str, day_start, day_end ) -> bytes: - """Eén dag als grafiek: glucoselijn + basaal/bolus/koolhydraten/streefwaarde-overlay.""" + """Eén dag als twee gestapelde grafieken (gedeelde tijdas, één gedeelde legenda): + boven glucose + streefwaarde + koolhydraten + voeding/correctiebolus, onder + basaal + SMB — gescheiden omdat één gecombineerde grafiek te veel informatie + bevatte om nog leesbaar te zijn.""" hours = day_df["timestamp"].dt.hour + day_df["timestamp"].dt.minute / 60 - fig, ax = plt.subplots(figsize=(11, 3.8)) + fig, (ax, ax_basal) = plt.subplots(2, 1, figsize=(11, 5.2), sharex=True, height_ratios=[2.2, 1]) ax2 = ax.twinx() - basal = effective_basal_series(profile, day_treatments, day_start, day_end) - _add_basal_overlay(ax2, basal, day_start) + bolus = classify_bolus(day_treatments) _add_carbs_overlay(ax2, day_treatments, day_start) - lane_y = (day_df["mmol"].min() - 1.0) if not day_df.empty else LOW - 1.0 - _add_bolus_overlay(ax, day_treatments, day_start, lane_y) + _add_bolus_overlay(ax, bolus, day_start, lane_y, categories=(BOLUS_CATEGORY_CORRECTION, BOLUS_CATEGORY_FOOD)) _add_target_overlay(ax, profile, day_start, day_end) ax.plot(hours, day_df["mmol"], color="#2a78d6", linewidth=1.4, label="Glucose", zorder=6) _add_threshold_lines(ax) - ax.set_xlim(0, 24) - ax.set_xticks(range(0, 25, 2)) ax.set_ylabel("mmol/L", fontsize=9) - ax2.set_ylabel("Basaal (E/uur) · KH (g)", fontsize=9) + ax2.set_ylabel("Koolhydraten (g)", fontsize=9) ax.set_title(day_label, fontsize=11, loc="left") ax.tick_params(labelsize=8) ax2.tick_params(labelsize=8) + basal = effective_basal_series(profile, day_treatments, day_start, day_end) + _add_basal_overlay(ax_basal, basal, day_start) + smb_lane_y = (basal["rate"].max() * 1.15) if not basal.empty else 1.0 + _add_bolus_overlay(ax_basal, bolus, day_start, smb_lane_y, categories=(BOLUS_CATEGORY_SMB,)) + ax_basal.set_xlim(0, 24) + ax_basal.set_xticks(range(0, 25, 2)) + ax_basal.set_xlabel(_HOUR_OF_DAY_LABEL) + ax_basal.set_ylabel("Basaal (E/uur)", fontsize=9) + ax_basal.tick_params(labelsize=8) + lines1, labels1 = ax.get_legend_handles_labels() lines2, labels2 = ax2.get_legend_handles_labels() - ax.legend( - lines1 + lines2, labels1 + labels2, loc="upper center", bbox_to_anchor=(0.5, -0.2), ncol=6, fontsize=7, frameon=False + lines3, labels3 = ax_basal.get_legend_handles_labels() + ax_basal.legend( + lines1 + lines2 + lines3, + labels1 + labels2 + labels3, + loc="upper center", + bbox_to_anchor=(0.5, -0.35), + ncol=6, + fontsize=7, + frameon=False, ) - fig.tight_layout() + # fig.tight_layout() introspecteert elke as se bounding box om marges te + # berekenen, en matplotlib ondersteunt dat niet betrouwbaar zodra er twee + # hoofd-assen (ax/ax_basal) plus een twinx (ax2) tegelijk in de figuur + # zitten (waarschuwing "Axes ... not compatible with tight_layout"). + # subplots_adjust() met vaste marges omzeilt die introspectie. + fig.subplots_adjust(left=0.06, right=0.94, top=0.93, bottom=0.24, hspace=0.35) return _fig_to_png(fig) diff --git a/tests/test_nightscout_client.py b/tests/test_nightscout_client.py index 6f7c538..8a3b082 100644 --- a/tests/test_nightscout_client.py +++ b/tests/test_nightscout_client.py @@ -168,6 +168,50 @@ def test_fetch_treatments_range_filters_to_date_boundaries(monkeypatch): assert result.iloc[0]["insulin"] == pytest.approx(4.0) +def test_fetch_entries_for_day_scopes_to_a_single_calendar_day(monkeypatch): + monkeypatch.setenv("NIGHTSCOUT_URL", "https://example.com") + monkeypatch.delenv("NIGHTSCOUT_TOKEN", raising=False) + monkeypatch.delenv("NIGHTSCOUT_API_SECRET", raising=False) + + payload = [ + {"date": int(datetime(2026, 1, 15, 12, 0, tzinfo=timezone.utc).timestamp() * 1000), "sgv": 126}, + {"date": int(datetime(2026, 1, 16, 12, 0, tzinfo=timezone.utc).timestamp() * 1000), "sgv": 100}, + ] + monkeypatch.setattr(ns.requests, "get", lambda *a, **k: FakeResponse(payload)) + + result = ns.fetch_entries_for_day(date(2026, 1, 15)) + assert len(result) == 1 + assert result.iloc[0]["timestamp"].date() == date(2026, 1, 15) + + +def test_fetch_treatments_for_day_includes_lookback_before_midnight(monkeypatch): + """Een Temp Basal die net vóór middernacht is gestart moet toch meekomen + (zie de docstring bij fetch_treatments_for_day) — zonder lookback-marge + zou effective_basal_series() de vroege ochtenduren fout berekenen.""" + monkeypatch.setenv("NIGHTSCOUT_URL", "https://example.com") + payload = [ + {"created_at": "2026-01-14T22:00:00Z", "eventType": "Temp Basal", "absolute": 1.2, "duration": 480}, + {"created_at": "2026-01-15T12:00:00Z", "eventType": "Meal Bolus", "insulin": 4.0, "carbs": 40}, + {"created_at": "2026-01-10T08:00:00Z", "eventType": "Meal Bolus", "insulin": 3.0, "carbs": 30}, + ] + monkeypatch.setattr(ns.requests, "get", lambda *a, **k: FakeResponse(payload)) + + result = ns.fetch_treatments_for_day(date(2026, 1, 15), lookback_hours=6) + assert len(result) == 2 # de 10 januari-bolus valt buiten de lookback-marge + assert set(result["eventType"]) == {"Temp Basal", "Meal Bolus"} + + +def test_fetch_treatments_for_day_excludes_treatments_before_the_lookback_window(monkeypatch): + monkeypatch.setenv("NIGHTSCOUT_URL", "https://example.com") + payload = [ + {"created_at": "2026-01-14T12:00:00Z", "eventType": "Meal Bolus", "insulin": 4.0, "carbs": 40}, + ] + monkeypatch.setattr(ns.requests, "get", lambda *a, **k: FakeResponse(payload)) + + result = ns.fetch_treatments_for_day(date(2026, 1, 15), lookback_hours=6) + assert result.empty + + def _entry(dt, sgv=100): return {"date": int(dt.timestamp() * 1000), "sgv": sgv, "dateString": dt.isoformat()} diff --git a/ui_common.py b/ui_common.py index e58193a..fbd47ae 100644 --- a/ui_common.py +++ b/ui_common.py @@ -14,7 +14,13 @@ 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 nightscout_client import ( + fetch_entries_for_day, + fetch_entries_range, + fetch_profile, + fetch_treatments_for_day, + fetch_treatments_range, +) from profile_data import parse_profile from reporting import ( HIGH, @@ -148,6 +154,25 @@ def load_range_data(start: date, end: date): # pragma: no cover return glucose, treatments +@st.cache_data(ttl=300, show_spinner="Data ophalen van Nightscout...") +def load_day_data(day: date): # pragma: no cover + """Gecachete Nightscout-fetch voor precies één kalenderdag (Dagoverzicht-navigatie). + + Kortere TTL dan load_range_data (5 min i.p.v. 1 uur): in tegenstelling tot + een afgeronde periode kan de geselecteerde dag "vandaag" zijn, waar + nieuwe metingen blijven binnenkomen. fetch_treatments_for_day() haalt + zelf een lookback-marge vóór middernacht op zodat effective_basal_series() + de vroege ochtenduren correct kan berekenen. + + Dunne st.cache_data-wrapper rond al-geteste fetch_entries_for_day()/ + fetch_treatments_for_day() — buiten scope van de unit-testdekking net als + pages/*.py, zie pyproject.toml se coverage-omit-lijst. + """ + glucose = fetch_entries_for_day(day) + treatments = fetch_treatments_for_day(day) + 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.