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] 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 }; +}