From 1b354de68bc40bfa4f8dcc60ea4a9bfe4a81c5a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 18 Jun 2026 18:25:03 +0200 Subject: [PATCH] fix(tz): anchor medication-intake day floor at local midnight MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The intake aggregator built its day floor from en-US calendar parts pinned to UTC midnight, so today's window opened off by the zone offset for any non-UTC user — a 23:30-local reading could land in the wrong day's bucket. Promote the DST-safe start-of-local-day primitive into src/lib/tz/local-day as startOfLocalDayInTz and route the intake route, the cadence start-of-day, and the recurrence day floor through it, removing three divergent copies. --- src/app/api/medications/intake/route.ts | 17 +------ src/lib/medications/scheduling/cadence.ts | 4 +- src/lib/medications/scheduling/recurrence.ts | 15 ++---- src/lib/tz/__tests__/local-day.test.ts | 44 +++++++++++++++++ src/lib/tz/local-day.ts | 52 ++++++++++++++++++++ 5 files changed, 103 insertions(+), 29 deletions(-) create mode 100644 src/lib/tz/__tests__/local-day.test.ts diff --git a/src/app/api/medications/intake/route.ts b/src/app/api/medications/intake/route.ts index 14a53b392..2490e4d98 100644 --- a/src/app/api/medications/intake/route.ts +++ b/src/app/api/medications/intake/route.ts @@ -34,7 +34,7 @@ import { expectedSlotCountForDay, lastNonSkippedTakenAt, } from "@/lib/analytics/compliance"; -import { getUserTodayBounds } from "@/lib/tz/local-day"; +import { getUserTodayBounds, startOfLocalDayInTz } from "@/lib/tz/local-day"; import { projectTodayIntakesAndRecompute } from "@/lib/medications/scheduling/project-today-intakes"; import { applyCanonicalSlotWrite, @@ -84,19 +84,6 @@ const updateSchema = z.object({ .optional(), }); -function startOfDayInTz(date: Date, tz: string): Date { - const parts = new Intl.DateTimeFormat("en-US", { - timeZone: tz, - year: "numeric", - month: "2-digit", - day: "2-digit", - }).formatToParts(date); - const y = parts.find((p) => p.type === "year")?.value ?? "1970"; - const m = parts.find((p) => p.type === "month")?.value ?? "01"; - const d = parts.find((p) => p.type === "day")?.value ?? "01"; - return new Date(`${y}-${m}-${d}T00:00:00.000Z`); -} - export const GET = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); @@ -138,7 +125,7 @@ export const GET = apiHandler(async (request: NextRequest) => { const userTz = user.timezone ?? DEFAULT_TIMEZONE; if (scope === "today") { - const todayStart = startOfDayInTz(new Date(), userTz); + const todayStart = startOfLocalDayInTz(new Date(), userTz); const todayEnd = new Date(todayStart.getTime() + 86_400_000); // v1.4.39 W-SERVER-FIX — project pending intake rows for every diff --git a/src/lib/medications/scheduling/cadence.ts b/src/lib/medications/scheduling/cadence.ts index 33431c5a2..03d75072b 100644 --- a/src/lib/medications/scheduling/cadence.ts +++ b/src/lib/medications/scheduling/cadence.ts @@ -47,6 +47,7 @@ import { } from "@/lib/medications/scheduling/recurrence"; import { normaliseDoseWindows } from "@/lib/medications/scheduling/worker-helpers"; import { wallClockInTz } from "@/lib/tz/wall-clock"; +import { startOfLocalDayInTz } from "@/lib/tz/local-day"; /** * v1.13.x compliance — opt-in retrospective-rolling expansion. @@ -336,8 +337,7 @@ function hhmmToMinutes(hhmm: string): number | null { /** Snap a Date down to the user-local midnight. */ 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); + return startOfLocalDayInTz(d, tz); } /** Snap a Date down to the Sunday-rooted user-local week. */ diff --git a/src/lib/medications/scheduling/recurrence.ts b/src/lib/medications/scheduling/recurrence.ts index 3dc24dbc9..608ab4024 100644 --- a/src/lib/medications/scheduling/recurrence.ts +++ b/src/lib/medications/scheduling/recurrence.ts @@ -59,6 +59,7 @@ import { RRule } from "rrule"; import { annotate } from "@/lib/logging/context"; import { parseScheduleRecurrence } from "@/lib/medication-schedule"; import { wallClockInTz } from "@/lib/tz/wall-clock"; +import { startOfLocalDayInTz } from "@/lib/tz/local-day"; /** * v1.7.0 — schedule-type discriminator on the canonical schedule. @@ -303,7 +304,7 @@ export function nextOccurrenceAfter( // keeps a slot the user has not yet acted on visible through the rest // of its due day instead of silently rolling it forward by N. if (schedule.rollingIntervalDays !== null) { - const dayFloor = startOfDayInTz(after, ctx.timeZone); + const dayFloor = startOfLocalDayInTz(after, ctx.timeZone); // With no intake logged the first dose anchors at `startsOn ?? createdAt`. // When that anchor is on a PRIOR calendar day the dose is overdue by ≥1 // day and must still surface ("take now"); flooring only to the start of @@ -434,7 +435,7 @@ function expandOneShot( */ function firstRollingDoseDayFloor(ctx: RecurrenceContext): number { const anchor = ctx.medication.startsOn ?? ctx.medication.createdAt; - return startOfDayInTz(anchor, ctx.timeZone).getTime(); + return startOfLocalDayInTz(anchor, ctx.timeZone).getTime(); } function expandRolling( @@ -782,16 +783,6 @@ function applyTimeOfDayToDate(day: Date, hhmm: string, tz: string): Date { return guess; } -/** - * The UTC instant of midnight (00:00 wall-clock) on `instant`'s day in - * the user's IANA timezone. Used by the rolling-cadence next-due search - * to floor the window to the start of the user's current day so an - * overdue / due-earlier-today rolling dose still surfaces. - */ -function startOfDayInTz(instant: Date, tz: string): Date { - return applyTimeOfDayToDate(instant, "00:00", tz); -} - function tzOffsetMinutes(date: Date, tz: string): number { const parts = wallClockInTz(date, tz); const asIfUtc = Date.UTC( diff --git a/src/lib/tz/__tests__/local-day.test.ts b/src/lib/tz/__tests__/local-day.test.ts new file mode 100644 index 000000000..f116dd3bb --- /dev/null +++ b/src/lib/tz/__tests__/local-day.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { startOfLocalDayInTz } from "@/lib/tz/local-day"; +import { wallClockInTz } from "@/lib/tz/wall-clock"; + +describe("startOfLocalDayInTz", () => { + it("anchors at the user's local midnight, not UTC midnight", () => { + // 2026-06-15 22:00 UTC is already 2026-06-16 00:00 in Berlin (UTC+2), + // so the local day floor must land on the Berlin 2026-06-16 midnight + // instant (2026-06-15T22:00:00Z) — never 2026-06-16T00:00:00Z. + const instant = new Date("2026-06-15T23:30:00.000Z"); + const floor = startOfLocalDayInTz(instant, "Europe/Berlin"); + const parts = wallClockInTz(floor, "Europe/Berlin"); + expect(parts.hour).toBe(0); + expect(parts.minute).toBe(0); + expect(parts.second).toBe(0); + expect(floor.toISOString()).toBe("2026-06-15T22:00:00.000Z"); + }); + + it("handles negative offsets (Pacific/Auckland) — late-local reading stays on its day", () => { + // 2026-06-15 23:30 NZST (UTC+12) is 2026-06-15T11:30Z; the local day + // floor is Auckland 2026-06-15 midnight = 2026-06-14T12:00:00Z. + const instant = new Date("2026-06-15T11:30:00.000Z"); + const floor = startOfLocalDayInTz(instant, "Pacific/Auckland"); + const parts = wallClockInTz(floor, "Pacific/Auckland"); + expect(parts.hour).toBe(0); + expect(parts.day).toBe(15); + }); + + it("is DST-safe across the spring-forward boundary", () => { + // Europe/Berlin springs forward 2026-03-29 02:00→03:00. + const instant = new Date("2026-03-29T10:00:00.000Z"); + const floor = startOfLocalDayInTz(instant, "Europe/Berlin"); + const parts = wallClockInTz(floor, "Europe/Berlin"); + expect(parts.hour).toBe(0); + expect(parts.day).toBe(29); + }); + + it("falls back to host-local day when tz is undefined", () => { + const instant = new Date(2026, 5, 15, 14, 30, 0); + const floor = startOfLocalDayInTz(instant, undefined); + expect(floor.getHours()).toBe(0); + expect(floor.getDate()).toBe(15); + }); +}); diff --git a/src/lib/tz/local-day.ts b/src/lib/tz/local-day.ts index 3c6bc2631..42efc54eb 100644 --- a/src/lib/tz/local-day.ts +++ b/src/lib/tz/local-day.ts @@ -49,6 +49,58 @@ export function getUserTodayBounds( return { start, end }; } +/** + * UTC instant of local midnight (00:00 wall-clock) on the calendar day + * `instant` falls on in `tz`. DST-safe: derives the zone offset at the + * target local time so the returned instant always represents the user's + * local start-of-day, never UTC midnight of that calendar date. + * + * Canonical start-of-local-day primitive. The medication-scheduling + * `startOfLocalDay` (cadence) and `startOfDayInTz` (recurrence) route + * through this so the day-floor never drifts between surfaces. When `tz` + * is omitted the host's system-local day is used. + */ +export function startOfLocalDayInTz( + instant: Date, + tz: string | undefined, +): Date { + if (!tz) { + return new Date( + instant.getFullYear(), + instant.getMonth(), + instant.getDate(), + 0, + 0, + 0, + 0, + ); + } + const parts = wallClockInTz(instant, tz); + // Two-pass converge: treat the wall clock as UTC, then correct by the + // zone offset at that approximate instant. The second pass settles the + // offset across a DST transition for every IANA zone. + let guess = new Date( + Date.UTC(parts.year, parts.month - 1, parts.day, 0, 0, 0, 0), + ); + for (let i = 0; i < 2; i++) { + const at = wallClockInTz(guess, tz); + const asIfUtc = Date.UTC( + at.year, + at.month - 1, + at.day, + at.hour, + at.minute, + at.second, + ); + const offsetMin = Math.round((asIfUtc - guess.getTime()) / 60_000); + guess = new Date( + Date.UTC(parts.year, parts.month - 1, parts.day, 0, 0, 0, 0) - + offsetMin * 60_000, + ); + } + return guess; +} + /** * Get the day of the week (0=Sun..6=Sat) for a given instant in a specific * timezone.