First increment of the clinical reporting feature (see docs/requirements-reporting.md and docs/design-reporting.md): moves the existing live view to pages/1_Live.py, adds a password gate since the dashboard URL will be shared with the diabetes team, adds fetch_entries_range/fetch_treatments_range for multi-day report queries, and adds reporting.py with the core clinical calculations (GMI, %CV, 5-band time-in-range, hypo/hyper event detection per the Battelino et al. 2019 persistence rule). No report pages wired up yet — that's next. Co-Authored-By: Claude Sonnet 5 <[email protected]>
9.4 KiB
Design: Clinical reporting page
Status: implementation in progress Implements: requirements-reporting.md (see that doc for the final, user-edited FR list)
1. Architecture overview
Streamlit's classic pages/ directory convention (numbered filenames, auto-populated
sidebar nav) rather than the newer st.navigation/st.Page object API — same
end result (multipage nav), simpler to reason about since each page is an
independent script sharing only imported modules, and it's what the project's
own homelab-cicd-werkinstructie.md anticipated.
app.py # home/landing page — auth gate + short intro
pages/
1_Live.py # existing rolling 6-48h view, moved as-is from current app.py
2_Samenvatting.py # FR2 (summary stats) + FR9 (data completeness)
3_AGP_Profiel.py # FR3 (percentile chart)
4_Dagoverzicht.py # FR4 (day-by-day graphs)
5_Hypo_Events.py # FR5
6_Hyper_Events.py # FR6
7_Trends.py # FR7 (GMI trend) + FR8 (other stats)
8_Export.py # FR10 (PDF export)
auth.py # password gate, imported by app.py and every page
reporting.py # new pure-logic module (mirrors data_processing.py's role)
nightscout_client.py # + fetch_entries_range / fetch_treatments_range
Each report page has its own period control per the user's edit to FR1 (90-day default for statistics/TIR pages, 14-day default for the daily-log page), rather than one global selector — consistent with "each page separately."
2. Auth (NFR6 — new requirement from the user's edit)
Streamlit has no built-in auth. Given this is a single-user-plus-a-few-clinicians
homelab app (not a multi-tenant product), a lightweight session-based password
gate is proportionate — consistent with how nightscout_client._auth() already
hashes the API secret rather than storing/sending it raw:
# auth.py
import hashlib, os
import streamlit as st
def require_auth() -> None:
if st.session_state.get("authenticated"):
return
st.title("Glucose dashboard")
password = st.text_input("Wachtwoord", type="password")
if not password:
st.stop()
expected = os.environ.get("DASHBOARD_PASSWORD_HASH", "")
if hashlib.sha256(password.encode()).hexdigest() == expected:
st.session_state["authenticated"] = True
st.rerun()
st.error("Onjuist wachtwoord")
st.stop()
Called as the first line of app.py and every pages/*.py file (Streamlit runs
each page as an independent script, so the guard must repeat per-file — no
shared middleware). New env var DASHBOARD_PASSWORD_HASH (sha256 hex digest,
not the plaintext password) added to .env.example/README with instructions to
generate it (python -c "import hashlib; print(hashlib.sha256(b'yourpassword').hexdigest())").
Session-based means each browser session re-enters the password once; this is a
basic gate against casual URL sharing, not a hardened auth system — acceptable
for this context but worth knowing if the diabetes team needs individual
accounts later.
3. nightscout_client.py additions
fetch_entries/fetch_treatments scale count linearly with hours — fine for
the existing rolling 6-48h view, not for a 90-day report (~26,000 5-min
readings). New functions follow the exact same "overfetch by count, then filter
locally by timestamp" pattern already used (rather than introducing Mongo
find[] date-query params, which the existing code's own comments flag as
inconsistent across Nightscout versions):
def fetch_entries_range(start_date: date, end_date: date) -> pd.DataFrame:
"""Haal glucosemetingen op tussen start_date en end_date (inclusief)."""
days = (end_date - start_date).days + 1
count = min(days * 300, 100_000) # ruime marge boven 5-min-interval
... # zelfde _get/parse-logica als fetch_entries, lokaal gefilterd op [start_date, end_date]
def fetch_treatments_range(start_date: date, end_date: date) -> pd.DataFrame:
... # analoog, count = min(days * 50, 50_000)
4. reporting.py (new module)
Pure functions only (no Streamlit imports), mirroring data_processing.py's
existing style — testable with the same fixture-based pytest pattern.
gmi_percent(mean_mmol: float) -> float—3.31 + 0.4311 * mean_mmolpercent_cv(glucose: pd.DataFrame) -> float—stdev/mean * 100tir_breakdown_5band(glucose) -> dict— extendsdata_processing.summarize_time_in_range(which only has 3 bands) to the 5 clinical bands (<3.0, 3.0–3.9, 3.9–10.0, 10.0–13.9, >13.9), each as % and as minutes/daydetect_events(glucose, threshold, direction, min_duration_minutes=15) -> pd.DataFrame— generic engine for FR5/FR6 (direction="below" for hypo, "above" for hyper); finds runs of consecutive out-of-threshold readings ≥15 min, merging across brief in-range blips per the persistence rule (a return above/below threshold only "counts" once it has itself persisted ≥15 min)summarize_events(events: pd.DataFrame, period_days: int) -> dict— count, avg/longest duration, total time, events/weekovernight_daytime_split(glucose, overnight_start=0, overnight_end=6) -> dict— TIR breakdown for each segmentday_of_week_pattern(glucose) -> pd.DataFrame— per-weekday summary statsbolus_stats(treatments) -> dict— meal vs. correction bolus split, using the existingINSULIN_EVENT_TYPESset fromnightscout_client.py(currently defined but unused — this is its first real consumer)site_change_glucose_impact(glucose, site_changes, window_hours=6) -> pd.DataFrame— TIR in the N hours after each site change vs. the period baseline
Scoping caveat surfaced during design (flagging rather than silently
under-delivering): a true basal:bolus insulin ratio and total-daily-dose trend
need the scheduled basal rate (from the Nightscout profile) reconciled against
any temp-basal overrides — neither the profile nor "Temp Basal" treatments are
fetched anywhere in this codebase today, and profile access is explicitly the
other feature's territory (requirements-profile-tuning.md).
Implementing this now would mean quietly duplicating profile-fetch logic between
two features that are supposed to stay separate. First pass ships bolus-only
stats (which are fully derivable from treatments alone) with an explicit label
that basal totals aren't included; true basal:bolus/TDD becomes a fast-follow
once the profile-tuning feature's Nightscout profile client exists and can be
reused read-only from here.
5. PDF export (FR10)
kaleido (Plotly's static-image renderer) bundles its own Chromium build —
meaningfully bigger image and more Trivy surface for not much benefit here.
Instead: matplotlib (already a transitive pandas dependency's peer, tiny
incremental footprint) renders simplified static charts specifically for the
PDF, and fpdf2 (pure-Python, no system deps) assembles the PDF — same
approach, smaller/simpler Docker image, consistent with how carefully the
Trivy/registry-hostname issues were chased down in this project's CI history.
pages/8_Export.py lets the user check which sections to include, renders the
selected stats/charts via a new pdf_export.py (build_report_pdf(sections: list[str], ...) -> bytes),
and offers it via st.download_button.
6. Testing strategy
reporting.pyfunctions: unit tests intests/test_reporting.py, same fixture style astests/test_data_processing.py, explicitly covering the edge cases named in the requirements doc (14:59 vs 15:00 min events, two dips separated by a brief recovery, empty input, 100%-in-range).nightscout_client.pyadditions: tests in the existingtests/test_nightscout_client.py, samemonkeypatch-on-requestspattern.auth.py: the hashing/compare logic is trivial enough to test directly (hashlib.sha256(...).hexdigest() == expected) without needing to drive Streamlit's UI.- Page files (
pages/*.py) are UI wiring only (like the existingapp.py) — consistent with this project's existing convention of not unit-testing the Streamlit UI layer itself, only the logic it calls into. pdf_export.py: unit test that a given set of sections produces a non-empty, valid PDF (fpdf2's output starts with%PDF), without asserting on exact rendered pixels.
7. Implementation order
- Foundation:
auth.py, multipage restructure (move currentapp.py→pages/1_Live.py, new minimalapp.py),nightscout_client.pyrange-fetch additions — all independently testable, no visible feature yet. reporting.pycore: GMI, %CV, 5-band TIR, event detection + summaries — the clinically load-bearing pieces, fully unit tested.- Wire pages 2–3–4–5–6 (summary, AGP, daily, hypo/hyper events) to
reporting.py. reporting.pyextras: overnight/daytime split, day-of-week, bolus stats, site-change impact; wire page 7 (trends).- PDF export (page 8) — last, since it's the most novel dependency and lowest clinical risk if delayed a step.
Each step lands as its own commit(s) behind ruff check . + pytest tests/ -v,
pushed through the existing dev → PR → main flow so the CI/CD pipeline
(test → SonarQube → build+Trivy+push → deploy) validates every increment, per
the user's original request to implement requirement-by-requirement with as
much automated testing as possible.