feat(medications): canonical recurrence engine + Zod validation

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) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-28 07:52:38 +02:00
1 parent f80e3375b2
commit 65b923ad53
3 files changed
+1434 -8

No files matched your search

@@ -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> = {},
): 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<RecurrenceContext["medication"]>;
}
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);
});
});
@@ -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`
);
}
+97 -8
View File
@@ -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(),
});