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..6a43f03cb 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 { resolveNextDueDayLabel } from "@/lib/medications/next-due-day-label"; import { resolveDisplayedSlotInstant } from "@/components/medications/card-parts/displayed-slot-instant"; import { estimateDailyDoseCount, @@ -242,7 +240,7 @@ export function MedicationCard({ a.windowStart.localeCompare(b.windowStart) || a.windowEnd.localeCompare(b.windowEnd), ); - const nowBerlin = toZonedDate(new Date(), userTz); + const now = new Date(); // 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 +259,7 @@ export function MedicationCard({ const currentWindowStatus = reduceCurrentWindowStatus({ schedules: sortedSchedules, - nowBerlin, + now, lateMinutes, missedMinutes, active: medication.active, @@ -418,27 +416,15 @@ 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()}`; - - const diffDays = Math.round( - (nextDate.getTime() - nowBerlin.getTime()) / - (24 * 60 * 60 * 1000), - ); - - if (nextStr === todayStr) { - dayLabel = t("medications.today"); - } else if (nextStr === tomorrowStr) { - dayLabel = t("medications.tomorrow"); - } else if (diffDays <= 5) { - dayLabel = weekdayLabel(nextDate.getDay()); - } else { - dayLabel = fmt.dateWithWeekdaySmart(nextDate); - } + 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 b7f045796..aa6adcdf3 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 { 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"; @@ -420,7 +418,6 @@ function MedicationTableRowItem({ a.windowEnd.localeCompare(b.windowEnd), ); const now = new Date(); - const nowZoned = toZonedDate(now, userTz); const nextDueMs = medication.nextDueAt ? new Date(medication.nextDueAt).getTime() : NaN; @@ -431,7 +428,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 +532,15 @@ 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), - ); + const label = resolveNextDueDayLabel(new Date(nextAt), now, userTz); const dayLabel = - nextStr === todayStr + label.kind === "today" ? t("medications.today") - : nextStr === tomorrowStr + : label.kind === "tomorrow" ? t("medications.tomorrow") - : diffDays <= 5 - ? weekdayLabel(nextDate.getDay()) - : fmt.dateWithWeekdaySmart(nextDate); + : label.kind === "weekday" + ? weekdayLabel(label.weekday) + : fmt.dateWithWeekdaySmart(label.instant); 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/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/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 ce6328f62..eb1bbded6 100644 --- a/src/lib/medications/__tests__/window-status.test.ts +++ b/src/lib/medications/__tests__/window-status.test.ts @@ -11,18 +11,26 @@ * 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, - 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", @@ -457,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 }; +} 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"); + }); +}); 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/__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); } 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, 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.