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.