mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
fix(medications): read schedule days as calendar days, not in the user's zone
A medication's startsOn/endsOn are calendar dates stored as UTC midnight, and the rrule library emits its day anchors the same way. The engine read those midnights on the user's wall clock, which west of UTC is the evening before. A FREQ=WEEKLY;BYDAY=MO plan therefore fired on Sundays in New York, a daily course starting on the 14th produced a dose on the 13th, and an evening dose on the last course day was cut off. One-shot doses, the first dose of a rolling cadence and the legacy weekday walk (startsOn floor, endsOn cap, every-N-weeks phase) carried the same shift. The engine now keeps a calendar day as a calendar day end to end: startsOn and rrule anchors are read from their UTC fields, instants (createdAt, intake times) are converted to the local day they fall on, and only then is the HH:mm materialised in the user's zone. Rolling cadences count calendar days from the local day of the last intake, so a late-evening dose across a DST change no longer slides a day. A nonexistent local time now always moves forward, including in zones whose clocks skip midnight. RRULE anchors with BYHOUR are collapsed per day so a twice-daily protocol cannot emit duplicate slots. CYCLIC on/off weeks are counted as seven-day blocks from the start day in the local calendar. They used to snap to Sunday-rooted UTC weeks, so a three-weeks-on course starting on a Wednesday lost its first days and the off week began on the wrong day east and west of UTC. Measurement reminders pass their anchor and course end as instants; the adapter now converts them to the user's calendar day before handing them to the engine.
This commit is contained in:
1 parent
4af6b87a2a
commit
8a03654e8f
4 files changed
+666
-96
No files matched your search
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* A reminder's `anchorDate` / `endsOn` are instants (the form sends local
|
||||
* midnight), while the recurrence engine takes calendar dates. The adapter
|
||||
* must hand over the day the instant falls on in the user's zone: east of
|
||||
* UTC local midnight is still the previous UTC day, and a weekly plan
|
||||
* anchored there must not start a day early.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { wallClockInTz, zonedWallClockToUtc } from "@/lib/tz/wall-clock";
|
||||
|
||||
import { computeReminderNextDueAt } from "../scheduling";
|
||||
|
||||
const ZONES = [
|
||||
"America/New_York",
|
||||
"America/Los_Angeles",
|
||||
"America/Santiago",
|
||||
"Europe/Berlin",
|
||||
"Asia/Tokyo",
|
||||
"Pacific/Auckland",
|
||||
"Pacific/Tongatapu",
|
||||
] as const;
|
||||
|
||||
function localMidnight(tz: string, y: number, m: number, d: number): Date {
|
||||
return zonedWallClockToUtc(
|
||||
{ year: y, month: m, day: d, hour: 0, minute: 0 },
|
||||
tz,
|
||||
);
|
||||
}
|
||||
|
||||
function localLabel(at: Date | null, tz: string): string | null {
|
||||
if (!at) return null;
|
||||
const p = wallClockInTz(at, tz);
|
||||
return `${p.year}-${String(p.month).padStart(2, "0")}-${String(p.day).padStart(2, "0")} ${String(p.hour).padStart(2, "0")}:${String(p.minute).padStart(2, "0")} w${p.weekday}`;
|
||||
}
|
||||
|
||||
describe.each(ZONES)("reminder anchors in %s", (tz) => {
|
||||
it("a daily RRULE starts on the local anchor day", () => {
|
||||
const anchor = localMidnight(tz, 2026, 9, 14);
|
||||
const next = computeReminderNextDueAt(
|
||||
{
|
||||
intervalDays: null,
|
||||
rrule: "FREQ=DAILY",
|
||||
anchorDate: anchor,
|
||||
notifyHour: 8,
|
||||
lastSatisfiedAt: null,
|
||||
createdAt: new Date("2026-09-01T12:00:00Z"),
|
||||
},
|
||||
tz,
|
||||
new Date("2026-09-10T12:00:00Z"),
|
||||
);
|
||||
expect(localLabel(next, tz)).toBe("2026-09-14 08:00 w1");
|
||||
});
|
||||
|
||||
it("a course that ends on a local day keeps that day's evening slot", () => {
|
||||
const next = computeReminderNextDueAt(
|
||||
{
|
||||
intervalDays: null,
|
||||
rrule: "FREQ=DAILY;BYHOUR=7,19",
|
||||
anchorDate: localMidnight(tz, 2026, 9, 14),
|
||||
endsOn: zonedWallClockToUtc(
|
||||
{ year: 2026, month: 9, day: 20, hour: 10, minute: 0 },
|
||||
tz,
|
||||
),
|
||||
notifyHour: 7,
|
||||
lastSatisfiedAt: null,
|
||||
createdAt: new Date("2026-09-01T12:00:00Z"),
|
||||
},
|
||||
tz,
|
||||
zonedWallClockToUtc(
|
||||
{ year: 2026, month: 9, day: 20, hour: 8, minute: 0 },
|
||||
tz,
|
||||
),
|
||||
);
|
||||
expect(localLabel(next, tz)).toBe("2026-09-20 19:00 w0");
|
||||
});
|
||||
});
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
type RecurrenceContext,
|
||||
nextOccurrenceAfter,
|
||||
} from "@/lib/medications/scheduling/recurrence";
|
||||
import { wallClockInTz } from "@/lib/tz/wall-clock";
|
||||
|
||||
/**
|
||||
* The reminder fields this module reads. A subset of the Prisma row so
|
||||
@@ -50,6 +51,12 @@ export interface ReminderScheduleInput {
|
||||
endsOn?: Date | null;
|
||||
}
|
||||
|
||||
/** The calendar day `instant` falls on in `tz`, as UTC midnight of that date. */
|
||||
function calendarDayInZone(instant: Date, tz: string): Date {
|
||||
const p = wallClockInTz(instant, tz);
|
||||
return new Date(Date.UTC(p.year, p.month - 1, p.day));
|
||||
}
|
||||
|
||||
function hourToHhmm(hour: number): string {
|
||||
const safe = Number.isInteger(hour) && hour >= 0 && hour <= 23 ? hour : 9;
|
||||
return `${safe.toString().padStart(2, "0")}:00`;
|
||||
@@ -120,17 +127,30 @@ export function buildReminderRecurrence(
|
||||
// First-due anchor when never satisfied: anchorDate ?? createdAt. After
|
||||
// a satisfy the rolling path re-anchors on `lastIntakeAt + N`, so we
|
||||
// feed `lastSatisfiedAt` through `lastIntakeAt`.
|
||||
const startsOn = reminder.anchorDate ?? reminder.createdAt;
|
||||
//
|
||||
// The engine's `startsOn` / `endsOn` are calendar dates (the medication
|
||||
// columns are `@db.Date`, UTC midnight of the date). A reminder's
|
||||
// `anchorDate` / `endsOn` are instants instead (the form sends local
|
||||
// midnight, a Coach course sends `now + days`), so hand the engine the
|
||||
// calendar day each instant falls on in the user's zone.
|
||||
const tz = timeZone || "Europe/Berlin";
|
||||
const startsOn = calendarDayInZone(
|
||||
reminder.anchorDate ?? reminder.createdAt,
|
||||
tz,
|
||||
);
|
||||
const endsOn = reminder.endsOn
|
||||
? calendarDayInZone(reminder.endsOn, tz)
|
||||
: null;
|
||||
|
||||
const ctx: RecurrenceContext = {
|
||||
medication: {
|
||||
id: "measurement-reminder",
|
||||
startsOn,
|
||||
endsOn: reminder.endsOn ?? null,
|
||||
endsOn,
|
||||
oneShot: false,
|
||||
createdAt: reminder.createdAt,
|
||||
},
|
||||
timeZone: timeZone || "Europe/Berlin",
|
||||
timeZone: tz,
|
||||
lastIntakeAt: reminder.lastSatisfiedAt,
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,364 @@
|
||||
/**
|
||||
* Calendar-day correctness of the recurrence engine across time zones.
|
||||
*
|
||||
* `Medication.startsOn` / `endsOn` are `@db.Date` columns: a calendar date
|
||||
* carried as UTC midnight. The engine used to read that midnight in the
|
||||
* user's zone, which west of UTC is the previous evening, so every
|
||||
* calendar-anchored cadence landed one day early there: a Monday plan fired
|
||||
* on Sundays in New York, a course starting on the 14th produced a dose on
|
||||
* the 13th, and a dose on the last course day in the evening vanished.
|
||||
*
|
||||
* Every assertion here is read back on the user's wall clock, and the whole
|
||||
* matrix runs under several HOST zones as well (the engine must not depend
|
||||
* on the machine it runs on). The host zone is switched through
|
||||
* `process.env.TZ` inside the test, because a `TZ=` prefix on the command
|
||||
* line is overridden by the vitest config.
|
||||
*/
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
|
||||
import { wallClockInTz } from "@/lib/tz/wall-clock";
|
||||
|
||||
import {
|
||||
advanceRollingOccurrence,
|
||||
expandRollingRetrospective,
|
||||
nextOccurrenceAfter,
|
||||
occurrencesBetween,
|
||||
type CanonicalSchedule,
|
||||
type RecurrenceContext,
|
||||
} from "../recurrence";
|
||||
|
||||
const USER_ZONES = [
|
||||
"America/New_York",
|
||||
"America/Los_Angeles",
|
||||
"America/Santiago",
|
||||
"Europe/Berlin",
|
||||
"Asia/Tokyo",
|
||||
"Pacific/Auckland",
|
||||
"Pacific/Tongatapu",
|
||||
"UTC",
|
||||
] as const;
|
||||
|
||||
const HOST_ZONES = ["UTC", "America/Los_Angeles", "Pacific/Auckland"] as const;
|
||||
|
||||
function makeSchedule(
|
||||
overrides: Partial<CanonicalSchedule> = {},
|
||||
): CanonicalSchedule {
|
||||
return {
|
||||
id: "sched-tz",
|
||||
rrule: null,
|
||||
rollingIntervalDays: null,
|
||||
timesOfDay: ["09:00"],
|
||||
daysOfWeek: null,
|
||||
windowStart: "09:00",
|
||||
windowEnd: "10:00",
|
||||
reminderGraceMinutes: null,
|
||||
scheduleType: "SCHEDULED",
|
||||
cyclicOnWeeks: null,
|
||||
cyclicOffWeeks: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeCtx(
|
||||
timeZone: string,
|
||||
medication: Partial<RecurrenceContext["medication"]> = {},
|
||||
lastIntakeAt: Date | null = null,
|
||||
): RecurrenceContext {
|
||||
return {
|
||||
timeZone,
|
||||
lastIntakeAt,
|
||||
medication: {
|
||||
id: "med-tz",
|
||||
startsOn: null,
|
||||
endsOn: null,
|
||||
oneShot: false,
|
||||
createdAt: new Date("2026-01-01T12:00:00Z"),
|
||||
...medication,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** A calendar date the way Prisma hands back a `@db.Date` column. */
|
||||
function date(ymd: string): Date {
|
||||
return new Date(`${ymd}T00:00:00.000Z`);
|
||||
}
|
||||
|
||||
function localYmd(at: Date, tz: string): string {
|
||||
const p = wallClockInTz(at, tz);
|
||||
return `${p.year}-${String(p.month).padStart(2, "0")}-${String(p.day).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function localHm(at: Date, tz: string): string {
|
||||
const p = wallClockInTz(at, tz);
|
||||
return `${String(p.hour).padStart(2, "0")}:${String(p.minute).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
const WIDE_FROM = new Date("2026-07-25T00:00:00Z");
|
||||
const WIDE_TO = new Date("2026-12-31T00:00:00Z");
|
||||
|
||||
describe.each(HOST_ZONES)("recurrence engine on a %s host", (hostZone) => {
|
||||
const originalTz = process.env.TZ;
|
||||
beforeAll(() => {
|
||||
process.env.TZ = hostZone;
|
||||
});
|
||||
afterAll(() => {
|
||||
process.env.TZ = originalTz;
|
||||
});
|
||||
|
||||
describe.each(USER_ZONES)("user in %s", (tz) => {
|
||||
it("FREQ=WEEKLY;BYDAY=MO fires on local Mondays at the local time", () => {
|
||||
const slots = occurrencesBetween(
|
||||
makeSchedule({ rrule: "FREQ=WEEKLY;BYDAY=MO" }),
|
||||
WIDE_FROM,
|
||||
WIDE_TO,
|
||||
makeCtx(tz, { startsOn: date("2026-09-01") }),
|
||||
);
|
||||
expect(slots.length).toBeGreaterThan(10);
|
||||
for (const s of slots) {
|
||||
expect(wallClockInTz(s.at, tz).weekday).toBe(1);
|
||||
expect(localHm(s.at, tz)).toBe("09:00");
|
||||
}
|
||||
expect(localYmd(slots[0].at, tz)).toBe("2026-09-07");
|
||||
});
|
||||
|
||||
it("FREQ=DAILY starts on startsOn, never the day before", () => {
|
||||
const slots = occurrencesBetween(
|
||||
makeSchedule({ rrule: "FREQ=DAILY", timesOfDay: ["00:30", "23:30"] }),
|
||||
WIDE_FROM,
|
||||
new Date("2026-09-20T00:00:00Z"),
|
||||
makeCtx(tz, { startsOn: date("2026-09-14") }),
|
||||
);
|
||||
expect(localYmd(slots[0].at, tz)).toBe("2026-09-14");
|
||||
expect(localHm(slots[0].at, tz)).toBe("00:30");
|
||||
// One slot per time per day: no duplicate, no missing day.
|
||||
const days = slots.map((s) => localYmd(s.at, tz));
|
||||
for (const day of ["2026-09-14", "2026-09-15", "2026-09-16"]) {
|
||||
expect(days.filter((d) => d === day)).toHaveLength(2);
|
||||
}
|
||||
expect(days.every((d) => d >= "2026-09-14")).toBe(true);
|
||||
});
|
||||
|
||||
it("FREQ=DAILY keeps the evening dose of the last course day", () => {
|
||||
const ctx = makeCtx(tz, {
|
||||
startsOn: date("2026-09-14"),
|
||||
endsOn: date("2026-09-16"),
|
||||
});
|
||||
const schedule = makeSchedule({
|
||||
rrule: "FREQ=DAILY",
|
||||
timesOfDay: ["08:00", "23:00"],
|
||||
});
|
||||
const slots = occurrencesBetween(schedule, WIDE_FROM, WIDE_TO, ctx);
|
||||
expect(
|
||||
slots.map((s) => `${localYmd(s.at, tz)} ${localHm(s.at, tz)}`),
|
||||
).toEqual([
|
||||
"2026-09-14 08:00",
|
||||
"2026-09-14 23:00",
|
||||
"2026-09-15 08:00",
|
||||
"2026-09-15 23:00",
|
||||
"2026-09-16 08:00",
|
||||
"2026-09-16 23:00",
|
||||
]);
|
||||
// The next-due walk must reach the same last dose.
|
||||
const lastMorning = slots[4].at;
|
||||
const next = nextOccurrenceAfter(schedule, lastMorning, ctx);
|
||||
expect(next?.at.toISOString()).toBe(slots[5].at.toISOString());
|
||||
expect(nextOccurrenceAfter(schedule, slots[5].at, ctx)).toBeNull();
|
||||
});
|
||||
|
||||
it("FREQ=MONTHLY;BYMONTHDAY=1 lands on the first of the local month", () => {
|
||||
const slots = occurrencesBetween(
|
||||
makeSchedule({ rrule: "FREQ=MONTHLY;BYMONTHDAY=1" }),
|
||||
WIDE_FROM,
|
||||
WIDE_TO,
|
||||
makeCtx(tz, { startsOn: date("2026-08-01") }),
|
||||
);
|
||||
expect(slots.map((s) => localYmd(s.at, tz))).toEqual([
|
||||
"2026-08-01",
|
||||
"2026-09-01",
|
||||
"2026-10-01",
|
||||
"2026-11-01",
|
||||
"2026-12-01",
|
||||
]);
|
||||
});
|
||||
|
||||
it("legacy every-other-Monday honours startsOn and the week phase", () => {
|
||||
const slots = occurrencesBetween(
|
||||
makeSchedule({ daysOfWeek: "i2;1" }),
|
||||
WIDE_FROM,
|
||||
new Date("2026-10-31T00:00:00Z"),
|
||||
makeCtx(tz, { startsOn: date("2026-09-14") }),
|
||||
);
|
||||
expect(slots.map((s) => localYmd(s.at, tz))).toEqual([
|
||||
"2026-09-14",
|
||||
"2026-09-28",
|
||||
"2026-10-12",
|
||||
"2026-10-26",
|
||||
]);
|
||||
});
|
||||
|
||||
it("legacy daily walk stops after the evening dose of endsOn", () => {
|
||||
const slots = occurrencesBetween(
|
||||
makeSchedule({ timesOfDay: ["21:00"] }),
|
||||
WIDE_FROM,
|
||||
WIDE_TO,
|
||||
makeCtx(tz, {
|
||||
startsOn: date("2026-09-14"),
|
||||
endsOn: date("2026-09-15"),
|
||||
}),
|
||||
);
|
||||
expect(slots.map((s) => localYmd(s.at, tz))).toEqual([
|
||||
"2026-09-14",
|
||||
"2026-09-15",
|
||||
]);
|
||||
});
|
||||
|
||||
it("a one-shot dose lands on its own date", () => {
|
||||
const slots = occurrencesBetween(
|
||||
makeSchedule({ timesOfDay: ["08:00"] }),
|
||||
WIDE_FROM,
|
||||
WIDE_TO,
|
||||
makeCtx(tz, {
|
||||
startsOn: date("2026-09-14"),
|
||||
endsOn: date("2026-09-14"),
|
||||
oneShot: true,
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
slots.map((s) => `${localYmd(s.at, tz)} ${localHm(s.at, tz)}`),
|
||||
).toEqual(["2026-09-14 08:00"]);
|
||||
});
|
||||
|
||||
it("a rolling first dose is due on startsOn", () => {
|
||||
const schedule = makeSchedule({ rollingIntervalDays: 7 });
|
||||
const ctx = makeCtx(tz, { startsOn: date("2026-09-14") });
|
||||
const next = nextOccurrenceAfter(
|
||||
schedule,
|
||||
new Date("2026-09-01T12:00:00Z"),
|
||||
ctx,
|
||||
);
|
||||
expect(next && localYmd(next.at, tz)).toBe("2026-09-14");
|
||||
expect(next && localHm(next.at, tz)).toBe("09:00");
|
||||
});
|
||||
|
||||
it("a rolling cadence counts calendar days from a late-evening intake", () => {
|
||||
// 23:30 local on 2026-10-30; +7 calendar days crosses the northern
|
||||
// fall-back weekend and the southern spring-forward season.
|
||||
const intake = zoned(tz, 2026, 10, 30, 23, 30);
|
||||
const schedule = makeSchedule({ rollingIntervalDays: 7 });
|
||||
const ctx = makeCtx(tz, { startsOn: date("2026-09-14") }, intake);
|
||||
const next = nextOccurrenceAfter(schedule, intake, ctx);
|
||||
expect(next && localYmd(next.at, tz)).toBe("2026-11-06");
|
||||
const after = next && advanceRollingOccurrence(schedule, next, ctx);
|
||||
expect(after && localYmd(after.at, tz)).toBe("2026-11-13");
|
||||
});
|
||||
|
||||
it("a rolling retrospective back-fill keeps calendar days", () => {
|
||||
const first = zoned(tz, 2026, 9, 1, 23, 30);
|
||||
const last = zoned(tz, 2026, 9, 22, 23, 30);
|
||||
const slots = expandRollingRetrospective(
|
||||
makeSchedule({ rollingIntervalDays: 7 }),
|
||||
makeCtx(tz, { startsOn: date("2026-09-01") }),
|
||||
WIDE_FROM,
|
||||
new Date("2026-09-25T00:00:00Z"),
|
||||
[first, last],
|
||||
new Date("2026-09-24T12:00:00Z"),
|
||||
);
|
||||
expect(slots.map((s) => localYmd(s.at, tz))).toEqual([
|
||||
"2026-09-01",
|
||||
"2026-09-08",
|
||||
"2026-09-15",
|
||||
"2026-09-22",
|
||||
]);
|
||||
});
|
||||
|
||||
it("CYCLIC 3 on / 1 off counts whole weeks from startsOn in the local calendar", () => {
|
||||
// Starts on a Wednesday; the three "on" weeks are 21 consecutive days.
|
||||
const slots = occurrencesBetween(
|
||||
makeSchedule({
|
||||
rrule: "FREQ=DAILY",
|
||||
scheduleType: "CYCLIC",
|
||||
cyclicOnWeeks: 3,
|
||||
cyclicOffWeeks: 1,
|
||||
timesOfDay: ["23:30"],
|
||||
}),
|
||||
WIDE_FROM,
|
||||
new Date("2026-11-10T00:00:00Z"),
|
||||
makeCtx(tz, { startsOn: date("2026-09-16") }),
|
||||
);
|
||||
const days = slots.map((s) => localYmd(s.at, tz));
|
||||
expect(days[0]).toBe("2026-09-16");
|
||||
expect(days).toContain("2026-10-06");
|
||||
expect(days).not.toContain("2026-10-07");
|
||||
expect(days).not.toContain("2026-10-13");
|
||||
expect(days).toContain("2026-10-14");
|
||||
expect(days.filter((d) => d < "2026-10-14")).toHaveLength(21);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DST transition days", () => {
|
||||
const cases: Array<[string, string, string]> = [
|
||||
["America/New_York", "2026-03-08", "2026-11-01"],
|
||||
["America/Los_Angeles", "2026-03-08", "2026-11-01"],
|
||||
["Europe/Berlin", "2026-03-29", "2026-10-25"],
|
||||
["America/Santiago", "2026-09-06", "2026-04-05"],
|
||||
["Pacific/Auckland", "2026-09-27", "2026-04-05"],
|
||||
];
|
||||
it.each(cases)(
|
||||
"%s keeps one dose per local day across %s and %s",
|
||||
(tz, springDay, fallDay) => {
|
||||
for (const day of [springDay, fallDay]) {
|
||||
const start = date(day);
|
||||
const slots = occurrencesBetween(
|
||||
makeSchedule({
|
||||
rrule: "FREQ=DAILY",
|
||||
timesOfDay: ["00:30", "08:00", "23:30"],
|
||||
}),
|
||||
new Date(start.getTime() - 3 * 86_400_000),
|
||||
new Date(start.getTime() + 3 * 86_400_000),
|
||||
makeCtx(tz, { startsOn: date("2026-01-01") }),
|
||||
);
|
||||
const onDay = slots.filter((s) => localYmd(s.at, tz) === day);
|
||||
expect(onDay).toHaveLength(3);
|
||||
expect(localHm(onDay[1].at, tz)).toBe("08:00");
|
||||
expect(localHm(onDay[2].at, tz)).toBe("23:30");
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each(cases)(
|
||||
"%s: a rolling cadence across the spring-forward day %s keeps calendar days",
|
||||
(tz, springDay) => {
|
||||
const [y, m, d] = springDay.split("-").map(Number);
|
||||
// 23:30 local three days before the clocks go forward; seven times
|
||||
// 24 h later is 00:30 on the day after the due day.
|
||||
const intake = zoned(tz, y, m, d - 3, 23, 30);
|
||||
const schedule = makeSchedule({ rollingIntervalDays: 7 });
|
||||
const ctx = makeCtx(tz, { startsOn: date("2026-01-01") }, intake);
|
||||
const next = nextOccurrenceAfter(schedule, intake, ctx);
|
||||
const due = new Date(Date.UTC(y, m - 1, d - 3 + 7));
|
||||
expect(next && localYmd(next.at, tz)).toBe(
|
||||
due.toISOString().slice(0, 10),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function zoned(
|
||||
tz: string,
|
||||
year: number,
|
||||
month: number,
|
||||
day: number,
|
||||
hour: number,
|
||||
minute: number,
|
||||
): Date {
|
||||
// Solve the local wall clock to an instant without the engine under test.
|
||||
let guess = Date.UTC(year, month - 1, day, hour, minute);
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const p = wallClockInTz(new Date(guess), tz);
|
||||
const asUtc = Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute);
|
||||
guess += Date.UTC(year, month - 1, day, hour, minute) - asUtc;
|
||||
}
|
||||
return new Date(guess);
|
||||
}
|
||||
@@ -225,8 +225,8 @@ export function occurrencesBetween(
|
||||
|
||||
/**
|
||||
* v1.7.0 — true when `instant` falls in an "on" week of the cyclic
|
||||
* on/off phase. The anchor is the medication's `startsOn ?? createdAt`,
|
||||
* snapped to its UTC week start. `phase = weeksFromAnchor mod
|
||||
* on/off phase. The anchor is the local calendar day of the medication's
|
||||
* `startsOn ?? createdAt`; a week is seven calendar days from it. `phase = weeksFromAnchor mod
|
||||
* (on + off)`; the slot survives iff `phase < on`. A non-positive or
|
||||
* missing `cyclicOnWeeks` keeps every slot (defensive — the route + Zod
|
||||
* require a positive value for CYCLIC, but the engine never throws on a
|
||||
@@ -243,11 +243,13 @@ function isInCyclicOnWeek(
|
||||
const cycleLen = onWeeks + offWeeks;
|
||||
if (cycleLen <= 0) return true;
|
||||
|
||||
const anchor = ctx.medication.startsOn ?? ctx.medication.createdAt;
|
||||
const anchorWeekStart = startOfUtcWeek(anchor).getTime();
|
||||
const instantWeekStart = startOfUtcWeek(instant).getTime();
|
||||
const weeksFromAnchor = Math.round(
|
||||
(instantWeekStart - anchorWeekStart) / WEEK_MS,
|
||||
// Whole weeks counted in the user's calendar from the start day itself:
|
||||
// "three weeks on" starting on a Wednesday is 21 consecutive days, and the
|
||||
// slot's day is its local day, not the UTC day its instant falls on.
|
||||
const anchorDay = anchorCivilDay(ctx).getTime();
|
||||
const slotDay = civilDayOfInstant(instant, ctx.timeZone).getTime();
|
||||
const weeksFromAnchor = Math.floor(
|
||||
Math.round((slotDay - anchorDay) / DAY_MS) / 7,
|
||||
);
|
||||
const phase = ((weeksFromAnchor % cycleLen) + cycleLen) % cycleLen;
|
||||
return phase < onWeeks;
|
||||
@@ -272,10 +274,15 @@ export function nextOccurrenceAfter(
|
||||
// v1.7.0 — PRN never has a next due instant.
|
||||
if (schedule.scheduleType === "PRN") return null;
|
||||
|
||||
const endsOn = ctx.medication.endsOn;
|
||||
const endsOnDay = endsOnCivilDay(ctx);
|
||||
const hardCap = new Date(after.getTime() + 365 * 10 * DAY_MS);
|
||||
const limit = endsOn
|
||||
? new Date(Math.min(endOfUtcDay(endsOn).getTime(), hardCap.getTime()))
|
||||
const limit = endsOnDay
|
||||
? new Date(
|
||||
Math.min(
|
||||
endOfCivilDayInstant(endsOnDay, ctx.timeZone).getTime(),
|
||||
hardCap.getTime(),
|
||||
),
|
||||
)
|
||||
: hardCap;
|
||||
|
||||
const cyclic = schedule.scheduleType === "CYCLIC";
|
||||
@@ -331,7 +338,7 @@ export function nextOccurrenceAfter(
|
||||
// day short and re-clips the very slot it meant to admit. `endsOn` stays
|
||||
// enforced inside `expandRolling`, so a course-window reminder still
|
||||
// self-expires.
|
||||
const rollingSlotInstant = applyTimeOfDayToDate(
|
||||
const rollingSlotInstant = applyTimeOfDayToCivilDay(
|
||||
rollingDoseAnchor(schedule, ctx),
|
||||
schedule.timesOfDay[0] ?? schedule.windowStart,
|
||||
ctx.timeZone,
|
||||
@@ -403,11 +410,11 @@ function expandOneShot(
|
||||
from: Date,
|
||||
to: Date,
|
||||
): Occurrence[] {
|
||||
const anchor = ctx.medication.startsOn ?? ctx.medication.createdAt;
|
||||
const anchor = anchorCivilDay(ctx);
|
||||
const times = effectiveTimesOfDay(schedule);
|
||||
const slots: Occurrence[] = [];
|
||||
for (const time of times) {
|
||||
const at = applyTimeOfDayToDate(anchor, time, ctx.timeZone);
|
||||
const at = applyTimeOfDayToCivilDay(anchor, time, ctx.timeZone);
|
||||
if (at.getTime() < from.getTime() || at.getTime() > to.getTime()) continue;
|
||||
slots.push(buildOccurrence(at, time, schedule));
|
||||
}
|
||||
@@ -460,17 +467,25 @@ function rollingDoseAnchor(
|
||||
ctx: RecurrenceContext,
|
||||
): Date {
|
||||
const intervalDays = schedule.rollingIntervalDays ?? 0;
|
||||
// N calendar days after the local day of the last intake, not N × 24 h:
|
||||
// a late-evening dose across a DST change would otherwise slide onto the
|
||||
// wrong day.
|
||||
return ctx.lastIntakeAt !== null
|
||||
? new Date(ctx.lastIntakeAt.getTime() + intervalDays * DAY_MS)
|
||||
: (ctx.medication.startsOn ?? ctx.medication.createdAt);
|
||||
? addCivilDays(
|
||||
civilDayOfInstant(ctx.lastIntakeAt, ctx.timeZone),
|
||||
intervalDays,
|
||||
)
|
||||
: anchorCivilDay(ctx);
|
||||
}
|
||||
|
||||
function rollingDoseDayFloor(
|
||||
schedule: CanonicalSchedule,
|
||||
ctx: RecurrenceContext,
|
||||
): number {
|
||||
const anchor = rollingDoseAnchor(schedule, ctx);
|
||||
return startOfLocalDayInTz(anchor, ctx.timeZone).getTime();
|
||||
return startOfCivilDayInstant(
|
||||
rollingDoseAnchor(schedule, ctx),
|
||||
ctx.timeZone,
|
||||
).getTime();
|
||||
}
|
||||
|
||||
function expandRolling(
|
||||
@@ -486,16 +501,14 @@ function expandRolling(
|
||||
// FIRST dose is the start date itself (no `+ N`) — see the doc comment.
|
||||
const nextDue = rollingDoseAnchor(schedule, ctx);
|
||||
|
||||
// endsOn cap.
|
||||
if (
|
||||
ctx.medication.endsOn &&
|
||||
nextDue.getTime() > endOfUtcDay(ctx.medication.endsOn).getTime()
|
||||
) {
|
||||
// endsOn cap — both sides are calendar days.
|
||||
const endsOnDay = endsOnCivilDay(ctx);
|
||||
if (endsOnDay && nextDue.getTime() > endsOnDay.getTime()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const time = schedule.timesOfDay[0] ?? schedule.windowStart;
|
||||
const at = applyTimeOfDayToDate(nextDue, time, ctx.timeZone);
|
||||
const at = applyTimeOfDayToCivilDay(nextDue, time, ctx.timeZone);
|
||||
if (at.getTime() < from.getTime() || at.getTime() > to.getTime()) return [];
|
||||
|
||||
return [buildOccurrence(at, time, schedule)];
|
||||
@@ -515,14 +528,15 @@ export function advanceRollingOccurrence(
|
||||
const n = schedule.rollingIntervalDays;
|
||||
if (n === null || n <= 0) return null;
|
||||
const time = schedule.timesOfDay[0] ?? schedule.windowStart;
|
||||
const nextDay = new Date(occurrence.at.getTime() + n * DAY_MS);
|
||||
const at = applyTimeOfDayToDate(nextDay, time, ctx.timeZone);
|
||||
if (
|
||||
ctx.medication.endsOn &&
|
||||
at.getTime() > endOfUtcDay(ctx.medication.endsOn).getTime()
|
||||
) {
|
||||
const nextDay = addCivilDays(
|
||||
civilDayOfInstant(occurrence.at, ctx.timeZone),
|
||||
n,
|
||||
);
|
||||
const endsOnDay = endsOnCivilDay(ctx);
|
||||
if (endsOnDay && nextDay.getTime() > endsOnDay.getTime()) {
|
||||
return null;
|
||||
}
|
||||
const at = applyTimeOfDayToCivilDay(nextDay, time, ctx.timeZone);
|
||||
return buildOccurrence(at, time, schedule);
|
||||
}
|
||||
|
||||
@@ -586,8 +600,9 @@ export function expandRollingRetrospective(
|
||||
// Tolerance: a gap up to 1.5·N is "on time" (a dose logged a little
|
||||
// late). Only a gap strictly beyond this synthesizes skipped cycles.
|
||||
const gapToleranceMs = 1.5 * cycleMs;
|
||||
const endsCap = ctx.medication.endsOn
|
||||
? endOfUtcDay(ctx.medication.endsOn).getTime()
|
||||
const endsOnDay = endsOnCivilDay(ctx);
|
||||
const endsCap = endsOnDay
|
||||
? endOfCivilDayInstant(endsOnDay, ctx.timeZone).getTime()
|
||||
: Infinity;
|
||||
|
||||
const inWindow = (at: Date): boolean =>
|
||||
@@ -616,11 +631,21 @@ export function expandRollingRetrospective(
|
||||
const gap = next.getTime() - prev.getTime();
|
||||
if (gap <= gapToleranceMs) continue;
|
||||
// Number of whole cycles the user skipped: floor(gap/N) − 1 missed
|
||||
// cycles sit strictly between the two intakes.
|
||||
const cyclesInGap = Math.floor(gap / cycleMs);
|
||||
// cycles sit strictly between the two intakes. Counted in calendar
|
||||
// days, so a gap spanning a DST change (one hour short) still counts
|
||||
// its last cycle.
|
||||
const prevDay = civilDayOfInstant(prev, ctx.timeZone);
|
||||
const gapDays = Math.round(
|
||||
(civilDayOfInstant(next, ctx.timeZone).getTime() - prevDay.getTime()) /
|
||||
DAY_MS,
|
||||
);
|
||||
const cyclesInGap = Math.floor(gapDays / n);
|
||||
for (let k = 1; k < cyclesInGap; k++) {
|
||||
const anchor = new Date(prev.getTime() + k * cycleMs);
|
||||
const at = applyTimeOfDayToDate(anchor, time, ctx.timeZone);
|
||||
const at = applyTimeOfDayToCivilDay(
|
||||
addCivilDays(prevDay, k * n),
|
||||
time,
|
||||
ctx.timeZone,
|
||||
);
|
||||
if (inWindow(at)) {
|
||||
slots.push(buildOccurrence(at, time, schedule));
|
||||
}
|
||||
@@ -639,12 +664,16 @@ export function expandRollingRetrospective(
|
||||
const forwardToleranceMs = cycleMs / 2;
|
||||
const lastInstant =
|
||||
sorted.length > 0 ? sorted[sorted.length - 1] : ctx.lastIntakeAt;
|
||||
const nextDueDay =
|
||||
lastInstant !== null
|
||||
? addCivilDays(civilDayOfInstant(lastInstant, ctx.timeZone), n)
|
||||
: anchorCivilDay(ctx);
|
||||
const nextDue =
|
||||
lastInstant !== null
|
||||
? new Date(lastInstant.getTime() + cycleMs)
|
||||
: (ctx.medication.startsOn ?? ctx.medication.createdAt);
|
||||
: startOfCivilDayInstant(nextDueDay, ctx.timeZone);
|
||||
if (now.getTime() - nextDue.getTime() > forwardToleranceMs) {
|
||||
const at = applyTimeOfDayToDate(nextDue, time, ctx.timeZone);
|
||||
const at = applyTimeOfDayToCivilDay(nextDueDay, time, ctx.timeZone);
|
||||
// Dedupe: a forward slot already represented by a logged intake (the
|
||||
// intake re-anchored exactly N days out) must not double-count.
|
||||
const alreadyPresent = slots.some(
|
||||
@@ -671,16 +700,21 @@ function expandRrule(
|
||||
const rruleStr = schedule.rrule;
|
||||
if (!rruleStr) return [];
|
||||
|
||||
const dtstart = ctx.medication.startsOn ?? ctx.medication.createdAt;
|
||||
const dtstartLine = `DTSTART:${formatUtcBasic(startOfUtcDay(dtstart))}`;
|
||||
// The rrule library works in "floating" UTC: DTSTART and every emitted
|
||||
// anchor are calendar days carried as UTC midnight. Keep them that way end
|
||||
// to end — the anchor's day is read from its UTC fields and only then is
|
||||
// the HH:mm materialised in the user's zone. Reading the anchor in the
|
||||
// user's zone instead turned Monday 00:00Z into Sunday evening west of UTC.
|
||||
const dtstartLine = `DTSTART:${formatUtcBasic(anchorCivilDay(ctx))}`;
|
||||
// Skip the engine-side UNTIL suffix when the user's RRULE already
|
||||
// bounds the recurrence with COUNT or UNTIL — RFC 5545 forbids both
|
||||
// (and any two-UNTIL collision), and RRule.fromString throws on the
|
||||
// duplicate, silently collapsing the schedule to zero slots.
|
||||
const userBoundsRecurrence = /(?:^|;)(?:COUNT|UNTIL)=/.test(rruleStr);
|
||||
const endsOnDay = endsOnCivilDay(ctx);
|
||||
const untilSuffix =
|
||||
ctx.medication.endsOn && !userBoundsRecurrence
|
||||
? `;UNTIL=${formatUtcBasic(endOfUtcDay(ctx.medication.endsOn))}`
|
||||
endsOnDay && !userBoundsRecurrence
|
||||
? `;UNTIL=${formatUtcBasic(endOfUtcDay(endsOnDay))}`
|
||||
: "";
|
||||
const full = `${dtstartLine}\nRRULE:${rruleStr}${untilSuffix}`;
|
||||
|
||||
@@ -697,20 +731,25 @@ function expandRrule(
|
||||
|
||||
// Walk a generously padded day-anchor window so a per-day timesOfDay
|
||||
// expansion still hits the requested [from, to] after the time-of-day
|
||||
// is applied. The rrule lib's day-anchored dates land at midnight UTC
|
||||
// of the BYDAY/BYMONTHDAY day; the time-of-day might push back across
|
||||
// a day boundary in some timezones, so widen the window by 2 days on
|
||||
// each side.
|
||||
// is applied: a calendar day spans instants up to 14 h either side of
|
||||
// its UTC midnight, so widen the window by 2 days on each side.
|
||||
const padded = 2 * DAY_MS;
|
||||
const after = new Date(from.getTime() - padded);
|
||||
const before = new Date(to.getTime() + padded);
|
||||
const dayAnchors = rule.between(after, before, true);
|
||||
// An RRULE with BYHOUR (the measurement-reminder protocols) emits several
|
||||
// anchors per day; the times of day are expanded per calendar day, so
|
||||
// collapse the anchors to their days first.
|
||||
const dayKeys = new Set<number>();
|
||||
for (const anchor of rule.between(after, before, true)) {
|
||||
dayKeys.add(startOfUtcDay(anchor).getTime());
|
||||
}
|
||||
|
||||
const times = effectiveTimesOfDay(schedule);
|
||||
const slots: Occurrence[] = [];
|
||||
for (const anchor of dayAnchors) {
|
||||
for (const dayKey of dayKeys) {
|
||||
const day = new Date(dayKey);
|
||||
for (const time of times) {
|
||||
const at = applyTimeOfDayToDate(anchor, time, ctx.timeZone);
|
||||
const at = applyTimeOfDayToCivilDay(day, time, ctx.timeZone);
|
||||
if (at.getTime() < from.getTime()) continue;
|
||||
if (at.getTime() > to.getTime()) continue;
|
||||
slots.push(buildOccurrence(at, time, schedule));
|
||||
@@ -730,17 +769,19 @@ function expandLegacy(
|
||||
to: Date,
|
||||
): Occurrence[] {
|
||||
const recurrence = parseScheduleRecurrence(schedule.daysOfWeek);
|
||||
const anchor = ctx.medication.startsOn ?? ctx.medication.createdAt;
|
||||
const anchorWeekStartUtc = startOfUtcWeek(anchor).getTime();
|
||||
const anchorWeekStart = startOfCivilWeek(anchorCivilDay(ctx)).getTime();
|
||||
const times = effectiveTimesOfDay(schedule);
|
||||
const endsOnDay = endsOnCivilDay(ctx);
|
||||
|
||||
const slots: Occurrence[] = [];
|
||||
// Iterate every UTC day in [from-1, to+1] to cover any time-of-day
|
||||
// that might land in [from, to] after applying the local-tz HH:mm.
|
||||
// Iterate every calendar day (carried as UTC midnight) in [from-1, to+1]
|
||||
// to cover any time-of-day that might land in [from, to] once the HH:mm
|
||||
// is materialised in the user's zone. Every comparison below is between
|
||||
// calendar days; only `applyTimeOfDayToCivilDay` touches the zone.
|
||||
const start = startOfUtcDay(new Date(from.getTime() - DAY_MS));
|
||||
const end = startOfUtcDay(new Date(to.getTime() + DAY_MS));
|
||||
const startsOnFloor = ctx.medication.startsOn
|
||||
? startOfUtcDay(ctx.medication.startsOn).getTime()
|
||||
? civilDayOfDate(ctx.medication.startsOn).getTime()
|
||||
: null;
|
||||
for (
|
||||
let day = start;
|
||||
@@ -754,13 +795,12 @@ function expandLegacy(
|
||||
if (startsOnFloor !== null && day.getTime() < startsOnFloor) {
|
||||
continue;
|
||||
}
|
||||
// Day-of-week filter (empty = every day). Use the user's
|
||||
// timezone weekday so the legacy "Mon = 1" encoding aligns with
|
||||
// the user's local-Mon, not UTC-Mon.
|
||||
const localWeekday = wallClockInTz(day, ctx.timeZone).weekday;
|
||||
// Day-of-week filter (empty = every day). `day` IS the user's
|
||||
// calendar day, so its UTC weekday is the local weekday.
|
||||
const weekday = day.getUTCDay();
|
||||
if (
|
||||
recurrence.daysOfWeek.length > 0 &&
|
||||
!recurrence.daysOfWeek.includes(localWeekday)
|
||||
!recurrence.daysOfWeek.includes(weekday)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
@@ -768,9 +808,9 @@ function expandLegacy(
|
||||
// Multi-week interval — the fix vs the legacy worker, which
|
||||
// silently ignored intervalWeeks.
|
||||
if (recurrence.intervalWeeks > 1) {
|
||||
const dayWeekStart = startOfUtcWeek(day).getTime();
|
||||
const dayWeekStart = startOfCivilWeek(day).getTime();
|
||||
const weeksFromAnchor = Math.round(
|
||||
(dayWeekStart - anchorWeekStartUtc) / WEEK_MS,
|
||||
(dayWeekStart - anchorWeekStart) / WEEK_MS,
|
||||
);
|
||||
const phase =
|
||||
((weeksFromAnchor % recurrence.intervalWeeks) +
|
||||
@@ -780,15 +820,12 @@ function expandLegacy(
|
||||
}
|
||||
|
||||
// endsOn cap.
|
||||
if (
|
||||
ctx.medication.endsOn &&
|
||||
day.getTime() > endOfUtcDay(ctx.medication.endsOn).getTime()
|
||||
) {
|
||||
if (endsOnDay && day.getTime() > endsOnDay.getTime()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const time of times) {
|
||||
const at = applyTimeOfDayToDate(day, time, ctx.timeZone);
|
||||
const at = applyTimeOfDayToCivilDay(day, time, ctx.timeZone);
|
||||
if (at.getTime() < from.getTime()) continue;
|
||||
if (at.getTime() > to.getTime()) continue;
|
||||
slots.push(buildOccurrence(at, time, schedule));
|
||||
@@ -807,37 +844,115 @@ function effectiveTimesOfDay(schedule: CanonicalSchedule): string[] {
|
||||
: [schedule.windowStart];
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// Calendar days
|
||||
//
|
||||
// A "civil day" is a calendar date carried as UTC midnight of its
|
||||
// Y/M/D, the same shape Prisma returns for a `@db.Date` column and the
|
||||
// rrule library works in. Its UTC fields ARE the calendar date; it is
|
||||
// never read in the user's zone. Instants (createdAt, intake times,
|
||||
// slot instants) become civil days through `civilDayOfInstant`, which
|
||||
// reads them on the user's wall clock. Only `applyTimeOfDayToCivilDay`
|
||||
// turns a civil day back into an instant.
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** A `@db.Date` value (UTC midnight of the calendar date) as a civil day. */
|
||||
function civilDayOfDate(d: Date): Date {
|
||||
return startOfUtcDay(d);
|
||||
}
|
||||
|
||||
/** The calendar day an instant falls on in `tz`, as a civil day. */
|
||||
function civilDayOfInstant(instant: Date, tz: string): Date {
|
||||
const p = wallClockInTz(instant, tz);
|
||||
return new Date(Date.UTC(p.year, p.month - 1, p.day));
|
||||
}
|
||||
|
||||
function addCivilDays(day: Date, n: number): Date {
|
||||
return new Date(
|
||||
Date.UTC(day.getUTCFullYear(), day.getUTCMonth(), day.getUTCDate() + n),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply an "HH:mm" time-of-day to `day` in the user's IANA timezone,
|
||||
* returning the corresponding UTC instant. DST-aware via the two-pass
|
||||
* solver pattern from `medication-schedule.ts`.
|
||||
*
|
||||
* `day` is interpreted as "the day in the user's timezone at which
|
||||
* the time-of-day should land" — we read its wall-clock Y/M/D in the
|
||||
* target zone, then materialise the instant for that Y/M/D + H/M in
|
||||
* that zone. So passing midnight-UTC on the spring-forward day with
|
||||
* Europe/Berlin gives the local-Berlin day, not the previous-Berlin
|
||||
* day even if the UTC midnight technically falls before the local
|
||||
* day boundary.
|
||||
* The schedule's first calendar day: `startsOn` is a calendar date; the
|
||||
* `createdAt` fallback is an instant and so is read on the user's clock.
|
||||
*/
|
||||
function applyTimeOfDayToDate(day: Date, hhmm: string, tz: string): Date {
|
||||
function anchorCivilDay(ctx: RecurrenceContext): Date {
|
||||
return ctx.medication.startsOn
|
||||
? civilDayOfDate(ctx.medication.startsOn)
|
||||
: civilDayOfInstant(ctx.medication.createdAt, ctx.timeZone);
|
||||
}
|
||||
|
||||
function endsOnCivilDay(ctx: RecurrenceContext): Date | null {
|
||||
return ctx.medication.endsOn ? civilDayOfDate(ctx.medication.endsOn) : null;
|
||||
}
|
||||
|
||||
/** Local midnight of a civil day (DST-safe, via its local noon). */
|
||||
function startOfCivilDayInstant(day: Date, tz: string): Date {
|
||||
return startOfLocalDayInTz(applyTimeOfDayToCivilDay(day, "12:00", tz), tz);
|
||||
}
|
||||
|
||||
/** The last millisecond of a civil day on the user's clock. */
|
||||
function endOfCivilDayInstant(day: Date, tz: string): Date {
|
||||
return new Date(
|
||||
startOfCivilDayInstant(addCivilDays(day, 1), tz).getTime() - 1,
|
||||
);
|
||||
}
|
||||
|
||||
/** The Sunday that starts the calendar week of a civil day. */
|
||||
function startOfCivilWeek(day: Date): Date {
|
||||
return addCivilDays(day, -day.getUTCDay());
|
||||
}
|
||||
|
||||
/**
|
||||
* Materialise an "HH:mm" on a civil day in the user's IANA timezone,
|
||||
* returning the corresponding UTC instant. DST-aware via the two-pass
|
||||
* solver pattern from `medication-schedule.ts`: a wall-clock time that
|
||||
* does not exist on a spring-forward day resolves forward (02:30 in
|
||||
* Europe/Berlin on 2026-03-29 becomes 03:30).
|
||||
*
|
||||
* The calendar date comes from the civil day's UTC fields and is never
|
||||
* re-read in `tz` — see the section comment above.
|
||||
*/
|
||||
function applyTimeOfDayToCivilDay(day: Date, hhmm: string, tz: string): Date {
|
||||
const [hStr, mStr] = hhmm.split(":");
|
||||
const h = Number(hStr);
|
||||
const m = Number(mStr);
|
||||
if (!Number.isFinite(h) || !Number.isFinite(m)) return day;
|
||||
|
||||
const parts = wallClockInTz(day, tz);
|
||||
let guess = new Date(
|
||||
Date.UTC(parts.year, parts.month - 1, parts.day, h, m, 0, 0),
|
||||
const wall = Date.UTC(
|
||||
day.getUTCFullYear(),
|
||||
day.getUTCMonth(),
|
||||
day.getUTCDate(),
|
||||
h,
|
||||
m,
|
||||
0,
|
||||
0,
|
||||
);
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const offsetMin = tzOffsetMinutes(guess, tz);
|
||||
guess = new Date(
|
||||
Date.UTC(parts.year, parts.month - 1, parts.day, h, m, 0, 0) -
|
||||
offsetMin * 60_000,
|
||||
);
|
||||
// Try the zone offset in force a day before and a day after. A candidate
|
||||
// is valid when it reads back as the requested wall clock. On a
|
||||
// fall-back day both are valid and the earlier one wins; on a
|
||||
// spring-forward day neither is, and the pre-transition offset moves the
|
||||
// time forward past the gap. That also covers zones whose clocks skip
|
||||
// midnight itself (America/Santiago), where an iterative solver settled
|
||||
// on the previous evening.
|
||||
const before = new Date(
|
||||
wall - tzOffsetMinutes(new Date(wall - DAY_MS), tz) * 60_000,
|
||||
);
|
||||
const after = new Date(
|
||||
wall - tzOffsetMinutes(new Date(wall + DAY_MS), tz) * 60_000,
|
||||
);
|
||||
const readsBack = (c: Date): boolean => {
|
||||
const p = wallClockInTz(c, tz);
|
||||
return Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute) === wall;
|
||||
};
|
||||
const validBefore = readsBack(before);
|
||||
const validAfter = readsBack(after);
|
||||
if (validBefore && validAfter) {
|
||||
return before.getTime() <= after.getTime() ? before : after;
|
||||
}
|
||||
return guess;
|
||||
if (validAfter) return after;
|
||||
return before;
|
||||
}
|
||||
|
||||
function tzOffsetMinutes(date: Date, tz: string): number {
|
||||
@@ -916,12 +1031,6 @@ function endOfUtcDay(d: Date): Date {
|
||||
);
|
||||
}
|
||||
|
||||
function startOfUtcWeek(d: Date): Date {
|
||||
const midnight = startOfUtcDay(d);
|
||||
const weekday = midnight.getUTCDay(); // 0 = Sun
|
||||
return new Date(midnight.getTime() - weekday * DAY_MS);
|
||||
}
|
||||
|
||||
/** RFC 5545 basic-format UTC instant: `YYYYMMDDTHHMMSSZ`. */
|
||||
function formatUtcBasic(d: Date): string {
|
||||
const pad = (n: number, w = 2): string => String(n).padStart(w, "0");
|
||||
|
||||
Reference in new issue
Block a user