diff --git a/src/components/dashboard/medication-intake-quick-add.tsx b/src/components/dashboard/medication-intake-quick-add.tsx index e6d5b7ee7..892455f32 100644 --- a/src/components/dashboard/medication-intake-quick-add.tsx +++ b/src/components/dashboard/medication-intake-quick-add.tsx @@ -25,7 +25,6 @@ import { } from "@/lib/query-keys"; import { reduceCurrentWindowStatus, - toZonedDate, type ScheduleWindowInput, } from "@/lib/medications/window-status"; import { useAuth } from "@/hooks/use-auth"; @@ -142,7 +141,6 @@ export function pickDefaultMedicationId( if (actives.length === 0) return null; if (actives.length === 1) return actives[0].id; - const nowLocal = toZonedDate(now, tz); const due = actives.find((m) => { // v1.16.9 — the same server display-due gate the cards apply: a // future (non-overdue) next-due suppresses the overdue tiers and a @@ -150,7 +148,7 @@ export function pickDefaultMedicationId( const nextDueMs = m.nextDueAt ? new Date(m.nextDueAt).getTime() : NaN; const status = reduceCurrentWindowStatus({ schedules: m.schedules, - nowBerlin: nowLocal, + now, lateMinutes: thresholds.lateMinutes, missedMinutes: thresholds.missedMinutes, active: true, diff --git a/src/components/medications/card-parts/displayed-slot-instant.ts b/src/components/medications/card-parts/displayed-slot-instant.ts index 667ad028f..62eedae0d 100644 --- a/src/components/medications/card-parts/displayed-slot-instant.ts +++ b/src/components/medications/card-parts/displayed-slot-instant.ts @@ -54,10 +54,10 @@ export interface ResolveDisplayedSlotInstantInput { /** Wall-clock reference (injected for testability). */ now: Date; /** - * IANA timezone the card's day boundary is reasoned in. The cards model - * the user's day in Europe/Berlin (see `toBerlinDate`), so this defaults - * to that; passing the user's actual zone keeps the slot on the right - * calendar day across the rare cross-midnight edge. + * IANA timezone the card's day boundary is reasoned in. Berlin is the + * last-resort fallback the card surfaces share; passing the user's + * actual zone keeps the slot on the right calendar day across the rare + * cross-midnight edge. */ timeZone?: string; } diff --git a/src/components/medications/glp1-medication-card.tsx b/src/components/medications/glp1-medication-card.tsx index a10519cbb..002b04117 100644 --- a/src/components/medications/glp1-medication-card.tsx +++ b/src/components/medications/glp1-medication-card.tsx @@ -27,10 +27,7 @@ import { getDayOfWeekInTz } from "@/lib/tz/local-day"; import { type InjectionSiteKey } from "@/lib/medications/injection-sites"; import { LogInjectionSiteDialog } from "@/components/medications/log-injection-site-dialog"; import { useGlobalExcludedInjectionSites } from "@/lib/medications/use-injection-site-prefs"; -import { - reduceCurrentWindowStatus, - toZonedDate, -} from "@/lib/medications/window-status"; +import { reduceCurrentWindowStatus } from "@/lib/medications/window-status"; import { resolveDisplayedSlotInstant } from "@/components/medications/card-parts/displayed-slot-instant"; import { estimateDailyDoseCount, @@ -325,12 +322,11 @@ export function Glp1MedicationCard({ a.windowStart.localeCompare(b.windowStart) || a.windowEnd.localeCompare(b.windowEnd), ); - const nowBerlin = toZonedDate(now, userTz); const lateMinutes = thresholds?.lateMinutes ?? 120; const missedMinutes = thresholds?.missedMinutes ?? 240; const currentWindowStatus = reduceCurrentWindowStatus({ schedules: sortedSchedules, - nowBerlin, + now, lateMinutes, missedMinutes, active: medication.active, diff --git a/src/components/medications/medication-card.tsx b/src/components/medications/medication-card.tsx index b6c64fa5c..a3bc9a214 100644 --- a/src/components/medications/medication-card.tsx +++ b/src/components/medications/medication-card.tsx @@ -13,10 +13,8 @@ import { formatDateTime, formatTime } from "@/lib/format"; import { getDateTimeFormat } from "@/lib/intl/formatter-cache"; import { getMedicationCategoryLabel } from "@/lib/medications/category-label"; import { formatDose } from "@/lib/medications/format-dose"; -import { - reduceCurrentWindowStatus, - toZonedDate, -} from "@/lib/medications/window-status"; +import { reduceCurrentWindowStatus } from "@/lib/medications/window-status"; +import { localDayIndex, wallClockInTz } from "@/lib/tz/wall-clock"; import { resolveDisplayedSlotInstant } from "@/components/medications/card-parts/displayed-slot-instant"; import { estimateDailyDoseCount, @@ -242,7 +240,8 @@ export function MedicationCard({ a.windowStart.localeCompare(b.windowStart) || a.windowEnd.localeCompare(b.windowEnd), ); - const nowBerlin = toZonedDate(new Date(), userTz); + const now = new Date(); + const nowParts = wallClockInTz(now, userTz); // v1.8.4 — the next-due instant comes from the server (`nextDueAt`, // computed by the canonical recurrence engine anchored on the last // intake). The day label below derives from it; the window-range / @@ -261,7 +260,7 @@ export function MedicationCard({ const currentWindowStatus = reduceCurrentWindowStatus({ schedules: sortedSchedules, - nowBerlin, + now, lateMinutes, missedMinutes, active: medication.active, @@ -418,26 +417,26 @@ export function MedicationCard({ // Format day label relative to today let dayLabel = ""; if (nextAt) { - const nextDate = toZonedDate(new Date(nextAt), userTz); - const todayStr = `${nowBerlin.getFullYear()}-${nowBerlin.getMonth()}-${nowBerlin.getDate()}`; - const nextStr = `${nextDate.getFullYear()}-${nextDate.getMonth()}-${nextDate.getDate()}`; - const tomorrow = new Date(nowBerlin); - tomorrow.setDate(tomorrow.getDate() + 1); - const tomorrowStr = `${tomorrow.getFullYear()}-${tomorrow.getMonth()}-${tomorrow.getDate()}`; + // Both clocks are read in the PROFILE timezone, and the day + // distance is a calendar-day difference — not an hour count — + // so a 23 h / 25 h transition day cannot round "tomorrow" into + // "today". `dateWithWeekdaySmart` receives the RAW instant: the + // formatter applies `userTz` itself, and feeding it a + // zone-shifted Date applied the offset a second time, which + // rendered the wrong calendar day for anyone whose browser zone + // differed from their profile zone. + const nextInstant = new Date(nextAt); + const nextParts = wallClockInTz(nextInstant, userTz); + const dayDelta = localDayIndex(nextParts) - localDayIndex(nowParts); - const diffDays = Math.round( - (nextDate.getTime() - nowBerlin.getTime()) / - (24 * 60 * 60 * 1000), - ); - - if (nextStr === todayStr) { + if (dayDelta === 0) { dayLabel = t("medications.today"); - } else if (nextStr === tomorrowStr) { + } else if (dayDelta === 1) { dayLabel = t("medications.tomorrow"); - } else if (diffDays <= 5) { - dayLabel = weekdayLabel(nextDate.getDay()); + } else if (dayDelta <= 5) { + dayLabel = weekdayLabel(nextParts.weekday); } else { - dayLabel = fmt.dateWithWeekdaySmart(nextDate); + dayLabel = fmt.dateWithWeekdaySmart(nextInstant); } } diff --git a/src/components/medications/medication-table.tsx b/src/components/medications/medication-table.tsx index b7f045796..c04c6a634 100644 --- a/src/components/medications/medication-table.tsx +++ b/src/components/medications/medication-table.tsx @@ -28,10 +28,8 @@ import { useWeekdayLabel } from "@/components/medications/card-parts/medication- import { resolveDisplayedSlotInstant } from "@/components/medications/card-parts/displayed-slot-instant"; import { estimateRunwayDays } from "@/components/medications/detail/supply-runway"; import { useMedicationIntake } from "@/components/medications/use-medication-intake"; -import { - reduceCurrentWindowStatus, - toZonedDate, -} from "@/lib/medications/window-status"; +import { reduceCurrentWindowStatus } from "@/lib/medications/window-status"; +import { localDayIndex, wallClockInTz } from "@/lib/tz/wall-clock"; import { formatTime } from "@/lib/format"; import { formatTimeWindowRange } from "@/lib/time-window-format"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; @@ -420,7 +418,7 @@ function MedicationTableRowItem({ a.windowEnd.localeCompare(b.windowEnd), ); const now = new Date(); - const nowZoned = toZonedDate(now, userTz); + const nowParts = wallClockInTz(now, userTz); const nextDueMs = medication.nextDueAt ? new Date(medication.nextDueAt).getTime() : NaN; @@ -431,7 +429,7 @@ function MedicationTableRowItem({ // next dose is tomorrow can never paint an overdue pill today. const currentWindowStatus = reduceCurrentWindowStatus({ schedules: sortedSchedules, - nowBerlin: nowZoned, + now, lateMinutes, missedMinutes, active: medication.active, @@ -535,23 +533,22 @@ function MedicationTableRowItem({ ); } else if (nextAt) { - const nextDate = toZonedDate(new Date(nextAt), userTz); - const todayStr = `${nowZoned.getFullYear()}-${nowZoned.getMonth()}-${nowZoned.getDate()}`; - const nextStr = `${nextDate.getFullYear()}-${nextDate.getMonth()}-${nextDate.getDate()}`; - const tomorrow = new Date(nowZoned); - tomorrow.setDate(tomorrow.getDate() + 1); - const tomorrowStr = `${tomorrow.getFullYear()}-${tomorrow.getMonth()}-${tomorrow.getDate()}`; - const diffDays = Math.round( - (nextDate.getTime() - nowZoned.getTime()) / (24 * 60 * 60 * 1000), - ); + // Same contract as the card: both clocks read in the PROFILE + // timezone, a calendar-day distance rather than an hour count, and + // the RAW instant handed to `dateWithWeekdaySmart` — the formatter + // applies `userTz` itself, so passing a zone-shifted Date converted + // twice and could render the wrong calendar day. + const nextInstant = new Date(nextAt); + const nextParts = wallClockInTz(nextInstant, userTz); + const dayDelta = localDayIndex(nextParts) - localDayIndex(nowParts); const dayLabel = - nextStr === todayStr + dayDelta === 0 ? t("medications.today") - : nextStr === tomorrowStr + : dayDelta === 1 ? t("medications.tomorrow") - : diffDays <= 5 - ? weekdayLabel(nextDate.getDay()) - : fmt.dateWithWeekdaySmart(nextDate); + : dayDelta <= 5 + ? weekdayLabel(nextParts.weekday) + : fmt.dateWithWeekdaySmart(nextInstant); nextCell = ( {dayLabel}, {formatTime(new Date(nextAt).toISOString())} diff --git a/src/components/medications/take-all-due.ts b/src/components/medications/take-all-due.ts index 267166637..800625e4b 100644 --- a/src/components/medications/take-all-due.ts +++ b/src/components/medications/take-all-due.ts @@ -42,7 +42,6 @@ import { apiPost } from "@/lib/api/api-fetch"; import { invalidateKeys, medicationDependentKeys } from "@/lib/query-keys"; import { reduceCurrentWindowStatus, - toZonedDate, type ScheduleWindowInput, } from "@/lib/medications/window-status"; import { resolveDisplayedSlotInstant } from "@/components/medications/card-parts/displayed-slot-instant"; @@ -133,7 +132,6 @@ export function deriveDueMedications( const tz = options.tz ?? "Europe/Berlin"; const { lateMinutes, missedMinutes } = options.thresholds ?? DEFAULT_THRESHOLDS; - const nowLocal = toZonedDate(now, tz); const due: DueMedication[] = []; for (const m of medications) { @@ -146,7 +144,7 @@ export function deriveDueMedications( const nextDueMs = m.nextDueAt ? new Date(m.nextDueAt).getTime() : NaN; const status = reduceCurrentWindowStatus({ schedules: m.schedules, - nowBerlin: nowLocal, + now, lateMinutes, missedMinutes, active: m.active, diff --git a/src/lib/medications/__tests__/window-status.test.ts b/src/lib/medications/__tests__/window-status.test.ts index ce6328f62..09f4aa8d5 100644 --- a/src/lib/medications/__tests__/window-status.test.ts +++ b/src/lib/medications/__tests__/window-status.test.ts @@ -13,16 +13,24 @@ */ import { describe, expect, it } from "vitest"; -import { - reduceCurrentWindowStatus, - toBerlinDate, - toZonedDate, -} from "../window-status"; +import { reduceCurrentWindowStatus } from "../window-status"; +import { zonedWallClockToUtc } from "@/lib/tz/wall-clock"; -/** Build a local wall-clock Date (what the cards pass as `nowBerlin`). */ -function localClock(hours: number, minutes = 0): Date { - // Wednesday, June 10 2026 — weekday-agnostic fixtures (no daysOfWeek). - return new Date(2026, 5, 10, hours, minutes, 0, 0); +/** + * The real UTC instant at which an observer in `tz` reads `hh:mm` on + * Wednesday, June 10 2026 (weekday-agnostic fixtures carry no daysOfWeek). + * + * The fixtures used to build a HOST-local `Date` and hand it over as a + * pre-shifted wall clock, which meant they asserted nothing about the + * zone: the same literal meant a different instant on every developer + * machine, and swapping the helper's zone left the suite green. Naming the + * zone here makes each expectation say which clock it means. + */ +function instantAt(hours: number, minutes = 0, tz = "Europe/Berlin"): Date { + return zonedWallClockToUtc( + { year: 2026, month: 6, day: 10, hour: hours, minute: minutes }, + tz, + ); } const BASE = { @@ -33,21 +41,6 @@ const BASE = { todayEventCount: 0, }; -describe("toZonedDate", () => { - it("shifts an instant into the requested timezone's wall clock", () => { - const instant = new Date("2026-06-10T12:00:00Z"); - expect(toZonedDate(instant, "Europe/Berlin").getHours()).toBe(14); // CEST - expect(toZonedDate(instant, "America/New_York").getHours()).toBe(8); // EDT - }); - - it("toBerlinDate stays the Berlin alias for legacy call sites", () => { - const instant = new Date("2026-06-10T12:00:00Z"); - expect(toBerlinDate(instant).getTime()).toBe( - toZonedDate(instant, "Europe/Berlin").getTime(), - ); - }); -}); - describe("reduceCurrentWindowStatus — timezone threading", () => { it("compares lastTakenAt in the supplied timezone, not hardcoded Berlin", () => { // 08:30 local in New York = 12:30 UTC. With tz=America/New_York the @@ -57,12 +50,14 @@ describe("reduceCurrentWindowStatus — timezone threading", () => { const schedules = [ { windowStart: "08:00", windowEnd: "09:00", daysOfWeek: null }, ]; - const nowLocal = localClock(8, 45); - + // One intake instant, two zones. 12:30Z is 08:30 in New York (inside + // the band → the pill is suppressed) and 14:30 in Berlin (outside it → + // the pill shows). Each `now` is 08:45 on its OWN clock, so the only + // thing that differs between the two calls is the zone. const withUserTz = reduceCurrentWindowStatus({ ...BASE, schedules, - nowBerlin: nowLocal, + now: instantAt(8, 45, "America/New_York"), lastTakenAt: "2026-06-10T12:30:00Z", todayEventCount: 1, tz: "America/New_York", @@ -72,7 +67,7 @@ describe("reduceCurrentWindowStatus — timezone threading", () => { const withDefaultTz = reduceCurrentWindowStatus({ ...BASE, schedules, - nowBerlin: nowLocal, + now: instantAt(8, 45, "Europe/Berlin"), lastTakenAt: "2026-06-10T12:30:00Z", todayEventCount: 1, }); @@ -89,7 +84,7 @@ describe("reduceCurrentWindowStatus — degenerate point window", () => { const res = reduceCurrentWindowStatus({ ...BASE, schedules: pointSchedule, - nowBerlin: localClock(15, 0), + now: instantAt(15, 0), }); // 15:00 is hours past the widened 07:00–09:00 point window and past // the late + missed thresholds → no pill at all. @@ -100,7 +95,7 @@ describe("reduceCurrentWindowStatus — degenerate point window", () => { const res = reduceCurrentWindowStatus({ ...BASE, schedules: pointSchedule, - nowBerlin: localClock(8, 30), + now: instantAt(8, 30), }); expect(res.status).toBe("in_window"); }); @@ -109,7 +104,7 @@ describe("reduceCurrentWindowStatus — degenerate point window", () => { const res = reduceCurrentWindowStatus({ ...BASE, schedules: pointSchedule, - nowBerlin: localClock(10, 0), + now: instantAt(10, 0), }); // Window end 09:00 + 60 min past → late tier. expect(res.status).toBe("late"); @@ -122,7 +117,7 @@ describe("reduceCurrentWindowStatus — degenerate point window", () => { const res = reduceCurrentWindowStatus({ ...BASE, schedules: overnight, - nowBerlin: localClock(23, 30), + now: instantAt(23, 30), }); expect(res.status).toBe("in_window"); }); @@ -145,7 +140,7 @@ describe("reduceCurrentWindowStatus — timesOfDay-aware overdue coverage", () = const res = reduceCurrentWindowStatus({ ...BASE, schedules: twoDoseSchedule, - nowBerlin: localClock(21, 30), + now: instantAt(21, 30), todayEventCount: 1, }); // 21:30 — both bands have passed (ends 09:00 + 21:00); one event @@ -164,7 +159,7 @@ describe("reduceCurrentWindowStatus — timesOfDay-aware overdue coverage", () = const res = reduceCurrentWindowStatus({ ...BASE, schedules: twoDoseSchedule, - nowBerlin: localClock(21, 30), + now: instantAt(21, 30), todayEventCount: 2, }); expect(res.status).toBeNull(); @@ -175,7 +170,7 @@ describe("reduceCurrentWindowStatus — timesOfDay-aware overdue coverage", () = const res = reduceCurrentWindowStatus({ ...BASE, schedules: twoDoseSchedule, - nowBerlin: localClock(20, 30), + now: instantAt(20, 30), }); expect(res.status).toBe("in_window"); expect(res.window?.timeOfDay).toBe("20:00"); @@ -188,7 +183,7 @@ describe("reduceCurrentWindowStatus — timesOfDay-aware overdue coverage", () = const res = reduceCurrentWindowStatus({ ...BASE, schedules: legacy, - nowBerlin: localClock(21, 0), + now: instantAt(21, 0), todayEventCount: 1, }); expect(res.status).toBeNull(); @@ -212,7 +207,7 @@ describe("reduceCurrentWindowStatus — stale window never beats timesOfDay (v1. const res = reduceCurrentWindowStatus({ ...BASE, schedules: staleSchedule, - nowBerlin: localClock(7, 0), + now: instantAt(7, 0), }); expect(res.status).toBeNull(); }); @@ -221,7 +216,7 @@ describe("reduceCurrentWindowStatus — stale window never beats timesOfDay (v1. const res = reduceCurrentWindowStatus({ ...BASE, schedules: staleSchedule, - nowBerlin: localClock(9, 15), + now: instantAt(9, 15), }); expect(res.status).toBe("in_window"); expect(res.window).toEqual({ @@ -235,7 +230,7 @@ describe("reduceCurrentWindowStatus — stale window never beats timesOfDay (v1. const res = reduceCurrentWindowStatus({ ...BASE, schedules: staleSchedule, - nowBerlin: localClock(11, 0), + now: instantAt(11, 0), }); expect(res.status).toBe("late"); expect(res.window?.timeOfDay).toBe("09:00"); @@ -254,7 +249,7 @@ describe("reduceCurrentWindowStatus — stale window never beats timesOfDay (v1. const res = reduceCurrentWindowStatus({ ...BASE, schedules: withExplicit, - nowBerlin: localClock(11, 0), + now: instantAt(11, 0), }); expect(res.status).toBe("in_window"); expect(res.window).toEqual({ @@ -281,7 +276,7 @@ describe("reduceCurrentWindowStatus — display-due gate (v1.16.6)", () => { }, ]; // June 11 2026, 07:00 Berlin (CEST) — "tomorrow" relative to the - // localClock fixtures (June 10). + // instantAt fixtures (June 10). const dueTomorrow = new Date("2026-06-11T05:00:00Z"); // June 10 2026, 08:00 Berlin — "today's" dose anchor. const dueToday = new Date("2026-06-10T06:00:00Z"); @@ -292,7 +287,7 @@ describe("reduceCurrentWindowStatus — display-due gate (v1.16.6)", () => { const ungated = reduceCurrentWindowStatus({ ...BASE, schedules: rolling, - nowBerlin: localClock(13, 0), + now: instantAt(13, 0), }); expect(ungated.status).toBe("very_late"); @@ -302,7 +297,7 @@ describe("reduceCurrentWindowStatus — display-due gate (v1.16.6)", () => { const gated = reduceCurrentWindowStatus({ ...BASE, schedules: rolling, - nowBerlin: localClock(13, 0), + now: instantAt(13, 0), nextDue: { at: dueTomorrow, overdue: false }, }); expect(gated.status).toBeNull(); @@ -311,7 +306,7 @@ describe("reduceCurrentWindowStatus — display-due gate (v1.16.6)", () => { const lateTier = reduceCurrentWindowStatus({ ...BASE, schedules: rolling, - nowBerlin: localClock(10, 0), + now: instantAt(10, 0), nextDue: { at: dueTomorrow, overdue: false }, }); expect(lateTier.status).toBeNull(); @@ -321,7 +316,7 @@ describe("reduceCurrentWindowStatus — display-due gate (v1.16.6)", () => { const late = reduceCurrentWindowStatus({ ...BASE, schedules: rolling, - nowBerlin: localClock(10, 0), + now: instantAt(10, 0), nextDue: { at: dueToday, overdue: true }, }); expect(late.status).toBe("late"); @@ -329,7 +324,7 @@ describe("reduceCurrentWindowStatus — display-due gate (v1.16.6)", () => { const veryLate = reduceCurrentWindowStatus({ ...BASE, schedules: rolling, - nowBerlin: localClock(12, 30), + now: instantAt(12, 30), nextDue: { at: dueToday, overdue: true }, }); expect(veryLate.status).toBe("very_late"); @@ -340,7 +335,7 @@ describe("reduceCurrentWindowStatus — display-due gate (v1.16.6)", () => { const notYetDue = reduceCurrentWindowStatus({ ...BASE, schedules: rolling, - nowBerlin: localClock(8, 30), + now: instantAt(8, 30), nextDue: { at: dueTomorrow, overdue: false }, }); expect(notYetDue.status).toBeNull(); @@ -349,7 +344,7 @@ describe("reduceCurrentWindowStatus — display-due gate (v1.16.6)", () => { const dueNow = reduceCurrentWindowStatus({ ...BASE, schedules: rolling, - nowBerlin: localClock(8, 30), + now: instantAt(8, 30), nextDue: { at: dueToday, overdue: false }, }); expect(dueNow.status).toBe("in_window"); @@ -359,7 +354,7 @@ describe("reduceCurrentWindowStatus — display-due gate (v1.16.6)", () => { const res = reduceCurrentWindowStatus({ ...BASE, schedules: rolling, - nowBerlin: localClock(8, 30), + now: instantAt(8, 30), nextDue: null, }); expect(res.status).toBeNull(); @@ -388,7 +383,7 @@ describe("reduceCurrentWindowStatus — day-scale taken-early context", () => { const res = reduceCurrentWindowStatus({ ...BASE, schedules: [weeklyWedSchedule], - nowBerlin: localClock(9, 15), + now: instantAt(9, 15), lastTakenAt: MONDAY_TAKE, todayEventCount: 0, }); @@ -401,7 +396,7 @@ describe("reduceCurrentWindowStatus — day-scale taken-early context", () => { const res = reduceCurrentWindowStatus({ ...BASE, schedules: [weeklyWedSchedule], - nowBerlin: localClock(9, 15), + now: instantAt(9, 15), lastTakenAt: "2026-06-03T07:05:00.000Z", todayEventCount: 0, }); @@ -420,7 +415,7 @@ describe("reduceCurrentWindowStatus — day-scale taken-early context", () => { timesOfDay: ["09:00"], }, ], - nowBerlin: localClock(9, 15), + now: instantAt(9, 15), lastTakenAt: "2026-06-09T07:05:00.000Z", // yesterday — normal daily take todayEventCount: 0, }); @@ -432,7 +427,7 @@ describe("reduceCurrentWindowStatus — day-scale taken-early context", () => { const res = reduceCurrentWindowStatus({ ...BASE, schedules: [weeklyWedSchedule], - nowBerlin: localClock(11, 30), // past the band + inside lateMinutes + now: instantAt(11, 30), // past the band + inside lateMinutes lastTakenAt: MONDAY_TAKE, todayEventCount: 0, }); @@ -448,7 +443,7 @@ describe("reduceCurrentWindowStatus — day-scale taken-early context", () => { const res = reduceCurrentWindowStatus({ ...BASE, schedules: [weeklyWedSchedule], - nowBerlin: localClock(9, 15), // interpreted as Auckland wall clock + now: instantAt(9, 15, "Pacific/Auckland"), // 09:15 on Auckland's clock lastTakenAt: "2026-06-08T13:00:00.000Z", todayEventCount: 0, tz: "Pacific/Auckland", diff --git a/src/lib/medications/window-status.ts b/src/lib/medications/window-status.ts index 0c8ad5f18..d1add2e47 100644 --- a/src/lib/medications/window-status.ts +++ b/src/lib/medications/window-status.ts @@ -43,6 +43,11 @@ */ import { parseScheduleRecurrence } from "@/lib/medication-schedule"; import { DOSE_WINDOW_DEFAULTS } from "@/lib/medications/scheduling/dose-window-defaults"; +import { + localDayIndex, + wallClockInTz, + type WallClockParts, +} from "@/lib/tz/wall-clock"; /** One explicit per-dose on-time window as the API serialises it. */ export interface DoseWindowEntryInput { @@ -122,22 +127,17 @@ export interface NextDueGate { const DEFAULT_TZ = "Europe/Berlin"; /** - * Convert a `Date` from its underlying UTC instant into the wall-clock - * value in the given IANA timezone so the per-minute arithmetic below - * treats the user's day boundary correctly. Intentionally en-US — this is - * not a user-facing format string, only a parseable round-trip to shift - * the value into the target local time. + * Minute-of-day an observer in the target zone reads off the wall clock. + * Every band comparison below is minute-of-day arithmetic, so this is the + * only projection the status math needs. */ -export function toZonedDate(date: Date, tz: string = DEFAULT_TZ): Date { - return new Date(date.toLocaleString("en-US", { timeZone: tz })); +function minuteOfDay(parts: WallClockParts): number { + return parts.hour * 60 + parts.minute; } -/** - * Legacy alias kept for the existing card call sites; new code should call - * `toZonedDate(date, userTz)` with the user's real timezone. - */ -export function toBerlinDate(date: Date): Date { - return toZonedDate(date, DEFAULT_TZ); +/** True when two wall clocks name the same local calendar day. */ +function sameLocalDay(a: WallClockParts, b: WallClockParts): boolean { + return a.year === b.year && a.month === b.month && a.day === b.day; } export function parseTimeToMinutes(value: string): number { @@ -293,14 +293,14 @@ function bandStatus( function getScheduleStatus( schedule: ScheduleWindowInput, - nowLocal: Date, + nowParts: WallClockParts, lateMinutes: number, missedMinutes: number, ): { status: Exclude; band: DoseWindowBounds; } | null { - const nowMins = nowLocal.getHours() * 60 + nowLocal.getMinutes(); + const nowMins = minuteOfDay(nowParts); let best: { status: Exclude; band: DoseWindowBounds; @@ -329,7 +329,7 @@ const EARLY_GRACE_MINUTES = DOSE_WINDOW_DEFAULTS.earlyGraceMinutes; function isLastIntakeInBand( lastTakenAt: string | null, band: DoseWindowBounds, - nowLocal: Date, + nowParts: WallClockParts, tz: string, /** * Lower bound (minutes-of-day) the early grace may not cross — the end @@ -340,18 +340,12 @@ function isLastIntakeInBand( ): boolean { if (!lastTakenAt) return false; - const intake = toZonedDate(new Date(lastTakenAt), tz); + const intake = wallClockInTz(new Date(lastTakenAt), tz); // Must be same calendar day - if ( - intake.getFullYear() !== nowLocal.getFullYear() || - intake.getMonth() !== nowLocal.getMonth() || - intake.getDate() !== nowLocal.getDate() - ) { - return false; - } + if (!sameLocalDay(intake, nowParts)) return false; - const intakeMins = intake.getHours() * 60 + intake.getMinutes(); + const intakeMins = minuteOfDay(intake); // Handle legacy overnight windows const adjustedIntake = @@ -411,28 +405,13 @@ function dayScaleCadence(daysOfWeek: string | null): { */ function earlyTakeDaysAgo( lastTakenAt: string | null, - nowLocal: Date, + nowParts: WallClockParts, tz: string, periodDays: number, ): number | null { if (!lastTakenAt || periodDays < 2) return null; - const intake = toZonedDate(new Date(lastTakenAt), tz); - const dayMs = 24 * 60 * 60 * 1000; - const intakeDay = Math.floor( - new Date( - intake.getFullYear(), - intake.getMonth(), - intake.getDate(), - ).getTime() / dayMs, - ); - const nowDay = Math.floor( - new Date( - nowLocal.getFullYear(), - nowLocal.getMonth(), - nowLocal.getDate(), - ).getTime() / dayMs, - ); - const diff = nowDay - intakeDay; + const intake = wallClockInTz(new Date(lastTakenAt), tz); + const diff = localDayIndex(nowParts) - localDayIndex(intake); return diff >= 1 && diff < periodDays ? diff : null; } @@ -452,14 +431,14 @@ function earlyTakeDaysAgo( */ function countPassedDoses( schedules: Schedule[], - nowLocal: Date, + nowParts: WallClockParts, ): number { - const nowMins = nowLocal.getHours() * 60 + nowLocal.getMinutes(); + const nowMins = minuteOfDay(nowParts); return schedules.reduce((sum, s) => { const recurrence = parseScheduleRecurrence(s.daysOfWeek); if ( recurrence.daysOfWeek.length > 0 && - !recurrence.daysOfWeek.includes(nowLocal.getDay()) + !recurrence.daysOfWeek.includes(nowParts.weekday) ) { return sum; } @@ -485,17 +464,22 @@ function countPassedDoses( * status when the last intake already falls inside the matched band so * the pill doesn't nag after the user took the dose. * - * `nowBerlin` is the wall-clock "now" already shifted into the user's - * timezone (via `toZonedDate`); `tz` must name the same timezone so the - * last-intake comparison shifts `lastTakenAt` identically. The name and - * the default are the legacy Berlin contract — callers that serve other - * timezones pass their own. + * `now` is the real "now" instant — NOT a pre-shifted wall clock. The + * helper decomposes it in `tz` itself, so caller and helper can no longer + * disagree about which zone the comparison runs in. Callers used to pass a + * `Date` fabricated by rendering the instant in the target zone and + * re-parsing it as host-local; when that fabricated wall clock landed in + * the HOST zone's spring-forward gap the engine silently advanced it an + * hour, so a dose inside its window could read late and a just-taken dose + * could fail to suppress the take-now pill. Passing the instant removes + * that class outright. */ export function reduceCurrentWindowStatus< Schedule extends ScheduleWindowInput, >(options: { schedules: Schedule[]; - nowBerlin: Date; + /** The real current instant; decomposed in `tz` internally. */ + now: Date; lateMinutes: number; missedMinutes: number; active: boolean; @@ -508,7 +492,7 @@ export function reduceCurrentWindowStatus< * dashboard visit and the overdue pill went dark nondeterministically. */ todayEventCount: number; - /** IANA timezone matching `nowBerlin`'s conversion; defaults to Berlin. */ + /** IANA timezone the status math reasons in; defaults to Berlin. */ tz?: string; /** * v1.16.6 — the server display-due gate. `undefined` keeps the legacy @@ -526,7 +510,7 @@ export function reduceCurrentWindowStatus< }): CurrentWindowStatus { const { schedules, - nowBerlin, + now, lateMinutes, missedMinutes, active, @@ -535,6 +519,7 @@ export function reduceCurrentWindowStatus< tz = DEFAULT_TZ, nextDue, } = options; + const nowParts = wallClockInTz(now, tz); const none: CurrentWindowStatus = { status: null, @@ -545,25 +530,17 @@ export function reduceCurrentWindowStatus< if (!active) return none; if (nextDue === null) return none; - const passedDoseCount = countPassedDoses(schedules, nowBerlin); + const passedDoseCount = countPassedDoses(schedules, nowParts); const hasUncoveredOverdue = todayEventCount < passedDoseCount; // Display-due gate (see the option's doc above). Computed once: it does // not vary per schedule row — the server already reduced the rows to one // verdict. const dueIsToday = - nextDue != null && - (() => { - const dueLocal = toZonedDate(nextDue.at, tz); - return ( - dueLocal.getFullYear() === nowBerlin.getFullYear() && - dueLocal.getMonth() === nowBerlin.getMonth() && - dueLocal.getDate() === nowBerlin.getDate() - ); - })(); + nextDue != null && sameLocalDay(wallClockInTz(nextDue.at, tz), nowParts); return schedules.reduce>((best, s) => { - const hit = getScheduleStatus(s, nowBerlin, lateMinutes, missedMinutes); + const hit = getScheduleStatus(s, nowParts, lateMinutes, missedMinutes); if (!hit) return best; // The pill must never read more overdue than the next-due line: a // future (non-overdue) display-due suppresses every overdue tier, and @@ -588,7 +565,7 @@ export function reduceCurrentWindowStatus< ); if ( hit.status === "in_window" && - isLastIntakeInBand(lastTakenAt, hit.band, nowBerlin, tz, earlyFloorMins) + isLastIntakeInBand(lastTakenAt, hit.band, nowParts, tz, earlyFloorMins) ) { return best; } @@ -604,7 +581,7 @@ export function reduceCurrentWindowStatus< // framing changes; the day count rides along for the copy. const cadence = dayScaleCadence(s.daysOfWeek); const takenEarlyDaysAgo = cadence.dayScale - ? earlyTakeDaysAgo(lastTakenAt, nowBerlin, tz, cadence.periodDays) + ? earlyTakeDaysAgo(lastTakenAt, nowParts, tz, cadence.periodDays) : null; return { status: hit.status, diff --git a/src/lib/tz/wall-clock.ts b/src/lib/tz/wall-clock.ts index 68df50b26..27db95150 100644 --- a/src/lib/tz/wall-clock.ts +++ b/src/lib/tz/wall-clock.ts @@ -133,6 +133,22 @@ export function zonedWallClockToUtc( return guess; } +/** + * The local calendar day as a day index, for day-distance arithmetic + * between two wall clocks in the SAME zone. + * + * Built from the wall-clock fields through `Date.UTC`, so the difference + * of two indices is a pure calendar-day count: no host offset and no + * 23 h/25 h DST-length term can enter it. Subtracting two instants and + * dividing by 86 400 000 does admit both, which is how a "tomorrow" label + * could land on a transition day. + */ +export function localDayIndex(parts: WallClockParts): number { + return Math.floor( + Date.UTC(parts.year, parts.month - 1, parts.day) / 86_400_000, + ); +} + export function wallClockInTz( date: Date, tz: string | undefined,