From d91eb905fe1696517870278b2bd920046b8edf27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 20 Oct 2026 09:58:03 +0200 Subject: [PATCH] refactor(compliance): require the zone a dose window is read in The UTC reading of a window's HH:mm was right only for a UTC user, and the one production caller always passes the zone. --- .../compliance/__tests__/compliance.test.ts | 106 ++++++++++++------ src/lib/analytics/compliance/parsing.ts | 32 ++---- 2 files changed, 80 insertions(+), 58 deletions(-) diff --git a/src/lib/analytics/compliance/__tests__/compliance.test.ts b/src/lib/analytics/compliance/__tests__/compliance.test.ts index a2b355166..8ca4f82c6 100644 --- a/src/lib/analytics/compliance/__tests__/compliance.test.ts +++ b/src/lib/analytics/compliance/__tests__/compliance.test.ts @@ -1229,9 +1229,11 @@ describe("classifyIntakeTiming", () => { const scheduledDate = new Date("2025-01-15T00:00:00Z"); it('returns "missed" when takenAt is null', () => { - expect(classifyIntakeTiming(null, "08:00", "09:00", scheduledDate)).toBe( - "missed", - ); + expect( + classifyIntakeTiming(null, "08:00", "09:00", scheduledDate, { + tz: "UTC", + }), + ).toBe("missed"); }); // A degenerate POINT window (`windowStart === windowEnd`) is a real shape on @@ -1243,14 +1245,18 @@ describe("classifyIntakeTiming", () => { it('keeps a dose at the point itself "on_time"', () => { const takenAt = new Date("2025-01-15T07:00:00Z"); expect( - classifyIntakeTiming(takenAt, "07:00", "07:00", scheduledDate), + classifyIntakeTiming(takenAt, "07:00", "07:00", scheduledDate, { + tz: "UTC", + }), ).toBe("on_time"); }); it('does not classify a dose ~16h late as "on_time"', () => { const takenAt = new Date("2025-01-15T23:00:00Z"); expect( - classifyIntakeTiming(takenAt, "07:00", "07:00", scheduledDate), + classifyIntakeTiming(takenAt, "07:00", "07:00", scheduledDate, { + tz: "UTC", + }), ).toBe("very_late"); }); @@ -1258,7 +1264,9 @@ describe("classifyIntakeTiming", () => { // 13:00 is 6h past the point — outside the widened band + graces. const takenAt = new Date("2025-01-15T13:00:00Z"); expect( - classifyIntakeTiming(takenAt, "07:00", "07:00", scheduledDate), + classifyIntakeTiming(takenAt, "07:00", "07:00", scheduledDate, { + tz: "UTC", + }), ).not.toBe("on_time"); }); @@ -1266,30 +1274,38 @@ describe("classifyIntakeTiming", () => { // 00:30 the next day sits inside a 23:00 -> 01:00 window. const takenAt = new Date("2025-01-16T00:30:00Z"); expect( - classifyIntakeTiming(takenAt, "23:00", "01:00", scheduledDate), + classifyIntakeTiming(takenAt, "23:00", "01:00", scheduledDate, { + tz: "UTC", + }), ).toBe("on_time"); }); }); it('returns "on_time" when taken within the window', () => { const takenAt = new Date("2025-01-15T08:30:00Z"); - expect(classifyIntakeTiming(takenAt, "08:00", "09:00", scheduledDate)).toBe( - "on_time", - ); + expect( + classifyIntakeTiming(takenAt, "08:00", "09:00", scheduledDate, { + tz: "UTC", + }), + ).toBe("on_time"); }); it('returns "on_time" when taken exactly at windowStart', () => { const takenAt = new Date("2025-01-15T08:00:00Z"); - expect(classifyIntakeTiming(takenAt, "08:00", "09:00", scheduledDate)).toBe( - "on_time", - ); + expect( + classifyIntakeTiming(takenAt, "08:00", "09:00", scheduledDate, { + tz: "UTC", + }), + ).toBe("on_time"); }); it('returns "on_time" when taken exactly at windowEnd', () => { const takenAt = new Date("2025-01-15T09:00:00Z"); - expect(classifyIntakeTiming(takenAt, "08:00", "09:00", scheduledDate)).toBe( - "on_time", - ); + expect( + classifyIntakeTiming(takenAt, "08:00", "09:00", scheduledDate, { + tz: "UTC", + }), + ).toBe("on_time"); }); // Parameterised offset matrix relative to a `08:00 → 09:00` window. @@ -1319,16 +1335,20 @@ describe("classifyIntakeTiming", () => { // base instant is 08:00Z; positive offsets walk forward from there. const baseMs = new Date("2025-01-15T08:00:00Z").getTime(); const takenAt = new Date(baseMs + offsetMin * 60 * 1000); - expect(classifyIntakeTiming(takenAt, "08:00", "09:00", scheduledDate)).toBe( - expected, - ); + expect( + classifyIntakeTiming(takenAt, "08:00", "09:00", scheduledDate, { + tz: "UTC", + }), + ).toBe(expected); }); it('returns "very_late" when taken way before the grace period', () => { const takenAt = new Date("2025-01-15T03:00:00Z"); // 5h before 08:00 - expect(classifyIntakeTiming(takenAt, "08:00", "09:00", scheduledDate)).toBe( - "very_late", - ); + expect( + classifyIntakeTiming(takenAt, "08:00", "09:00", scheduledDate, { + tz: "UTC", + }), + ).toBe("very_late"); }); it("respects the configurable `lateMinutes` tail", () => { @@ -1339,6 +1359,7 @@ describe("classifyIntakeTiming", () => { const takenLate = new Date("2025-01-15T12:15:00Z"); expect( classifyIntakeTiming(takenLate, "08:00", "09:00", scheduledDate, { + tz: "UTC", lateMinutes: 30, }), ).toBe("late"); @@ -1346,6 +1367,7 @@ describe("classifyIntakeTiming", () => { const takenVeryLate = new Date("2025-01-15T12:45:00Z"); expect( classifyIntakeTiming(takenVeryLate, "08:00", "09:00", scheduledDate, { + tz: "UTC", lateMinutes: 30, }), ).toBe("very_late"); @@ -1354,33 +1376,41 @@ describe("classifyIntakeTiming", () => { it("handles overnight windows (windowEnd < windowStart)", () => { // Schedule: 23:00 - 01:00 (overnight) const takenAt = new Date("2025-01-15T23:30:00Z"); - expect(classifyIntakeTiming(takenAt, "23:00", "01:00", scheduledDate)).toBe( - "on_time", - ); + expect( + classifyIntakeTiming(takenAt, "23:00", "01:00", scheduledDate, { + tz: "UTC", + }), + ).toBe("on_time"); }); it("handles overnight window early intake", () => { // Schedule: 23:00 - 01:00, taken at 22:30 (30 min before windowStart) const takenAt = new Date("2025-01-15T22:30:00Z"); - expect(classifyIntakeTiming(takenAt, "23:00", "01:00", scheduledDate)).toBe( - "early", - ); + expect( + classifyIntakeTiming(takenAt, "23:00", "01:00", scheduledDate, { + tz: "UTC", + }), + ).toBe("early"); }); it("handles overnight window late intake", () => { // Schedule: 23:00 - 01:00, taken at 04:30 (3.5h past 01:00) const takenAt = new Date("2025-01-16T04:30:00Z"); - expect(classifyIntakeTiming(takenAt, "23:00", "01:00", scheduledDate)).toBe( - "late", - ); + expect( + classifyIntakeTiming(takenAt, "23:00", "01:00", scheduledDate, { + tz: "UTC", + }), + ).toBe("late"); }); it("handles overnight window very late intake", () => { // Schedule: 23:00 - 01:00, taken at 07:00 (6h past 01:00) const takenAt = new Date("2025-01-16T07:00:00Z"); - expect(classifyIntakeTiming(takenAt, "23:00", "01:00", scheduledDate)).toBe( - "very_late", - ); + expect( + classifyIntakeTiming(takenAt, "23:00", "01:00", scheduledDate, { + tz: "UTC", + }), + ).toBe("very_late"); }); it("interprets the window HH:mm in the supplied tz (non-UTC user)", () => { @@ -1400,9 +1430,11 @@ describe("classifyIntakeTiming", () => { // Without a tz the window HH:mm is read as UTC (08:00 UTC), so the very // same on-time dose reads as 7h15m off — very_late. This is the defect // the achievements perfect-day check hit for users far from UTC. - expect(classifyIntakeTiming(takenAt, "08:00", "09:00", slotInstant)).toBe( - "very_late", - ); + expect( + classifyIntakeTiming(takenAt, "08:00", "09:00", slotInstant, { + tz: "UTC", + }), + ).toBe("very_late"); }); }); diff --git a/src/lib/analytics/compliance/parsing.ts b/src/lib/analytics/compliance/parsing.ts index f863f6973..0c7b7d580 100644 --- a/src/lib/analytics/compliance/parsing.ts +++ b/src/lib/analytics/compliance/parsing.ts @@ -16,22 +16,12 @@ function parseHHmm(time: string): { hours: number; minutes: number } { } /** - * Build the UTC instant of local "HH:mm" on the calendar day `day` falls on. - * - * When `tz` is supplied the window `HH:mm` is interpreted in the user's zone - * (via {@link localHmAsUtc}) on the local day of `day` — so an "08:00" slot is - * 08:00 LOCAL, not 08:00 UTC. Without `tz` it keeps the legacy UTC anchoring - * (`setUTCHours`), which is only correct for a UTC user; a caller far from UTC - * must pass its zone or the punctuality band is offset by the zone's offset. + * Build the UTC instant of local "HH:mm" in `tz` on the local day of `day` + * (via {@link localHmAsUtc}), so an "08:00" slot is 08:00 LOCAL. */ -function toDateOnDay(time: string, day: Date, tz?: string): Date { +function toDateOnDay(time: string, day: Date, tz: string): Date { const { hours, minutes } = parseHHmm(time); - if (tz) { - return localHmAsUtc(day, tz, hours, minutes); - } - const d = new Date(day); - d.setUTCHours(hours, minutes, 0, 0); - return d; + return localHmAsUtc(day, tz, hours, minutes); } /** @@ -56,21 +46,21 @@ function toDateOnDay(time: string, day: Date, tz?: string): Date { * @param options.lateMinutes Width of the `late` band beyond the 3-hour * on-time tolerance, in minutes. Defaults to 120. Doses past this * tail land in `very_late`. - * @param options.tz IANA zone the window `HH:mm` is interpreted in. Supply - * the user's zone so an "08:00" slot means 08:00 LOCAL; omitting it keeps - * the legacy UTC interpretation (correct only for a UTC user). + * @param options.tz IANA zone the window `HH:mm` is interpreted in, so an + * "08:00" slot means 08:00 LOCAL. Required: a UTC fallback was right only + * for a UTC user and no caller relied on it. */ export function classifyIntakeTiming( takenAt: Date | null, windowStart: string, // "HH:mm" windowEnd: string, // "HH:mm" scheduledDate: Date, // the date this was scheduled - options?: { lateMinutes?: number; tz?: string }, + options: { lateMinutes?: number; tz: string }, ): IntakeTimingClass { if (takenAt === null) return "missed"; - let start = toDateOnDay(windowStart, scheduledDate, options?.tz); - let end = toDateOnDay(windowEnd, scheduledDate, options?.tz); + let start = toDateOnDay(windowStart, scheduledDate, options.tz); + let end = toDateOnDay(windowEnd, scheduledDate, options.tz); // A degenerate POINT window (`windowStart === windowEnd` — a single dose time // echoed into both fields, a real shape on legacy rows) used to fall into the @@ -94,7 +84,7 @@ export function classifyIntakeTiming( // 3h post-window grace: doses up to here are still `on_time`. const onTimeEnd = new Date(end.getTime() + 3 * HOUR_MS); // Configurable `late` tail past the on-time grace (default 120 min). - const lateTolerance = (options?.lateMinutes ?? 120) * 60 * 1000; + const lateTolerance = (options.lateMinutes ?? 120) * 60 * 1000; const lateEnd = new Date(onTimeEnd.getTime() + lateTolerance); const t = takenAt.getTime();