mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 06:40:50 +00:00
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:
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();
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in new issue
Block a user