Redesign the PDF report: landscape pages, headers/footers, icon, daily stats

Every report section now gets its own landscape A4 page (or, for
Dagoverzicht, one page per day) via a GlucoseReportPDF(FPDF) subclass
with a real header (section title, report version, insulin/pump/sensor
type from new REPORT_* env vars) and footer (page number, generation
timestamp, Nightscout link) on every page, plus an original SVG icon
in the header — inspired by the LibreView AGP report.

New "Insulineprofiel" PDF section mirrors the dashboard page (basal
bar chart, ISF/carb-ratio segments, DIA). Each Dagoverzicht day now
draws the same basal/bolus/carb/target overlays as the interactive
charts (matplotlib port of the Plotly helpers from #30) plus a new
per-day stats block (reporting.daily_stats: avg/min/max glucose,
total/basal/bolus/SMB insulin, hypo/hyper counts and durations).

Builds on #29 (profile data) and #30 (overlay logic/colors).
This commit is contained in:
2026-09-05 22:44:14 +02:00
parent d33516e2a1
commit f2c3f97b1f
8 changed files with 718 additions and 100 deletions
+70
View File
@@ -11,6 +11,7 @@ from reporting import (
classify_bolus,
completeness_warning,
daily_gmi,
daily_stats,
day_of_week_pattern,
detect_events,
effective_basal_series,
@@ -618,6 +619,75 @@ def test_target_glucose_series_handles_none_profile():
assert result.empty
def test_daily_stats_computes_glucose_and_insulin_breakdown():
day_start = pd.Timestamp("2026-01-01")
day_end = pd.Timestamp("2026-01-02")
glucose = _series(
[("2026-01-01 08:00", 3.2), ("2026-01-01 12:00", 14.1), ("2026-01-01 20:00", 6.5)]
)
treatments = pd.DataFrame(
[
{
"timestamp": pd.Timestamp("2026-01-01 08:00"),
"eventType": "Meal Bolus",
"insulin": 4.0,
"carbs": 40.0,
"type": "",
"absolute": None,
"duration": None,
},
{
"timestamp": pd.Timestamp("2026-01-01 12:00"),
"eventType": "Correction Bolus",
"insulin": 0.3,
"carbs": None,
"type": "SMB",
"absolute": None,
"duration": None,
},
{
"timestamp": pd.Timestamp("2026-01-01 06:00"),
"eventType": "Temp Basal",
"insulin": None,
"carbs": None,
"type": "",
"absolute": 2.0,
"duration": 60,
},
]
)
profile = {"basal": [{"minutes": 0, "value": 0.8}]}
result = daily_stats(glucose, treatments, profile, day_start, day_end)
assert result["mean_mmol"] == pytest.approx((3.2 + 14.1 + 6.5) / 3, abs=0.05)
assert result["min_mmol"] == pytest.approx(3.2)
assert result["max_mmol"] == pytest.approx(14.1)
assert result["bolus_insulin"] == pytest.approx(4.0)
assert result["smb_insulin"] == pytest.approx(0.3)
# basaal: 23h @ 0.8 profieltempo + 1h @ 2.0 (temp basal 06:00-07:00) = 18.4 + 2.0 = 20.4
assert result["basal_insulin"] == pytest.approx(20.4, abs=0.05)
assert result["total_insulin"] == pytest.approx(4.0 + 0.3 + 20.4, abs=0.05)
assert result["hypo_count"] == 1
assert result["hyper_count"] == 1
def test_daily_stats_handles_empty_glucose_and_treatments():
day_start = pd.Timestamp("2026-01-01")
day_end = pd.Timestamp("2026-01-02")
empty_glucose = pd.DataFrame(columns=["timestamp", "mmol"])
empty_treatments = pd.DataFrame(columns=["timestamp", "eventType", "insulin", "carbs", "type", "absolute", "duration"])
result = daily_stats(empty_glucose, empty_treatments, None, day_start, day_end)
assert result["mean_mmol"] == 0.0
assert result["min_mmol"] == 0.0
assert result["max_mmol"] == 0.0
assert result["total_insulin"] == 0.0
assert result["hypo_count"] == 0
assert result["hyper_count"] == 0
def test_site_interval_stats_splits_by_site_change():
glucose = _series(
[