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

@@ -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 };
}