diff --git a/src/lib/measurement-reminders/__tests__/scheduling-timezones.test.ts b/src/lib/measurement-reminders/__tests__/scheduling-timezones.test.ts new file mode 100644 index 000000000..560d08bbc --- /dev/null +++ b/src/lib/measurement-reminders/__tests__/scheduling-timezones.test.ts @@ -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"); + }); +}); diff --git a/src/lib/measurement-reminders/scheduling.ts b/src/lib/measurement-reminders/scheduling.ts index abb58ba5d..11ea9440b 100644 --- a/src/lib/measurement-reminders/scheduling.ts +++ b/src/lib/measurement-reminders/scheduling.ts @@ -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, }; diff --git a/src/lib/medications/scheduling/__tests__/recurrence-timezones.test.ts b/src/lib/medications/scheduling/__tests__/recurrence-timezones.test.ts new file mode 100644 index 000000000..c20840400 --- /dev/null +++ b/src/lib/medications/scheduling/__tests__/recurrence-timezones.test.ts @@ -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 { + 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 = {}, + 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); +} diff --git a/src/lib/medications/scheduling/recurrence.ts b/src/lib/medications/scheduling/recurrence.ts index 71d2717b3..75716adc9 100644 --- a/src/lib/medications/scheduling/recurrence.ts +++ b/src/lib/medications/scheduling/recurrence.ts @@ -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(); + 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");