From 91c4f789db1582332e918432d4a233a15d1ed2a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 19 Jul 2026 02:05:24 +0200 Subject: [PATCH 1/5] fix(tz): settle localHmAsUtc's offset at the target local time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit localHmAsUtc sampled the zone offset at the reference instant and applied it to the target wall clock, so on a DST-transition day the answer depended on which side of the transition the reference sat: a 20:00 slot resolved to 19:00 local when the caller ticked before the Berlin autumn change and correctly when it ticked after. That broke the slot-dedupe contract. The projector, the reminder worker and the intake write path each pass their own reference instant for the same slot and key on the resulting instant byte-for-byte, so on that day they disagreed and a duplicate pending row became reachable. Rebuild it on zonedWallClockToUtc: resolve the local calendar day from the reference, then convert that day's hh:mm. The offset now settles at the target local time, and because zonedWallClockToUtc runs the same two-pass converge as startOfLocalDayInTz, localHmAsUtc(x, tz, 0, 0) equals startOfLocalDayInTz(x, tz) by construction — the two used to disagree by an hour on transition days, which opened the one-shot on-time band an hour into the previous local day. Cover both Berlin transitions from references on either side, plus a southern-hemisphere zone whose transitions invert. --- src/lib/tz/__tests__/local-day.test.ts | 97 +++++++++++++++++++++++++- src/lib/tz/local-day.ts | 55 +++++++++------ 2 files changed, 130 insertions(+), 22 deletions(-) diff --git a/src/lib/tz/__tests__/local-day.test.ts b/src/lib/tz/__tests__/local-day.test.ts index 7d6205abf..3041677c7 100644 --- a/src/lib/tz/__tests__/local-day.test.ts +++ b/src/lib/tz/__tests__/local-day.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "vitest"; -import { getUserTodayBounds, startOfLocalDayInTz } from "@/lib/tz/local-day"; +import { + getUserTodayBounds, + localHmAsUtc, + startOfLocalDayInTz, +} from "@/lib/tz/local-day"; import { wallClockInTz, zonedWallClockToUtc } from "@/lib/tz/wall-clock"; const HOUR_MS = 60 * 60 * 1000; @@ -151,3 +155,94 @@ describe("getUserTodayBounds — DST-safe local-day window", () => { expect(wallClockInTz(start, "America/Los_Angeles").day).toBe(15); }); }); + +describe("localHmAsUtc — DST transition days", () => { + const TZ = "Europe/Berlin"; + // Berlin 2026 transitions: spring forward Mar 29 (02:00 CET → 03:00 CEST), + // autumn back Oct 25 (03:00 CEST → 02:00 CET). + + /** The wall clock an observer in `tz` reads at `instant`, as "HH:mm". */ + function localHm(instant: Date, tz: string): string { + const p = wallClockInTz(instant, tz); + return `${String(p.hour).padStart(2, "0")}:${String(p.minute).padStart(2, "0")}`; + } + + it("resolves a 20:00 slot to 20:00 local from a PRE-transition reference (autumn back)", () => { + // 02:30 CEST — before the 03:00 fall-back. The old implementation + // sampled the +02:00 offset here and applied it to the 20:00 target, + // which by then sits at +01:00, yielding 19:00 local. + const ref = new Date("2026-10-25T00:30:00.000Z"); + const slot = localHmAsUtc(ref, TZ, 20, 0); + expect(localHm(slot, TZ)).toBe("20:00"); + expect(slot.toISOString()).toBe("2026-10-25T19:00:00.000Z"); + }); + + it("resolves a 20:00 slot to 20:00 local from a POST-transition reference (autumn back)", () => { + const ref = new Date("2026-10-25T05:00:00.000Z"); // 06:00 CET + const slot = localHmAsUtc(ref, TZ, 20, 0); + expect(localHm(slot, TZ)).toBe("20:00"); + expect(slot.toISOString()).toBe("2026-10-25T19:00:00.000Z"); + }); + + it("keys the SAME instant for one slot regardless of reference side (autumn back)", () => { + // The dedupe contract: projector, reminder worker and intake write path + // each pass their own reference instant for the same slot. They must + // agree byte-for-byte or a duplicate pending row becomes reachable. + const pre = new Date("2026-10-25T00:30:00.000Z"); + const mid = new Date("2026-10-25T01:30:00.000Z"); + const post = new Date("2026-10-25T05:00:00.000Z"); + const slots = [pre, mid, post].map((r) => + localHmAsUtc(r, TZ, 20, 0).getTime(), + ); + expect(new Set(slots).size).toBe(1); + }); + + it("keys the SAME instant for one slot regardless of reference side (spring forward)", () => { + const pre = new Date("2026-03-29T00:30:00.000Z"); // 01:30 CET + const post = new Date("2026-03-29T09:00:00.000Z"); // 11:00 CEST + expect(localHmAsUtc(pre, TZ, 20, 0).getTime()).toBe( + localHmAsUtc(post, TZ, 20, 0).getTime(), + ); + expect(localHm(localHmAsUtc(pre, TZ, 20, 0), TZ)).toBe("20:00"); + }); + + it("agrees with startOfLocalDayInTz at 00:00 on BOTH transition days", () => { + // By construction now — both route through the same two-pass converge. + // They used to disagree by an hour here, which opened the one-shot + // on-time band an hour into the previous local day. + for (const iso of [ + "2026-03-29T00:30:00.000Z", // spring forward, pre + "2026-03-29T09:00:00.000Z", // spring forward, post + "2026-10-25T00:30:00.000Z", // autumn back, pre + "2026-10-25T05:00:00.000Z", // autumn back, post + ]) { + const ref = new Date(iso); + expect(localHmAsUtc(ref, TZ, 0, 0).getTime()).toBe( + startOfLocalDayInTz(ref, TZ).getTime(), + ); + } + }); + + it("resolves an early-morning slot on the spring-forward day", () => { + // 01:30 local exists (CET, +01:00) on the spring-forward day; 03:30 + // exists (CEST, +02:00). Both must round-trip to their own wall clock. + const ref = new Date("2026-03-29T12:00:00.000Z"); + expect(localHmAsUtc(ref, TZ, 1, 30).toISOString()).toBe( + "2026-03-29T00:30:00.000Z", + ); + expect(localHmAsUtc(ref, TZ, 3, 30).toISOString()).toBe( + "2026-03-29T01:30:00.000Z", + ); + }); + + it("resolves slots in a southern-hemisphere zone whose transitions invert", () => { + // Australia/Sydney falls back 2026-04-05 03:00 AEDT → 02:00 AEST. + const sydney = "Australia/Sydney"; + const pre = new Date("2026-04-04T14:30:00.000Z"); // 01:30 AEDT (+11), Apr 5 + const post = new Date("2026-04-05T09:00:00.000Z"); // 19:00 AEST (+10), Apr 5 + const a = localHmAsUtc(pre, sydney, 20, 0); + const b = localHmAsUtc(post, sydney, 20, 0); + expect(a.getTime()).toBe(b.getTime()); + expect(localHm(a, sydney)).toBe("20:00"); + }); +}); diff --git a/src/lib/tz/local-day.ts b/src/lib/tz/local-day.ts index 76cf81573..09e778842 100644 --- a/src/lib/tz/local-day.ts +++ b/src/lib/tz/local-day.ts @@ -8,7 +8,7 @@ * user's local calendar day — today's bounds for database queries, * the local weekday, and the UTC instant of a local `hh:mm`. */ -import { wallClockInTz } from "./wall-clock"; +import { wallClockInTz, zonedWallClockToUtc } from "./wall-clock"; /** * Get the start (midnight) and end (23:59:59.999) of "today" in the user's @@ -103,11 +103,29 @@ export function getDayOfWeekInTz(now: Date, tz: string): number { /** * Compute the UTC instant of local `hh:mm` on the local calendar day - * implied by `now` in `tz`. DST-safe — `todayStart.getTime() + h * 3.6e6` - * drifts by an hour on spring-forward / fall-back days because raw UTC - * arithmetic ignores the DST jump between local midnight and local - * `hh:mm`. This helper re-derives the offset at the target local time - * so the returned instant always represents local `hh:mm`. + * implied by `now` in `tz`. + * + * Two steps, both zone-aware: resolve which local calendar day `now` + * falls on via {@link wallClockInTz}, then convert that day's `hh:mm` + * wall clock to its UTC instant via `zonedWallClockToUtc`. + * + * The offset is therefore settled AT THE TARGET local time, not at the + * reference instant. The previous implementation sampled the offset at + * `now` and applied it to the target, so on a DST-transition day the + * answer depended on which side of the transition the reference sat: + * a 20:00 slot resolved to 19:00 local when the caller ticked before the + * Berlin autumn change and correctly when it ticked after. That broke + * two contracts at once — the projector, the reminder worker and the + * intake write path key on the byte-identical slot instant, and they + * pass different reference instants for the same slot, so a duplicate + * pending row became reachable on that day. + * + * `zonedWallClockToUtc` runs the same two-pass converge as + * {@link startOfLocalDayInTz}, so `localHmAsUtc(x, tz, 0, 0)` now equals + * `startOfLocalDayInTz(x, tz)` BY CONSTRUCTION rather than by + * coincidence — the two used to disagree by an hour on transition days, + * which opened the one-shot on-time band an hour into the previous + * local day. */ export function localHmAsUtc( now: Date, @@ -116,20 +134,15 @@ export function localHmAsUtc( minute: number, ): Date { const parts = wallClockInTz(now, tz); - const localAtTargetAsUtc = new Date( - Date.UTC(parts.year, parts.month - 1, parts.day, hour, minute, 0, 0), + return zonedWallClockToUtc( + { + year: parts.year, + month: parts.month, + day: parts.day, + hour, + minute, + second: 0, + }, + tz, ); - const localNowAsUtc = new Date( - Date.UTC( - parts.year, - parts.month - 1, - parts.day, - parts.hour, - parts.minute, - parts.second, - ), - ); - const offsetMs = - Math.round((localNowAsUtc.getTime() - now.getTime()) / 60000) * 60000; - return new Date(localAtTargetAsUtc.getTime() - offsetMs); } From eb071b5dd8a29cfadc7d37677424418566754a48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 19 Jul 2026 02:13:08 +0200 Subject: [PATCH 2/5] fix(medications): drop the fabricated-Date wall clock from the window status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit toZonedDate rendered an instant as an en-US string in the target zone and re-parsed it as host-local. Two defects rode on that. When the 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 be classified late and a just-taken dose could fail to suppress the take-now pill — for users whose device zone differs from their profile zone. The medication card and table then passed that already-shifted value into dateWithWeekdaySmart, which applies the profile zone itself, so the next- intake day label converted twice and could name the wrong calendar day. Invisible whenever browser zone and profile zone agree. Migrate every consumer to wallClockInTz, which returns the fields directly. The status math only ever reads year/month/day/hour/minute/weekday, so it now runs on WallClockParts throughout and reduceCurrentWindowStatus takes the real instant instead of a pre-shifted Date — caller and helper can no longer disagree about which zone the comparison runs in. Day labels use a calendar-day distance (localDayIndex) rather than an hour count, so a 23 h or 25 h day cannot round tomorrow into today, and the formatter receives the raw instant. Fixtures name the zone they mean instead of building host-local Dates. --- .../dashboard/medication-intake-quick-add.tsx | 4 +- .../card-parts/displayed-slot-instant.ts | 8 +- .../medications/glp1-medication-card.tsx | 8 +- .../medications/medication-card.tsx | 43 ++++--- .../medications/medication-table.tsx | 37 +++--- src/components/medications/take-all-due.ts | 4 +- .../__tests__/window-status.test.ts | 103 ++++++++-------- src/lib/medications/window-status.ts | 111 +++++++----------- src/lib/tz/wall-clock.ts | 16 +++ 9 files changed, 155 insertions(+), 179 deletions(-) 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, From 4ba600d90855c5a4e6eacacb4d12e731d8968122 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 19 Jul 2026 02:19:37 +0200 Subject: [PATCH 3/5] refactor(medications): share the next-intake day label between card and table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The card and the table carried byte-identical copies of the day-label arithmetic. Two copies is how they drift, and neither was reachable from a test, which is why the double conversion in both survived review. Lift it into resolveNextDueDayLabel, which returns a discriminated label the callers map onto their own strings, and cover the case the old code could not get right: a browser zone that differs from the profile zone. Those tests pin the host zone explicitly — the defect was invisible whenever host and profile agree, which is the only configuration a UTC-only suite runs in. Also pin that the window-status verdict is identical from every host zone, including one whose spring-forward gap swallows the profile wall clock. --- .../medications/medication-card.tsx | 33 ++-- .../medications/medication-table.tsx | 22 +-- .../__tests__/next-due-day-label.test.ts | 168 ++++++++++++++++++ .../__tests__/window-status.test.ts | 78 +++++++- src/lib/medications/next-due-day-label.ts | 55 ++++++ 5 files changed, 317 insertions(+), 39 deletions(-) create mode 100644 src/lib/medications/__tests__/next-due-day-label.test.ts create mode 100644 src/lib/medications/next-due-day-label.ts diff --git a/src/components/medications/medication-card.tsx b/src/components/medications/medication-card.tsx index a3bc9a214..6a43f03cb 100644 --- a/src/components/medications/medication-card.tsx +++ b/src/components/medications/medication-card.tsx @@ -14,7 +14,7 @@ import { getDateTimeFormat } from "@/lib/intl/formatter-cache"; import { getMedicationCategoryLabel } from "@/lib/medications/category-label"; import { formatDose } from "@/lib/medications/format-dose"; import { reduceCurrentWindowStatus } from "@/lib/medications/window-status"; -import { localDayIndex, wallClockInTz } from "@/lib/tz/wall-clock"; +import { resolveNextDueDayLabel } from "@/lib/medications/next-due-day-label"; import { resolveDisplayedSlotInstant } from "@/components/medications/card-parts/displayed-slot-instant"; import { estimateDailyDoseCount, @@ -241,7 +241,6 @@ export function MedicationCard({ a.windowEnd.localeCompare(b.windowEnd), ); 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 / @@ -417,27 +416,15 @@ export function MedicationCard({ // Format day label relative to today let dayLabel = ""; if (nextAt) { - // 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); - - if (dayDelta === 0) { - dayLabel = t("medications.today"); - } else if (dayDelta === 1) { - dayLabel = t("medications.tomorrow"); - } else if (dayDelta <= 5) { - dayLabel = weekdayLabel(nextParts.weekday); - } else { - dayLabel = fmt.dateWithWeekdaySmart(nextInstant); - } + const label = resolveNextDueDayLabel(new Date(nextAt), now, userTz); + dayLabel = + label.kind === "today" + ? t("medications.today") + : label.kind === "tomorrow" + ? t("medications.tomorrow") + : label.kind === "weekday" + ? weekdayLabel(label.weekday) + : fmt.dateWithWeekdaySmart(label.instant); } // v1.16.1 — the TIME is the canonical next-due instant (the diff --git a/src/components/medications/medication-table.tsx b/src/components/medications/medication-table.tsx index c04c6a634..aa6adcdf3 100644 --- a/src/components/medications/medication-table.tsx +++ b/src/components/medications/medication-table.tsx @@ -29,7 +29,7 @@ import { resolveDisplayedSlotInstant } from "@/components/medications/card-parts import { estimateRunwayDays } from "@/components/medications/detail/supply-runway"; import { useMedicationIntake } from "@/components/medications/use-medication-intake"; import { reduceCurrentWindowStatus } from "@/lib/medications/window-status"; -import { localDayIndex, wallClockInTz } from "@/lib/tz/wall-clock"; +import { resolveNextDueDayLabel } from "@/lib/medications/next-due-day-label"; import { formatTime } from "@/lib/format"; import { formatTimeWindowRange } from "@/lib/time-window-format"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; @@ -418,7 +418,6 @@ function MedicationTableRowItem({ a.windowEnd.localeCompare(b.windowEnd), ); const now = new Date(); - const nowParts = wallClockInTz(now, userTz); const nextDueMs = medication.nextDueAt ? new Date(medication.nextDueAt).getTime() : NaN; @@ -533,22 +532,15 @@ function MedicationTableRowItem({ ); } else if (nextAt) { - // 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 label = resolveNextDueDayLabel(new Date(nextAt), now, userTz); const dayLabel = - dayDelta === 0 + label.kind === "today" ? t("medications.today") - : dayDelta === 1 + : label.kind === "tomorrow" ? t("medications.tomorrow") - : dayDelta <= 5 - ? weekdayLabel(nextParts.weekday) - : fmt.dateWithWeekdaySmart(nextInstant); + : label.kind === "weekday" + ? weekdayLabel(label.weekday) + : fmt.dateWithWeekdaySmart(label.instant); nextCell = ( {dayLabel}, {formatTime(new Date(nextAt).toISOString())} diff --git a/src/lib/medications/__tests__/next-due-day-label.test.ts b/src/lib/medications/__tests__/next-due-day-label.test.ts new file mode 100644 index 000000000..90d8b4798 --- /dev/null +++ b/src/lib/medications/__tests__/next-due-day-label.test.ts @@ -0,0 +1,168 @@ +/** + * The next-intake day label, including the case the previous + * implementation could not get right: a user whose BROWSER zone differs + * from their PROFILE zone. + * + * Every test here pins the host zone explicitly, because the defect this + * module exists to remove was invisible whenever host and profile agreed — + * which is the configuration a UTC-only suite always runs in. + */ +import { afterAll, beforeEach, describe, expect, it } from "vitest"; + +import { resolveNextDueDayLabel } from "../next-due-day-label"; +import { zonedWallClockToUtc } from "@/lib/tz/wall-clock"; + +const ORIGINAL_TZ = process.env.TZ; + +/** Repoint the HOST zone — what `new Date(...)` parses offset-less text as. */ +function setHostZone(tz: string): void { + process.env.TZ = tz; +} + +beforeEach(() => setHostZone("UTC")); +afterAll(() => { + process.env.TZ = ORIGINAL_TZ; +}); + +const BERLIN = "Europe/Berlin"; + +function berlin( + year: number, + month: number, + day: number, + hour: number, + minute = 0, +): Date { + return zonedWallClockToUtc({ year, month, day, hour, minute }, BERLIN); +} + +describe("resolveNextDueDayLabel — profile zone drives the label", () => { + it("names today when both instants share the profile calendar day", () => { + const now = berlin(2026, 6, 10, 9, 0); + const next = berlin(2026, 6, 10, 21, 0); + expect(resolveNextDueDayLabel(next, now, BERLIN)).toEqual({ + kind: "today", + }); + }); + + it("names tomorrow across the profile midnight", () => { + const now = berlin(2026, 6, 10, 23, 30); + const next = berlin(2026, 6, 11, 0, 30); + expect(resolveNextDueDayLabel(next, now, BERLIN)).toEqual({ + kind: "tomorrow", + }); + }); + + it("hands back the RAW instant for a far-out dose", () => { + const now = berlin(2026, 6, 10, 9, 0); + const next = berlin(2026, 6, 20, 9, 0); + const label = resolveNextDueDayLabel(next, now, BERLIN); + expect(label.kind).toBe("date"); + // The formatter applies the profile zone itself, so the instant must + // arrive unshifted. A pre-shifted value would be an hour or more off. + expect(label).toEqual({ kind: "date", instant: next }); + }); +}); + +describe("resolveNextDueDayLabel — HOST zone differs from PROFILE zone", () => { + // The shape of the defect: the label was derived by rendering the instant + // in the profile zone and re-parsing it as HOST-local, so a value carrying + // the profile wall clock but tagged with the HOST offset reached the + // formatter — which then applied the profile offset a SECOND time. The + // rendered calendar day was wrong for anyone whose two zones differ, and + // invisible for everyone whose zones agree. + const HOSTS = ["UTC", "America/New_York", "Australia/Sydney", "Asia/Kolkata"]; + + it("returns the untouched instant for the date branch from every host zone", () => { + const now = berlin(2026, 6, 10, 9, 0); + const next = berlin(2026, 6, 20, 9, 0); + for (const host of HOSTS) { + setHostZone(host); + const label = resolveNextDueDayLabel(next, now, BERLIN); + expect(label.kind).toBe("date"); + // Not "within an hour" — byte-identical. Any re-parse through a + // fabricated wall clock lands somewhere else. + expect((label as { instant: Date }).instant.getTime()).toBe( + next.getTime(), + ); + } + }); + + it("names the same profile day from every host zone", () => { + const now = berlin(2026, 6, 10, 23, 30); + const next = berlin(2026, 6, 11, 0, 30); + for (const host of HOSTS) { + setHostZone(host); + expect(resolveNextDueDayLabel(next, now, BERLIN)).toEqual({ + kind: "tomorrow", + }); + } + }); + + it("keeps a same-profile-day dose on 'today' even when the host clock has already rolled over", () => { + // 2026-06-10 22:00 Berlin is 2026-06-11 06:00 in Sydney — the host has + // crossed midnight, the profile has not. The label follows the profile. + setHostZone("Australia/Sydney"); + const now = berlin(2026, 6, 10, 21, 0); + const next = berlin(2026, 6, 10, 22, 0); + expect(resolveNextDueDayLabel(next, now, BERLIN)).toEqual({ + kind: "today", + }); + }); + + it("reads the weekday in the PROFILE zone, not the host's", () => { + // 2026-06-14 is a Sunday in Berlin. In Sydney the same instant is + // already Monday, so a host-derived weekday would name the wrong day. + setHostZone("Australia/Sydney"); + const now = berlin(2026, 6, 10, 9, 0); + const next = berlin(2026, 6, 14, 23, 0); + expect(resolveNextDueDayLabel(next, now, BERLIN)).toEqual({ + kind: "weekday", + weekday: 0, // Sunday in Berlin + }); + }); + + it("survives a profile wall clock that lands in the HOST zone's spring-forward gap", () => { + // New York springs forward 2026-03-08 02:00 → 03:00, so 02:30 does not + // exist there. Re-parsing the profile wall clock "2026-03-08 02:30" as + // host-local silently advanced it an hour. + setHostZone("America/New_York"); + const now = berlin(2026, 3, 8, 2, 30); + const next = berlin(2026, 3, 18, 2, 30); + const label = resolveNextDueDayLabel(next, now, BERLIN); + expect(label).toEqual({ kind: "date", instant: next }); + }); +}); + +describe("resolveNextDueDayLabel — DST transition days", () => { + it("does not round a 25 h fall-back day's 'tomorrow' into 'today'", () => { + // Berlin falls back 2026-10-25 (a 25 h local day). An hour-count + // distance divides 25 h by 24 h and rounds to 1 — which happens to be + // right here — but the same arithmetic across the 23 h spring-forward + // day rounds a genuine next-day dose down toward today. Calendar-day + // indices are immune to both. + const now = berlin(2026, 10, 25, 8, 0); + const next = berlin(2026, 10, 26, 8, 0); + expect(resolveNextDueDayLabel(next, now, BERLIN)).toEqual({ + kind: "tomorrow", + }); + }); + + it("labels across the 23 h spring-forward day by calendar day", () => { + // Berlin springs forward 2026-03-29. 2026-03-29 23:30 → 2026-03-30 + // 00:30 is 1 h of wall clock but a genuine calendar-day crossing. + const now = berlin(2026, 3, 29, 23, 30); + const next = berlin(2026, 3, 30, 0, 30); + expect(resolveNextDueDayLabel(next, now, BERLIN)).toEqual({ + kind: "tomorrow", + }); + }); + + it("still reads 'today' for a later dose on the spring-forward day itself", () => { + const now = berlin(2026, 3, 29, 1, 30); + const next = berlin(2026, 3, 29, 20, 0); + expect(resolveNextDueDayLabel(next, now, BERLIN)).toEqual({ + kind: "today", + }); + }); +}); diff --git a/src/lib/medications/__tests__/window-status.test.ts b/src/lib/medications/__tests__/window-status.test.ts index 09f4aa8d5..eb1bbded6 100644 --- a/src/lib/medications/__tests__/window-status.test.ts +++ b/src/lib/medications/__tests__/window-status.test.ts @@ -11,7 +11,7 @@ * so a two-dose row keeps the overdue pill up until BOTH doses are * covered by today's intake events. */ -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it } from "vitest"; import { reduceCurrentWindowStatus } from "../window-status"; import { zonedWallClockToUtc } from "@/lib/tz/wall-clock"; @@ -452,3 +452,79 @@ describe("reduceCurrentWindowStatus — day-scale taken-early context", () => { expect(res.takenEarlyDaysAgo).toBe(1); }); }); + +describe("reduceCurrentWindowStatus — HOST zone must not reach the verdict", () => { + // The status math used to receive a `Date` fabricated by rendering the + // instant in the profile 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 sitting inside its window + // could be classified late and a just-taken dose could fail to suppress + // the take-now pill. One hour a year, only for users whose device zone + // differs from their profile zone — i.e. never visible to a UTC-only, + // single-zone suite. + const ORIGINAL_TZ = process.env.TZ; + afterEach(() => { + process.env.TZ = ORIGINAL_TZ; + }); + + /** 02:30 Berlin on 2026-03-08 — a perfectly ordinary CET wall clock. */ + const nowBerlin0230 = zonedWallClockToUtc( + { year: 2026, month: 3, day: 8, hour: 2, minute: 30 }, + "Europe/Berlin", + ); + const band0200to0300 = [ + { windowStart: "02:00", windowEnd: "03:00", daysOfWeek: null }, + ]; + + it("reads in_window under a host zone whose spring-forward gap contains the profile clock", () => { + // America/New_York springs forward 2026-03-08 02:00 → 03:00, so 02:30 + // does not exist on the host clock that day. + process.env.TZ = "America/New_York"; + const res = reduceCurrentWindowStatus({ + ...BASE, + schedules: band0200to0300, + now: nowBerlin0230, + tz: "Europe/Berlin", + }); + expect(res.status).toBe("in_window"); + }); + + it("returns the same verdict from every host zone", () => { + const verdicts = new Set(); + for (const host of [ + "UTC", + "America/New_York", + "Australia/Sydney", + "Asia/Kolkata", + "Europe/Berlin", + ]) { + process.env.TZ = host; + verdicts.add( + String( + reduceCurrentWindowStatus({ + ...BASE, + schedules: band0200to0300, + now: nowBerlin0230, + tz: "Europe/Berlin", + }).status, + ), + ); + } + expect([...verdicts]).toEqual(["in_window"]); + }); + + it("suppresses the take-now pill for a just-taken dose under the same host gap", () => { + // 01:15Z is 02:15 Berlin — inside the 02:00–03:00 band, 15 minutes + // before `now`. The pill must stay dark. + process.env.TZ = "America/New_York"; + const res = reduceCurrentWindowStatus({ + ...BASE, + schedules: band0200to0300, + now: nowBerlin0230, + lastTakenAt: "2026-03-08T01:15:00.000Z", + todayEventCount: 1, + tz: "Europe/Berlin", + }); + expect(res.status).toBeNull(); + }); +}); diff --git a/src/lib/medications/next-due-day-label.ts b/src/lib/medications/next-due-day-label.ts new file mode 100644 index 000000000..a8b94f5fc --- /dev/null +++ b/src/lib/medications/next-due-day-label.ts @@ -0,0 +1,55 @@ +/** + * Which day label the next-intake line carries — "today", "tomorrow", a + * weekday name, or a full date. + * + * Lifted out of `` and ``, which carried + * byte-identical copies of this arithmetic. Both surfaces must agree; two + * copies is how they drift. + * + * The caller maps the result onto its own i18n strings, so this module + * stays free of translation plumbing and is directly testable. + */ +import { localDayIndex, wallClockInTz } from "@/lib/tz/wall-clock"; + +export type NextDueDayLabel = + | { kind: "today" } + | { kind: "tomorrow" } + /** 0 = Sunday … 6 = Saturday, read in the PROFILE zone. */ + | { kind: "weekday"; weekday: number } + /** + * Far enough out to want a full date. Carries the RAW instant — the + * formatter applies the profile zone itself, so handing it a + * zone-shifted `Date` would convert twice. + */ + | { kind: "date"; instant: Date }; + +/** + * Resolve the label for a next-due instant relative to `now`, both read in + * `tz` (the user's PROFILE timezone — never the browser's). + * + * Two properties this function exists to guarantee: + * + * - The distance is a CALENDAR-day difference, not an hour count. A + * `(next - now) / 86_400_000` round admits the 23 h / 25 h length of a + * DST-transition day and can round "tomorrow" into "today". + * - Every field is read through `wallClockInTz` in the profile zone, and + * the `date` case hands back the untouched instant. The previous + * implementation pre-shifted the instant into the profile zone and then + * passed that shifted value to a formatter that applies the zone AGAIN, + * so the rendered day was wrong for any user whose browser zone differs + * from their profile zone — invisible whenever the two agree. + */ +export function resolveNextDueDayLabel( + nextAt: Date, + now: Date, + tz: string, +): NextDueDayLabel { + const nextParts = wallClockInTz(nextAt, tz); + const dayDelta = + localDayIndex(nextParts) - localDayIndex(wallClockInTz(now, tz)); + + if (dayDelta === 0) return { kind: "today" }; + if (dayDelta === 1) return { kind: "tomorrow" }; + if (dayDelta <= 5) return { kind: "weekday", weekday: nextParts.weekday }; + return { kind: "date", instant: nextAt }; +} From 4791d6f543b890049ac723608c6b50bffe28e067 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 19 Jul 2026 02:27:03 +0200 Subject: [PATCH 4/5] test: pin the suite's timezone and make the zone-sensitive fixtures say which zone they mean MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The suite was not timezone-pinned. CI runs UTC, so a whole class of day-bucketing defect was invisible to the gate while a contributor outside UTC saw a red suite on unmodified main. Verified: a UTC+14 host fails five tests on an otherwise green tree. Pin TZ=UTC in both vitest configs; tests that need a different HOST zone now set and restore it explicitly. The resting-pulse fixtures passed offset-less ISO literals, which parse as host-local, so they could not distinguish the Berlin day-key the implementation uses from a UTC one — swapping the day-key's zone left the file green. Build each instant explicitly in Berlin and add cases whose local day and UTC day genuinely differ; that swap now fails two of them. Delete the 'DST boundary (spring-forward)' compliance case. It passed no timezone, so the slot builder took the legacy UTC path where the date carries no transition, and it asserted totalExpected === 7 — which holds in every zone and therefore could never see the double-emit its own comment described. Threading a real zone does not rescue it: the assertion stays true in Berlin, UTC, UTC+14 and UTC-11 alike. The property it claimed is already pinned properly in the scheduling suite, which walks buildCadenceTimeline with an explicit zone across both the 23 h spring-forward and the 25 h fall-back day. --- .../analytics/__tests__/resting-pulse.test.ts | 94 ++++++++++++++++++- .../compliance/__tests__/compliance.test.ts | 46 +++------ vitest.config.mts | 9 ++ vitest.integration.config.mts | 3 + 4 files changed, 114 insertions(+), 38 deletions(-) diff --git a/src/lib/analytics/__tests__/resting-pulse.test.ts b/src/lib/analytics/__tests__/resting-pulse.test.ts index 666e6500f..af41b0e4d 100644 --- a/src/lib/analytics/__tests__/resting-pulse.test.ts +++ b/src/lib/analytics/__tests__/resting-pulse.test.ts @@ -12,11 +12,45 @@ import { resolveRestingPulseSeries, type PulseSample, } from "../resting-pulse"; +import { zonedWallClockToUtc } from "@/lib/tz/wall-clock"; -const day = (iso: string, value: number): PulseSample => ({ - measuredAt: new Date(iso), - value, -}); +/** + * The zone `deriveRestingProxyFromPulse` buckets in by default + * (`toBerlinDayKey`). Naming it here is the point: the fixtures used to + * pass offset-less ISO literals, which `new Date(...)` reads as HOST-local. + * That made every expectation mean something different on every developer + * machine, and — because a Berlin day and a UTC day coincide for samples in + * the middle of the day — swapping the implementation's day-key zone left + * the whole file green. Building each instant explicitly in Berlin lets the + * assertions actually pin the bucketing. + */ +const PROXY_DAY_ZONE = "Europe/Berlin"; + +/** + * A pulse sample at the given `YYYY-MM-DDTHH:MM:SS` wall clock **in + * Berlin**, expressed as the UTC instant it denotes. + */ +const day = (wallClock: string, value: number): PulseSample => { + const m = wallClock.match( + /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})$/, + ); + if (!m) throw new Error(`fixture wall clock must be local, got ${wallClock}`); + const [, y, mo, d, h, mi, sec] = m; + return { + measuredAt: zonedWallClockToUtc( + { + year: Number(y), + month: Number(mo), + day: Number(d), + hour: Number(h), + minute: Number(mi), + second: Number(sec), + }, + PROXY_DAY_ZONE, + ), + value, + }; +}; describe("deriveRestingProxyFromPulse", () => { it("excludes a workout burst — the daily proxy tracks the resting floor", () => { @@ -154,3 +188,55 @@ describe("resolveRestingPulseSeries", () => { ).toEqual({ series: [], which: "none" }); }); }); + +describe("deriveRestingProxyFromPulse — the day bucket is the PROFILE day", () => { + it("keeps after-midnight local samples on their own local day, not the UTC one", () => { + // Berlin is UTC+2 in June, so 00:20–01:00 local on 2026-06-02 is + // 22:20–23:00 UTC on 2026-06-01 — a DIFFERENT UTC calendar day. All + // six samples belong to one Berlin day and must produce ONE bucket. + // Under a UTC day-key they split 3/3 and produce two. + const samples = [ + day("2026-06-02T00:20:00", 60), + day("2026-06-02T00:40:00", 62), + day("2026-06-02T01:00:00", 64), + day("2026-06-02T09:00:00", 66), + day("2026-06-02T15:00:00", 68), + day("2026-06-02T21:00:00", 70), + ]; + const proxy = deriveRestingProxyFromPulse(samples); + expect(proxy).toHaveLength(1); + }); + + it("splits two local days that a UTC key would merge", () => { + // 23:10–23:50 Berlin on 2026-06-01 is 21:10–21:50 UTC the same day, + // while 00:10–00:50 Berlin on 2026-06-02 is 22:10–22:50 UTC on + // 2026-06-01. A UTC key merges all six into one bucket; the Berlin key + // must see two days. + const samples = [ + day("2026-06-01T23:10:00", 60), + day("2026-06-01T23:30:00", 62), + day("2026-06-01T23:50:00", 64), + day("2026-06-02T00:10:00", 80), + day("2026-06-02T00:30:00", 82), + day("2026-06-02T00:50:00", 84), + ]; + const proxy = deriveRestingProxyFromPulse(samples); + expect(proxy).toHaveLength(2); + }); + + it("honours an injected day-key function over the Berlin default", () => { + // The targets route threads `userDayKey(d, userTz)`. Same samples, + // different zone → a different bucketing. This is the seam the default + // hides, so it gets its own pin. + const samples = [ + day("2026-06-02T00:20:00", 60), + day("2026-06-02T00:40:00", 62), + day("2026-06-02T01:00:00", 64), + ]; + const utcKey = (d: Date) => d.toISOString().slice(0, 10); + // In UTC these three are 2026-06-01 22:20/22:40/23:00 → one UTC day. + expect(deriveRestingProxyFromPulse(samples, utcKey)).toHaveLength(1); + // And that UTC day is 06-01, whereas the Berlin bucket is 06-02. + expect(utcKey(samples[0].measuredAt)).toBe("2026-06-01"); + }); +}); diff --git a/src/lib/analytics/compliance/__tests__/compliance.test.ts b/src/lib/analytics/compliance/__tests__/compliance.test.ts index f14be4e05..b360f6218 100644 --- a/src/lib/analytics/compliance/__tests__/compliance.test.ts +++ b/src/lib/analytics/compliance/__tests__/compliance.test.ts @@ -5,8 +5,18 @@ * the numbers now honour `daysOfWeek` and `intervalWeeks`. These * tests pin the contract for every cadence the production app * exercises: daily 1×/day, daily multi-dose, weekly Mondays-only, - * bi-weekly, weekday-only multi-dose, and the DST-boundary day. - * Closes #214. + * bi-weekly, and weekday-only multi-dose. Closes #214. + * + * The DST local-day walk is NOT pinned here. A "DST boundary" case used + * to sit in this file passing no timezone, so the slot builder took the + * legacy UTC path where the date carries no transition — it asserted + * `totalExpected === 7`, which holds in every zone and therefore could + * never see a double-emit. Threading a real zone through it does not + * help: the assertion stays true in Berlin, UTC, UTC+14 and UTC−11 + * alike. The property it claimed lives in + * `src/lib/medications/scheduling/__tests__/compliance.test.ts`, which + * walks `buildCadenceTimeline` with an explicit zone across BOTH the 23 h + * spring-forward and the 25 h fall-back day. * * Each test uses `vi.useFakeTimers()` to pin `now` so the rolling * window is deterministic. `classifyIntakeTiming` tests below the @@ -343,38 +353,6 @@ describe("calculateCompliance — cadence-aware adapter", () => { expect(result.missed).toBe(0); }); - it("DST boundary (Europe/Berlin spring-forward) does not inflate the expected count", () => { - // Spring-forward in Berlin: 2025-03-30 02:00 → 03:00. A daily - // schedule across this boundary must still emit one slot per - // local day — not two on the short day and zero on the next. - // Pin `now` to Apr-02 noon UTC and run a 7-day window so the - // boundary day is inside it. Generate one event per day - // (working backwards from NOW − 1 day so each event lands inside - // the window) and assert the totalExpected lands at 7 (not 8). - vi.setSystemTime(new Date("2025-04-02T12:00:00Z")); - const schedules: ComplianceSchedule[] = [ - { windowStart: "10:00", windowEnd: "11:00", daysOfWeek: null }, - ]; - // Slots emitted by the timeline: Mar-27..Apr-02 (Mar-26's window - // sits below the rolling-window start of Mar-26 12:00). Generate - // one taken event per slot — today's 10:30 slot is also in the - // past relative to NOW = 12:00 so it counts as taken too. - const events = []; - for (let d = 0; d <= 6; d++) { - const at = new Date("2025-04-02T10:30:00Z"); - at.setUTCDate(at.getUTCDate() - d); - events.push(eventAt(at, true)); - } - const result = calculateCompliance(events, schedules, 7); - // The hard contract: totalExpected stays at 7 — not 8 (which - // would mean the DST short day double-emitted) and not 6 (which - // would mean the short day silently dropped a slot). - expect(result.totalExpected).toBe(7); - expect(result.taken).toBe(7); - expect(result.missed).toBe(0); - expect(result.rate).toBe(100); - }); - it("filters events outside the rolling window", () => { const schedules: ComplianceSchedule[] = [ { windowStart: "08:00", windowEnd: "09:00", daysOfWeek: null }, diff --git a/vitest.config.mts b/vitest.config.mts index 6972701c9..caee54df7 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -9,6 +9,15 @@ export default defineConfig({ test: { globals: true, environment: "node", + // Pin the host timezone. CI runs UTC; without this a contributor in any + // other zone sees a different suite than the gate does — verified: a + // UTC+14 host fails five tests on an otherwise green tree. Pinning makes + // local and CI agree, and makes "which zone does this test mean?" an + // explicit choice the fixture has to state rather than a property of the + // machine it runs on. Tests that need a different HOST zone set + // `process.env.TZ` themselves and restore it afterwards (see + // `next-due-day-label.test.ts`). + env: { TZ: "UTC" }, // Primes the i18n locale cache so provider mounts resolve every // locale synchronously (production gets the active bundle as an RSC // prop instead — see src/lib/i18n/load-locale.ts). diff --git a/vitest.integration.config.mts b/vitest.integration.config.mts index 4ae52780a..3a36a7989 100644 --- a/vitest.integration.config.mts +++ b/vitest.integration.config.mts @@ -13,6 +13,9 @@ export default defineConfig({ test: { globals: true, environment: "node", + // Same host-timezone pin as the unit config — CI runs UTC, so the + // integration contracts must be read against the same clock. + env: { TZ: "UTC" }, include: ["tests/integration/**/*.test.ts"], // Container boot + migration apply is slow; give each test a generous // budget and beforeAll/afterAll twice that. From 40ef4b40b0f0c3441115ab55466b27b3c6cadf41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 19 Jul 2026 02:28:01 +0200 Subject: [PATCH 5/5] test(medications): pin the slot dedupe contract across differing reference instants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The projector, the reminder worker and the intake write path key on the slot instant byte-for-byte, and all three derive it from a DIFFERENT reference — a cron tick, a later cron tick, and the moment the user pressed take. That is the whole hazard: the primitive used to sample the zone offset at its reference, so on a transition day two ticks on opposite sides minted instants an hour apart for the same slot and the dedupe key stopped matching. The existing DST cases compare the resolver to a projector mint computed from the SAME reference, which cannot see this. Pass deliberately different references across both Berlin transitions. --- .../__tests__/resolve-slot-instant.test.ts | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/src/lib/medications/scheduling/__tests__/resolve-slot-instant.test.ts b/src/lib/medications/scheduling/__tests__/resolve-slot-instant.test.ts index ac88428a1..efd0f9ae7 100644 --- a/src/lib/medications/scheduling/__tests__/resolve-slot-instant.test.ts +++ b/src/lib/medications/scheduling/__tests__/resolve-slot-instant.test.ts @@ -457,3 +457,71 @@ describe("resolveCanonicalSlotInstant — DST robustness vs projector mint", () expect(result?.toISOString()).toBe(projectorMint.toISOString()); }); }); + +describe("slot dedupe contract — projector, reminder worker and intake write", () => { + // The three surfaces key on the slot instant BYTE-FOR-BYTE: the projector + // mints a pending row at `localHmAsUtc(, tz, h, m)`, the reminder + // worker re-derives the same slot from ITS tick, and the intake write path + // snaps an incoming take to the canonical instant via + // `resolveCanonicalSlotInstant`. All three reference instants are + // different by construction — a cron tick, a later cron tick, and the + // moment the user actually pressed "take". + // + // That is the whole hazard. The primitive used to sample the zone offset + // at its REFERENCE rather than at the target local time, so on a + // transition day a pre-transition tick and a post-transition tick minted + // instants an hour apart for the same slot — the dedupe key stopped + // matching and a duplicate pending row became reachable. + // + // The existing DST cases above compare the resolver to a projector mint + // computed from the SAME reference, which cannot see this. These pass + // deliberately different references. + + const SLOT_HOUR = 20; + const med = makeMedication([ + makeSchedule({ + timesOfDay: ["20:00"], + windowStart: "20:00", + windowEnd: "21:00", + }), + ]); + + it("agrees across references on either side of the autumn fall-back", () => { + // Europe/Berlin 2026-10-25: 03:00 CEST → 02:00 CET. + const projectorTick = new Date("2026-10-25T00:30:00.000Z"); // 02:30 CEST + const workerTick = new Date("2026-10-25T17:00:00.000Z"); // 18:00 CET + const takePressedAt = new Date("2026-10-25T19:10:00.000Z"); // 20:10 CET + + const projectorMint = localHmAsUtc(projectorTick, TZ, SLOT_HOUR, 0); + const workerMint = localHmAsUtc(workerTick, TZ, SLOT_HOUR, 0); + const writeMint = resolveCanonicalSlotInstant({ + medication: med, + userTz: TZ, + incoming: takePressedAt, + }); + + expect(workerMint.toISOString()).toBe(projectorMint.toISOString()); + expect(writeMint?.toISOString()).toBe(projectorMint.toISOString()); + // And the shared instant really is 20:00 on the user's clock. + expect(projectorMint.toISOString()).toBe("2026-10-25T19:00:00.000Z"); + }); + + it("agrees across references on either side of the spring-forward", () => { + // Europe/Berlin 2026-03-29: 02:00 CET → 03:00 CEST. + const projectorTick = new Date("2026-03-29T00:30:00.000Z"); // 01:30 CET + const workerTick = new Date("2026-03-29T16:00:00.000Z"); // 18:00 CEST + const takePressedAt = new Date("2026-03-29T18:10:00.000Z"); // 20:10 CEST + + const projectorMint = localHmAsUtc(projectorTick, TZ, SLOT_HOUR, 0); + const workerMint = localHmAsUtc(workerTick, TZ, SLOT_HOUR, 0); + const writeMint = resolveCanonicalSlotInstant({ + medication: med, + userTz: TZ, + incoming: takePressedAt, + }); + + expect(workerMint.toISOString()).toBe(projectorMint.toISOString()); + expect(writeMint?.toISOString()).toBe(projectorMint.toISOString()); + expect(projectorMint.toISOString()).toBe("2026-03-29T18:00:00.000Z"); + }); +});