mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +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
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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