fix(medications): drop the fabricated-Date wall clock from the window status

toZonedDate rendered an instant as an en-US string in the target zone and
re-parsed it as host-local. Two defects rode on that.

When the fabricated wall clock landed in the HOST zone's spring-forward gap
the engine silently advanced it an hour, so a dose inside its window could
be classified late and a just-taken dose could fail to suppress the take-now
pill — for users whose device zone differs from their profile zone.

The medication card and table then passed that already-shifted value into
dateWithWeekdaySmart, which applies the profile zone itself, so the next-
intake day label converted twice and could name the wrong calendar day.
Invisible whenever browser zone and profile zone agree.

Migrate every consumer to wallClockInTz, which returns the fields directly.
The status math only ever reads year/month/day/hour/minute/weekday, so it
now runs on WallClockParts throughout and reduceCurrentWindowStatus takes
the real instant instead of a pre-shifted Date — caller and helper can no
longer disagree about which zone the comparison runs in. Day labels use a
calendar-day distance (localDayIndex) rather than an hour count, so a 23 h
or 25 h day cannot round tomorrow into today, and the formatter receives
the raw instant.

Fixtures name the zone they mean instead of building host-local Dates.
This commit is contained in:
Marc-André Bombeck committed 2026-07-19 02:13:08 +02:00
1 parent 91c4f789db
commit eb071b5dd8
9 files changed
+155 -179

No files matched your search

@@ -25,7 +25,6 @@ import {
} from "@/lib/query-keys";
import {
reduceCurrentWindowStatus,
toZonedDate,
type ScheduleWindowInput,
} from "@/lib/medications/window-status";
import { useAuth } from "@/hooks/use-auth";
@@ -142,7 +141,6 @@ export function pickDefaultMedicationId(
if (actives.length === 0) return null;
if (actives.length === 1) return actives[0].id;
const nowLocal = toZonedDate(now, tz);
const due = actives.find((m) => {
// v1.16.9 — the same server display-due gate the cards apply: a
// future (non-overdue) next-due suppresses the overdue tiers and a
@@ -150,7 +148,7 @@ export function pickDefaultMedicationId(
const nextDueMs = m.nextDueAt ? new Date(m.nextDueAt).getTime() : NaN;
const status = reduceCurrentWindowStatus({
schedules: m.schedules,
nowBerlin: nowLocal,
now,
lateMinutes: thresholds.lateMinutes,
missedMinutes: thresholds.missedMinutes,
active: true,
@@ -54,10 +54,10 @@ export interface ResolveDisplayedSlotInstantInput {
/** Wall-clock reference (injected for testability). */
now: Date;
/**
* IANA timezone the card's day boundary is reasoned in. The cards model
* the user's day in Europe/Berlin (see `toBerlinDate`), so this defaults
* to that; passing the user's actual zone keeps the slot on the right
* calendar day across the rare cross-midnight edge.
* IANA timezone the card's day boundary is reasoned in. Berlin is the
* last-resort fallback the card surfaces share; passing the user's
* actual zone keeps the slot on the right calendar day across the rare
* cross-midnight edge.
*/
timeZone?: string;
}
@@ -27,10 +27,7 @@ import { getDayOfWeekInTz } from "@/lib/tz/local-day";
import { type InjectionSiteKey } from "@/lib/medications/injection-sites";
import { LogInjectionSiteDialog } from "@/components/medications/log-injection-site-dialog";
import { useGlobalExcludedInjectionSites } from "@/lib/medications/use-injection-site-prefs";
import {
reduceCurrentWindowStatus,
toZonedDate,
} from "@/lib/medications/window-status";
import { reduceCurrentWindowStatus } from "@/lib/medications/window-status";
import { resolveDisplayedSlotInstant } from "@/components/medications/card-parts/displayed-slot-instant";
import {
estimateDailyDoseCount,
@@ -325,12 +322,11 @@ export function Glp1MedicationCard({
a.windowStart.localeCompare(b.windowStart) ||
a.windowEnd.localeCompare(b.windowEnd),
);
const nowBerlin = toZonedDate(now, userTz);
const lateMinutes = thresholds?.lateMinutes ?? 120;
const missedMinutes = thresholds?.missedMinutes ?? 240;
const currentWindowStatus = reduceCurrentWindowStatus({
schedules: sortedSchedules,
nowBerlin,
now,
lateMinutes,
missedMinutes,
active: medication.active,
+21 -22
View File
@@ -13,10 +13,8 @@ import { formatDateTime, formatTime } from "@/lib/format";
import { getDateTimeFormat } from "@/lib/intl/formatter-cache";
import { getMedicationCategoryLabel } from "@/lib/medications/category-label";
import { formatDose } from "@/lib/medications/format-dose";
import {
reduceCurrentWindowStatus,
toZonedDate,
} from "@/lib/medications/window-status";
import { reduceCurrentWindowStatus } from "@/lib/medications/window-status";
import { localDayIndex, wallClockInTz } from "@/lib/tz/wall-clock";
import { resolveDisplayedSlotInstant } from "@/components/medications/card-parts/displayed-slot-instant";
import {
estimateDailyDoseCount,
@@ -242,7 +240,8 @@ export function MedicationCard({
a.windowStart.localeCompare(b.windowStart) ||
a.windowEnd.localeCompare(b.windowEnd),
);
const nowBerlin = toZonedDate(new Date(), userTz);
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 /
@@ -261,7 +260,7 @@ export function MedicationCard({
const currentWindowStatus = reduceCurrentWindowStatus({
schedules: sortedSchedules,
nowBerlin,
now,
lateMinutes,
missedMinutes,
active: medication.active,
@@ -418,26 +417,26 @@ export function MedicationCard({
// Format day label relative to today
let dayLabel = "";
if (nextAt) {
const nextDate = toZonedDate(new Date(nextAt), userTz);
const todayStr = `${nowBerlin.getFullYear()}-${nowBerlin.getMonth()}-${nowBerlin.getDate()}`;
const nextStr = `${nextDate.getFullYear()}-${nextDate.getMonth()}-${nextDate.getDate()}`;
const tomorrow = new Date(nowBerlin);
tomorrow.setDate(tomorrow.getDate() + 1);
const tomorrowStr = `${tomorrow.getFullYear()}-${tomorrow.getMonth()}-${tomorrow.getDate()}`;
// 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);
const diffDays = Math.round(
(nextDate.getTime() - nowBerlin.getTime()) /
(24 * 60 * 60 * 1000),
);
if (nextStr === todayStr) {
if (dayDelta === 0) {
dayLabel = t("medications.today");
} else if (nextStr === tomorrowStr) {
} else if (dayDelta === 1) {
dayLabel = t("medications.tomorrow");
} else if (diffDays <= 5) {
dayLabel = weekdayLabel(nextDate.getDay());
} else if (dayDelta <= 5) {
dayLabel = weekdayLabel(nextParts.weekday);
} else {
dayLabel = fmt.dateWithWeekdaySmart(nextDate);
dayLabel = fmt.dateWithWeekdaySmart(nextInstant);
}
}
+17 -20
View File
@@ -28,10 +28,8 @@ import { useWeekdayLabel } from "@/components/medications/card-parts/medication-
import { resolveDisplayedSlotInstant } from "@/components/medications/card-parts/displayed-slot-instant";
import { estimateRunwayDays } from "@/components/medications/detail/supply-runway";
import { useMedicationIntake } from "@/components/medications/use-medication-intake";
import {
reduceCurrentWindowStatus,
toZonedDate,
} from "@/lib/medications/window-status";
import { reduceCurrentWindowStatus } from "@/lib/medications/window-status";
import { localDayIndex, wallClockInTz } from "@/lib/tz/wall-clock";
import { formatTime } from "@/lib/format";
import { formatTimeWindowRange } from "@/lib/time-window-format";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
@@ -420,7 +418,7 @@ function MedicationTableRowItem({
a.windowEnd.localeCompare(b.windowEnd),
);
const now = new Date();
const nowZoned = toZonedDate(now, userTz);
const nowParts = wallClockInTz(now, userTz);
const nextDueMs = medication.nextDueAt
? new Date(medication.nextDueAt).getTime()
: NaN;
@@ -431,7 +429,7 @@ function MedicationTableRowItem({
// next dose is tomorrow can never paint an overdue pill today.
const currentWindowStatus = reduceCurrentWindowStatus({
schedules: sortedSchedules,
nowBerlin: nowZoned,
now,
lateMinutes,
missedMinutes,
active: medication.active,
@@ -535,23 +533,22 @@ function MedicationTableRowItem({
</span>
);
} else if (nextAt) {
const nextDate = toZonedDate(new Date(nextAt), userTz);
const todayStr = `${nowZoned.getFullYear()}-${nowZoned.getMonth()}-${nowZoned.getDate()}`;
const nextStr = `${nextDate.getFullYear()}-${nextDate.getMonth()}-${nextDate.getDate()}`;
const tomorrow = new Date(nowZoned);
tomorrow.setDate(tomorrow.getDate() + 1);
const tomorrowStr = `${tomorrow.getFullYear()}-${tomorrow.getMonth()}-${tomorrow.getDate()}`;
const diffDays = Math.round(
(nextDate.getTime() - nowZoned.getTime()) / (24 * 60 * 60 * 1000),
);
// 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 dayLabel =
nextStr === todayStr
dayDelta === 0
? t("medications.today")
: nextStr === tomorrowStr
: dayDelta === 1
? t("medications.tomorrow")
: diffDays <= 5
? weekdayLabel(nextDate.getDay())
: fmt.dateWithWeekdaySmart(nextDate);
: dayDelta <= 5
? weekdayLabel(nextParts.weekday)
: fmt.dateWithWeekdaySmart(nextInstant);
nextCell = (
<span>
{dayLabel}, {formatTime(new Date(nextAt).toISOString())}
+1 -3
View File
@@ -42,7 +42,6 @@ import { apiPost } from "@/lib/api/api-fetch";
import { invalidateKeys, medicationDependentKeys } from "@/lib/query-keys";
import {
reduceCurrentWindowStatus,
toZonedDate,
type ScheduleWindowInput,
} from "@/lib/medications/window-status";
import { resolveDisplayedSlotInstant } from "@/components/medications/card-parts/displayed-slot-instant";
@@ -133,7 +132,6 @@ export function deriveDueMedications(
const tz = options.tz ?? "Europe/Berlin";
const { lateMinutes, missedMinutes } =
options.thresholds ?? DEFAULT_THRESHOLDS;
const nowLocal = toZonedDate(now, tz);
const due: DueMedication[] = [];
for (const m of medications) {
@@ -146,7 +144,7 @@ export function deriveDueMedications(
const nextDueMs = m.nextDueAt ? new Date(m.nextDueAt).getTime() : NaN;
const status = reduceCurrentWindowStatus({
schedules: m.schedules,
nowBerlin: nowLocal,
now,
lateMinutes,
missedMinutes,
active: m.active,