Files
glucose-dashboard/docs/design-reporting.md
willemjanandClaude Sonnet 5 3ef9a7666a add reporting foundation: multipage nav, auth gate, date-range fetch
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]>
2026-07-26 20:00:48 +02:00

179 lines
9.4 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Design: Clinical reporting page
Status: **implementation in progress**
Implements: [requirements-reporting.md](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:
```python
# 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):
```python
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_mmol`
- `percent_cv(glucose: pd.DataFrame) -> float` — `stdev/mean * 100`
- `tir_breakdown_5band(glucose) -> dict` — extends `data_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/day
- `detect_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/week
- `overnight_daytime_split(glucose, overnight_start=0, overnight_end=6) -> dict`
— TIR breakdown for each segment
- `day_of_week_pattern(glucose) -> pd.DataFrame` — per-weekday summary stats
- `bolus_stats(treatments) -> dict` — meal vs. correction bolus split, using the
existing `INSULIN_EVENT_TYPES` set from `nightscout_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](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.py` functions: unit tests in `tests/test_reporting.py`, same
fixture style as `tests/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.py` additions: tests in the existing
`tests/test_nightscout_client.py`, same `monkeypatch`-on-`requests` pattern.
- `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 existing `app.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
1. Foundation: `auth.py`, multipage restructure (move current `app.py` →
`pages/1_Live.py`, new minimal `app.py`), `nightscout_client.py` range-fetch
additions — all independently testable, no visible feature yet.
2. `reporting.py` core: GMI, %CV, 5-band TIR, event detection + summaries —
the clinically load-bearing pieces, fully unit tested.
3. Wire pages 2–3–4–5–6 (summary, AGP, daily, hypo/hyper events) to `reporting.py`.
4. `reporting.py` extras: overnight/daytime split, day-of-week, bolus stats,
site-change impact; wire page 7 (trends).
5. 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.