refactor(medications): share the next-intake day label between card and table

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.
This commit is contained in:
Marc-André Bombeck committed 2026-07-19 02:19:37 +02:00
1 parent eb071b5dd8
commit 4ba600d908
5 files changed
+317 -39

No files matched your search

+10 -23
View File
@@ -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
@@ -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({
</span>
);
} 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 = (
<span>
{dayLabel}, {formatTime(new Date(nextAt).toISOString())}
@@ -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",
});
});
});
@@ -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<string>();
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();
});
});
+55
View File
@@ -0,0 +1,55 @@
/**
* Which day label the next-intake line carries — "today", "tomorrow", a
* weekday name, or a full date.
*
* Lifted out of `<MedicationCard>` and `<MedicationTable>`, 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 };
}