mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
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:
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`
|
||||
);
|
||||
}
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user