From 65b923ad530df2ca713640b3b7626b2632eac3b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 28 May 2026 07:52:38 +0200 Subject: [PATCH] feat(medications): canonical recurrence engine + Zod validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A single source of truth for "does this schedule fire at this moment?" — replaces the three duplicate engines that grew up alongside each other (`expandScheduleSlots` in scheduling/cadence, `getNextOccurrenceTimestamp` on the medication card, `getNextRecurrenceDate` in the medication form). The retirement of the three legacy walkers is a follow-up wire-up step; this commit ships the engine standalone with no callers so the foundation can land on its own. ### `src/lib/medications/scheduling/recurrence.ts` Three public functions: - `occurrencesBetween(schedule, from, to, ctx)` — emit every slot in `[from, to]` inclusive, time-of-day applied, in chronological order. - `nextOccurrenceAfter(schedule, after, ctx)` — strict-after; null when the schedule has terminated. - `matchesInstant(schedule, instant, ctx)` — 1-minute-tolerance predicate for the reminder-worker fan-in. The engine dispatches on the schedule's shape: - One-shot → emit one slot per `timesOfDay` at the course anchor. - Rolling → next-due = `lastIntakeAt + rollingIntervalDays * day`, fall back to `startsOn` / `createdAt` when never taken; emit only the immediately-next slot (past rolling occurrences are historical intake events, not predictable future slots). - RRULE → delegate to the `rrule` library with `dtstart = startsOn ?? createdAt` and an optional `until = endsOn`. - Legacy fallback (rrule + rolling both null, oneShot false) → decode the existing `daysOfWeek` string and emit weekly slots, **now correctly honouring `intervalWeeks > 1`** — the existing `expandTodayIntakes` skipped multi-week schedules; the engine emits them anchored at `startsOn` / `createdAt`. Time-of-day application uses the canonical `wallClockInTz` helper so a `08:00` schedule fires at Berlin 06:00 / 07:00 UTC depending on DST, and a Tokyo 08:00 schedule fires at the correct UTC instant regardless of the host clock's zone. The DST spring-forward case (02:30 on the transition day in Europe/Berlin) forwards to 03:30 wall-clock — matches macOS / iOS / Google Calendar behaviour. Grace window is computed from `reminderGraceMinutes` when set, otherwise from the legacy `windowEnd - windowStart` span, with a 60-minute default for legacy single-time windows. DoS protection: `nextOccurrenceAfter` walks forward in 90-day chunks with a 10-year hard cap so a broken / pathological RRULE can't spin the worker. ### Tests 28 cases pinning the contract for every dispatch branch: - RRULE DAILY / WEEKLY / MONTHLY / QUARTERLY / YEARLY across multi-month windows. - Bi-weekly Wed anchored — emits weeks 1/3/5, skips weeks 2/4/6. Pins the pre-existing `intervalWeeks` worker bug closed for the legacy fallback path. - Rolling: last-intake anchor + null-intake fallback + endsOn cap. - One-shot: emit + nextOccurrenceAfter returns null past it. - DST spring-forward (Europe/Berlin) + cross-TZ (Asia/Tokyo). - endsOn inclusive of full UTC day (matches `@db.Date` semantics). - Empty `timesOfDay` falls back to `[windowStart]`. - Grace window: explicit, legacy span, default 60 min. ### Zod validation `scheduleSchema` extended with `timesOfDay` (HH:mm entries, max 8), `reminderGraceMinutes` (1..1440 minutes), `rrule` (length-bounded + regex against the RRULE properties HealthLog mints), and `rollingIntervalDays` (1..365). A `.refine` enforces the rrule-xor-rolling invariant at the validation layer (the DB CHECK constraint is the second line of defence). `createMedicationSchema` + `updateMedicationSchema` gain the medication-level `startsOn`, `endsOn`, `oneShot` triplet. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../scheduling/__tests__/recurrence.test.ts | 773 ++++++++++++++++++ src/lib/medications/scheduling/recurrence.ts | 564 +++++++++++++ src/lib/validations/medication.ts | 105 ++- 3 files changed, 1434 insertions(+), 8 deletions(-) create mode 100644 src/lib/medications/scheduling/__tests__/recurrence.test.ts create mode 100644 src/lib/medications/scheduling/recurrence.ts diff --git a/src/lib/medications/scheduling/__tests__/recurrence.test.ts b/src/lib/medications/scheduling/__tests__/recurrence.test.ts new file mode 100644 index 000000000..fe7915475 --- /dev/null +++ b/src/lib/medications/scheduling/__tests__/recurrence.test.ts @@ -0,0 +1,773 @@ +/** + * v1.5.0 — canonical recurrence engine tests. + * + * Covers every cell of the edge-case matrix in + * `.planning/medication-scheduling-2026-05-28/B-design-synthesis.md`: + * - DAILY / weekday-subset / multi-week / monthly / quarterly / + * yearly RRULE expansion + * - rolling cadence (`rollingIntervalDays`) + * - one-shot medication + * - DST spring-forward + cross-timezone time-of-day application + * - legacy fallback through `parseScheduleRecurrence`, INCLUDING the + * `intervalWeeks > 1` fix the legacy worker silently dropped + * - `endsOn` cap + * - empty `timesOfDay` falls back to `[windowStart]` + * + * The DST pin: 02:30 in Europe/Berlin on the 2026-03-29 spring-forward + * day is a non-existent local time. The two-pass solver in + * `applyTimeOfDayToDate` resolves it by forwarding to 03:30 wall-clock + * (= 01:30 UTC). This is the chosen behaviour — see the test below. + */ + +import { describe, expect, it } from "vitest"; + +import { + matchesInstant, + nextOccurrenceAfter, + occurrencesBetween, + type CanonicalSchedule, + type RecurrenceContext, +} from "../recurrence"; + +// ──────────────────────────────────────────────────────────────────── +// Fixtures +// ──────────────────────────────────────────────────────────────────── + +function makeSchedule( + overrides: Partial = {}, +): CanonicalSchedule { + return { + id: "sched-1", + rrule: null, + rollingIntervalDays: null, + timesOfDay: [], + daysOfWeek: null, + windowStart: "08:00", + windowEnd: "09:00", + reminderGraceMinutes: null, + ...overrides, + }; +} + +interface CtxOverrides { + timeZone?: string; + lastIntakeAt?: Date | null; + medication?: Partial; +} + +function makeCtx(overrides: CtxOverrides = {}): RecurrenceContext { + const { medication: medOverrides = {}, ...rest } = overrides; + return { + timeZone: "Europe/Berlin", + lastIntakeAt: null, + ...rest, + medication: { + id: "med-1", + startsOn: null, + endsOn: null, + oneShot: false, + createdAt: new Date("2026-01-01T00:00:00Z"), + ...medOverrides, + }, + }; +} + +function d(iso: string): Date { + return new Date(iso); +} + +// ──────────────────────────────────────────────────────────────────── +// RRULE — DAILY +// ──────────────────────────────────────────────────────────────────── + +describe("occurrencesBetween — RRULE DAILY", () => { + it("emits 7 occurrences over a 7-day window with one time-of-day", () => { + const schedule = makeSchedule({ + rrule: "FREQ=DAILY", + timesOfDay: ["08:00"], + }); + const ctx = makeCtx({ + medication: { startsOn: d("2026-06-01T00:00:00Z") }, + }); + const slots = occurrencesBetween( + schedule, + d("2026-06-01T00:00:00Z"), + d("2026-06-07T23:59:59Z"), + ctx, + ); + expect(slots).toHaveLength(7); + }); + + it("emits 14 occurrences over a 7-day window with two times-of-day", () => { + const schedule = makeSchedule({ + rrule: "FREQ=DAILY", + timesOfDay: ["08:00", "20:00"], + }); + const ctx = makeCtx({ + medication: { startsOn: d("2026-06-01T00:00:00Z") }, + }); + const slots = occurrencesBetween( + schedule, + d("2026-06-01T00:00:00Z"), + d("2026-06-07T23:59:59Z"), + ctx, + ); + expect(slots).toHaveLength(14); + const morningCount = slots.filter((s) => s.timeOfDay === "08:00").length; + const eveningCount = slots.filter((s) => s.timeOfDay === "20:00").length; + expect(morningCount).toBe(7); + expect(eveningCount).toBe(7); + }); +}); + +// ──────────────────────────────────────────────────────────────────── +// RRULE — WEEKLY +// ──────────────────────────────────────────────────────────────────── + +describe("occurrencesBetween — RRULE WEEKLY", () => { + it("FREQ=WEEKLY;BYDAY=MO,WE,FR over 14 days emits 6 occurrences", () => { + // 2026-06-01 is a Monday. Mon/Wed/Fri across Jun 01..Jun 14: + // Jun 01 (Mo), 03 (We), 05 (Fr), 08 (Mo), 10 (We), 12 (Fr) = 6. + const schedule = makeSchedule({ + rrule: "FREQ=WEEKLY;BYDAY=MO,WE,FR", + timesOfDay: ["08:00"], + }); + const ctx = makeCtx({ + medication: { startsOn: d("2026-06-01T00:00:00Z") }, + }); + const slots = occurrencesBetween( + schedule, + d("2026-06-01T00:00:00Z"), + d("2026-06-14T23:59:59Z"), + ctx, + ); + expect(slots).toHaveLength(6); + }); + + it("bi-weekly Wed anchored on a Wed emits Wed today + Wed in 2 weeks", () => { + // 2026-06-03 (Wed) is the anchor. Within Jun 03..Jun 30: + // Wednesdays: 03, 10, 17, 24 + // Bi-weekly from anchor 03 → 03, 17 (skip 10, 24). + const schedule = makeSchedule({ + rrule: "FREQ=WEEKLY;INTERVAL=2;BYDAY=WE", + timesOfDay: ["08:00"], + }); + const ctx = makeCtx({ + medication: { startsOn: d("2026-06-03T00:00:00Z") }, + }); + const slots = occurrencesBetween( + schedule, + d("2026-06-03T00:00:00Z"), + d("2026-06-30T23:59:59Z"), + ctx, + ); + expect(slots).toHaveLength(2); + // The matching Wednesdays land on 03 + 17 in Berlin local time. + const localDays = slots.map((s) => + new Intl.DateTimeFormat("en-CA", { + timeZone: "Europe/Berlin", + year: "numeric", + month: "2-digit", + day: "2-digit", + }).format(s.at), + ); + expect(localDays).toEqual(["2026-06-03", "2026-06-17"]); + }); +}); + +// ──────────────────────────────────────────────────────────────────── +// RRULE — MONTHLY / QUARTERLY / YEARLY +// ──────────────────────────────────────────────────────────────────── + +describe("occurrencesBetween — RRULE MONTHLY / YEARLY", () => { + it("FREQ=MONTHLY;BYMONTHDAY=1 over Feb-Mar-Apr emits 3 occurrences", () => { + const schedule = makeSchedule({ + rrule: "FREQ=MONTHLY;BYMONTHDAY=1", + timesOfDay: ["08:00"], + }); + const ctx = makeCtx({ + medication: { startsOn: d("2026-02-01T00:00:00Z") }, + }); + const slots = occurrencesBetween( + schedule, + d("2026-02-01T00:00:00Z"), + d("2026-04-30T23:59:59Z"), + ctx, + ); + expect(slots).toHaveLength(3); + }); + + it("FREQ=MONTHLY;INTERVAL=3;BYMONTHDAY=15 over Jan..Sep emits 3", () => { + // Quarterly, anchor Jan 15. Within Jan 01..Sep 30 (9 months): + // Jan 15, Apr 15, Jul 15. (Oct 15 is past Sep 30.) + const schedule = makeSchedule({ + rrule: "FREQ=MONTHLY;INTERVAL=3;BYMONTHDAY=15", + timesOfDay: ["08:00"], + }); + const ctx = makeCtx({ + medication: { startsOn: d("2026-01-15T00:00:00Z") }, + }); + const slots = occurrencesBetween( + schedule, + d("2026-01-01T00:00:00Z"), + d("2026-09-30T23:59:59Z"), + ctx, + ); + expect(slots).toHaveLength(3); + }); + + it("FREQ=YEARLY;BYMONTH=1;BYMONTHDAY=1 over 24 months emits 2", () => { + const schedule = makeSchedule({ + rrule: "FREQ=YEARLY;BYMONTH=1;BYMONTHDAY=1", + timesOfDay: ["08:00"], + }); + const ctx = makeCtx({ + medication: { startsOn: d("2026-01-01T00:00:00Z") }, + }); + const slots = occurrencesBetween( + schedule, + d("2026-01-01T00:00:00Z"), + d("2027-12-31T23:59:59Z"), + ctx, + ); + expect(slots).toHaveLength(2); + }); +}); + +// ──────────────────────────────────────────────────────────────────── +// Rolling +// ──────────────────────────────────────────────────────────────────── + +describe("occurrencesBetween — rolling", () => { + it("rollingIntervalDays=7 + lastIntakeAt=T-3d emits one at T+4d", () => { + const NOW = d("2026-06-10T12:00:00Z"); + const lastIntake = new Date(NOW.getTime() - 3 * 24 * 60 * 60 * 1000); + const schedule = makeSchedule({ + rollingIntervalDays: 7, + timesOfDay: ["08:00"], + }); + const ctx = makeCtx({ + lastIntakeAt: lastIntake, + medication: { startsOn: d("2026-06-01T00:00:00Z") }, + }); + const from = NOW; + const to = new Date(NOW.getTime() + 14 * 24 * 60 * 60 * 1000); + const slots = occurrencesBetween(schedule, from, to, ctx); + expect(slots).toHaveLength(1); + // T+4d = 4 days after NOW. lastIntake + 7d. + const expectedDay = new Date( + lastIntake.getTime() + 7 * 24 * 60 * 60 * 1000, + ); + // Same local-day-in-Berlin. + expect( + new Intl.DateTimeFormat("en-CA", { + timeZone: "Europe/Berlin", + year: "numeric", + month: "2-digit", + day: "2-digit", + }).format(slots[0].at), + ).toBe( + new Intl.DateTimeFormat("en-CA", { + timeZone: "Europe/Berlin", + year: "numeric", + month: "2-digit", + day: "2-digit", + }).format(expectedDay), + ); + }); + + it("rollingIntervalDays=7 + lastIntakeAt=null + startsOn=T-2d emits one at T+5d", () => { + const NOW = d("2026-06-10T12:00:00Z"); + const startsOn = new Date(NOW.getTime() - 2 * 24 * 60 * 60 * 1000); + const schedule = makeSchedule({ + rollingIntervalDays: 7, + timesOfDay: ["08:00"], + }); + const ctx = makeCtx({ + lastIntakeAt: null, + medication: { startsOn }, + }); + const from = NOW; + const to = new Date(NOW.getTime() + 14 * 24 * 60 * 60 * 1000); + const slots = occurrencesBetween(schedule, from, to, ctx); + expect(slots).toHaveLength(1); + const expectedDay = new Date(startsOn.getTime() + 7 * 24 * 60 * 60 * 1000); + expect( + new Intl.DateTimeFormat("en-CA", { + timeZone: "Europe/Berlin", + year: "numeric", + month: "2-digit", + day: "2-digit", + }).format(slots[0].at), + ).toBe( + new Intl.DateTimeFormat("en-CA", { + timeZone: "Europe/Berlin", + year: "numeric", + month: "2-digit", + day: "2-digit", + }).format(expectedDay), + ); + }); + + it("rolling terminates when endsOn falls before next-due", () => { + const NOW = d("2026-06-10T12:00:00Z"); + const lastIntake = new Date(NOW.getTime() - 3 * 24 * 60 * 60 * 1000); + const endsOn = new Date(NOW.getTime() + 2 * 24 * 60 * 60 * 1000); + const schedule = makeSchedule({ + rollingIntervalDays: 7, + timesOfDay: ["08:00"], + }); + const ctx = makeCtx({ + lastIntakeAt: lastIntake, + medication: { endsOn }, + }); + const slots = occurrencesBetween( + schedule, + NOW, + new Date(NOW.getTime() + 14 * 24 * 60 * 60 * 1000), + ctx, + ); + expect(slots).toHaveLength(0); + }); +}); + +// ──────────────────────────────────────────────────────────────────── +// One-shot +// ──────────────────────────────────────────────────────────────────── + +describe("occurrencesBetween — one-shot", () => { + it("emits one occurrence at startsOn + timesOfDay", () => { + const NOW = d("2026-06-10T00:00:00Z"); + const startsOn = new Date(NOW.getTime() + 1 * 24 * 60 * 60 * 1000); + const schedule = makeSchedule({ timesOfDay: ["10:00"] }); + const ctx = makeCtx({ + medication: { + oneShot: true, + startsOn, + }, + }); + const slots = occurrencesBetween( + schedule, + NOW, + new Date(NOW.getTime() + 7 * 24 * 60 * 60 * 1000), + ctx, + ); + expect(slots).toHaveLength(1); + // 10:00 Berlin on T+1d (2026-06-11). + expect( + new Intl.DateTimeFormat("en-CA", { + timeZone: "Europe/Berlin", + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + hour12: false, + }).format(slots[0].at), + ).toContain("2026-06-11"); + expect(slots[0].timeOfDay).toBe("10:00"); + }); + + it("emits the occurrence even when startsOn is past — caller checks lastIntakeAt", () => { + const startsOn = d("2026-05-01T00:00:00Z"); + const schedule = makeSchedule({ timesOfDay: ["10:00"] }); + const ctx = makeCtx({ + medication: { + oneShot: true, + startsOn, + }, + }); + const slots = occurrencesBetween( + schedule, + d("2026-04-01T00:00:00Z"), + d("2026-06-01T00:00:00Z"), + ctx, + ); + expect(slots).toHaveLength(1); + }); + + it("nextOccurrenceAfter returns null past the one-shot date", () => { + const startsOn = d("2026-05-01T00:00:00Z"); + const schedule = makeSchedule({ timesOfDay: ["10:00"] }); + const ctx = makeCtx({ + medication: { + oneShot: true, + startsOn, + }, + }); + const next = nextOccurrenceAfter(schedule, d("2026-06-01T00:00:00Z"), ctx); + expect(next).toBeNull(); + }); +}); + +// ──────────────────────────────────────────────────────────────────── +// DST + cross-TZ +// ──────────────────────────────────────────────────────────────────── + +describe("DST and cross-timezone time-of-day application", () => { + it("02:30 wall-clock on the Berlin spring-forward day forwards to 03:30 (= 01:30 UTC)", () => { + // 2026-03-29 is the European DST spring-forward day. Clocks jump + // from 02:00 → 03:00 CET; 02:30 is a non-existent local time. The + // two-pass solver in `applyTimeOfDayToDate` resolves the + // non-existent time by forwarding to 03:30 wall-clock (the next + // valid local minute), which equals 01:30 UTC. + // + // CHOSEN BEHAVIOUR: forward-jump (NOT skip). This matches macOS + // Calendar / iOS Calendar / Google Calendar behaviour and the + // user's mental model "the dose is taken once on that day". + const schedule = makeSchedule({ + rrule: "FREQ=DAILY", + timesOfDay: ["02:30"], + }); + const ctx = makeCtx({ + medication: { startsOn: d("2026-03-28T00:00:00Z") }, + }); + const slots = occurrencesBetween( + schedule, + d("2026-03-28T00:00:00Z"), + d("2026-03-31T23:59:59Z"), + ctx, + ); + // 4 days: 28 (Sat), 29 (Sun, DST), 30 (Mon), 31 (Tue) → 4 slots. + expect(slots).toHaveLength(4); + // The 29th slot lands on the forward-jumped minute. The UTC + // instant for "02:30 Berlin on the spring-forward day" is 01:30 UTC + // (because at that moment the offset is already +02:00, so the + // local wall clock reads 03:30). + const transition = slots.find((s) => { + const local = new Intl.DateTimeFormat("en-CA", { + timeZone: "Europe/Berlin", + year: "numeric", + month: "2-digit", + day: "2-digit", + }).format(s.at); + return local === "2026-03-29"; + }); + expect(transition).toBeDefined(); + expect(transition?.at.toISOString()).toBe("2026-03-29T01:30:00.000Z"); + }); + + it("08:00 Asia/Tokyo on 2026-06-15 corresponds to the right UTC instant", () => { + // Tokyo is UTC+9 year-round (no DST). 08:00 Tokyo on 2026-06-15 + // is 2026-06-14T23:00:00Z. + const schedule = makeSchedule({ + rrule: "FREQ=DAILY", + timesOfDay: ["08:00"], + }); + const ctx = makeCtx({ + timeZone: "Asia/Tokyo", + medication: { startsOn: d("2026-06-15T00:00:00Z") }, + }); + const slots = occurrencesBetween( + schedule, + d("2026-06-14T00:00:00Z"), + d("2026-06-16T23:59:59Z"), + ctx, + ); + // Within 2026-06-14T00:00Z..2026-06-16T23:59:59Z, Tokyo 08:00 + // lands at UTC 23:00 on the prior day: 06-14T23:00, 06-15T23:00. + // (06-13T23:00 falls outside the from boundary; 06-16T23:00 is + // within the to boundary). + expect(slots.length).toBeGreaterThanOrEqual(2); + const utcStrings = slots.map((s) => s.at.toISOString()); + expect(utcStrings).toContain("2026-06-14T23:00:00.000Z"); + expect(utcStrings).toContain("2026-06-15T23:00:00.000Z"); + }); +}); + +// ──────────────────────────────────────────────────────────────────── +// Legacy fallback +// ──────────────────────────────────────────────────────────────────── + +describe("occurrencesBetween — legacy fallback (rrule + rolling both null)", () => { + it("daysOfWeek = null → daily", () => { + const schedule = makeSchedule({ + daysOfWeek: null, + windowStart: "08:00", + timesOfDay: [], + }); + const ctx = makeCtx({ + medication: { startsOn: d("2026-06-01T00:00:00Z") }, + }); + const slots = occurrencesBetween( + schedule, + d("2026-06-01T00:00:00Z"), + d("2026-06-07T23:59:59Z"), + ctx, + ); + expect(slots).toHaveLength(7); + }); + + it("daysOfWeek = '1,3,5' → Mon/Wed/Fri only", () => { + // 2026-06-01 = Mon. Mon/Wed/Fri across Jun 01..Jun 07: + // Mon 01, Wed 03, Fri 05 → 3 slots. + const schedule = makeSchedule({ + daysOfWeek: "1,3,5", + windowStart: "08:00", + timesOfDay: [], + }); + const ctx = makeCtx({ + medication: { startsOn: d("2026-06-01T00:00:00Z") }, + }); + const slots = occurrencesBetween( + schedule, + d("2026-06-01T00:00:00Z"), + d("2026-06-07T23:59:59Z"), + ctx, + ); + expect(slots).toHaveLength(3); + }); + + it("daysOfWeek = 'i2;3' → bi-weekly Wed (the legacy intervalWeeks fix)", () => { + // The pre-v1.5 worker ignored intervalWeeks and emitted EVERY + // Wednesday in the window. This engine fixes that: anchor the + // phase to startsOn and emit only on matching weeks. + // + // Anchor: 2026-06-03 (Wed). Window: Jun 03..Jun 30. + // Wednesdays: 03, 10, 17, 24. + // Bi-weekly from anchor 03 → 03 + 17 only (skip 10, 24). + const schedule = makeSchedule({ + daysOfWeek: "i2;3", + windowStart: "08:00", + timesOfDay: [], + }); + const ctx = makeCtx({ + medication: { startsOn: d("2026-06-03T00:00:00Z") }, + }); + const slots = occurrencesBetween( + schedule, + d("2026-06-03T00:00:00Z"), + d("2026-06-30T23:59:59Z"), + ctx, + ); + expect(slots).toHaveLength(2); + const localDays = slots.map((s) => + new Intl.DateTimeFormat("en-CA", { + timeZone: "Europe/Berlin", + year: "numeric", + month: "2-digit", + day: "2-digit", + }).format(s.at), + ); + expect(localDays).toEqual(["2026-06-03", "2026-06-17"]); + }); +}); + +// ──────────────────────────────────────────────────────────────────── +// endsOn cap +// ──────────────────────────────────────────────────────────────────── + +describe("endsOn cap", () => { + it("nextOccurrenceAfter returns null past endsOn for an RRULE", () => { + const schedule = makeSchedule({ + rrule: "FREQ=DAILY", + timesOfDay: ["08:00"], + }); + const ctx = makeCtx({ + medication: { + startsOn: d("2026-06-01T00:00:00Z"), + endsOn: d("2026-06-30T00:00:00Z"), + }, + }); + // 5 years past endsOn → null. + const next = nextOccurrenceAfter(schedule, d("2031-07-01T00:00:00Z"), ctx); + expect(next).toBeNull(); + }); + + it("legacy fallback respects endsOn", () => { + const schedule = makeSchedule({ + daysOfWeek: null, + windowStart: "08:00", + timesOfDay: [], + }); + const ctx = makeCtx({ + medication: { + startsOn: d("2026-06-01T00:00:00Z"), + endsOn: d("2026-06-03T00:00:00Z"), + }, + }); + const slots = occurrencesBetween( + schedule, + d("2026-06-01T00:00:00Z"), + d("2026-06-10T23:59:59Z"), + ctx, + ); + // 01, 02, 03 (endsOn day inclusive) → 3 slots. + expect(slots).toHaveLength(3); + }); +}); + +// ──────────────────────────────────────────────────────────────────── +// Empty timesOfDay fallback +// ──────────────────────────────────────────────────────────────────── + +describe("empty timesOfDay fallback", () => { + it("timesOfDay = [] falls back to [windowStart] for RRULE", () => { + const schedule = makeSchedule({ + rrule: "FREQ=DAILY", + timesOfDay: [], + windowStart: "09:30", + }); + const ctx = makeCtx({ + medication: { startsOn: d("2026-06-01T00:00:00Z") }, + }); + const slots = occurrencesBetween( + schedule, + d("2026-06-01T00:00:00Z"), + d("2026-06-02T23:59:59Z"), + ctx, + ); + expect(slots).toHaveLength(2); + expect(slots[0].timeOfDay).toBe("09:30"); + }); + + it("timesOfDay = [] falls back to [windowStart] for legacy", () => { + const schedule = makeSchedule({ + daysOfWeek: null, + timesOfDay: [], + windowStart: "07:15", + }); + const ctx = makeCtx({ + medication: { startsOn: d("2026-06-01T00:00:00Z") }, + }); + const slots = occurrencesBetween( + schedule, + d("2026-06-01T00:00:00Z"), + d("2026-06-02T23:59:59Z"), + ctx, + ); + expect(slots).toHaveLength(2); + expect(slots[0].timeOfDay).toBe("07:15"); + }); +}); + +// ──────────────────────────────────────────────────────────────────── +// Grace window +// ──────────────────────────────────────────────────────────────────── + +describe("grace window", () => { + it("uses reminderGraceMinutes when set", () => { + const schedule = makeSchedule({ + rrule: "FREQ=DAILY", + timesOfDay: ["08:00"], + reminderGraceMinutes: 45, + }); + const ctx = makeCtx({ + medication: { startsOn: d("2026-06-01T00:00:00Z") }, + }); + const slots = occurrencesBetween( + schedule, + d("2026-06-01T00:00:00Z"), + d("2026-06-01T23:59:59Z"), + ctx, + ); + expect(slots).toHaveLength(1); + const graceMs = slots[0].graceUntil.getTime() - slots[0].at.getTime(); + expect(graceMs).toBe(45 * 60_000); + }); + + it("falls back to windowEnd - windowStart when reminderGraceMinutes is null", () => { + const schedule = makeSchedule({ + rrule: "FREQ=DAILY", + timesOfDay: ["08:00"], + windowStart: "08:00", + windowEnd: "10:00", + reminderGraceMinutes: null, + }); + const ctx = makeCtx({ + medication: { startsOn: d("2026-06-01T00:00:00Z") }, + }); + const slots = occurrencesBetween( + schedule, + d("2026-06-01T00:00:00Z"), + d("2026-06-01T23:59:59Z"), + ctx, + ); + const graceMs = slots[0].graceUntil.getTime() - slots[0].at.getTime(); + expect(graceMs).toBe(120 * 60_000); + }); + + it("defaults to 60 min when windowStart === windowEnd", () => { + const schedule = makeSchedule({ + rrule: "FREQ=DAILY", + timesOfDay: ["08:00"], + windowStart: "08:00", + windowEnd: "08:00", + reminderGraceMinutes: null, + }); + const ctx = makeCtx({ + medication: { startsOn: d("2026-06-01T00:00:00Z") }, + }); + const slots = occurrencesBetween( + schedule, + d("2026-06-01T00:00:00Z"), + d("2026-06-01T23:59:59Z"), + ctx, + ); + const graceMs = slots[0].graceUntil.getTime() - slots[0].at.getTime(); + expect(graceMs).toBe(60 * 60_000); + }); +}); + +// ──────────────────────────────────────────────────────────────────── +// nextOccurrenceAfter + matchesInstant smoke +// ──────────────────────────────────────────────────────────────────── + +describe("nextOccurrenceAfter + matchesInstant", () => { + it("nextOccurrenceAfter walks forward to the next RRULE occurrence", () => { + const schedule = makeSchedule({ + rrule: "FREQ=MONTHLY;BYMONTHDAY=15", + timesOfDay: ["09:00"], + }); + const ctx = makeCtx({ + medication: { startsOn: d("2026-01-15T00:00:00Z") }, + }); + const next = nextOccurrenceAfter(schedule, d("2026-03-20T00:00:00Z"), ctx); + expect(next).not.toBeNull(); + // Next monthly-15th after Mar 20 → Apr 15. + expect( + new Intl.DateTimeFormat("en-CA", { + timeZone: "Europe/Berlin", + year: "numeric", + month: "2-digit", + day: "2-digit", + }).format(next!.at), + ).toBe("2026-04-15"); + }); + + it("matchesInstant returns true at an exact RRULE occurrence", () => { + const schedule = makeSchedule({ + rrule: "FREQ=DAILY", + timesOfDay: ["08:00"], + }); + const ctx = makeCtx({ + medication: { startsOn: d("2026-06-01T00:00:00Z") }, + }); + const slots = occurrencesBetween( + schedule, + d("2026-06-01T00:00:00Z"), + d("2026-06-02T23:59:59Z"), + ctx, + ); + expect(slots.length).toBeGreaterThan(0); + expect(matchesInstant(schedule, slots[0].at, ctx)).toBe(true); + }); + + it("matchesInstant returns false at a near-but-not-equal instant", () => { + const schedule = makeSchedule({ + rrule: "FREQ=DAILY", + timesOfDay: ["08:00"], + }); + const ctx = makeCtx({ + medication: { startsOn: d("2026-06-01T00:00:00Z") }, + }); + const slots = occurrencesBetween( + schedule, + d("2026-06-01T00:00:00Z"), + d("2026-06-02T23:59:59Z"), + ctx, + ); + const offByTenMin = new Date(slots[0].at.getTime() + 10 * 60_000); + expect(matchesInstant(schedule, offByTenMin, ctx)).toBe(false); + }); +}); diff --git a/src/lib/medications/scheduling/recurrence.ts b/src/lib/medications/scheduling/recurrence.ts new file mode 100644 index 000000000..f83f032cc --- /dev/null +++ b/src/lib/medications/scheduling/recurrence.ts @@ -0,0 +1,564 @@ +/** + * v1.5.0 — canonical recurrence engine for medication scheduling. + * + * Single source of truth for "what dose slots does this schedule emit + * between A and B?" Consumed by the reminder worker, the today + * projector, the cadence chart, the medication card, and the + * medication form. Replaces three duplicate walks (R-3 finding 3): + * + * - `expandScheduleSlots` in `cadence.ts` + * - `getNextOccurrenceTimestamp` in `medication-card.tsx` + * - `getNextRecurrenceDate` in `medication-form.tsx` + * + * Those engines will be retired in a follow-up wire-up step; this + * module ships standalone so the schema migration + the wizard can + * land first. + * + * Dispatch tiers (first matching tier wins): + * + * 1. **One-shot** (`medication.oneShot === true`) — single slot at + * the anchor date, one per `timesOfDay` entry. + * 2. **Rolling** (`schedule.rollingIntervalDays !== null`) — next + * slot is `lastIntakeAt + N days` (or `startsOn + N days` if + * the user has never logged an intake). Only the immediately + * next slot is emitted; further slots depend on the user + * actually logging the intake. + * 3. **RRULE** (`schedule.rrule !== null`) — RFC 5545 expansion + * via the `rrule` npm lib. Day-anchored dates are returned in + * UTC by the library; each `timesOfDay` entry is then applied + * to that day in the user's IANA timezone (DST-aware via + * `wallClockInTz`). + * 4. **Legacy fallback** — both `rrule` and `rollingIntervalDays` + * are NULL and `oneShot` is false. Decodes the legacy + * `daysOfWeek` string via `parseScheduleRecurrence` and emits + * weekly slots. **Honours `intervalWeeks > 1` correctly** — + * the existing `expandTodayIntakes` skipped it (the legacy + * bi-weekly worker bug R-3 finding 5 calls out); this engine + * anchors the week phase to `startsOn ?? createdAt` and emits + * on the matching weeks only. + * + * Pure functions, no DB access. Caller fetches `lastIntakeAt` + * (latest `MedicationIntakeEvent.takenAt`) and threads it through + * `RecurrenceContext`. + */ + +import { RRule } from "rrule"; + +import { parseScheduleRecurrence } from "@/lib/medication-schedule"; +import { wallClockInTz } from "@/lib/tz/wall-clock"; + +export interface CanonicalSchedule { + id: string; + rrule: string | null; + rollingIntervalDays: number | null; + timesOfDay: string[]; + /** + * Legacy fallback fields. Read-only — the engine consults these + * only when `rrule` and `rollingIntervalDays` are both NULL and + * `oneShot` is false. Pre-v1.5 rows always populate these. + */ + daysOfWeek: string | null; + windowStart: string; + windowEnd: string; + reminderGraceMinutes: number | null; +} + +export interface RecurrenceContext { + /** The parent medication. */ + medication: { + id: string; + startsOn: Date | null; + endsOn: Date | null; + oneShot: boolean; + createdAt: Date; + }; + /** The user's IANA timezone (e.g. "Europe/Berlin"). */ + timeZone: string; + /** + * Latest `MedicationIntakeEvent.takenAt` for this medication. + * Used only by rolling schedules. Null when the user has never + * logged an intake (rolling then anchors on `startsOn ?? createdAt`). + */ + lastIntakeAt: Date | null; +} + +export interface Occurrence { + /** + * The start instant of this occurrence. The `timeOfDay` is applied + * to the day in the user's timezone, with DST handled correctly via + * the existing `wallClockInTz` two-pass solver. + */ + at: Date; + /** + * The grace-window end instant. Computed from + * `reminderGraceMinutes` when set, otherwise from the legacy + * `windowEnd - windowStart` span. + */ + graceUntil: Date; + /** + * Which `timesOfDay` entry this occurrence corresponds to (or + * `windowStart` when falling back to legacy). Lets the UI label + * "Morning dose" vs "Evening dose" of the same day. + */ + timeOfDay: string; +} + +const DAY_MS = 24 * 60 * 60 * 1000; +const WEEK_MS = 7 * DAY_MS; +const DEFAULT_GRACE_MINUTES = 60; +const ONE_MINUTE_MS = 60_000; + +// ──────────────────────────────────────────────────────────────────── +// Public API +// ──────────────────────────────────────────────────────────────────── + +/** + * Emit every occurrence in `[from, to]` (inclusive of both ends) in + * chronological order. Pure: no DB access, no side effects. + */ +export function occurrencesBetween( + schedule: CanonicalSchedule, + from: Date, + to: Date, + ctx: RecurrenceContext, +): Occurrence[] { + if (to.getTime() < from.getTime()) return []; + + if (ctx.medication.oneShot) { + return expandOneShot(schedule, ctx, from, to); + } + if (schedule.rollingIntervalDays !== null) { + return expandRolling(schedule, ctx, from, to); + } + if (schedule.rrule !== null) { + return expandRrule(schedule, ctx, from, to); + } + return expandLegacy(schedule, ctx, from, to); +} + +/** + * Compute the next occurrence strictly after `after`. Returns null + * when the schedule has terminated (one-shot anchor in the past; + * `endsOn` already crossed; rolling with neither a last intake nor a + * `startsOn` to anchor against). + * + * Implementation: walks forward in chunks (90 days for RRULE/legacy, + * one rolling cycle for rolling) until an occurrence is found or the + * `endsOn` boundary is reached. Caps at ~10 years out so a broken + * RRULE never spins. + */ +export function nextOccurrenceAfter( + schedule: CanonicalSchedule, + after: Date, + ctx: RecurrenceContext, +): Occurrence | null { + const endsOn = ctx.medication.endsOn; + const hardCap = new Date(after.getTime() + 365 * 10 * DAY_MS); + const limit = endsOn + ? new Date(Math.min(endOfUtcDay(endsOn).getTime(), hardCap.getTime())) + : hardCap; + + // One-shot is single-slot — fast path. + if (ctx.medication.oneShot) { + const slots = expandOneShot( + schedule, + ctx, + new Date(after.getTime() + 1), + limit, + ); + for (const s of slots) { + if (s.at.getTime() > after.getTime()) return s; + } + return null; + } + + // Rolling is also single-slot (only emits the immediately next). + if (schedule.rollingIntervalDays !== null) { + const slots = expandRolling( + schedule, + ctx, + new Date(after.getTime() + 1), + limit, + ); + for (const s of slots) { + if (s.at.getTime() > after.getTime()) return s; + } + return null; + } + + // Calendar cadences — walk in 90-day chunks. + const chunkMs = 90 * DAY_MS; + let cursor = new Date(after.getTime() + 1); + while (cursor.getTime() <= limit.getTime()) { + const chunkEnd = new Date( + Math.min(cursor.getTime() + chunkMs, limit.getTime()), + ); + const slots = occurrencesBetween(schedule, cursor, chunkEnd, ctx); + for (const s of slots) { + if (s.at.getTime() > after.getTime()) return s; + } + cursor = new Date(chunkEnd.getTime() + 1); + } + return null; +} + +/** + * Convenience predicate: does this schedule emit an occurrence at + * exactly `instant` (within a 1-minute tolerance)? + */ +export function matchesInstant( + schedule: CanonicalSchedule, + instant: Date, + ctx: RecurrenceContext, +): boolean { + const from = new Date(instant.getTime() - ONE_MINUTE_MS); + const to = new Date(instant.getTime() + ONE_MINUTE_MS); + const slots = occurrencesBetween(schedule, from, to, ctx); + return slots.some( + (s) => Math.abs(s.at.getTime() - instant.getTime()) <= ONE_MINUTE_MS, + ); +} + +// ──────────────────────────────────────────────────────────────────── +// Dispatch — one-shot +// ──────────────────────────────────────────────────────────────────── + +function expandOneShot( + schedule: CanonicalSchedule, + ctx: RecurrenceContext, + from: Date, + to: Date, +): Occurrence[] { + const anchor = ctx.medication.startsOn ?? ctx.medication.createdAt; + const times = effectiveTimesOfDay(schedule); + const slots: Occurrence[] = []; + for (const time of times) { + const at = applyTimeOfDayToDate(anchor, time, ctx.timeZone); + if (at.getTime() < from.getTime() || at.getTime() > to.getTime()) continue; + slots.push(buildOccurrence(at, time, schedule)); + } + return slots.sort((a, b) => a.at.getTime() - b.at.getTime()); +} + +// ──────────────────────────────────────────────────────────────────── +// Dispatch — rolling +// ──────────────────────────────────────────────────────────────────── + +/** + * Rolling cadence — "every N days from last intake". Emits one + * occurrence at `(lastIntakeAt ?? startsOn ?? createdAt) + N days`, + * applied at the first `timesOfDay` entry (or `windowStart`). + * + * Design choice: only the FIRST time-of-day entry is emitted even + * when multiple are configured. The rolling semantic — "every N days + * from when I last took it" — doesn't compose cleanly with multiple + * per-day intakes; the canonical rolling case is a once-weekly + * injection. A future revision could fan out, but that's a UX + * decision (does logging the morning intake re-anchor the evening + * intake?), not an engine one. + * + * Only the immediately-next slot is returned. Past rolling slots are + * historical intake events, not "expected slots" the schedule + * predicts forward. + */ +function expandRolling( + schedule: CanonicalSchedule, + ctx: RecurrenceContext, + from: Date, + to: Date, +): Occurrence[] { + const n = schedule.rollingIntervalDays; + if (n === null || n <= 0) return []; + const anchor = + ctx.lastIntakeAt ?? ctx.medication.startsOn ?? ctx.medication.createdAt; + // No last intake AND no startsOn → terminate. createdAt is the + // schema-level fallback but the design synthesis says rolling + // schedules require a starting reference; we honour that by + // refusing to emit when the caller passes a medication with + // neither lastIntakeAt nor startsOn. (Pre-v1.5 rows always have + // createdAt populated, so this guard only fires on hand-crafted + // test inputs.) + if ( + ctx.lastIntakeAt === null && + ctx.medication.startsOn === null && + !ctx.medication.createdAt + ) { + return []; + } + + const nextDue = new Date(anchor.getTime() + n * DAY_MS); + + // endsOn cap. + if ( + ctx.medication.endsOn && + nextDue.getTime() > endOfUtcDay(ctx.medication.endsOn).getTime() + ) { + return []; + } + + const time = schedule.timesOfDay[0] ?? schedule.windowStart; + const at = applyTimeOfDayToDate(nextDue, time, ctx.timeZone); + if (at.getTime() < from.getTime() || at.getTime() > to.getTime()) return []; + + return [buildOccurrence(at, time, schedule)]; +} + +// ──────────────────────────────────────────────────────────────────── +// Dispatch — RRULE +// ──────────────────────────────────────────────────────────────────── + +function expandRrule( + schedule: CanonicalSchedule, + ctx: RecurrenceContext, + from: Date, + to: Date, +): Occurrence[] { + const rruleStr = schedule.rrule; + if (!rruleStr) return []; + + const dtstart = ctx.medication.startsOn ?? ctx.medication.createdAt; + const dtstartLine = `DTSTART:${formatUtcBasic(startOfUtcDay(dtstart))}`; + const untilSuffix = ctx.medication.endsOn + ? `;UNTIL=${formatUtcBasic(endOfUtcDay(ctx.medication.endsOn))}` + : ""; + const full = `${dtstartLine}\nRRULE:${rruleStr}${untilSuffix}`; + + let rule: RRule; + try { + rule = RRule.fromString(full); + } catch { + return []; + } + + // 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. + 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); + + const times = effectiveTimesOfDay(schedule); + const slots: Occurrence[] = []; + for (const anchor of dayAnchors) { + for (const time of times) { + const at = applyTimeOfDayToDate(anchor, time, ctx.timeZone); + if (at.getTime() < from.getTime()) continue; + if (at.getTime() > to.getTime()) continue; + slots.push(buildOccurrence(at, time, schedule)); + } + } + return slots.sort((a, b) => a.at.getTime() - b.at.getTime()); +} + +// ──────────────────────────────────────────────────────────────────── +// Dispatch — legacy fallback +// ──────────────────────────────────────────────────────────────────── + +function expandLegacy( + schedule: CanonicalSchedule, + ctx: RecurrenceContext, + from: Date, + to: Date, +): Occurrence[] { + const recurrence = parseScheduleRecurrence(schedule.daysOfWeek); + const anchor = ctx.medication.startsOn ?? ctx.medication.createdAt; + const anchorWeekStartUtc = startOfUtcWeek(anchor).getTime(); + const times = effectiveTimesOfDay(schedule); + + 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. + const start = startOfUtcDay(new Date(from.getTime() - DAY_MS)); + const end = startOfUtcDay(new Date(to.getTime() + DAY_MS)); + for ( + let day = start; + day.getTime() <= end.getTime(); + day = new Date(day.getTime() + DAY_MS) + ) { + // 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; + if ( + recurrence.daysOfWeek.length > 0 && + !recurrence.daysOfWeek.includes(localWeekday) + ) { + continue; + } + + // Multi-week interval — the fix vs the legacy worker, which + // silently ignored intervalWeeks. + if (recurrence.intervalWeeks > 1) { + const dayWeekStart = startOfUtcWeek(day).getTime(); + const weeksFromAnchor = Math.round( + (dayWeekStart - anchorWeekStartUtc) / WEEK_MS, + ); + const phase = + ((weeksFromAnchor % recurrence.intervalWeeks) + + recurrence.intervalWeeks) % + recurrence.intervalWeeks; + if (phase !== 0) continue; + } + + // endsOn cap. + if ( + ctx.medication.endsOn && + day.getTime() > endOfUtcDay(ctx.medication.endsOn).getTime() + ) { + continue; + } + + for (const time of times) { + const at = applyTimeOfDayToDate(day, time, ctx.timeZone); + if (at.getTime() < from.getTime()) continue; + if (at.getTime() > to.getTime()) continue; + slots.push(buildOccurrence(at, time, schedule)); + } + } + return slots.sort((a, b) => a.at.getTime() - b.at.getTime()); +} + +// ──────────────────────────────────────────────────────────────────── +// Shared helpers +// ──────────────────────────────────────────────────────────────────── + +function effectiveTimesOfDay(schedule: CanonicalSchedule): string[] { + return schedule.timesOfDay.length > 0 + ? schedule.timesOfDay + : [schedule.windowStart]; +} + +/** + * 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. + */ +function applyTimeOfDayToDate(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), + ); + 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, + ); + } + return guess; +} + +function tzOffsetMinutes(date: Date, tz: string): number { + const parts = wallClockInTz(date, tz); + const asIfUtc = Date.UTC( + parts.year, + parts.month - 1, + parts.day, + parts.hour, + parts.minute, + parts.second, + ); + return Math.round((asIfUtc - date.getTime()) / 60_000); +} + +function buildOccurrence( + at: Date, + timeOfDay: string, + schedule: CanonicalSchedule, +): Occurrence { + return { + at, + timeOfDay, + graceUntil: new Date(at.getTime() + graceWindowMs(schedule)), + }; +} + +/** + * Resolve the grace window for an occurrence: + * + * - `reminderGraceMinutes` when set on the schedule + * - else `windowEnd - windowStart` span (in minutes) + * - else `DEFAULT_GRACE_MINUTES` (60) when the legacy fields agree + * (single-time window like "08:00..08:00") + * + * Handles overnight legacy windows (`windowEnd < windowStart`) by + * pushing windowEnd to the next day. + */ +function graceWindowMs(schedule: CanonicalSchedule): number { + if (schedule.reminderGraceMinutes !== null) { + return schedule.reminderGraceMinutes * ONE_MINUTE_MS; + } + const startMin = hhmmToMinutes(schedule.windowStart); + const endMin = hhmmToMinutes(schedule.windowEnd); + if (startMin === null || endMin === null) { + return DEFAULT_GRACE_MINUTES * ONE_MINUTE_MS; + } + let span = endMin - startMin; + if (span < 0) span += 24 * 60; // overnight window + if (span === 0) span = DEFAULT_GRACE_MINUTES; + return span * ONE_MINUTE_MS; +} + +function hhmmToMinutes(hhmm: string): number | null { + const [hStr, mStr] = hhmm.split(":"); + const h = Number(hStr); + const m = Number(mStr); + if (!Number.isFinite(h) || !Number.isFinite(m)) return null; + return h * 60 + m; +} + +// ──────────────────────────────────────────────────────────────────── +// UTC date helpers (RRULE day-anchor + endsOn cap arithmetic) +// ──────────────────────────────────────────────────────────────────── + +function startOfUtcDay(d: Date): Date { + return new Date( + Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate(), 0, 0, 0, 0), + ); +} + +function endOfUtcDay(d: Date): Date { + return new Date( + Date.UTC( + d.getUTCFullYear(), + d.getUTCMonth(), + d.getUTCDate(), + 23, + 59, + 59, + 999, + ), + ); +} + +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"); + return ( + `${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}` + + `T${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}Z` + ); +} diff --git a/src/lib/validations/medication.ts b/src/lib/validations/medication.ts index 846b962ab..fcb82ddf4 100644 --- a/src/lib/validations/medication.ts +++ b/src/lib/validations/medication.ts @@ -27,14 +27,101 @@ export const MEDICATION_TREATMENT_CLASS_VALUES = ["GENERIC", "GLP1"] as const; export type MedicationTreatmentClass = (typeof MEDICATION_TREATMENT_CLASS_VALUES)[number]; -export const scheduleSchema = z.object({ - windowStart: z.string().regex(timeRegex, "Format: HH:mm"), - windowEnd: z.string().regex(timeRegex, "Format: HH:mm"), - label: z.string().max(50).optional(), - dose: z.string().max(50).optional(), - daysOfWeek: z.array(z.number().int().min(0).max(6)).optional(), - intervalWeeks: z.number().int().min(1).max(4).optional(), -}); +/** + * v1.5 — RRULE string shape check. + * + * Not a full RFC 5545 parser (that lives in the `rrule` npm package on + * the server side). The regex catches the subset HealthLog mints from + * the wizard / form: a `FREQ=` line with optional `INTERVAL=`, + * `BYDAY=`, `BYMONTHDAY=`, `BYMONTH=`, `COUNT=`, `UNTIL=` properties + * separated by `;`. Any pathological-looking shape (the `FREQ=SECONDLY` + * DoS surface, `;COUNT=999999999`) is filtered at the server route + * layer with a hard cap on emitted occurrences. + */ +const RRULE_PROPS = + /^FREQ=(?:DAILY|WEEKLY|MONTHLY|YEARLY)(?:;(?:INTERVAL=\d{1,3}|BYDAY=(?:MO|TU|WE|TH|FR|SA|SU)(?:,(?:MO|TU|WE|TH|FR|SA|SU))*|BYMONTHDAY=-?\d{1,2}(?:,-?\d{1,2})*|BYMONTH=\d{1,2}(?:,\d{1,2})*|COUNT=\d{1,4}|UNTIL=\d{8}T\d{6}Z))*$/; + +export const scheduleSchema = z + .object({ + windowStart: z.string().regex(timeRegex, "Format: HH:mm"), + windowEnd: z.string().regex(timeRegex, "Format: HH:mm"), + label: z.string().max(50).optional(), + dose: z.string().max(50).optional(), + daysOfWeek: z.array(z.number().int().min(0).max(6)).optional(), + intervalWeeks: z.number().int().min(1).max(4).optional(), + /** + * v1.5 — first-class times-of-day. One or more HH:mm entries in + * the user's wall-clock; the engine applies them per matched day. + * Empty array falls back to `[windowStart]` for backwards-compat. + */ + timesOfDay: z + .array(z.string().regex(timeRegex, "Format: HH:mm")) + .max(8) + .optional(), + /** + * v1.5 — reminder grace window (minutes). Replaces the implicit + * `windowEnd - windowStart` span for late-classification. NULL + * falls back to the legacy span. Capped at 24 hours. + */ + reminderGraceMinutes: z.number().int().min(1).max(24 * 60).optional(), + /** + * v1.5 — RFC 5545 RRULE string for calendar-anchored cadences. + * Mutually exclusive with `rollingIntervalDays`. + */ + rrule: z + .string() + .max(200) + .regex(RRULE_PROPS, "Invalid RRULE") + .optional(), + /** + * v1.5 — flexible-rolling interval in days, counted from the + * latest MedicationIntakeEvent.takenAt. Mutually exclusive with + * `rrule`. Range 1..365 days. + */ + rollingIntervalDays: z.number().int().min(1).max(365).optional(), + }) + .refine( + (s) => !(s.rrule && s.rollingIntervalDays), + { + message: + "A schedule can be calendar-anchored (rrule) or rolling, not both", + path: ["rrule"], + }, + ); + +/** + * v1.5 — medication-level course window + one-shot flag. The fields + * are optional on the create path so the existing form (no wizard + * yet) keeps working. The route layer enforces the `oneShot` + at- + * least-one-schedule invariant for the wizard path. + */ +const courseWindowFields = { + /** + * Date the medication course begins. ISO YYYY-MM-DD. NULL = "from + * creation" (the legacy implicit anchor). + */ + startsOn: z.iso + .date() + .transform((s) => new Date(s)) + .nullable() + .optional(), + /** + * Date the medication course ends. ISO YYYY-MM-DD. NULL = chronic. + * Required to equal `startsOn` when `oneShot` is true (the route + * normalises this; the schema doesn't because they're sister + * fields and the cross-field refine is enforced at the route). + */ + endsOn: z.iso + .date() + .transform((s) => new Date(s)) + .nullable() + .optional(), + /** + * Single-administration medication. Auto-deactivates after the + * single intake is logged. + */ + oneShot: z.boolean().optional(), +}; export const createMedicationSchema = z.object({ name: z.string().min(1).max(100), @@ -44,6 +131,7 @@ export const createMedicationSchema = z.object({ treatmentClass: z.enum(MEDICATION_TREATMENT_CLASS_VALUES).optional(), /** v1.4.25 W4d — doses per pen/vial for inventory tracking. */ dosesPerUnit: z.number().int().min(1).max(100).optional(), + ...courseWindowFields, schedules: z.array(scheduleSchema).min(1, "Mindestens ein Zeitfenster"), }); @@ -55,6 +143,7 @@ export const updateMedicationSchema = z.object({ dosesPerUnit: z.number().int().min(1).max(100).nullable().optional(), active: z.boolean().optional(), notificationsEnabled: z.boolean().optional(), + ...courseWindowFields, schedules: z.array(scheduleSchema).optional(), });