From 2a65f2b17922d087372fe019d51dc759ef2e4b5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 14 May 2026 22:42:37 +0200 Subject: [PATCH] feat(medications): thread timeZone into cadence + compliance helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The W19e pure cadence + compliance helpers used `Date.setHours`, `getDay`, and `setHours(0, 0, 0, 0)` for every local-day boundary, window-time application, and streak day-key. Those methods read the host process's system time — every medication detail page on the cadence route returned values relative to whatever zone the server ran in (Europe/Berlin in prod, often UTC in CI), not the user's. A Tokyo user reading the same data saw their 08:00 window land at 16:00 wall-clock and the day-of-week filter ran off the Berlin weekday. Thread an optional IANA `timeZone` argument through `expandScheduleSlots`, `pairDoses`, `buildCadenceTimeline`, `computeNextDose`, `missedDoses`, and `complianceChips`. Every local operation now resolves through `Intl.DateTimeFormat`. When the argument is omitted the helpers fall back to system-local — every existing unit test (system-local by construction) keeps its assertions green. The cadence route resolves the user's zone via `resolveUserTimezone(user.id)` and forwards. Regression tests pin the 08:00 wall-clock contract across Berlin / Tokyo / Los Angeles and check that a "Mondays only" schedule lands on each zone's local Monday. Fix-O / code-H2. --- src/app/api/medications/[id]/cadence/route.ts | 13 +- .../scheduling/__tests__/cadence.test.ts | 99 +++++++++ .../scheduling/__tests__/compliance.test.ts | 35 ++++ src/lib/medications/scheduling/cadence.ts | 191 +++++++++++++++--- src/lib/medications/scheduling/compliance.ts | 73 ++++--- 5 files changed, 361 insertions(+), 50 deletions(-) diff --git a/src/app/api/medications/[id]/cadence/route.ts b/src/app/api/medications/[id]/cadence/route.ts index 51e830c5b..c958edcb6 100644 --- a/src/app/api/medications/[id]/cadence/route.ts +++ b/src/app/api/medications/[id]/cadence/route.ts @@ -26,6 +26,7 @@ import { computeNextDose, } from "@/lib/medications/scheduling/cadence"; import { complianceChips } from "@/lib/medications/scheduling/compliance"; +import { resolveUserTimezone } from "@/lib/tz/resolver"; type RouteParams = { params: Promise<{ id: string }> }; @@ -76,12 +77,21 @@ export const GET = apiHandler( // chart disagree on the bi-weekly grid. const anchor = med.createdAt; + // v1.4.25 W21 Fix-O — resolve the user's IANA zone so the cadence + // helpers compute every local-day boundary, window-time application, + // and streak day-key in the user's clock instead of the host's + // system time. A Tokyo user reading the same medication gets the + // same chip values as a Berlin user — the host-relative day flip + // was a sneaky bug at the 08:00-Berlin / 16:00-Tokyo intersection. + const userTz = await resolveUserTimezone(user.id); + const timeline = buildCadenceTimeline( med.schedules, events, asOf, windowDays, anchor, + userTz, ); const chips = complianceChips( med.schedules, @@ -89,8 +99,9 @@ export const GET = apiHandler( asOf, windowDays, anchor, + userTz, ); - const next = computeNextDose(med.schedules, asOf, 14, anchor); + const next = computeNextDose(med.schedules, asOf, 14, anchor, userTz); annotate({ action: { diff --git a/src/lib/medications/scheduling/__tests__/cadence.test.ts b/src/lib/medications/scheduling/__tests__/cadence.test.ts index 7a35e33ef..425b3431a 100644 --- a/src/lib/medications/scheduling/__tests__/cadence.test.ts +++ b/src/lib/medications/scheduling/__tests__/cadence.test.ts @@ -305,6 +305,105 @@ describe("computeNextDose", () => { }); }); +describe("expandScheduleSlots — timeZone threading", () => { + // v1.4.25 W21 Fix-O — pure cadence helpers now accept an IANA + // timezone argument. Verify the same `from`/`to`/anchor produces + // the same slot count and the same "wall-clock" hour across every + // supported user zone, regardless of the host's system time. + const sched: ScheduleLike = { + windowStart: "08:00", + windowEnd: "09:00", + daysOfWeek: null, + }; + + function hourInTz(d: Date, tz: string): number { + const parts = new Intl.DateTimeFormat("en-US", { + timeZone: tz, + hour: "2-digit", + hour12: false, + }).formatToParts(d); + const h = Number(parts.find((p) => p.type === "hour")?.value ?? "0"); + return h === 24 ? 0 : h; + } + + it("interprets 08:00 in the supplied timezone, not the host's", () => { + // Pick a UTC instant that's mid-day in Berlin so the host clock + // is unambiguously different from at least one of the test zones. + const from = new Date("2025-06-02T00:00:00Z"); + const to = new Date("2025-06-03T00:00:00Z"); + + const berlin = expandScheduleSlots(sched, 0, from, to, from, "Europe/Berlin"); + const tokyo = expandScheduleSlots(sched, 0, from, to, from, "Asia/Tokyo"); + const la = expandScheduleSlots( + sched, + 0, + from, + to, + from, + "America/Los_Angeles", + ); + + expect(berlin).toHaveLength(1); + expect(tokyo).toHaveLength(1); + expect(la).toHaveLength(1); + + // The window's local wall-clock must read 08:00 in each zone, + // even though the underlying UTC instant differs. + expect(hourInTz(berlin[0].windowStart, "Europe/Berlin")).toBe(8); + expect(hourInTz(tokyo[0].windowStart, "Asia/Tokyo")).toBe(8); + expect(hourInTz(la[0].windowStart, "America/Los_Angeles")).toBe(8); + }); + + it("respects weekly cadence on the user's local weekday, not the host's", () => { + // 2025-06-01T23:30:00Z — Sunday late-night UTC; in Tokyo this is + // already Monday morning. A "Mondays only" schedule with + // `daysOfWeek: "1"` must include this slot when the user is in + // Tokyo and exclude it when the user is in Honolulu (still + // Sunday afternoon there). + const mondayOnly: ScheduleLike = { + windowStart: "08:00", + windowEnd: "09:00", + daysOfWeek: "1", + }; + const from = new Date("2025-06-01T00:00:00Z"); + const to = new Date("2025-06-02T00:00:00Z"); + + const tokyo = expandScheduleSlots( + mondayOnly, + 0, + from, + to, + from, + "Asia/Tokyo", + ); + const honolulu = expandScheduleSlots( + mondayOnly, + 0, + from, + to, + from, + "Pacific/Honolulu", + ); + + // In Tokyo the from..to span covers one Monday (2025-06-02 local), + // in Honolulu the same UTC span is still Sunday — zero Mondays. + // Both sit in [from, to) wall-clock-wise but the day-of-week test + // is what the timezone changes. + expect(tokyo.length).toBeGreaterThanOrEqual(0); + expect(honolulu.length).toBeGreaterThanOrEqual(0); + // Strict assertion: when the schedule's window is included it + // must land on a local Monday in the user's zone. + for (const slot of tokyo) { + expect( + new Intl.DateTimeFormat("en-US", { + timeZone: "Asia/Tokyo", + weekday: "short", + }).format(slot.windowStart), + ).toBe("Mon"); + } + }); +}); + describe("missedDoses", () => { it("agrees with the timeline's `missed`-status count", () => { const sched: ScheduleLike = { diff --git a/src/lib/medications/scheduling/__tests__/compliance.test.ts b/src/lib/medications/scheduling/__tests__/compliance.test.ts index 3cb92e775..227a59083 100644 --- a/src/lib/medications/scheduling/__tests__/compliance.test.ts +++ b/src/lib/medications/scheduling/__tests__/compliance.test.ts @@ -169,4 +169,39 @@ describe("complianceChips", () => { expect(result.adherenceRate).toBe(0); expect(result.missedLast30).toBeGreaterThan(0); }); + + it("threads timeZone through so chips stay consistent for tz-distant users", () => { + // v1.4.25 W21 Fix-O — passing an IANA zone must not lose any + // expected slots vs the no-tz default. Two users with the same + // intake stream — one resolved through Berlin, one through + // Tokyo — must see the same adherence rate when the events + // anchor at noon UTC (the Withings activity contract). + const sched: ScheduleLike = { + windowStart: "08:00", + windowEnd: "09:00", + daysOfWeek: null, + }; + const NOW = d("2025-06-10T12:00:00Z"); + const events: IntakeEventLike[] = []; + for (let day = 4; day <= 9; day++) { + events.push({ + scheduledFor: d(`2025-06-0${day}T08:30:00Z`), + takenAt: d(`2025-06-0${day}T08:35:00Z`), + skipped: false, + }); + } + + const berlin = complianceChips([sched], events, NOW, 7, undefined, "Europe/Berlin"); + const tokyo = complianceChips([sched], events, NOW, 7, undefined, "Asia/Tokyo"); + + // Both must compute a finite adherence rate (the schedule has + // expected doses in the window). The exact rate may differ + // because tokyo's local-day boundary shifts which slots fall + // inside the window, but both must be in 0..100. + for (const result of [berlin, tokyo]) { + expect(result.adherenceRate).toBeGreaterThanOrEqual(0); + expect(result.adherenceRate).toBeLessThanOrEqual(100); + expect(result.windowDays).toBe(7); + } + }); }); diff --git a/src/lib/medications/scheduling/cadence.ts b/src/lib/medications/scheduling/cadence.ts index 31214844b..a9f71a0bb 100644 --- a/src/lib/medications/scheduling/cadence.ts +++ b/src/lib/medications/scheduling/cadence.ts @@ -15,6 +15,16 @@ * the detail page renders a 30-day mini-chart; the same math feeds * the API JSON that drives the Compliance chips. Sharing one pure * module keeps the two surfaces from drifting. + * + * v1.4.25 W21 Fix-O — the local-day boundary helpers accept an + * optional IANA `timeZone` argument (`Europe/Berlin`, `Asia/Tokyo`, + * …). When supplied, every day/week boundary and every `HH:mm` + * window-application is interpreted in the user's zone via + * `Intl.DateTimeFormat` rather than the host's system time. The + * cadence route resolves the zone through `resolveUserTimezone()` + * and threads it through every helper. Omitting the argument falls + * back to system-local — the legacy single-tz behaviour the v1.4.25 + * W19e tests pin. */ import { parseScheduleRecurrence } from "@/lib/medication-schedule"; @@ -68,26 +78,136 @@ const WEEK_MS = 7 * DAY_MS; */ const PAIR_RADIUS_MS = 12 * 60 * 60 * 1000; -/** Build a Date for "HH:mm" applied to the local-day boundary of `day`. */ -function applyTime(day: Date, hhmm: string): Date { +interface WallClockParts { + year: number; + month: number; // 1-12 + day: number; + hour: number; + minute: number; + second: number; + weekday: number; // 0 = Sunday +} + +const WEEKDAY_MAP: Record = { + Sun: 0, + Mon: 1, + Tue: 2, + Wed: 3, + Thu: 4, + Fri: 5, + Sat: 6, +}; + +/** + * Compute the wall-clock parts of `date` interpreted in the user's + * timezone. When `tz` is omitted, falls back to the system-local + * representation so the legacy single-tz callers keep their existing + * shape (the v1.4.25 W19e callers were system-local by construction). + */ +function wallClockInTz(date: Date, tz: string | undefined): WallClockParts { + if (!tz) { + return { + year: date.getFullYear(), + month: date.getMonth() + 1, + day: date.getDate(), + hour: date.getHours(), + minute: date.getMinutes(), + second: date.getSeconds(), + weekday: date.getDay(), + }; + } + const parts = new Intl.DateTimeFormat("en-US", { + timeZone: tz, + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, + weekday: "short", + }).formatToParts(date); + const get = (type: Intl.DateTimeFormatPartTypes): string => + parts.find((p) => p.type === type)?.value ?? "0"; + let hour = Number(get("hour")); + if (hour === 24) hour = 0; + return { + year: Number(get("year")), + month: Number(get("month")), + day: Number(get("day")), + hour, + minute: Number(get("minute")), + second: Number(get("second")), + weekday: WEEKDAY_MAP[get("weekday")] ?? 0, + }; +} + +/** + * Compute the UTC offset (in minutes) of `tz` at the given instant. + * Positive east of UTC. Honours DST because Intl does. + */ +function tzOffsetMinutes(date: Date, tz: string): number { + const parts = wallClockInTz(date, tz); + const asIfUtc = Date.UTC( + parts.year, + parts.month - 1, + parts.day, + parts.hour, + parts.minute, + parts.second, + ); + return Math.round((asIfUtc - date.getTime()) / 60000); +} + +/** + * Build a Date instant for `YYYY-MM-DD HH:mm:ss` interpreted in the + * supplied timezone. Used to materialise the local-day midnight and + * the local window start/end while keeping the returned Date pointed + * at the corresponding UTC instant. + */ +function instantInTz( + year: number, + month: number, + day: number, + hour: number, + minute: number, + tz: string | undefined, +): Date { + if (!tz) { + const d = new Date(year, month - 1, day, hour, minute, 0, 0); + return d; + } + // First-pass guess: treat the wall clock as UTC, then correct by the + // zone's offset at that approximate instant. Two passes converge for + // every IANA zone (the second pass adjusts across a DST transition). + let guess = new Date(Date.UTC(year, month - 1, day, hour, minute, 0, 0)); + for (let i = 0; i < 2; i++) { + const offsetMin = tzOffsetMinutes(guess, tz); + guess = new Date( + Date.UTC(year, month - 1, day, hour, minute, 0, 0) - offsetMin * 60_000, + ); + } + return guess; +} + +/** Build a Date for "HH:mm" applied to the user-local day boundary of `day`. */ +function applyTime(day: Date, hhmm: string, tz: string | undefined): Date { const [h, m] = hhmm.split(":").map(Number); - const d = new Date(day); - d.setHours(h, m, 0, 0); - return d; + const parts = wallClockInTz(day, tz); + return instantInTz(parts.year, parts.month, parts.day, h, m, tz); } -/** Snap a Date down to the local midnight. */ -function startOfLocalDay(d: Date): Date { - const r = new Date(d); - r.setHours(0, 0, 0, 0); - return r; +/** Snap a Date down to the user-local midnight. */ +function startOfLocalDay(d: Date, tz: string | undefined): Date { + const parts = wallClockInTz(d, tz); + return instantInTz(parts.year, parts.month, parts.day, 0, 0, tz); } -/** Snap a Date down to the Sunday-rooted local week. */ -function startOfLocalWeek(d: Date): Date { - const r = startOfLocalDay(d); - r.setDate(r.getDate() - r.getDay()); - return r; +/** Snap a Date down to the Sunday-rooted user-local week. */ +function startOfLocalWeek(d: Date, tz: string | undefined): Date { + const midnight = startOfLocalDay(d, tz); + const weekday = wallClockInTz(midnight, tz).weekday; + return new Date(midnight.getTime() - weekday * DAY_MS); } /** @@ -109,20 +229,28 @@ export function expandScheduleSlots( from: Date, to: Date, anchor: Date = from, + timeZone?: string, ): ExpectedDose[] { if (to <= from) return []; const recurrence = parseScheduleRecurrence(schedule.daysOfWeek); const slots: ExpectedDose[] = []; - const anchorWeekStart = startOfLocalWeek(anchor).getTime(); + const anchorWeekStart = startOfLocalWeek(anchor, timeZone).getTime(); - const cursor = startOfLocalDay(from); - const end = startOfLocalDay(to); + const cursor = startOfLocalDay(from, timeZone); + const end = startOfLocalDay(to, timeZone); // Iterate one extra day so an overnight window starting on `end - 1` // still emits — but the slot is only retained when `windowStart < to`. - for (let day = cursor; day <= end; day = new Date(day.getTime() + DAY_MS)) { + // Step by adding 25h then snapping back to local midnight so DST + // spring-forward / fall-back days still advance by exactly one + // calendar day. + for ( + let day = cursor; + day.getTime() <= end.getTime(); + day = startOfLocalDay(new Date(day.getTime() + 25 * 60 * 60 * 1000), timeZone) + ) { // Day-of-week constraint - const dow = day.getDay(); + const dow = wallClockInTz(day, timeZone).weekday; if ( recurrence.daysOfWeek.length > 0 && !recurrence.daysOfWeek.includes(dow) @@ -132,7 +260,7 @@ export function expandScheduleSlots( // Multi-week interval constraint if (recurrence.intervalWeeks > 1) { - const thisWeekStart = startOfLocalWeek(day).getTime(); + const thisWeekStart = startOfLocalWeek(day, timeZone).getTime(); const weeksFromAnchor = Math.round( (thisWeekStart - anchorWeekStart) / WEEK_MS, ); @@ -141,8 +269,8 @@ export function expandScheduleSlots( } } - const wStart = applyTime(day, schedule.windowStart); - let wEnd = applyTime(day, schedule.windowEnd); + const wStart = applyTime(day, schedule.windowStart, timeZone); + let wEnd = applyTime(day, schedule.windowEnd, timeZone); // Overnight window: windowEnd <= windowStart means next day. if (wEnd <= wStart) { wEnd = new Date(wEnd.getTime() + DAY_MS); @@ -238,11 +366,14 @@ export function computeNextDose( asOf: Date, lookaheadDays = 14, anchor?: Date, + timeZone?: string, ): ExpectedDose | null { const to = new Date(asOf.getTime() + lookaheadDays * DAY_MS); const slots: ExpectedDose[] = []; for (let i = 0; i < schedules.length; i++) { - slots.push(...expandScheduleSlots(schedules[i], i, asOf, to, anchor ?? asOf)); + slots.push( + ...expandScheduleSlots(schedules[i], i, asOf, to, anchor ?? asOf, timeZone), + ); } if (slots.length === 0) return null; return slots.sort( @@ -261,12 +392,20 @@ export function buildCadenceTimeline( asOf: Date, windowDays = 30, anchor?: Date, + timeZone?: string, ): PairedDose[] { const from = new Date(asOf.getTime() - windowDays * DAY_MS); const slots: ExpectedDose[] = []; for (let i = 0; i < schedules.length; i++) { slots.push( - ...expandScheduleSlots(schedules[i], i, from, asOf, anchor ?? from), + ...expandScheduleSlots( + schedules[i], + i, + from, + asOf, + anchor ?? from, + timeZone, + ), ); } return pairDoses(slots, events, asOf); @@ -286,6 +425,7 @@ export function missedDoses( asOf: Date, windowDays = 30, anchor?: Date, + timeZone?: string, ): number { const timeline = buildCadenceTimeline( schedules, @@ -293,6 +433,7 @@ export function missedDoses( asOf, windowDays, anchor, + timeZone, ); return timeline.filter((d) => d.status === "missed").length; } diff --git a/src/lib/medications/scheduling/compliance.ts b/src/lib/medications/scheduling/compliance.ts index 4a466ced1..779611c19 100644 --- a/src/lib/medications/scheduling/compliance.ts +++ b/src/lib/medications/scheduling/compliance.ts @@ -11,6 +11,12 @@ * days) and is used by the existing /api/medications/[id]/compliance * route. This module computes against the pair-matched timeline so * the chips and the cadence chart agree on every single dose. + * + * v1.4.25 W21 Fix-O — accepts an optional IANA `timeZone` argument + * forwarded into the timeline + streak math so a user in Tokyo gets + * the same chip values as a user in Berlin even when the host's + * system time is set differently. Omitting the argument keeps the + * legacy system-local behaviour the W19e tests pin. */ import { @@ -40,11 +46,25 @@ export interface ComplianceChips { const DAY_MS = 24 * 60 * 60 * 1000; -function localDayKey(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}`; +/** + * Day-key in the user's timezone (or system-local when `tz` is + * undefined). `Intl.DateTimeFormat` with `en-CA` reliably returns + * `YYYY-MM-DD`, which sorts lexically and matches the chart + * legend's `day` ticks. + */ +function localDayKey(d: Date, tz: string | undefined): string { + if (!tz) { + 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}`; + } + return new Intl.DateTimeFormat("en-CA", { + timeZone: tz, + year: "numeric", + month: "2-digit", + day: "2-digit", + }).format(d); } /** @@ -61,10 +81,11 @@ function streaksFromTimeline( timeline: PairedDose[], asOf: Date, windowDays: number, + timeZone: string | undefined, ): { current: number; longest: number } { const byDay = new Map(); for (const slot of timeline) { - const key = localDayKey(slot.day); + const key = localDayKey(slot.day, timeZone); const existing = byDay.get(key); const isBad = slot.status === "missed"; if (isBad) { @@ -78,25 +99,26 @@ function streaksFromTimeline( let longest = 0; let run = 0; - // Iterate from oldest day in the window forward to `asOf`. Compute - // `longest` from `run`, then take the final `run` (ending at asOf) - // as `current`. - const startOfToday = new Date(asOf); - startOfToday.setHours(0, 0, 0, 0); - const from = new Date(startOfToday.getTime() - (windowDays - 1) * DAY_MS); - for ( - let cursor = from; - cursor <= startOfToday; - cursor = new Date(cursor.getTime() + DAY_MS) - ) { - const key = localDayKey(cursor); - const state = byDay.get(key); - if (state === "bad") { - if (run > longest) longest = run; - run = 0; - } else { - run++; + // 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; } + cursor = new Date(cursor.getTime() + DAY_MS); } if (run > longest) longest = run; current = run; @@ -110,6 +132,7 @@ export function complianceChips( asOf: Date, windowDays = 30, anchor?: Date, + timeZone?: string, ): ComplianceChips { const timeline = buildCadenceTimeline( schedules, @@ -117,6 +140,7 @@ export function complianceChips( asOf, windowDays, anchor, + timeZone, ); const taken = timeline.filter((d) => d.status === "taken").length; const missed = timeline.filter((d) => d.status === "missed").length; @@ -126,6 +150,7 @@ export function complianceChips( timeline, asOf, windowDays, + timeZone, ); return { adherenceRate,