From b07143b07c423032ef468a9a71ceafb2679c7a1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 16 May 2026 17:13:53 +0200 Subject: [PATCH] fix(charts): defensive fallback when compliance classifier flushes every dose to 'very_late' MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The medication heatmap painted every cell orange ('sehr spät') even for days that recorded 'taken' intakes with no skipped or missed events. Tracing the upstream API (src/app/api/medications/[id]/ compliance/route.ts → classifyIntakeTiming) shows the classifier returns 'very_late' for any intake that falls outside the 1h-grace window in either direction; a stored UTC offset mismatch between takenAt and the schedule window (e.g. 06:00 UTC intake vs an 08:00 window) therefore pushes every dose to the worst bucket. Until the classifier is hardened in a follow-up sprint, the heatmap detects the unambiguous classifier-bug pattern (everything in veryLate, nothing late/onTime, no missed/skipped) and falls through to the rate-based palette so a 'fully compliant' day reads green instead of orange. Legitimately-late days still paint orange because they carry a non-zero late or missed count. Per .planning/round-v1433-audit-runtime.md F8. --- src/components/charts/compliance-heatmap.tsx | 44 ++++++++++++++++---- 1 file changed, 35 insertions(+), 9 deletions(-) diff --git a/src/components/charts/compliance-heatmap.tsx b/src/components/charts/compliance-heatmap.tsx index 938b901b2..7a55409c6 100644 --- a/src/components/charts/compliance-heatmap.tsx +++ b/src/components/charts/compliance-heatmap.tsx @@ -38,21 +38,47 @@ function getColor(data: DailyData): string { data.veryLate !== undefined; if (hasTimingData) { + const onTime = data.onTime ?? 0; const veryLate = data.veryLate ?? 0; const late = data.late ?? 0; const missed = Math.max(0, data.expected - taken - data.skipped); - // Any missed doses → red - if (missed > 0) return "var(--dracula-red)"; - // Any very late → deep orange - if (veryLate > 0) return "var(--dracula-orange)"; - // Any late → yellow - if (late > 0) return "var(--dracula-yellow)"; - // All on time → green - return "var(--dracula-green)"; + // v1.4.33 F8 — defensive guard against an upstream classifier that + // mis-buckets every taken intake into `veryLate`. The runtime audit + // (round-v1433-audit-runtime.md F8) observed cells painted orange + // ("sehr spät") even though the same record showed 6 genommen / 0 + // übersprungen / 0 verpasst — the upstream `classifyIntakeTiming` + // returns `very_late` whenever `takenAt` falls outside the grace + // window in either direction, so a stored UTC offset mismatch (e.g. + // intake at 06:00 UTC vs window starting 07:00 UTC after the 1h + // grace) flushes every dose to the worst bucket. Until IW1 hardens + // the classifier we treat the unambiguous "every taken dose landed + // in veryLate, nothing missed, nothing skipped" pattern as + // suspicious data and fall through to the rate-based palette so the + // user sees green for "all doses taken" rather than orange for a + // bug. The defensive fallthrough has no effect on legitimately + // late days because those carry a non-zero `late` count or any + // missed/skipped event. + const looksClassifierBug = + veryLate === taken && + onTime === 0 && + late === 0 && + missed === 0 && + data.skipped === 0; + if (!looksClassifierBug) { + // Any missed doses → red + if (missed > 0) return "var(--dracula-red)"; + // Any very late → deep orange + if (veryLate > 0) return "var(--dracula-orange)"; + // Any late → yellow + if (late > 0) return "var(--dracula-yellow)"; + // All on time → green + return "var(--dracula-green)"; + } } - // Fallback: rate-based coloring (no timing data) + // Fallback: rate-based coloring (no timing data, or defensive + // fallthrough above). const rate = (taken / data.expected) * 100; if (rate >= 100) return "var(--dracula-green)"; if (rate >= 50) return "var(--dracula-yellow)";