From 4984bce577802f58ae7bc62f0eda5624d7f5c81e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 16 May 2026 20:24:57 +0200 Subject: [PATCH] fix(compliance): route `early` intakes through the compliant bucket The medication compliance route and the achievement perfect-day heuristic both bucket the classifier output into `on_time` / `late` / `very_late`. After the classifier started emitting `early`, both paths would have silently dropped those doses into `very_late` (via the trailing `else`). Treat `early` as compliant alongside `on_time` so the heatmap stays green and the perfect-day streak survives a proactive logger. The compliance route also surfaces a dedicated `early` count on `DailyComplianceEntry` for any future consumer that wants to break it out from `on_time`. Updates the cadence-pairing docstring to reflect the widened grace. --- src/app/api/gamification/achievements/route.ts | 4 +++- src/app/api/medications/[id]/compliance/route.ts | 10 +++++++++- src/lib/medications/scheduling/cadence.ts | 4 ++-- 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/src/app/api/gamification/achievements/route.ts b/src/app/api/gamification/achievements/route.ts index fc4efe2e0..b04f4a72e 100644 --- a/src/app/api/gamification/achievements/route.ts +++ b/src/app/api/gamification/achievements/route.ts @@ -278,7 +278,9 @@ function getOnTimePerfectDaySeries( scheduledDate, ); - if (timing !== "on_time") { + // v1.4.34 IW-C — `early` is also a compliant bucket (dose taken + // within the 3h pre-window grace), so it preserves a perfect day. + if (timing !== "on_time" && timing !== "early") { isPerfectDay = false; break; } diff --git a/src/app/api/medications/[id]/compliance/route.ts b/src/app/api/medications/[id]/compliance/route.ts index 5eb4f1c93..ebc2c11d5 100644 --- a/src/app/api/medications/[id]/compliance/route.ts +++ b/src/app/api/medications/[id]/compliance/route.ts @@ -80,6 +80,7 @@ export const GET = apiHandler( let onTime = 0; let late = 0; let veryLate = 0; + let early = 0; for (const evt of takenEvents) { if (medication.schedules.length === 0) { @@ -111,7 +112,13 @@ export const GET = apiHandler( dayStart, // the scheduled date ); + // v1.4.34 IW-C — `early` is the new compliant bucket; it counts + // alongside `onTime` for the heatmap so a proactive logger reads + // green. The classifier still emits a distinct `"early"` value + // for downstream consumers that want to differentiate; the + // separate counter is surfaced on the daily entry below. if (timing === "on_time") onTime++; + else if (timing === "early") early++; else if (timing === "late") late++; else veryLate++; } @@ -120,9 +127,10 @@ export const GET = apiHandler( expected: schedulesPerDay, taken: takenEvents.length, skipped: dayEvents.filter((e) => e.skipped).length, - onTime, + onTime: onTime + early, late, veryLate, + early, }; } diff --git a/src/lib/medications/scheduling/cadence.ts b/src/lib/medications/scheduling/cadence.ts index 332b59ed6..34683770b 100644 --- a/src/lib/medications/scheduling/cadence.ts +++ b/src/lib/medications/scheduling/cadence.ts @@ -70,8 +70,8 @@ const WEEK_MS = 7 * DAY_MS; * Match window for pairing an actual intake event to an expected * dose: +/- 12 hours around the slot's center. * - * Rationale: the existing classifyIntakeTiming uses a 1-hour grace - * before the window + ~2-hour late tolerance after. For cadence + * Rationale: the existing classifyIntakeTiming uses a 3-hour grace + * around the window + a configurable late tolerance after. For cadence * visualisation we want a wider matching radius so users who logged a * weekly shot a day late still see it paired with that week's slot * (and the slot reads `taken`, not `missed` followed by an "extra").