mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Merge branch 'worktree-agent-af686749a7ca278d4' into release/v1.30.21
This commit is contained in:
commit
caa37b74b5
18 files changed
+751
-245
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 { resolveNextDueDayLabel } from "@/lib/medications/next-due-day-label";
|
||||
import { resolveDisplayedSlotInstant } from "@/components/medications/card-parts/displayed-slot-instant";
|
||||
import {
|
||||
estimateDailyDoseCount,
|
||||
@@ -242,7 +240,7 @@ export function MedicationCard({
|
||||
a.windowStart.localeCompare(b.windowStart) ||
|
||||
a.windowEnd.localeCompare(b.windowEnd),
|
||||
);
|
||||
const nowBerlin = toZonedDate(new Date(), userTz);
|
||||
const now = new Date();
|
||||
// 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 +259,7 @@ export function MedicationCard({
|
||||
|
||||
const currentWindowStatus = reduceCurrentWindowStatus({
|
||||
schedules: sortedSchedules,
|
||||
nowBerlin,
|
||||
now,
|
||||
lateMinutes,
|
||||
missedMinutes,
|
||||
active: medication.active,
|
||||
@@ -418,27 +416,15 @@ 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()}`;
|
||||
|
||||
const diffDays = Math.round(
|
||||
(nextDate.getTime() - nowBerlin.getTime()) /
|
||||
(24 * 60 * 60 * 1000),
|
||||
);
|
||||
|
||||
if (nextStr === todayStr) {
|
||||
dayLabel = t("medications.today");
|
||||
} else if (nextStr === tomorrowStr) {
|
||||
dayLabel = t("medications.tomorrow");
|
||||
} else if (diffDays <= 5) {
|
||||
dayLabel = weekdayLabel(nextDate.getDay());
|
||||
} else {
|
||||
dayLabel = fmt.dateWithWeekdaySmart(nextDate);
|
||||
}
|
||||
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
|
||||
|
||||
@@ -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 { 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";
|
||||
@@ -420,7 +418,6 @@ function MedicationTableRowItem({
|
||||
a.windowEnd.localeCompare(b.windowEnd),
|
||||
);
|
||||
const now = new Date();
|
||||
const nowZoned = toZonedDate(now, userTz);
|
||||
const nextDueMs = medication.nextDueAt
|
||||
? new Date(medication.nextDueAt).getTime()
|
||||
: NaN;
|
||||
@@ -431,7 +428,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 +532,15 @@ 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),
|
||||
);
|
||||
const label = resolveNextDueDayLabel(new Date(nextAt), now, userTz);
|
||||
const dayLabel =
|
||||
nextStr === todayStr
|
||||
label.kind === "today"
|
||||
? t("medications.today")
|
||||
: nextStr === tomorrowStr
|
||||
: label.kind === "tomorrow"
|
||||
? t("medications.tomorrow")
|
||||
: diffDays <= 5
|
||||
? weekdayLabel(nextDate.getDay())
|
||||
: fmt.dateWithWeekdaySmart(nextDate);
|
||||
: label.kind === "weekday"
|
||||
? weekdayLabel(label.weekday)
|
||||
: fmt.dateWithWeekdaySmart(label.instant);
|
||||
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,
|
||||
|
||||
@@ -12,11 +12,45 @@ import {
|
||||
resolveRestingPulseSeries,
|
||||
type PulseSample,
|
||||
} from "../resting-pulse";
|
||||
import { zonedWallClockToUtc } from "@/lib/tz/wall-clock";
|
||||
|
||||
const day = (iso: string, value: number): PulseSample => ({
|
||||
measuredAt: new Date(iso),
|
||||
value,
|
||||
});
|
||||
/**
|
||||
* The zone `deriveRestingProxyFromPulse` buckets in by default
|
||||
* (`toBerlinDayKey`). Naming it here is the point: the fixtures used to
|
||||
* pass offset-less ISO literals, which `new Date(...)` reads as HOST-local.
|
||||
* That made every expectation mean something different on every developer
|
||||
* machine, and — because a Berlin day and a UTC day coincide for samples in
|
||||
* the middle of the day — swapping the implementation's day-key zone left
|
||||
* the whole file green. Building each instant explicitly in Berlin lets the
|
||||
* assertions actually pin the bucketing.
|
||||
*/
|
||||
const PROXY_DAY_ZONE = "Europe/Berlin";
|
||||
|
||||
/**
|
||||
* A pulse sample at the given `YYYY-MM-DDTHH:MM:SS` wall clock **in
|
||||
* Berlin**, expressed as the UTC instant it denotes.
|
||||
*/
|
||||
const day = (wallClock: string, value: number): PulseSample => {
|
||||
const m = wallClock.match(
|
||||
/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})$/,
|
||||
);
|
||||
if (!m) throw new Error(`fixture wall clock must be local, got ${wallClock}`);
|
||||
const [, y, mo, d, h, mi, sec] = m;
|
||||
return {
|
||||
measuredAt: zonedWallClockToUtc(
|
||||
{
|
||||
year: Number(y),
|
||||
month: Number(mo),
|
||||
day: Number(d),
|
||||
hour: Number(h),
|
||||
minute: Number(mi),
|
||||
second: Number(sec),
|
||||
},
|
||||
PROXY_DAY_ZONE,
|
||||
),
|
||||
value,
|
||||
};
|
||||
};
|
||||
|
||||
describe("deriveRestingProxyFromPulse", () => {
|
||||
it("excludes a workout burst — the daily proxy tracks the resting floor", () => {
|
||||
@@ -154,3 +188,55 @@ describe("resolveRestingPulseSeries", () => {
|
||||
).toEqual({ series: [], which: "none" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveRestingProxyFromPulse — the day bucket is the PROFILE day", () => {
|
||||
it("keeps after-midnight local samples on their own local day, not the UTC one", () => {
|
||||
// Berlin is UTC+2 in June, so 00:20–01:00 local on 2026-06-02 is
|
||||
// 22:20–23:00 UTC on 2026-06-01 — a DIFFERENT UTC calendar day. All
|
||||
// six samples belong to one Berlin day and must produce ONE bucket.
|
||||
// Under a UTC day-key they split 3/3 and produce two.
|
||||
const samples = [
|
||||
day("2026-06-02T00:20:00", 60),
|
||||
day("2026-06-02T00:40:00", 62),
|
||||
day("2026-06-02T01:00:00", 64),
|
||||
day("2026-06-02T09:00:00", 66),
|
||||
day("2026-06-02T15:00:00", 68),
|
||||
day("2026-06-02T21:00:00", 70),
|
||||
];
|
||||
const proxy = deriveRestingProxyFromPulse(samples);
|
||||
expect(proxy).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("splits two local days that a UTC key would merge", () => {
|
||||
// 23:10–23:50 Berlin on 2026-06-01 is 21:10–21:50 UTC the same day,
|
||||
// while 00:10–00:50 Berlin on 2026-06-02 is 22:10–22:50 UTC on
|
||||
// 2026-06-01. A UTC key merges all six into one bucket; the Berlin key
|
||||
// must see two days.
|
||||
const samples = [
|
||||
day("2026-06-01T23:10:00", 60),
|
||||
day("2026-06-01T23:30:00", 62),
|
||||
day("2026-06-01T23:50:00", 64),
|
||||
day("2026-06-02T00:10:00", 80),
|
||||
day("2026-06-02T00:30:00", 82),
|
||||
day("2026-06-02T00:50:00", 84),
|
||||
];
|
||||
const proxy = deriveRestingProxyFromPulse(samples);
|
||||
expect(proxy).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("honours an injected day-key function over the Berlin default", () => {
|
||||
// The targets route threads `userDayKey(d, userTz)`. Same samples,
|
||||
// different zone → a different bucketing. This is the seam the default
|
||||
// hides, so it gets its own pin.
|
||||
const samples = [
|
||||
day("2026-06-02T00:20:00", 60),
|
||||
day("2026-06-02T00:40:00", 62),
|
||||
day("2026-06-02T01:00:00", 64),
|
||||
];
|
||||
const utcKey = (d: Date) => d.toISOString().slice(0, 10);
|
||||
// In UTC these three are 2026-06-01 22:20/22:40/23:00 → one UTC day.
|
||||
expect(deriveRestingProxyFromPulse(samples, utcKey)).toHaveLength(1);
|
||||
// And that UTC day is 06-01, whereas the Berlin bucket is 06-02.
|
||||
expect(utcKey(samples[0].measuredAt)).toBe("2026-06-01");
|
||||
});
|
||||
});
|
||||
@@ -5,8 +5,18 @@
|
||||
* the numbers now honour `daysOfWeek` and `intervalWeeks`. These
|
||||
* tests pin the contract for every cadence the production app
|
||||
* exercises: daily 1×/day, daily multi-dose, weekly Mondays-only,
|
||||
* bi-weekly, weekday-only multi-dose, and the DST-boundary day.
|
||||
* Closes #214.
|
||||
* bi-weekly, and weekday-only multi-dose. Closes #214.
|
||||
*
|
||||
* The DST local-day walk is NOT pinned here. A "DST boundary" case used
|
||||
* to sit in this file passing no timezone, so the slot builder took the
|
||||
* legacy UTC path where the date carries no transition — it asserted
|
||||
* `totalExpected === 7`, which holds in every zone and therefore could
|
||||
* never see a double-emit. Threading a real zone through it does not
|
||||
* help: the assertion stays true in Berlin, UTC, UTC+14 and UTC−11
|
||||
* alike. The property it claimed lives in
|
||||
* `src/lib/medications/scheduling/__tests__/compliance.test.ts`, which
|
||||
* walks `buildCadenceTimeline` with an explicit zone across BOTH the 23 h
|
||||
* spring-forward and the 25 h fall-back day.
|
||||
*
|
||||
* Each test uses `vi.useFakeTimers()` to pin `now` so the rolling
|
||||
* window is deterministic. `classifyIntakeTiming` tests below the
|
||||
@@ -343,38 +353,6 @@ describe("calculateCompliance — cadence-aware adapter", () => {
|
||||
expect(result.missed).toBe(0);
|
||||
});
|
||||
|
||||
it("DST boundary (Europe/Berlin spring-forward) does not inflate the expected count", () => {
|
||||
// Spring-forward in Berlin: 2025-03-30 02:00 → 03:00. A daily
|
||||
// schedule across this boundary must still emit one slot per
|
||||
// local day — not two on the short day and zero on the next.
|
||||
// Pin `now` to Apr-02 noon UTC and run a 7-day window so the
|
||||
// boundary day is inside it. Generate one event per day
|
||||
// (working backwards from NOW − 1 day so each event lands inside
|
||||
// the window) and assert the totalExpected lands at 7 (not 8).
|
||||
vi.setSystemTime(new Date("2025-04-02T12:00:00Z"));
|
||||
const schedules: ComplianceSchedule[] = [
|
||||
{ windowStart: "10:00", windowEnd: "11:00", daysOfWeek: null },
|
||||
];
|
||||
// Slots emitted by the timeline: Mar-27..Apr-02 (Mar-26's window
|
||||
// sits below the rolling-window start of Mar-26 12:00). Generate
|
||||
// one taken event per slot — today's 10:30 slot is also in the
|
||||
// past relative to NOW = 12:00 so it counts as taken too.
|
||||
const events = [];
|
||||
for (let d = 0; d <= 6; d++) {
|
||||
const at = new Date("2025-04-02T10:30:00Z");
|
||||
at.setUTCDate(at.getUTCDate() - d);
|
||||
events.push(eventAt(at, true));
|
||||
}
|
||||
const result = calculateCompliance(events, schedules, 7);
|
||||
// The hard contract: totalExpected stays at 7 — not 8 (which
|
||||
// would mean the DST short day double-emitted) and not 6 (which
|
||||
// would mean the short day silently dropped a slot).
|
||||
expect(result.totalExpected).toBe(7);
|
||||
expect(result.taken).toBe(7);
|
||||
expect(result.missed).toBe(0);
|
||||
expect(result.rate).toBe(100);
|
||||
});
|
||||
|
||||
it("filters events outside the rolling window", () => {
|
||||
const schedules: ComplianceSchedule[] = [
|
||||
{ windowStart: "08:00", windowEnd: "09:00", daysOfWeek: null },
|
||||
|
||||
@@ -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,18 +11,26 @@
|
||||
* 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,
|
||||
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",
|
||||
@@ -457,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 };
|
||||
}
|
||||
@@ -457,3 +457,71 @@ describe("resolveCanonicalSlotInstant — DST robustness vs projector mint", ()
|
||||
expect(result?.toISOString()).toBe(projectorMint.toISOString());
|
||||
});
|
||||
});
|
||||
|
||||
describe("slot dedupe contract — projector, reminder worker and intake write", () => {
|
||||
// The three surfaces key on the slot instant BYTE-FOR-BYTE: the projector
|
||||
// mints a pending row at `localHmAsUtc(<its tick>, tz, h, m)`, the reminder
|
||||
// worker re-derives the same slot from ITS tick, and the intake write path
|
||||
// snaps an incoming take to the canonical instant via
|
||||
// `resolveCanonicalSlotInstant`. All three reference instants are
|
||||
// different by construction — a cron tick, a later cron tick, and the
|
||||
// moment the user actually pressed "take".
|
||||
//
|
||||
// That is the whole hazard. The primitive used to sample the zone offset
|
||||
// at its REFERENCE rather than at the target local time, so on a
|
||||
// transition day a pre-transition tick and a post-transition tick minted
|
||||
// instants an hour apart for the same slot — the dedupe key stopped
|
||||
// matching and a duplicate pending row became reachable.
|
||||
//
|
||||
// The existing DST cases above compare the resolver to a projector mint
|
||||
// computed from the SAME reference, which cannot see this. These pass
|
||||
// deliberately different references.
|
||||
|
||||
const SLOT_HOUR = 20;
|
||||
const med = makeMedication([
|
||||
makeSchedule({
|
||||
timesOfDay: ["20:00"],
|
||||
windowStart: "20:00",
|
||||
windowEnd: "21:00",
|
||||
}),
|
||||
]);
|
||||
|
||||
it("agrees across references on either side of the autumn fall-back", () => {
|
||||
// Europe/Berlin 2026-10-25: 03:00 CEST → 02:00 CET.
|
||||
const projectorTick = new Date("2026-10-25T00:30:00.000Z"); // 02:30 CEST
|
||||
const workerTick = new Date("2026-10-25T17:00:00.000Z"); // 18:00 CET
|
||||
const takePressedAt = new Date("2026-10-25T19:10:00.000Z"); // 20:10 CET
|
||||
|
||||
const projectorMint = localHmAsUtc(projectorTick, TZ, SLOT_HOUR, 0);
|
||||
const workerMint = localHmAsUtc(workerTick, TZ, SLOT_HOUR, 0);
|
||||
const writeMint = resolveCanonicalSlotInstant({
|
||||
medication: med,
|
||||
userTz: TZ,
|
||||
incoming: takePressedAt,
|
||||
});
|
||||
|
||||
expect(workerMint.toISOString()).toBe(projectorMint.toISOString());
|
||||
expect(writeMint?.toISOString()).toBe(projectorMint.toISOString());
|
||||
// And the shared instant really is 20:00 on the user's clock.
|
||||
expect(projectorMint.toISOString()).toBe("2026-10-25T19:00:00.000Z");
|
||||
});
|
||||
|
||||
it("agrees across references on either side of the spring-forward", () => {
|
||||
// Europe/Berlin 2026-03-29: 02:00 CET → 03:00 CEST.
|
||||
const projectorTick = new Date("2026-03-29T00:30:00.000Z"); // 01:30 CET
|
||||
const workerTick = new Date("2026-03-29T16:00:00.000Z"); // 18:00 CEST
|
||||
const takePressedAt = new Date("2026-03-29T18:10:00.000Z"); // 20:10 CEST
|
||||
|
||||
const projectorMint = localHmAsUtc(projectorTick, TZ, SLOT_HOUR, 0);
|
||||
const workerMint = localHmAsUtc(workerTick, TZ, SLOT_HOUR, 0);
|
||||
const writeMint = resolveCanonicalSlotInstant({
|
||||
medication: med,
|
||||
userTz: TZ,
|
||||
incoming: takePressedAt,
|
||||
});
|
||||
|
||||
expect(workerMint.toISOString()).toBe(projectorMint.toISOString());
|
||||
expect(writeMint?.toISOString()).toBe(projectorMint.toISOString());
|
||||
expect(projectorMint.toISOString()).toBe("2026-03-29T18:00:00.000Z");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getUserTodayBounds, startOfLocalDayInTz } from "@/lib/tz/local-day";
|
||||
import {
|
||||
getUserTodayBounds,
|
||||
localHmAsUtc,
|
||||
startOfLocalDayInTz,
|
||||
} from "@/lib/tz/local-day";
|
||||
import { wallClockInTz, zonedWallClockToUtc } from "@/lib/tz/wall-clock";
|
||||
|
||||
const HOUR_MS = 60 * 60 * 1000;
|
||||
@@ -151,3 +155,94 @@ describe("getUserTodayBounds — DST-safe local-day window", () => {
|
||||
expect(wallClockInTz(start, "America/Los_Angeles").day).toBe(15);
|
||||
});
|
||||
});
|
||||
|
||||
describe("localHmAsUtc — DST transition days", () => {
|
||||
const TZ = "Europe/Berlin";
|
||||
// Berlin 2026 transitions: spring forward Mar 29 (02:00 CET → 03:00 CEST),
|
||||
// autumn back Oct 25 (03:00 CEST → 02:00 CET).
|
||||
|
||||
/** The wall clock an observer in `tz` reads at `instant`, as "HH:mm". */
|
||||
function localHm(instant: Date, tz: string): string {
|
||||
const p = wallClockInTz(instant, tz);
|
||||
return `${String(p.hour).padStart(2, "0")}:${String(p.minute).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
it("resolves a 20:00 slot to 20:00 local from a PRE-transition reference (autumn back)", () => {
|
||||
// 02:30 CEST — before the 03:00 fall-back. The old implementation
|
||||
// sampled the +02:00 offset here and applied it to the 20:00 target,
|
||||
// which by then sits at +01:00, yielding 19:00 local.
|
||||
const ref = new Date("2026-10-25T00:30:00.000Z");
|
||||
const slot = localHmAsUtc(ref, TZ, 20, 0);
|
||||
expect(localHm(slot, TZ)).toBe("20:00");
|
||||
expect(slot.toISOString()).toBe("2026-10-25T19:00:00.000Z");
|
||||
});
|
||||
|
||||
it("resolves a 20:00 slot to 20:00 local from a POST-transition reference (autumn back)", () => {
|
||||
const ref = new Date("2026-10-25T05:00:00.000Z"); // 06:00 CET
|
||||
const slot = localHmAsUtc(ref, TZ, 20, 0);
|
||||
expect(localHm(slot, TZ)).toBe("20:00");
|
||||
expect(slot.toISOString()).toBe("2026-10-25T19:00:00.000Z");
|
||||
});
|
||||
|
||||
it("keys the SAME instant for one slot regardless of reference side (autumn back)", () => {
|
||||
// The dedupe contract: projector, reminder worker and intake write path
|
||||
// each pass their own reference instant for the same slot. They must
|
||||
// agree byte-for-byte or a duplicate pending row becomes reachable.
|
||||
const pre = new Date("2026-10-25T00:30:00.000Z");
|
||||
const mid = new Date("2026-10-25T01:30:00.000Z");
|
||||
const post = new Date("2026-10-25T05:00:00.000Z");
|
||||
const slots = [pre, mid, post].map((r) =>
|
||||
localHmAsUtc(r, TZ, 20, 0).getTime(),
|
||||
);
|
||||
expect(new Set(slots).size).toBe(1);
|
||||
});
|
||||
|
||||
it("keys the SAME instant for one slot regardless of reference side (spring forward)", () => {
|
||||
const pre = new Date("2026-03-29T00:30:00.000Z"); // 01:30 CET
|
||||
const post = new Date("2026-03-29T09:00:00.000Z"); // 11:00 CEST
|
||||
expect(localHmAsUtc(pre, TZ, 20, 0).getTime()).toBe(
|
||||
localHmAsUtc(post, TZ, 20, 0).getTime(),
|
||||
);
|
||||
expect(localHm(localHmAsUtc(pre, TZ, 20, 0), TZ)).toBe("20:00");
|
||||
});
|
||||
|
||||
it("agrees with startOfLocalDayInTz at 00:00 on BOTH transition days", () => {
|
||||
// By construction now — both route through the same two-pass converge.
|
||||
// They used to disagree by an hour here, which opened the one-shot
|
||||
// on-time band an hour into the previous local day.
|
||||
for (const iso of [
|
||||
"2026-03-29T00:30:00.000Z", // spring forward, pre
|
||||
"2026-03-29T09:00:00.000Z", // spring forward, post
|
||||
"2026-10-25T00:30:00.000Z", // autumn back, pre
|
||||
"2026-10-25T05:00:00.000Z", // autumn back, post
|
||||
]) {
|
||||
const ref = new Date(iso);
|
||||
expect(localHmAsUtc(ref, TZ, 0, 0).getTime()).toBe(
|
||||
startOfLocalDayInTz(ref, TZ).getTime(),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("resolves an early-morning slot on the spring-forward day", () => {
|
||||
// 01:30 local exists (CET, +01:00) on the spring-forward day; 03:30
|
||||
// exists (CEST, +02:00). Both must round-trip to their own wall clock.
|
||||
const ref = new Date("2026-03-29T12:00:00.000Z");
|
||||
expect(localHmAsUtc(ref, TZ, 1, 30).toISOString()).toBe(
|
||||
"2026-03-29T00:30:00.000Z",
|
||||
);
|
||||
expect(localHmAsUtc(ref, TZ, 3, 30).toISOString()).toBe(
|
||||
"2026-03-29T01:30:00.000Z",
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves slots in a southern-hemisphere zone whose transitions invert", () => {
|
||||
// Australia/Sydney falls back 2026-04-05 03:00 AEDT → 02:00 AEST.
|
||||
const sydney = "Australia/Sydney";
|
||||
const pre = new Date("2026-04-04T14:30:00.000Z"); // 01:30 AEDT (+11), Apr 5
|
||||
const post = new Date("2026-04-05T09:00:00.000Z"); // 19:00 AEST (+10), Apr 5
|
||||
const a = localHmAsUtc(pre, sydney, 20, 0);
|
||||
const b = localHmAsUtc(post, sydney, 20, 0);
|
||||
expect(a.getTime()).toBe(b.getTime());
|
||||
expect(localHm(a, sydney)).toBe("20:00");
|
||||
});
|
||||
});
|
||||
+34
-21
@@ -8,7 +8,7 @@
|
||||
* user's local calendar day — today's bounds for database queries,
|
||||
* the local weekday, and the UTC instant of a local `hh:mm`.
|
||||
*/
|
||||
import { wallClockInTz } from "./wall-clock";
|
||||
import { wallClockInTz, zonedWallClockToUtc } from "./wall-clock";
|
||||
|
||||
/**
|
||||
* Get the start (midnight) and end (23:59:59.999) of "today" in the user's
|
||||
@@ -103,11 +103,29 @@ export function getDayOfWeekInTz(now: Date, tz: string): number {
|
||||
|
||||
/**
|
||||
* Compute the UTC instant of local `hh:mm` on the local calendar day
|
||||
* implied by `now` in `tz`. DST-safe — `todayStart.getTime() + h * 3.6e6`
|
||||
* drifts by an hour on spring-forward / fall-back days because raw UTC
|
||||
* arithmetic ignores the DST jump between local midnight and local
|
||||
* `hh:mm`. This helper re-derives the offset at the target local time
|
||||
* so the returned instant always represents local `hh:mm`.
|
||||
* implied by `now` in `tz`.
|
||||
*
|
||||
* Two steps, both zone-aware: resolve which local calendar day `now`
|
||||
* falls on via {@link wallClockInTz}, then convert that day's `hh:mm`
|
||||
* wall clock to its UTC instant via `zonedWallClockToUtc`.
|
||||
*
|
||||
* The offset is therefore settled AT THE TARGET local time, not at the
|
||||
* reference instant. The previous implementation sampled the offset at
|
||||
* `now` and applied it to the target, so on a DST-transition day the
|
||||
* answer depended on which side of the transition the reference sat:
|
||||
* a 20:00 slot resolved to 19:00 local when the caller ticked before the
|
||||
* Berlin autumn change and correctly when it ticked after. That broke
|
||||
* two contracts at once — the projector, the reminder worker and the
|
||||
* intake write path key on the byte-identical slot instant, and they
|
||||
* pass different reference instants for the same slot, so a duplicate
|
||||
* pending row became reachable on that day.
|
||||
*
|
||||
* `zonedWallClockToUtc` runs the same two-pass converge as
|
||||
* {@link startOfLocalDayInTz}, so `localHmAsUtc(x, tz, 0, 0)` now equals
|
||||
* `startOfLocalDayInTz(x, tz)` BY CONSTRUCTION rather than by
|
||||
* coincidence — the two used to disagree by an hour on transition days,
|
||||
* which opened the one-shot on-time band an hour into the previous
|
||||
* local day.
|
||||
*/
|
||||
export function localHmAsUtc(
|
||||
now: Date,
|
||||
@@ -116,20 +134,15 @@ export function localHmAsUtc(
|
||||
minute: number,
|
||||
): Date {
|
||||
const parts = wallClockInTz(now, tz);
|
||||
const localAtTargetAsUtc = new Date(
|
||||
Date.UTC(parts.year, parts.month - 1, parts.day, hour, minute, 0, 0),
|
||||
return zonedWallClockToUtc(
|
||||
{
|
||||
year: parts.year,
|
||||
month: parts.month,
|
||||
day: parts.day,
|
||||
hour,
|
||||
minute,
|
||||
second: 0,
|
||||
},
|
||||
tz,
|
||||
);
|
||||
const localNowAsUtc = new Date(
|
||||
Date.UTC(
|
||||
parts.year,
|
||||
parts.month - 1,
|
||||
parts.day,
|
||||
parts.hour,
|
||||
parts.minute,
|
||||
parts.second,
|
||||
),
|
||||
);
|
||||
const offsetMs =
|
||||
Math.round((localNowAsUtc.getTime() - now.getTime()) / 60000) * 60000;
|
||||
return new Date(localAtTargetAsUtc.getTime() - offsetMs);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -9,6 +9,15 @@ export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: "node",
|
||||
// Pin the host timezone. CI runs UTC; without this a contributor in any
|
||||
// other zone sees a different suite than the gate does — verified: a
|
||||
// UTC+14 host fails five tests on an otherwise green tree. Pinning makes
|
||||
// local and CI agree, and makes "which zone does this test mean?" an
|
||||
// explicit choice the fixture has to state rather than a property of the
|
||||
// machine it runs on. Tests that need a different HOST zone set
|
||||
// `process.env.TZ` themselves and restore it afterwards (see
|
||||
// `next-due-day-label.test.ts`).
|
||||
env: { TZ: "UTC" },
|
||||
// Primes the i18n locale cache so provider mounts resolve every
|
||||
// locale synchronously (production gets the active bundle as an RSC
|
||||
// prop instead — see src/lib/i18n/load-locale.ts).
|
||||
|
||||
@@ -13,6 +13,9 @@ export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: "node",
|
||||
// Same host-timezone pin as the unit config — CI runs UTC, so the
|
||||
// integration contracts must be read against the same clock.
|
||||
env: { TZ: "UTC" },
|
||||
include: ["tests/integration/**/*.test.ts"],
|
||||
// Container boot + migration apply is slow; give each test a generous
|
||||
// budget and beforeAll/afterAll twice that.
|
||||
|
||||
Reference in new issue
Block a user