From 037da560adf2259d189a276286a9644f0ce699cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 5 Jun 2026 13:40:53 +0200 Subject: [PATCH] fix(compliance): compute medication streak in the user's timezone The analytics compliance streak keyed days with host-local getFullYear/getMonth/getDate against a timeline whose slot.day is minted in the user's IANA zone, so the streak drifted off by a day whenever the server clock's zone differed from the user's. Consolidate onto the detail-page streaksFromTimeline helper threaded with the medication's timezone so both call sites share one correct day-keyed walk. Also fix the DST step in that helper: it stepped a fixed +24h while its comment claimed 25h, which could skip a local day on the 23h spring-forward boundary and run the window short. Snap each cursor to the user-local midnight and advance +25h then re-snap, mirroring the cadence expander, so every local calendar day is visited exactly once across 23/24/25h boundaries. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../analytics/__tests__/compliance.test.ts | 38 ++++++++++ src/lib/analytics/compliance.ts | 46 +++++------- .../scheduling/__tests__/compliance.test.ts | 75 ++++++++++++++++++- src/lib/medications/scheduling/cadence.ts | 2 +- src/lib/medications/scheduling/compliance.ts | 55 +++++++++----- 5 files changed, 167 insertions(+), 49 deletions(-) diff --git a/src/lib/analytics/__tests__/compliance.test.ts b/src/lib/analytics/__tests__/compliance.test.ts index e94b7af4f..aa76b6b22 100644 --- a/src/lib/analytics/__tests__/compliance.test.ts +++ b/src/lib/analytics/__tests__/compliance.test.ts @@ -785,6 +785,44 @@ describe("calculateCompliance — engine-routed (medicationContext)", () => { expect(result.taken).toBe(2); expect(result.rate).toBe(100); }); + + it("streak is computed in the USER's timezone, not the host's", () => { + // The timeline `slot.day` is minted in the user's IANA zone. The + // streak walk must key days in the SAME zone — the prior host-tz + // `getFullYear/getMonth/getDate` walk drifted off by a day whenever + // the server clock's zone differed from the user's. Use a far-east + // user zone (UTC+13) so the user-local day for a near-UTC-midnight + // intake clearly differs from the host/UTC day; a daily-taken stream + // must still yield an unbroken streak. + const tz = "Pacific/Auckland"; + vi.setSystemTime(new Date("2025-06-10T11:00:00Z")); // ~23:00 NZST. + const schedules: ComplianceSchedule[] = [ + { + windowStart: "08:00", + windowEnd: "09:00", + daysOfWeek: null, + timesOfDay: ["08:00"], + }, + ]; + // Taken near 08:00 Auckland (= ~20:00 UTC previous day) for each of + // the past 7 user-local days. + const events = Array.from({ length: 7 }, (_, i) => { + const sched = new Date( + new Date("2025-06-09T20:00:00Z").getTime() - i * DAY_MS, + ); + return { scheduledFor: sched, takenAt: sched, skipped: false }; + }); + const result = calculateCompliance(events, schedules, 7, undefined, { + medicationContext: ctx({ + timeZone: tz, + createdAt: new Date("2025-05-01T00:00:00Z"), + }), + }); + // Every user-local day in the window was taken → a multi-day streak. + // The host-tz walk would have mis-keyed the boundary days and broken + // the streak early; the user-tz walk counts them all. + expect(result.streak).toBeGreaterThanOrEqual(6); + }); }); // ──────────────────────────────────────────────────────────────────── diff --git a/src/lib/analytics/compliance.ts b/src/lib/analytics/compliance.ts index ba45e05ad..ad530ee25 100644 --- a/src/lib/analytics/compliance.ts +++ b/src/lib/analytics/compliance.ts @@ -25,6 +25,7 @@ import { type IntakeEventLike, type ScheduleLike, } from "@/lib/medications/scheduling/cadence"; +import { streaksFromTimeline } from "@/lib/medications/scheduling/compliance"; import { occurrencesBetween, type CanonicalSchedule, @@ -694,33 +695,24 @@ export function calculateCompliance( // Streak: consecutive days, ending today, where every expected dose // was taken or skipped. Days with no expected dose advance the - // streak — out-of-cadence days are not failures. Walks from today - // backwards through the timeline grouped by local day. - const dayKey = (d: Date): string => { - const y = d.getFullYear(); - const m = String(d.getMonth() + 1).padStart(2, "0"); - const day = String(d.getDate()).padStart(2, "0"); - return `${y}-${m}-${day}`; - }; - const byDay = new Map(); - for (const slot of timeline) { - const key = dayKey(slot.day); - const existing = byDay.get(key); - if (slot.status === "missed") { - byDay.set(key, "bad"); - } else if (existing !== "bad") { - byDay.set(key, "all-good"); - } - } - let streak = 0; - for (let d = 0; d < effectiveDays; d++) { - const cursor = new Date(now.getTime() - d * DAY_MS); - if (medicationCreatedAt && cursor <= medicationCreatedAt) break; - const state = byDay.get(dayKey(cursor)); - if (state === "bad") break; - if (state === "all-good") streak++; - // No state = no expected dose that day → streak advances silently. - } + // streak — out-of-cadence days are not failures. Delegated to the + // shared `streaksFromTimeline` so the analytics streak and the + // detail-page chip streak agree on every dose AND so the day keys are + // computed in the USER's IANA timezone, not the host's. The prior + // host-tz `getFullYear/getMonth/getDate` walk drifted off by a day + // whenever the server clock's zone differed from the user's — the + // timeline `slot.day` is already minted in the user zone, so the + // walk has to match it. The window is `effectiveDays` ending at + // `now`, which starts no earlier than `effectiveStart` (= the later + // of the period start and the medication's creation) — so days + // before the medication existed are never iterated, preserving the + // old `cursor <= medicationCreatedAt` break by construction. + const { current: streak } = streaksFromTimeline( + timeline, + now, + effectiveDays, + engineCtx?.timeZone, + ); return { totalExpected, taken, skipped, missed, rate, streak }; } diff --git a/src/lib/medications/scheduling/__tests__/compliance.test.ts b/src/lib/medications/scheduling/__tests__/compliance.test.ts index cc8de1db1..0da08f17c 100644 --- a/src/lib/medications/scheduling/__tests__/compliance.test.ts +++ b/src/lib/medications/scheduling/__tests__/compliance.test.ts @@ -6,7 +6,7 @@ */ import { describe, expect, it } from "vitest"; -import { complianceChips } from "../compliance"; +import { complianceChips, streaksFromTimeline } from "../compliance"; import { buildCadenceTimeline } from "../cadence"; import type { CadenceEngineContext, @@ -357,3 +357,76 @@ describe("complianceChips — canonical-engine delegation (v1.7.0 SB-SCHED-2)", }); }); + +describe("streaksFromTimeline — DST-correct day walk", () => { + const DAILY_8AM: ScheduleLike = { + windowStart: "08:00", + windowEnd: "09:00", + daysOfWeek: null, + }; + + it("does not drop a day across a spring-forward (23h) boundary", () => { + // Europe/Berlin spring-forward is 2025-03-30 (23h-wide local day). + // Take every dose for a window spanning the transition; the streak + // must count every local calendar day, not skip the 23h day. The + // prior fixed +24h step landed past the 23h day and the windowDays + // iteration ran short. + const tz = "Europe/Berlin"; + // NOW = local noon on 2025-04-02 (CEST, UTC+2 → 10:00 UTC). + const NOW = new Date("2025-04-02T10:00:00Z"); + const windowDays = 7; // covers 2025-03-27 .. 2025-04-02 inclusive. + const events: IntakeEventLike[] = []; + for ( + let day = new Date("2025-03-27T06:30:00Z"); + day.getTime() <= NOW.getTime(); + day = new Date(day.getTime() + 24 * 60 * 60 * 1000) + ) { + events.push({ + scheduledFor: new Date(day), + takenAt: new Date(day.getTime() + 30 * 60_000), + skipped: false, + }); + } + const timeline = buildCadenceTimeline( + [DAILY_8AM], + events, + NOW, + windowDays, + undefined, + tz, + ); + const { current } = streaksFromTimeline(timeline, NOW, windowDays, tz); + // Every one of the 7 local days (including the 23h spring-forward + // day) was taken → an unbroken 7-day streak. + expect(current).toBe(7); + }); + + it("visits each local day once across a fall-back (25h) boundary", () => { + // Europe/Berlin fall-back is 2025-10-26 (25h-wide local day). + const tz = "Europe/Berlin"; + const NOW = new Date("2025-10-29T11:00:00Z"); // local noon CET. + const windowDays = 7; + const events: IntakeEventLike[] = []; + for ( + let day = new Date("2025-10-23T07:30:00Z"); + day.getTime() <= NOW.getTime(); + day = new Date(day.getTime() + 24 * 60 * 60 * 1000) + ) { + events.push({ + scheduledFor: new Date(day), + takenAt: new Date(day.getTime() + 30 * 60_000), + skipped: false, + }); + } + const timeline = buildCadenceTimeline( + [DAILY_8AM], + events, + NOW, + windowDays, + undefined, + tz, + ); + const { current } = streaksFromTimeline(timeline, NOW, windowDays, tz); + expect(current).toBe(7); + }); +}); diff --git a/src/lib/medications/scheduling/cadence.ts b/src/lib/medications/scheduling/cadence.ts index 39dc07989..1e5cb7ab3 100644 --- a/src/lib/medications/scheduling/cadence.ts +++ b/src/lib/medications/scheduling/cadence.ts @@ -274,7 +274,7 @@ function hhmmToMinutes(hhmm: string): number | null { } /** Snap a Date down to the user-local midnight. */ -function startOfLocalDay(d: Date, tz: string | undefined): Date { +export function startOfLocalDay(d: Date, tz: string | undefined): Date { const parts = wallClockInTz(d, tz); return instantInTz(parts.year, parts.month, parts.day, 0, 0, tz); } diff --git a/src/lib/medications/scheduling/compliance.ts b/src/lib/medications/scheduling/compliance.ts index 2e9ba7b2e..cb816f44c 100644 --- a/src/lib/medications/scheduling/compliance.ts +++ b/src/lib/medications/scheduling/compliance.ts @@ -21,6 +21,7 @@ import { buildCadenceTimeline, + startOfLocalDay, type CadenceEngineContext, type IntakeEventLike, type PairedDose, @@ -77,8 +78,13 @@ function localDayKey(d: Date, tz: string | undefined): string { * (inclusive of today). The streak does not reset on a day that had * no expected doses — the user gets credit for not breaking on * non-scheduled days. + * + * Exported so `src/lib/analytics/compliance.ts` can share this exact + * timezone-aware streak math instead of duplicating it with host-tz + * day keys — the two call sites stay byte-for-byte identical on the + * streak rule. */ -function streaksFromTimeline( +export function streaksFromTimeline( timeline: PairedDose[], asOf: Date, windowDays: number, @@ -100,26 +106,35 @@ function streaksFromTimeline( let longest = 0; let run = 0; - // Walk the window day-by-day from oldest to newest. Stepping by - // 25 h then re-keying via `localDayKey` keeps the cursor inside - // the right calendar day across DST boundaries — the absolute - // distance between two consecutive local midnights is 23/24/25 h - // around spring-forward / fall-back. - let cursor = new Date(asOf.getTime() - (windowDays - 1) * DAY_MS); - let lastKey = ""; - for (let i = 0; i < windowDays; i++) { - const key = localDayKey(cursor, timeZone); - if (key !== lastKey) { - const state = byDay.get(key); - if (state === "bad") { - if (run > longest) longest = run; - run = 0; - } else { - run++; - } - lastKey = key; + // Walk the window day-by-day from oldest to newest. Snap each cursor + // to the user-local midnight and advance by +25 h then re-snap — the + // distance between two consecutive local midnights is 23 / 24 / 25 h + // around spring-forward / fall-back, and +25 h always clears the + // shortest (23 h) day while the re-snap collapses the longest (25 h) + // day back to its own midnight. This visits every local calendar day + // in the window exactly once instead of the prior fixed +24 h step + // that could skip a day on a 23 h spring-forward boundary (its + // comment already claimed 25 h while the code stepped 24 h). + const startDay = startOfLocalDay( + new Date(asOf.getTime() - (windowDays - 1) * DAY_MS), + timeZone, + ); + const endDay = startOfLocalDay(asOf, timeZone); + for ( + let cursor = startDay; + cursor.getTime() <= endDay.getTime(); + cursor = startOfLocalDay( + new Date(cursor.getTime() + 25 * 60 * 60 * 1000), + timeZone, + ) + ) { + const state = byDay.get(localDayKey(cursor, timeZone)); + if (state === "bad") { + if (run > longest) longest = run; + run = 0; + } else { + run++; } - cursor = new Date(cursor.getTime() + DAY_MS); } if (run > longest) longest = run; current = run;