mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
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:
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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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())}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -13,16 +13,24 @@
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
reduceCurrentWindowStatus,
|
||||
toBerlinDate,
|
||||
toZonedDate,
|
||||
} from "../window-status";
|
||||
import { reduceCurrentWindowStatus } from "../window-status";
|
||||
import { zonedWallClockToUtc } from "@/lib/tz/wall-clock";
|
||||
|
||||
/** Build a local wall-clock Date (what the cards pass as `nowBerlin`). */
|
||||
function localClock(hours: number, minutes = 0): Date {
|
||||
// Wednesday, June 10 2026 — weekday-agnostic fixtures (no daysOfWeek).
|
||||
return new Date(2026, 5, 10, hours, minutes, 0, 0);
|
||||
/**
|
||||
* The real UTC instant at which an observer in `tz` reads `hh:mm` on
|
||||
* Wednesday, June 10 2026 (weekday-agnostic fixtures carry no daysOfWeek).
|
||||
*
|
||||
* The fixtures used to build a HOST-local `Date` and hand it over as a
|
||||
* pre-shifted wall clock, which meant they asserted nothing about the
|
||||
* zone: the same literal meant a different instant on every developer
|
||||
* machine, and swapping the helper's zone left the suite green. Naming the
|
||||
* zone here makes each expectation say which clock it means.
|
||||
*/
|
||||
function instantAt(hours: number, minutes = 0, tz = "Europe/Berlin"): Date {
|
||||
return zonedWallClockToUtc(
|
||||
{ year: 2026, month: 6, day: 10, hour: hours, minute: minutes },
|
||||
tz,
|
||||
);
|
||||
}
|
||||
|
||||
const BASE = {
|
||||
@@ -33,21 +41,6 @@ const BASE = {
|
||||
todayEventCount: 0,
|
||||
};
|
||||
|
||||
describe("toZonedDate", () => {
|
||||
it("shifts an instant into the requested timezone's wall clock", () => {
|
||||
const instant = new Date("2026-06-10T12:00:00Z");
|
||||
expect(toZonedDate(instant, "Europe/Berlin").getHours()).toBe(14); // CEST
|
||||
expect(toZonedDate(instant, "America/New_York").getHours()).toBe(8); // EDT
|
||||
});
|
||||
|
||||
it("toBerlinDate stays the Berlin alias for legacy call sites", () => {
|
||||
const instant = new Date("2026-06-10T12:00:00Z");
|
||||
expect(toBerlinDate(instant).getTime()).toBe(
|
||||
toZonedDate(instant, "Europe/Berlin").getTime(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reduceCurrentWindowStatus — timezone threading", () => {
|
||||
it("compares lastTakenAt in the supplied timezone, not hardcoded Berlin", () => {
|
||||
// 08:30 local in New York = 12:30 UTC. With tz=America/New_York the
|
||||
@@ -57,12 +50,14 @@ describe("reduceCurrentWindowStatus — timezone threading", () => {
|
||||
const schedules = [
|
||||
{ windowStart: "08:00", windowEnd: "09:00", daysOfWeek: null },
|
||||
];
|
||||
const nowLocal = localClock(8, 45);
|
||||
|
||||
// One intake instant, two zones. 12:30Z is 08:30 in New York (inside
|
||||
// the band → the pill is suppressed) and 14:30 in Berlin (outside it →
|
||||
// the pill shows). Each `now` is 08:45 on its OWN clock, so the only
|
||||
// thing that differs between the two calls is the zone.
|
||||
const withUserTz = reduceCurrentWindowStatus({
|
||||
...BASE,
|
||||
schedules,
|
||||
nowBerlin: nowLocal,
|
||||
now: instantAt(8, 45, "America/New_York"),
|
||||
lastTakenAt: "2026-06-10T12:30:00Z",
|
||||
todayEventCount: 1,
|
||||
tz: "America/New_York",
|
||||
@@ -72,7 +67,7 @@ describe("reduceCurrentWindowStatus — timezone threading", () => {
|
||||
const withDefaultTz = reduceCurrentWindowStatus({
|
||||
...BASE,
|
||||
schedules,
|
||||
nowBerlin: nowLocal,
|
||||
now: instantAt(8, 45, "Europe/Berlin"),
|
||||
lastTakenAt: "2026-06-10T12:30:00Z",
|
||||
todayEventCount: 1,
|
||||
});
|
||||
@@ -89,7 +84,7 @@ describe("reduceCurrentWindowStatus — degenerate point window", () => {
|
||||
const res = reduceCurrentWindowStatus({
|
||||
...BASE,
|
||||
schedules: pointSchedule,
|
||||
nowBerlin: localClock(15, 0),
|
||||
now: instantAt(15, 0),
|
||||
});
|
||||
// 15:00 is hours past the widened 07:00–09:00 point window and past
|
||||
// the late + missed thresholds → no pill at all.
|
||||
@@ -100,7 +95,7 @@ describe("reduceCurrentWindowStatus — degenerate point window", () => {
|
||||
const res = reduceCurrentWindowStatus({
|
||||
...BASE,
|
||||
schedules: pointSchedule,
|
||||
nowBerlin: localClock(8, 30),
|
||||
now: instantAt(8, 30),
|
||||
});
|
||||
expect(res.status).toBe("in_window");
|
||||
});
|
||||
@@ -109,7 +104,7 @@ describe("reduceCurrentWindowStatus — degenerate point window", () => {
|
||||
const res = reduceCurrentWindowStatus({
|
||||
...BASE,
|
||||
schedules: pointSchedule,
|
||||
nowBerlin: localClock(10, 0),
|
||||
now: instantAt(10, 0),
|
||||
});
|
||||
// Window end 09:00 + 60 min past → late tier.
|
||||
expect(res.status).toBe("late");
|
||||
@@ -122,7 +117,7 @@ describe("reduceCurrentWindowStatus — degenerate point window", () => {
|
||||
const res = reduceCurrentWindowStatus({
|
||||
...BASE,
|
||||
schedules: overnight,
|
||||
nowBerlin: localClock(23, 30),
|
||||
now: instantAt(23, 30),
|
||||
});
|
||||
expect(res.status).toBe("in_window");
|
||||
});
|
||||
@@ -145,7 +140,7 @@ describe("reduceCurrentWindowStatus — timesOfDay-aware overdue coverage", () =
|
||||
const res = reduceCurrentWindowStatus({
|
||||
...BASE,
|
||||
schedules: twoDoseSchedule,
|
||||
nowBerlin: localClock(21, 30),
|
||||
now: instantAt(21, 30),
|
||||
todayEventCount: 1,
|
||||
});
|
||||
// 21:30 — both bands have passed (ends 09:00 + 21:00); one event
|
||||
@@ -164,7 +159,7 @@ describe("reduceCurrentWindowStatus — timesOfDay-aware overdue coverage", () =
|
||||
const res = reduceCurrentWindowStatus({
|
||||
...BASE,
|
||||
schedules: twoDoseSchedule,
|
||||
nowBerlin: localClock(21, 30),
|
||||
now: instantAt(21, 30),
|
||||
todayEventCount: 2,
|
||||
});
|
||||
expect(res.status).toBeNull();
|
||||
@@ -175,7 +170,7 @@ describe("reduceCurrentWindowStatus — timesOfDay-aware overdue coverage", () =
|
||||
const res = reduceCurrentWindowStatus({
|
||||
...BASE,
|
||||
schedules: twoDoseSchedule,
|
||||
nowBerlin: localClock(20, 30),
|
||||
now: instantAt(20, 30),
|
||||
});
|
||||
expect(res.status).toBe("in_window");
|
||||
expect(res.window?.timeOfDay).toBe("20:00");
|
||||
@@ -188,7 +183,7 @@ describe("reduceCurrentWindowStatus — timesOfDay-aware overdue coverage", () =
|
||||
const res = reduceCurrentWindowStatus({
|
||||
...BASE,
|
||||
schedules: legacy,
|
||||
nowBerlin: localClock(21, 0),
|
||||
now: instantAt(21, 0),
|
||||
todayEventCount: 1,
|
||||
});
|
||||
expect(res.status).toBeNull();
|
||||
@@ -212,7 +207,7 @@ describe("reduceCurrentWindowStatus — stale window never beats timesOfDay (v1.
|
||||
const res = reduceCurrentWindowStatus({
|
||||
...BASE,
|
||||
schedules: staleSchedule,
|
||||
nowBerlin: localClock(7, 0),
|
||||
now: instantAt(7, 0),
|
||||
});
|
||||
expect(res.status).toBeNull();
|
||||
});
|
||||
@@ -221,7 +216,7 @@ describe("reduceCurrentWindowStatus — stale window never beats timesOfDay (v1.
|
||||
const res = reduceCurrentWindowStatus({
|
||||
...BASE,
|
||||
schedules: staleSchedule,
|
||||
nowBerlin: localClock(9, 15),
|
||||
now: instantAt(9, 15),
|
||||
});
|
||||
expect(res.status).toBe("in_window");
|
||||
expect(res.window).toEqual({
|
||||
@@ -235,7 +230,7 @@ describe("reduceCurrentWindowStatus — stale window never beats timesOfDay (v1.
|
||||
const res = reduceCurrentWindowStatus({
|
||||
...BASE,
|
||||
schedules: staleSchedule,
|
||||
nowBerlin: localClock(11, 0),
|
||||
now: instantAt(11, 0),
|
||||
});
|
||||
expect(res.status).toBe("late");
|
||||
expect(res.window?.timeOfDay).toBe("09:00");
|
||||
@@ -254,7 +249,7 @@ describe("reduceCurrentWindowStatus — stale window never beats timesOfDay (v1.
|
||||
const res = reduceCurrentWindowStatus({
|
||||
...BASE,
|
||||
schedules: withExplicit,
|
||||
nowBerlin: localClock(11, 0),
|
||||
now: instantAt(11, 0),
|
||||
});
|
||||
expect(res.status).toBe("in_window");
|
||||
expect(res.window).toEqual({
|
||||
@@ -281,7 +276,7 @@ describe("reduceCurrentWindowStatus — display-due gate (v1.16.6)", () => {
|
||||
},
|
||||
];
|
||||
// June 11 2026, 07:00 Berlin (CEST) — "tomorrow" relative to the
|
||||
// localClock fixtures (June 10).
|
||||
// instantAt fixtures (June 10).
|
||||
const dueTomorrow = new Date("2026-06-11T05:00:00Z");
|
||||
// June 10 2026, 08:00 Berlin — "today's" dose anchor.
|
||||
const dueToday = new Date("2026-06-10T06:00:00Z");
|
||||
@@ -292,7 +287,7 @@ describe("reduceCurrentWindowStatus — display-due gate (v1.16.6)", () => {
|
||||
const ungated = reduceCurrentWindowStatus({
|
||||
...BASE,
|
||||
schedules: rolling,
|
||||
nowBerlin: localClock(13, 0),
|
||||
now: instantAt(13, 0),
|
||||
});
|
||||
expect(ungated.status).toBe("very_late");
|
||||
|
||||
@@ -302,7 +297,7 @@ describe("reduceCurrentWindowStatus — display-due gate (v1.16.6)", () => {
|
||||
const gated = reduceCurrentWindowStatus({
|
||||
...BASE,
|
||||
schedules: rolling,
|
||||
nowBerlin: localClock(13, 0),
|
||||
now: instantAt(13, 0),
|
||||
nextDue: { at: dueTomorrow, overdue: false },
|
||||
});
|
||||
expect(gated.status).toBeNull();
|
||||
@@ -311,7 +306,7 @@ describe("reduceCurrentWindowStatus — display-due gate (v1.16.6)", () => {
|
||||
const lateTier = reduceCurrentWindowStatus({
|
||||
...BASE,
|
||||
schedules: rolling,
|
||||
nowBerlin: localClock(10, 0),
|
||||
now: instantAt(10, 0),
|
||||
nextDue: { at: dueTomorrow, overdue: false },
|
||||
});
|
||||
expect(lateTier.status).toBeNull();
|
||||
@@ -321,7 +316,7 @@ describe("reduceCurrentWindowStatus — display-due gate (v1.16.6)", () => {
|
||||
const late = reduceCurrentWindowStatus({
|
||||
...BASE,
|
||||
schedules: rolling,
|
||||
nowBerlin: localClock(10, 0),
|
||||
now: instantAt(10, 0),
|
||||
nextDue: { at: dueToday, overdue: true },
|
||||
});
|
||||
expect(late.status).toBe("late");
|
||||
@@ -329,7 +324,7 @@ describe("reduceCurrentWindowStatus — display-due gate (v1.16.6)", () => {
|
||||
const veryLate = reduceCurrentWindowStatus({
|
||||
...BASE,
|
||||
schedules: rolling,
|
||||
nowBerlin: localClock(12, 30),
|
||||
now: instantAt(12, 30),
|
||||
nextDue: { at: dueToday, overdue: true },
|
||||
});
|
||||
expect(veryLate.status).toBe("very_late");
|
||||
@@ -340,7 +335,7 @@ describe("reduceCurrentWindowStatus — display-due gate (v1.16.6)", () => {
|
||||
const notYetDue = reduceCurrentWindowStatus({
|
||||
...BASE,
|
||||
schedules: rolling,
|
||||
nowBerlin: localClock(8, 30),
|
||||
now: instantAt(8, 30),
|
||||
nextDue: { at: dueTomorrow, overdue: false },
|
||||
});
|
||||
expect(notYetDue.status).toBeNull();
|
||||
@@ -349,7 +344,7 @@ describe("reduceCurrentWindowStatus — display-due gate (v1.16.6)", () => {
|
||||
const dueNow = reduceCurrentWindowStatus({
|
||||
...BASE,
|
||||
schedules: rolling,
|
||||
nowBerlin: localClock(8, 30),
|
||||
now: instantAt(8, 30),
|
||||
nextDue: { at: dueToday, overdue: false },
|
||||
});
|
||||
expect(dueNow.status).toBe("in_window");
|
||||
@@ -359,7 +354,7 @@ describe("reduceCurrentWindowStatus — display-due gate (v1.16.6)", () => {
|
||||
const res = reduceCurrentWindowStatus({
|
||||
...BASE,
|
||||
schedules: rolling,
|
||||
nowBerlin: localClock(8, 30),
|
||||
now: instantAt(8, 30),
|
||||
nextDue: null,
|
||||
});
|
||||
expect(res.status).toBeNull();
|
||||
@@ -388,7 +383,7 @@ describe("reduceCurrentWindowStatus — day-scale taken-early context", () => {
|
||||
const res = reduceCurrentWindowStatus({
|
||||
...BASE,
|
||||
schedules: [weeklyWedSchedule],
|
||||
nowBerlin: localClock(9, 15),
|
||||
now: instantAt(9, 15),
|
||||
lastTakenAt: MONDAY_TAKE,
|
||||
todayEventCount: 0,
|
||||
});
|
||||
@@ -401,7 +396,7 @@ describe("reduceCurrentWindowStatus — day-scale taken-early context", () => {
|
||||
const res = reduceCurrentWindowStatus({
|
||||
...BASE,
|
||||
schedules: [weeklyWedSchedule],
|
||||
nowBerlin: localClock(9, 15),
|
||||
now: instantAt(9, 15),
|
||||
lastTakenAt: "2026-06-03T07:05:00.000Z",
|
||||
todayEventCount: 0,
|
||||
});
|
||||
@@ -420,7 +415,7 @@ describe("reduceCurrentWindowStatus — day-scale taken-early context", () => {
|
||||
timesOfDay: ["09:00"],
|
||||
},
|
||||
],
|
||||
nowBerlin: localClock(9, 15),
|
||||
now: instantAt(9, 15),
|
||||
lastTakenAt: "2026-06-09T07:05:00.000Z", // yesterday — normal daily take
|
||||
todayEventCount: 0,
|
||||
});
|
||||
@@ -432,7 +427,7 @@ describe("reduceCurrentWindowStatus — day-scale taken-early context", () => {
|
||||
const res = reduceCurrentWindowStatus({
|
||||
...BASE,
|
||||
schedules: [weeklyWedSchedule],
|
||||
nowBerlin: localClock(11, 30), // past the band + inside lateMinutes
|
||||
now: instantAt(11, 30), // past the band + inside lateMinutes
|
||||
lastTakenAt: MONDAY_TAKE,
|
||||
todayEventCount: 0,
|
||||
});
|
||||
@@ -448,7 +443,7 @@ describe("reduceCurrentWindowStatus — day-scale taken-early context", () => {
|
||||
const res = reduceCurrentWindowStatus({
|
||||
...BASE,
|
||||
schedules: [weeklyWedSchedule],
|
||||
nowBerlin: localClock(9, 15), // interpreted as Auckland wall clock
|
||||
now: instantAt(9, 15, "Pacific/Auckland"), // 09:15 on Auckland's clock
|
||||
lastTakenAt: "2026-06-08T13:00:00.000Z",
|
||||
todayEventCount: 0,
|
||||
tz: "Pacific/Auckland",
|
||||
|
||||
@@ -43,6 +43,11 @@
|
||||
*/
|
||||
import { parseScheduleRecurrence } from "@/lib/medication-schedule";
|
||||
import { DOSE_WINDOW_DEFAULTS } from "@/lib/medications/scheduling/dose-window-defaults";
|
||||
import {
|
||||
localDayIndex,
|
||||
wallClockInTz,
|
||||
type WallClockParts,
|
||||
} from "@/lib/tz/wall-clock";
|
||||
|
||||
/** One explicit per-dose on-time window as the API serialises it. */
|
||||
export interface DoseWindowEntryInput {
|
||||
@@ -122,22 +127,17 @@ export interface NextDueGate {
|
||||
const DEFAULT_TZ = "Europe/Berlin";
|
||||
|
||||
/**
|
||||
* Convert a `Date` from its underlying UTC instant into the wall-clock
|
||||
* value in the given IANA timezone so the per-minute arithmetic below
|
||||
* treats the user's day boundary correctly. Intentionally en-US — this is
|
||||
* not a user-facing format string, only a parseable round-trip to shift
|
||||
* the value into the target local time.
|
||||
* Minute-of-day an observer in the target zone reads off the wall clock.
|
||||
* Every band comparison below is minute-of-day arithmetic, so this is the
|
||||
* only projection the status math needs.
|
||||
*/
|
||||
export function toZonedDate(date: Date, tz: string = DEFAULT_TZ): Date {
|
||||
return new Date(date.toLocaleString("en-US", { timeZone: tz }));
|
||||
function minuteOfDay(parts: WallClockParts): number {
|
||||
return parts.hour * 60 + parts.minute;
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy alias kept for the existing card call sites; new code should call
|
||||
* `toZonedDate(date, userTz)` with the user's real timezone.
|
||||
*/
|
||||
export function toBerlinDate(date: Date): Date {
|
||||
return toZonedDate(date, DEFAULT_TZ);
|
||||
/** True when two wall clocks name the same local calendar day. */
|
||||
function sameLocalDay(a: WallClockParts, b: WallClockParts): boolean {
|
||||
return a.year === b.year && a.month === b.month && a.day === b.day;
|
||||
}
|
||||
|
||||
export function parseTimeToMinutes(value: string): number {
|
||||
@@ -293,14 +293,14 @@ function bandStatus(
|
||||
|
||||
function getScheduleStatus(
|
||||
schedule: ScheduleWindowInput,
|
||||
nowLocal: Date,
|
||||
nowParts: WallClockParts,
|
||||
lateMinutes: number,
|
||||
missedMinutes: number,
|
||||
): {
|
||||
status: Exclude<MedicationWindowStatus, null>;
|
||||
band: DoseWindowBounds;
|
||||
} | null {
|
||||
const nowMins = nowLocal.getHours() * 60 + nowLocal.getMinutes();
|
||||
const nowMins = minuteOfDay(nowParts);
|
||||
let best: {
|
||||
status: Exclude<MedicationWindowStatus, null>;
|
||||
band: DoseWindowBounds;
|
||||
@@ -329,7 +329,7 @@ const EARLY_GRACE_MINUTES = DOSE_WINDOW_DEFAULTS.earlyGraceMinutes;
|
||||
function isLastIntakeInBand(
|
||||
lastTakenAt: string | null,
|
||||
band: DoseWindowBounds,
|
||||
nowLocal: Date,
|
||||
nowParts: WallClockParts,
|
||||
tz: string,
|
||||
/**
|
||||
* Lower bound (minutes-of-day) the early grace may not cross — the end
|
||||
@@ -340,18 +340,12 @@ function isLastIntakeInBand(
|
||||
): boolean {
|
||||
if (!lastTakenAt) return false;
|
||||
|
||||
const intake = toZonedDate(new Date(lastTakenAt), tz);
|
||||
const intake = wallClockInTz(new Date(lastTakenAt), tz);
|
||||
|
||||
// Must be same calendar day
|
||||
if (
|
||||
intake.getFullYear() !== nowLocal.getFullYear() ||
|
||||
intake.getMonth() !== nowLocal.getMonth() ||
|
||||
intake.getDate() !== nowLocal.getDate()
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (!sameLocalDay(intake, nowParts)) return false;
|
||||
|
||||
const intakeMins = intake.getHours() * 60 + intake.getMinutes();
|
||||
const intakeMins = minuteOfDay(intake);
|
||||
|
||||
// Handle legacy overnight windows
|
||||
const adjustedIntake =
|
||||
@@ -411,28 +405,13 @@ function dayScaleCadence(daysOfWeek: string | null): {
|
||||
*/
|
||||
function earlyTakeDaysAgo(
|
||||
lastTakenAt: string | null,
|
||||
nowLocal: Date,
|
||||
nowParts: WallClockParts,
|
||||
tz: string,
|
||||
periodDays: number,
|
||||
): number | null {
|
||||
if (!lastTakenAt || periodDays < 2) return null;
|
||||
const intake = toZonedDate(new Date(lastTakenAt), tz);
|
||||
const dayMs = 24 * 60 * 60 * 1000;
|
||||
const intakeDay = Math.floor(
|
||||
new Date(
|
||||
intake.getFullYear(),
|
||||
intake.getMonth(),
|
||||
intake.getDate(),
|
||||
).getTime() / dayMs,
|
||||
);
|
||||
const nowDay = Math.floor(
|
||||
new Date(
|
||||
nowLocal.getFullYear(),
|
||||
nowLocal.getMonth(),
|
||||
nowLocal.getDate(),
|
||||
).getTime() / dayMs,
|
||||
);
|
||||
const diff = nowDay - intakeDay;
|
||||
const intake = wallClockInTz(new Date(lastTakenAt), tz);
|
||||
const diff = localDayIndex(nowParts) - localDayIndex(intake);
|
||||
return diff >= 1 && diff < periodDays ? diff : null;
|
||||
}
|
||||
|
||||
@@ -452,14 +431,14 @@ function earlyTakeDaysAgo(
|
||||
*/
|
||||
function countPassedDoses<Schedule extends ScheduleWindowInput>(
|
||||
schedules: Schedule[],
|
||||
nowLocal: Date,
|
||||
nowParts: WallClockParts,
|
||||
): number {
|
||||
const nowMins = nowLocal.getHours() * 60 + nowLocal.getMinutes();
|
||||
const nowMins = minuteOfDay(nowParts);
|
||||
return schedules.reduce((sum, s) => {
|
||||
const recurrence = parseScheduleRecurrence(s.daysOfWeek);
|
||||
if (
|
||||
recurrence.daysOfWeek.length > 0 &&
|
||||
!recurrence.daysOfWeek.includes(nowLocal.getDay())
|
||||
!recurrence.daysOfWeek.includes(nowParts.weekday)
|
||||
) {
|
||||
return sum;
|
||||
}
|
||||
@@ -485,17 +464,22 @@ function countPassedDoses<Schedule extends ScheduleWindowInput>(
|
||||
* status when the last intake already falls inside the matched band so
|
||||
* the pill doesn't nag after the user took the dose.
|
||||
*
|
||||
* `nowBerlin` is the wall-clock "now" already shifted into the user's
|
||||
* timezone (via `toZonedDate`); `tz` must name the same timezone so the
|
||||
* last-intake comparison shifts `lastTakenAt` identically. The name and
|
||||
* the default are the legacy Berlin contract — callers that serve other
|
||||
* timezones pass their own.
|
||||
* `now` is the real "now" instant — NOT a pre-shifted wall clock. The
|
||||
* helper decomposes it in `tz` itself, so caller and helper can no longer
|
||||
* disagree about which zone the comparison runs in. Callers used to pass a
|
||||
* `Date` fabricated by rendering the instant in the target 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 inside its window could read late and a just-taken dose
|
||||
* could fail to suppress the take-now pill. Passing the instant removes
|
||||
* that class outright.
|
||||
*/
|
||||
export function reduceCurrentWindowStatus<
|
||||
Schedule extends ScheduleWindowInput,
|
||||
>(options: {
|
||||
schedules: Schedule[];
|
||||
nowBerlin: Date;
|
||||
/** The real current instant; decomposed in `tz` internally. */
|
||||
now: Date;
|
||||
lateMinutes: number;
|
||||
missedMinutes: number;
|
||||
active: boolean;
|
||||
@@ -508,7 +492,7 @@ export function reduceCurrentWindowStatus<
|
||||
* dashboard visit and the overdue pill went dark nondeterministically.
|
||||
*/
|
||||
todayEventCount: number;
|
||||
/** IANA timezone matching `nowBerlin`'s conversion; defaults to Berlin. */
|
||||
/** IANA timezone the status math reasons in; defaults to Berlin. */
|
||||
tz?: string;
|
||||
/**
|
||||
* v1.16.6 — the server display-due gate. `undefined` keeps the legacy
|
||||
@@ -526,7 +510,7 @@ export function reduceCurrentWindowStatus<
|
||||
}): CurrentWindowStatus<Schedule> {
|
||||
const {
|
||||
schedules,
|
||||
nowBerlin,
|
||||
now,
|
||||
lateMinutes,
|
||||
missedMinutes,
|
||||
active,
|
||||
@@ -535,6 +519,7 @@ export function reduceCurrentWindowStatus<
|
||||
tz = DEFAULT_TZ,
|
||||
nextDue,
|
||||
} = options;
|
||||
const nowParts = wallClockInTz(now, tz);
|
||||
|
||||
const none: CurrentWindowStatus<Schedule> = {
|
||||
status: null,
|
||||
@@ -545,25 +530,17 @@ export function reduceCurrentWindowStatus<
|
||||
if (!active) return none;
|
||||
if (nextDue === null) return none;
|
||||
|
||||
const passedDoseCount = countPassedDoses(schedules, nowBerlin);
|
||||
const passedDoseCount = countPassedDoses(schedules, nowParts);
|
||||
const hasUncoveredOverdue = todayEventCount < passedDoseCount;
|
||||
|
||||
// Display-due gate (see the option's doc above). Computed once: it does
|
||||
// not vary per schedule row — the server already reduced the rows to one
|
||||
// verdict.
|
||||
const dueIsToday =
|
||||
nextDue != null &&
|
||||
(() => {
|
||||
const dueLocal = toZonedDate(nextDue.at, tz);
|
||||
return (
|
||||
dueLocal.getFullYear() === nowBerlin.getFullYear() &&
|
||||
dueLocal.getMonth() === nowBerlin.getMonth() &&
|
||||
dueLocal.getDate() === nowBerlin.getDate()
|
||||
);
|
||||
})();
|
||||
nextDue != null && sameLocalDay(wallClockInTz(nextDue.at, tz), nowParts);
|
||||
|
||||
return schedules.reduce<CurrentWindowStatus<Schedule>>((best, s) => {
|
||||
const hit = getScheduleStatus(s, nowBerlin, lateMinutes, missedMinutes);
|
||||
const hit = getScheduleStatus(s, nowParts, lateMinutes, missedMinutes);
|
||||
if (!hit) return best;
|
||||
// The pill must never read more overdue than the next-due line: a
|
||||
// future (non-overdue) display-due suppresses every overdue tier, and
|
||||
@@ -588,7 +565,7 @@ export function reduceCurrentWindowStatus<
|
||||
);
|
||||
if (
|
||||
hit.status === "in_window" &&
|
||||
isLastIntakeInBand(lastTakenAt, hit.band, nowBerlin, tz, earlyFloorMins)
|
||||
isLastIntakeInBand(lastTakenAt, hit.band, nowParts, tz, earlyFloorMins)
|
||||
) {
|
||||
return best;
|
||||
}
|
||||
@@ -604,7 +581,7 @@ export function reduceCurrentWindowStatus<
|
||||
// framing changes; the day count rides along for the copy.
|
||||
const cadence = dayScaleCadence(s.daysOfWeek);
|
||||
const takenEarlyDaysAgo = cadence.dayScale
|
||||
? earlyTakeDaysAgo(lastTakenAt, nowBerlin, tz, cadence.periodDays)
|
||||
? earlyTakeDaysAgo(lastTakenAt, nowParts, tz, cadence.periodDays)
|
||||
: null;
|
||||
return {
|
||||
status: hit.status,
|
||||
|
||||
@@ -133,6 +133,22 @@ export function zonedWallClockToUtc(
|
||||
return guess;
|
||||
}
|
||||
|
||||
/**
|
||||
* The local calendar day as a day index, for day-distance arithmetic
|
||||
* between two wall clocks in the SAME zone.
|
||||
*
|
||||
* Built from the wall-clock fields through `Date.UTC`, so the difference
|
||||
* of two indices is a pure calendar-day count: no host offset and no
|
||||
* 23 h/25 h DST-length term can enter it. Subtracting two instants and
|
||||
* dividing by 86 400 000 does admit both, which is how a "tomorrow" label
|
||||
* could land on a transition day.
|
||||
*/
|
||||
export function localDayIndex(parts: WallClockParts): number {
|
||||
return Math.floor(
|
||||
Date.UTC(parts.year, parts.month - 1, parts.day) / 86_400_000,
|
||||
);
|
||||
}
|
||||
|
||||
export function wallClockInTz(
|
||||
date: Date,
|
||||
tz: string | undefined,
|
||||
|
||||
Reference in new issue
Block a user