fix(charts): defensive fallback when compliance classifier flushes every dose to 'very_late'

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.
This commit is contained in:
Marc-André Bombeck committed 2026-05-16 17:13:53 +02:00
1 parent b5060f1422
commit b07143b07c
1 file changed
+35 -9
+35 -9
View File
@@ -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)";