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())}