From 75bf9829a87f75cafbfec978a05fb9cef5c44b47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 8 Jun 2026 17:28:55 +0200 Subject: [PATCH] feat(medications): shared cadence-aware band minter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the keystone that builds the correct SlotBand[] for every medication cadence, so the dose-history read-model, the compliance rate, and the write/edit attribution all consume one source of slot windows and can never contradict each other. Per cadence: - daily / multi-time: minute-scale on-time (±60min default) + daily late tail; - fixed weekdays / every-N-weeks: family derived from the realised inter-slot gap, not the field-shape heuristic that mislabels legacy daysOfWeek meds as daily; day-scale on-time + 4-day tail; - rolling (GLP-1): retrospective bands anchored at each logged intake, not the forward-only single slot that would orphan every past shot to ad-hoc; - cyclic: off-week slots already dropped by the engine; band the survivors; - one-shot: a single whole-day on-time band, never auto-missed; - PRN / empty / malformed rrule: no bands, hasExpectedSlots=false; - DST day-scale bounds minted via localHmAsUtc on calendar days, not ±N·DAY_MS; - multi-schedule: bands per schedule so a weekly tail is never clipped by a daily anchor. Pure and synchronous; the rolling intake instants are pre-fetched and threaded in like resolve-slot-instant. 25 tests covering each cadence plus DST summer/winter, the realised-gap family, and the per-schedule no-clip invariant. --- .../scheduling/__tests__/band-minter.test.ts | 440 +++++++++++++++++ src/lib/medications/scheduling/band-minter.ts | 455 ++++++++++++++++++ 2 files changed, 895 insertions(+) create mode 100644 src/lib/medications/scheduling/__tests__/band-minter.test.ts create mode 100644 src/lib/medications/scheduling/band-minter.ts diff --git a/src/lib/medications/scheduling/__tests__/band-minter.test.ts b/src/lib/medications/scheduling/__tests__/band-minter.test.ts new file mode 100644 index 000000000..af94cfdd2 --- /dev/null +++ b/src/lib/medications/scheduling/__tests__/band-minter.test.ts @@ -0,0 +1,440 @@ +/** + * v1.15.18 — shared cadence-aware band minter. + * + * The keystone that builds the correct `SlotBand[]` for EVERY medication + * cadence, so the read-model + the compliance % + the write/edit paths all + * consume ONE source of slot windows. These tests pin each cadence the audit + * called out: + * + * - daily / multi-time → minute-scale on-time + daily late tail; + * - fixed weekdays / N-weeks → day-scale on-time (realised-gap family, NOT + * the field-shape `doseCadenceFamily`); + * - rolling (GLP-1) → retrospective bands anchored AT each intake; + * - cyclic → off-week slots already filtered out; + * - one-shot → one WIDE whole-day on-time band, no auto-miss; + * - PRN / empty / bad rrule → [] + `hasExpectedSlots: false`; + * - DST day-scale → ±N calendar days via `localHmAsUtc`, not ±N·DAY; + * - multi-schedule → bands PER SCHEDULE, no cross-schedule clip. + */ +import { describe, expect, it } from "vitest"; + +import { + buildBandsForMedication, + buildBandsForSchedules, + type BandMinterMedication, +} from "../band-minter"; +import { + type CanonicalSchedule, + type RecurrenceContext, +} from "../recurrence"; +import { localHmAsUtc } from "@/lib/timezone"; + +const TZ = "Europe/Berlin"; +const MIN = 60_000; +const HOUR = 60 * MIN; +const DAY = 24 * HOUR; + +function med(over: Partial = {}): BandMinterMedication { + return { + id: "med-1", + startsOn: null, + endsOn: null, + oneShot: false, + createdAt: new Date("2026-01-01T00:00:00Z"), + ...over, + }; +} + +function schedule(over: Partial = {}): CanonicalSchedule { + return { + id: "sched-1", + rrule: null, + rollingIntervalDays: null, + timesOfDay: [], + daysOfWeek: null, + windowStart: "08:00", + windowEnd: "08:00", + reminderGraceMinutes: null, + scheduleType: "SCHEDULED", + cyclicOnWeeks: null, + cyclicOffWeeks: null, + ...over, + }; +} + +function ctxFor( + m: BandMinterMedication, + lastIntakeAt: Date | null = null, +): RecurrenceContext { + return { + medication: { + id: m.id, + startsOn: m.startsOn, + endsOn: m.endsOn, + oneShot: m.oneShot, + createdAt: m.createdAt, + }, + timeZone: TZ, + lastIntakeAt, + }; +} + +/** Berlin wall-clock HH:mm on the local day implied by `dayRef`. */ +function at(dayRef: Date, h: number, m: number): Date { + return localHmAsUtc(dayRef, TZ, h, m); +} + +// ──────────────────────────────────────────────────────────────────── +// 1. Daily / multi-time +// ──────────────────────────────────────────────────────────────────── + +describe("daily multi-time (07:00 / 19:00)", () => { + const m = med(); + const sched = schedule({ timesOfDay: ["07:00", "19:00"], daysOfWeek: null }); + const day = new Date("2026-06-08T12:00:00Z"); + const out = buildBandsForMedication({ + medication: m, + schedule: sched, + ctx: ctxFor(m), + userTz: TZ, + range: { from: at(day, 0, 0), to: at(day, 23, 59) }, + now: at(day, 23, 59), + }); + + it("mints one band per time-of-day", () => { + expect(out.hasExpectedSlots).toBe(true); + expect(out.bands.map((b) => b.timeOfDay)).toEqual(["07:00", "19:00"]); + }); + + it("classifies as a daily (minute-scale) family", () => { + expect(out.family).toBe("daily"); + }); + + it("uses a minute-scale on-time band (±60min default)", () => { + const morning = out.bands[0]; + expect(morning.onTimeStart.getTime()).toBe(at(day, 7, 0).getTime() - 60 * MIN); + expect(morning.onTimeEnd.getTime()).toBe(at(day, 7, 0).getTime() + 60 * MIN); + }); + + it("applies a daily late tail (180min default) capped before the next slot", () => { + const morning = out.bands[0]; + // 07:00 + 60 on-time + 180 tail = 10:00, well before 19:00 → not capped. + expect(morning.overdueEnd.getTime()).toBe(at(day, 7, 0).getTime() + (60 + 180) * MIN); + }); + + it("DST-correct anchors: 07:00 Berlin summer is 05:00Z", () => { + expect(out.bands[0].at.toISOString()).toBe("2026-06-08T05:00:00.000Z"); + }); +}); + +// ──────────────────────────────────────────────────────────────────── +// 2. Fixed weekdays / every-N-weeks (realised-gap family) +// ──────────────────────────────────────────────────────────────────── + +describe("fixed weekdays Mon/Thu via legacy daysOfWeek", () => { + // A Mon/Thu med stored the legacy way. `doseCadenceFamily` would mislabel + // it daily (±60min). The realised inter-slot gap (3–4 days) must classify + // it weekly → day-scale on-time. + const m = med({ startsOn: new Date("2026-06-01T00:00:00Z") }); + const sched = schedule({ daysOfWeek: "1,4", timesOfDay: ["09:00"] }); + // Mon 2026-06-08 .. Sun 2026-06-14 inclusive. + const from = new Date("2026-06-08T00:00:00Z"); + const to = new Date("2026-06-14T23:59:59Z"); + const out = buildBandsForMedication({ + medication: m, + schedule: sched, + ctx: ctxFor(m), + userTz: TZ, + range: { from, to }, + now: new Date("2026-06-20T00:00:00Z"), + }); + + it("derives the weekly family from the realised gap, not the field shape", () => { + expect(out.family).toBe("weekly"); + }); + + it("emits the Mon and Thu slots", () => { + expect(out.bands).toHaveLength(2); + }); + + it("uses a day-scale (±1 day) on-time band", () => { + const mon = out.bands[0]; + const span = mon.onTimeEnd.getTime() - mon.onTimeStart.getTime(); + // ±1 day on-time → roughly 2 days wide (DST may shift by an hour, allow slop). + expect(span).toBeGreaterThanOrEqual(2 * DAY - 2 * HOUR); + expect(span).toBeLessThanOrEqual(2 * DAY + 2 * HOUR); + }); + + it("applies the 4-day late tail (capped before the next slot)", () => { + const mon = out.bands[0]; + const thu = out.bands[1]; + // Mon on-time-end + 4d would overrun Thu's on-time start (Thu − 1d), so it + // is capped there. + expect(mon.overdueEnd.getTime()).toBeLessThanOrEqual(thu.onTimeStart.getTime()); + }); +}); + +// ──────────────────────────────────────────────────────────────────── +// 3. Rolling / "every N days from last intake" (GLP-1) +// ──────────────────────────────────────────────────────────────────── + +describe("rolling GLP-1 (every 7 days) — retrospective bands", () => { + const m = med({ startsOn: new Date("2026-05-01T00:00:00Z") }); + const sched = schedule({ + rollingIntervalDays: 7, + timesOfDay: ["08:00"], + windowStart: "08:00", + windowEnd: "08:00", + }); + // Three irregular real injections (not exactly 7d apart). + const shot1 = new Date("2026-05-04T09:12:00Z"); + const shot2 = new Date("2026-05-12T18:40:00Z"); // 8d later + const shot3 = new Date("2026-05-18T07:55:00Z"); // 6d later + const intakeInstants = [shot1, shot2, shot3]; + const now = new Date("2026-05-20T12:00:00Z"); + const out = buildBandsForMedication({ + medication: m, + schedule: sched, + ctx: ctxFor(m, shot3), + userTz: TZ, + range: { + from: new Date("2026-05-01T00:00:00Z"), + to: now, + }, + now, + intakeInstants, + }); + + it("anchors a band AT each logged injection (not one forward slot)", () => { + // Three intakes → at least three retrospective bands. + expect(out.bands.length).toBeGreaterThanOrEqual(3); + }); + + it("each shot's own injection time falls inside its band (on-time)", () => { + for (const shot of intakeInstants) { + const owning = out.bands.find( + (b) => shot.getTime() >= b.onTimeStart.getTime() && shot.getTime() <= b.onTimeEnd.getTime(), + ); + expect(owning, `shot ${shot.toISOString()} should anchor a band`).toBeDefined(); + } + }); + + it("uses a day-scale (weekly) family for the rolling cadence", () => { + expect(out.family).toBe("weekly"); + }); +}); + +// ──────────────────────────────────────────────────────────────────── +// 4. Cyclic (on/off weeks) +// ──────────────────────────────────────────────────────────────────── + +describe("cyclic (3 weeks on / 1 week off) — off-week slots dropped", () => { + const m = med({ startsOn: new Date("2026-06-01T00:00:00Z") }); + // Daily within an on-week, 3 on / 1 off; week 4 (2026-06-22..) is off. + const sched = schedule({ + scheduleType: "CYCLIC", + cyclicOnWeeks: 3, + cyclicOffWeeks: 1, + timesOfDay: ["08:00"], + daysOfWeek: null, + }); + // Off-week 4 runs Mon 2026-06-22 .. Sat 2026-06-27; Sunday 06-28 is already + // the start of the next (Sunday-rooted) on-week, so the range stops Saturday. + const offWeekFrom = new Date("2026-06-22T00:00:00Z"); + const offWeekTo = new Date("2026-06-27T23:59:59Z"); + const out = buildBandsForMedication({ + medication: m, + schedule: sched, + ctx: ctxFor(m), + userTz: TZ, + range: { from: offWeekFrom, to: offWeekTo }, + now: new Date("2026-07-10T00:00:00Z"), + }); + + it("mints no bands inside an off week", () => { + expect(out.bands).toHaveLength(0); + }); + + it("still reports the schedule HAS expected slots (it is scheduled)", () => { + expect(out.hasExpectedSlots).toBe(true); + }); +}); + +// ──────────────────────────────────────────────────────────────────── +// 5. One-shot +// ──────────────────────────────────────────────────────────────────── + +describe("one-shot — single wide whole-day on-time band", () => { + const m = med({ + oneShot: true, + startsOn: new Date("2026-06-08T00:00:00Z"), + }); + const sched = schedule({ timesOfDay: ["08:00"] }); + const day = new Date("2026-06-08T12:00:00Z"); + const out = buildBandsForMedication({ + medication: m, + schedule: sched, + ctx: ctxFor(m), + userTz: TZ, + range: { from: at(day, 0, 0), to: at(day, 23, 59) }, + now: at(day, 23, 59), + }); + + it("mints exactly one band", () => { + expect(out.bands).toHaveLength(1); + }); + + it("the on-time band spans the whole local day", () => { + const b = out.bands[0]; + // Whole-day on-time → start at local midnight, end at next local midnight. + expect(b.onTimeStart.getTime()).toBeLessThanOrEqual(at(day, 0, 0).getTime()); + expect(b.onTimeEnd.getTime()).toBeGreaterThanOrEqual(at(day, 23, 59).getTime()); + }); + + it("a take any time that day is on-time (a 23:00 take still counts)", () => { + const b = out.bands[0]; + const late = at(day, 23, 0); + expect(late.getTime()).toBeGreaterThanOrEqual(b.onTimeStart.getTime()); + expect(late.getTime()).toBeLessThanOrEqual(b.onTimeEnd.getTime()); + }); + + it("classifies as a one-shot family", () => { + expect(out.family).toBe("one_shot"); + }); +}); + +// ──────────────────────────────────────────────────────────────────── +// 6. PRN / empty / malformed rrule +// ──────────────────────────────────────────────────────────────────── + +describe("PRN / empty / malformed → no expected slots", () => { + const m = med(); + const day = new Date("2026-06-08T12:00:00Z"); + const range = { from: at(day, 0, 0), to: at(day, 23, 59) }; + + it("PRN yields [] and hasExpectedSlots=false", () => { + const out = buildBandsForMedication({ + medication: m, + schedule: schedule({ scheduleType: "PRN", timesOfDay: ["08:00"] }), + ctx: ctxFor(m), + userTz: TZ, + range, + now: at(day, 23, 59), + }); + expect(out.bands).toEqual([]); + expect(out.hasExpectedSlots).toBe(false); + expect(out.family).toBe("none"); + }); + + it("a malformed rrule yields [] and hasExpectedSlots=false", () => { + const out = buildBandsForMedication({ + medication: m, + schedule: schedule({ rrule: "this-is-not-a-valid-rrule" }), + ctx: ctxFor(m), + userTz: TZ, + range, + now: at(day, 23, 59), + }); + expect(out.bands).toEqual([]); + expect(out.hasExpectedSlots).toBe(false); + }); +}); + +// ──────────────────────────────────────────────────────────────────── +// 7. DST for day-scale bands +// ──────────────────────────────────────────────────────────────────── + +describe("DST day-scale bounds via localHmAsUtc (not ±N·DAY_MS)", () => { + // A weekly slot whose ±1-day window spans the spring-forward transition + // (Europe/Berlin 2026-03-29 02:00→03:00). The day-scale bound must be the + // calendar day before/after at the same wall-clock, not a raw 24h subtraction. + const m = med({ startsOn: new Date("2026-03-01T00:00:00Z") }); + const sched = schedule({ + rrule: "FREQ=WEEKLY;BYDAY=SU", + timesOfDay: ["09:00"], + }); + const from = new Date("2026-03-26T00:00:00Z"); + const to = new Date("2026-04-01T23:59:59Z"); + const out = buildBandsForMedication({ + medication: m, + schedule: sched, + ctx: ctxFor(m), + userTz: TZ, + range: { from, to }, + now: new Date("2026-04-10T00:00:00Z"), + }); + + it("emits the Sunday slot in the DST week", () => { + expect(out.bands.length).toBeGreaterThanOrEqual(1); + }); + + it("the on-time lower bound is 09:00 the previous calendar day (DST-correct)", () => { + const b = out.bands[0]; + const anchorParts = b.at; // 2026-03-29 09:00 Berlin + // The lower bound must read 09:00 Berlin on 2026-03-28, i.e. a calendar-day + // shift, even though the raw clock distance is 23h across the spring-forward. + const lower = b.onTimeStart; + const expectedLower = localHmAsUtc( + new Date(anchorParts.getTime() - DAY), + TZ, + 9, + 0, + ); + expect(lower.toISOString()).toBe(expectedLower.toISOString()); + // And critically NOT the naive ±24h subtraction. + const naive = new Date(anchorParts.getTime() - DAY); + expect(lower.getTime()).not.toBe(naive.getTime()); + }); +}); + +// ──────────────────────────────────────────────────────────────────── +// 8. Multi-schedule on one med +// ──────────────────────────────────────────────────────────────────── + +describe("multi-schedule — bands built PER SCHEDULE (no cross-clip)", () => { + // A daily oral 08:00 + a weekly injection Sunday 08:00 on the same med. If + // pooled into one buildSlotBands call, the weekly slot's 4-day tail would be + // clipped by the next daily 08:00 anchor. Per-schedule banding keeps the + // weekly tail intact. + const m = med({ startsOn: new Date("2026-06-01T00:00:00Z") }); + const daily = schedule({ id: "daily", timesOfDay: ["08:00"], daysOfWeek: null }); + const weekly = schedule({ + id: "weekly", + rrule: "FREQ=WEEKLY;BYDAY=SU", + timesOfDay: ["08:00"], + }); + const from = new Date("2026-06-07T00:00:00Z"); // Sunday + const to = new Date("2026-06-13T23:59:59Z"); + const out = buildBandsForSchedules({ + medication: m, + schedules: [daily, weekly], + ctx: ctxFor(m), + userTz: TZ, + range: { from, to }, + now: new Date("2026-06-20T00:00:00Z"), + }); + + it("returns one group per schedule", () => { + expect(out).toHaveLength(2); + expect(out.map((g) => g.scheduleId).sort()).toEqual(["daily", "weekly"]); + }); + + it("the weekly band keeps a multi-day late tail (not clipped by a daily anchor)", () => { + const weeklyGroup = out.find((g) => g.scheduleId === "weekly"); + expect(weeklyGroup).toBeDefined(); + expect(weeklyGroup!.family).toBe("weekly"); + const sun = weeklyGroup!.bands[0]; + const tailMs = sun.overdueEnd.getTime() - sun.onTimeEnd.getTime(); + // The weekly tail must be on the day scale (>1 day), not clipped to the + // next daily 08:00 (~24h − on-time) as a pooled call would do. + expect(tailMs).toBeGreaterThan(DAY); + }); + + it("the daily group keeps minute-scale bands", () => { + const dailyGroup = out.find((g) => g.scheduleId === "daily"); + expect(dailyGroup!.family).toBe("daily"); + const oneBand = dailyGroup!.bands[0]; + const span = oneBand.onTimeEnd.getTime() - oneBand.onTimeStart.getTime(); + expect(span).toBe(2 * 60 * MIN); // ±60min + }); +}); diff --git a/src/lib/medications/scheduling/band-minter.ts b/src/lib/medications/scheduling/band-minter.ts new file mode 100644 index 000000000..d7e44f7ac --- /dev/null +++ b/src/lib/medications/scheduling/band-minter.ts @@ -0,0 +1,455 @@ +/** + * v1.15.18 — shared cadence-aware band minter. + * + * THE keystone of the traceable dose-history model. It builds the correct + * `SlotBand[]` for EVERY medication cadence so the three downstream surfaces + * — the read-model (`reconstructDoseHistory`), the compliance %, and the + * write/edit attribution — all consume ONE source of slot windows and can + * never contradict each other (the v1.15.18 audit's CRITICAL-2 finding). + * + * Per the audit, each cadence needs its own band shape: + * + * 1. daily / multi-time — `occurrencesBetween` slots; minute-scale + * on-time (±60min default) + a daily late tail. + * 2. fixed weekdays / N-weeks — derive the family from the REALISED + * inter-slot gap, NOT `doseCadenceFamily`'s + * field-shape heuristic (which mislabels legacy + * `daysOfWeek` meds as daily); day-scale on-time + * (±1 day) + a 4-day late tail. + * 3. rolling (GLP-1) — bands from `expandRollingRetrospective`, + * anchored AT each logged intake (NOT the + * forward-only `occurrencesBetween`, which would + * make every past shot read ad-hoc). Day-scale. + * 4. cyclic — `occurrencesBetween` already drops off-week + * slots; mint bands only for the survivors. + * 5. one-shot — a single WIDE whole-day on-time band; never + * auto-flips to missed. + * 6. PRN / empty / bad rrule — `[]` (the caller renders intakes as plain + * entries) + `hasExpectedSlots: false`. + * 7. DST for day-scale bands — ±N-day bounds minted via `localHmAsUtc` on + * calendar days, never `±N·DAY_MS`. + * 8. multi-schedule on one med — bands built PER SCHEDULE so a weekly tail is + * never clipped by a daily oral's anchor. + * + * Pure / synchronous: no DB access. The DB-reading parts (the rolling + * `intakeInstants`, `lastIntakeAt`) are pre-fetched and threaded in, like + * `resolve-slot-instant.ts`. + */ +import { RRule } from "rrule"; + +import { DOSE_WINDOW_DEFAULTS } from "@/lib/analytics/compliance"; +import { + buildSlotBands, + type SlotBand, + type SlotWindowInput, +} from "@/lib/medications/scheduling/attribution"; +import { + expandRollingRetrospective, + occurrencesBetween, + type CanonicalSchedule, + type Occurrence, + type RecurrenceContext, +} from "@/lib/medications/scheduling/recurrence"; +import { localHmAsUtc } from "@/lib/timezone"; + +const MINUTE_MS = 60_000; +const HOUR_MS = 60 * MINUTE_MS; +const DAY_MS = 24 * HOUR_MS; + +/** + * Realised-gap threshold separating the day-scale (weekly) family from the + * minute-scale (daily) family. A cadence whose closest two slots sit at least + * 36h apart is treated as day-scale: that catches every-other-day, weekly, + * bi-weekly and monthly cadences while keeping daily (24h) and intraday + * multi-dose schedules on the minute scale. Derived from the REALISED gap, not + * `doseCadenceFamily`'s field-shape heuristic (audit MEDIUM-6). + */ +const DAY_SCALE_GAP_THRESHOLD_MS = 36 * HOUR_MS; + +/** Which band shape the minter chose for a schedule. */ +export type BandFamily = "daily" | "weekly" | "one_shot" | "none"; + +/** The medication projection the minter reads (pre-fetched, no DB access). */ +export interface BandMinterMedication { + id: string; + startsOn: Date | null; + endsOn: Date | null; + oneShot: boolean; + createdAt: Date; +} + +/** + * Per-dose window override. When absent the minter derives the on-time width + + * late tail from `DOSE_WINDOW_DEFAULTS` keyed on the realised family. A caller + * can widen the on-time band per Marc's configurable-window lever. + */ +export interface DoseWindowConfig { + /** Daily on-time half-width around the slot instant (minutes). */ + dailyOnTimeMinutes?: number; + /** Daily late tail past the on-time window (minutes). */ + dailyOverdueMinutes?: number; + /** Weekly / rolling on-time half-width around the slot day (days). */ + weeklyOnTimeDays?: number; + /** Weekly / rolling late tail past the on-time day (days). */ + weeklyOverdueDays?: number; +} + +export interface BuildBandsInput { + medication: BandMinterMedication; + schedule: CanonicalSchedule; + ctx: RecurrenceContext; + userTz: string; + /** The [from, to] window to mint expected slots over (inclusive). */ + range: { from: Date; to: Date }; + /** Wall-clock reference, threaded to the rolling retrospective expansion. */ + now: Date; + /** Per-dose window override; defaults to `DOSE_WINDOW_DEFAULTS`. */ + windowConfig?: DoseWindowConfig; + /** + * Non-skipped intake instants for THIS medication. Required for a rolling + * cadence (the retrospective grid anchors AT each intake); ignored by every + * other cadence. Pre-fetched by the caller — the minter has no DB access. + */ + intakeInstants?: Date[]; +} + +export interface BandMinterResult { + bands: SlotBand[]; + /** False for PRN / empty / malformed schedules (no slot machinery). */ + hasExpectedSlots: boolean; + /** The band shape chosen — surfaced for diagnostics + the UI label. */ + family: BandFamily; +} + +/** One schedule's band group, for a multi-schedule medication. */ +export interface ScheduleBandGroup extends BandMinterResult { + scheduleId: string; +} + +/** + * Build the `SlotBand[]` for one schedule of one medication over `range`. + * + * The single per-schedule entry point. A multi-schedule medication routes + * each schedule through here independently (see `buildBandsForSchedules`) so a + * sparse cadence's late tail is never clipped by a denser sibling's anchor. + */ +export function buildBandsForMedication( + input: BuildBandsInput, +): BandMinterResult { + const { medication, schedule, ctx, userTz, range, now } = input; + + // PRN / empty / malformed → no slot machinery. The caller renders the + // intakes as plain entries. `occurrencesBetween` already returns [] for PRN; + // we also short-circuit a schedule that describes no cadence at all (no + // rrule, no rolling, no legacy weekday/time, not one-shot) so a malformed or + // empty schedule never silently mints a stray daily band. + if (schedule.scheduleType === "PRN" || !describesACadence(schedule, medication)) { + return { bands: [], hasExpectedSlots: false, family: "none" }; + } + + // One-shot — a single WIDE whole-day on-time band that never auto-misses. + if (medication.oneShot) { + const bands = buildOneShotBands(schedule, ctx, userTz, range); + // A one-shot whose anchor lies outside the window mints no band here, but + // it IS a scheduled dose — keep `hasExpectedSlots` true. + return { bands, hasExpectedSlots: true, family: "one_shot" }; + } + + // Rolling (GLP-1) — retrospective bands anchored AT each logged intake. + if (schedule.rollingIntervalDays !== null) { + const occ = expandRollingRetrospective( + schedule, + ctx, + range.from, + range.to, + input.intakeInstants ?? [], + now, + ); + // Rolling is inherently day-scale (every-N-days). Mint day-scale bands. + const bands = mintBands(occ, "weekly", input.windowConfig, userTz); + return { bands, hasExpectedSlots: true, family: "weekly" }; + } + + // Daily / weekly / cyclic / rrule — `occurrencesBetween` already filters + // cyclic off-week slots. Classify the family by the REALISED inter-slot gap + // probed over a padded window (so a sparse cadence yields ≥2 slots), NOT the + // field-shape `doseCadenceFamily` heuristic. + const occurrences = occurrencesBetween(schedule, range.from, range.to, ctx); + const family = realisedFamily(schedule, ctx, range); + const bands = mintBands(occurrences, family, input.windowConfig, userTz); + return { bands, hasExpectedSlots: true, family }; +} + +/** + * Build per-schedule band groups for a multi-schedule medication. Each + * schedule is banded independently — the keystone invariant that keeps a + * weekly injection's 4-day tail from being clipped by a daily oral's anchor. + */ +export function buildBandsForSchedules(input: { + medication: BandMinterMedication; + schedules: CanonicalSchedule[]; + ctx: RecurrenceContext; + userTz: string; + range: { from: Date; to: Date }; + now: Date; + windowConfig?: DoseWindowConfig; + /** Per-medication intake instants (the rolling anchors). */ + intakeInstants?: Date[]; +}): ScheduleBandGroup[] { + return input.schedules.map((schedule) => { + const result = buildBandsForMedication({ + medication: input.medication, + schedule, + ctx: input.ctx, + userTz: input.userTz, + range: input.range, + now: input.now, + windowConfig: input.windowConfig, + intakeInstants: input.intakeInstants, + }); + return { scheduleId: schedule.id, ...result }; + }); +} + +// ──────────────────────────────────────────────────────────────────── +// Band construction +// ──────────────────────────────────────────────────────────────────── + +/** + * Turn engine occurrences into `SlotWindowInput`s of the given family and run + * them through `buildSlotBands` (which resolves + caps the late tails so + * adjacent slots never both claim one intake). + */ +function mintBands( + occurrences: Occurrence[], + family: "daily" | "weekly", + windowConfig: DoseWindowConfig | undefined, + userTz: string, +): SlotBand[] { + const w = resolveWindows(windowConfig); + const inputs: SlotWindowInput[] = occurrences.map((occ) => + family === "weekly" + ? weeklyWindow(occ, w, userTz) + : dailyWindow(occ, w), + ); + return buildSlotBands(inputs); +} + +/** Minute-scale band around a slot instant (daily / intraday cadences). */ +function dailyWindow( + occ: Occurrence, + w: ResolvedWindows, +): SlotWindowInput { + return { + at: occ.at, + timeOfDay: occ.timeOfDay, + onTimeStart: new Date(occ.at.getTime() - w.dailyOnTimeMinutes * MINUTE_MS), + onTimeEnd: new Date(occ.at.getTime() + w.dailyOnTimeMinutes * MINUTE_MS), + lateGraceMs: w.dailyOverdueMinutes * MINUTE_MS, + }; +} + +/** + * Day-scale band around a slot instant (weekly / rolling / sparse cadences). + * The ±N-day bounds are minted via `localHmAsUtc` on the calendar days N days + * before / after the slot's local day — DST-correct, unlike `±N·DAY_MS`, which + * drifts an hour across a transition (audit MEDIUM-8). + */ +function weeklyWindow( + occ: Occurrence, + w: ResolvedWindows, + userTz: string, +): SlotWindowInput { + const { hour, minute } = localHourMinute(occ.timeOfDay); + const onTimeStart = localHmAsUtc( + new Date(occ.at.getTime() - w.weeklyOnTimeDays * DAY_MS), + userTz, + hour, + minute, + ); + const onTimeEnd = localHmAsUtc( + new Date(occ.at.getTime() + w.weeklyOnTimeDays * DAY_MS), + userTz, + hour, + minute, + ); + // The late tail is also day-scale: the instant N overdue-days past the + // on-time end, minted on the calendar day for DST-correctness, then expressed + // as a millisecond grace `buildSlotBands` consumes. + const overdueAnchor = localHmAsUtc( + new Date(onTimeEnd.getTime() + w.weeklyOverdueDays * DAY_MS), + userTz, + hour, + minute, + ); + const lateGraceMs = Math.max(0, overdueAnchor.getTime() - onTimeEnd.getTime()); + return { + at: occ.at, + timeOfDay: occ.timeOfDay, + onTimeStart, + onTimeEnd, + lateGraceMs, + }; +} + +/** + * One-shot — a single whole-day on-time band per `timesOfDay` anchor. The + * band spans the local day start..end so a take any time that day is on-time, + * and the late tail is zero (a one-shot never auto-misses; an unfilled slot + * simply reads `upcoming` until the day closes and then stays a never-acted + * slot rather than a hard miss). + */ +function buildOneShotBands( + schedule: CanonicalSchedule, + ctx: RecurrenceContext, + userTz: string, + range: { from: Date; to: Date }, +): SlotBand[] { + const occurrences = occurrencesBetween(schedule, range.from, range.to, ctx); + const inputs: SlotWindowInput[] = occurrences.map((occ) => { + const dayStart = localHmAsUtc(occ.at, userTz, 0, 0); + // End at the next local midnight (start of the following day) so a 23:59 + // take is still inside the band. + const nextDayStart = localHmAsUtc( + new Date(dayStart.getTime() + 25 * HOUR_MS), + userTz, + 0, + 0, + ); + return { + at: occ.at, + timeOfDay: occ.timeOfDay, + onTimeStart: dayStart, + onTimeEnd: nextDayStart, + lateGraceMs: 0, + }; + }); + return buildSlotBands(inputs); +} + +// ──────────────────────────────────────────────────────────────────── +// Family classification (realised inter-slot gap) +// ──────────────────────────────────────────────────────────────────── + +/** + * Classify the schedule's family from the REALISED inter-slot gap rather than + * the field-shape `doseCadenceFamily` heuristic (which mislabels a legacy + * Mon/Thu `daysOfWeek` med as daily ±60min). Probe a window padded to ≥16 + * weeks so a sparse cadence yields ≥2 slots and a real gap is observable; the + * minimum consecutive gap decides the scale. + */ +function realisedFamily( + schedule: CanonicalSchedule, + ctx: RecurrenceContext, + range: { from: Date; to: Date }, +): "daily" | "weekly" { + const probeDays = Math.max( + Math.ceil((range.to.getTime() - range.from.getTime()) / DAY_MS), + 16 * 7, + ); + const probeFrom = new Date(range.to.getTime() - probeDays * DAY_MS); + const slots = occurrencesBetween(schedule, probeFrom, range.to, ctx); + if (slots.length < 2) { + // Too few slots to measure a gap. Fall back to the field-shape signal: + // an explicit rolling/weekly/monthly shape is day-scale, else daily. + return fieldShapeFamily(schedule); + } + const times = slots + .map((s) => s.at.getTime()) + .sort((a, b) => a - b); + let minGap = Infinity; + for (let i = 1; i < times.length; i++) { + const gap = times[i] - times[i - 1]; + if (gap > 0 && gap < minGap) minGap = gap; + } + if (!Number.isFinite(minGap)) return fieldShapeFamily(schedule); + return minGap >= DAY_SCALE_GAP_THRESHOLD_MS ? "weekly" : "daily"; +} + +/** Field-shape fallback when too few slots exist to measure a realised gap. */ +function fieldShapeFamily(schedule: CanonicalSchedule): "daily" | "weekly" { + if ( + schedule.rollingIntervalDays !== null && + schedule.rollingIntervalDays >= 2 + ) { + return "weekly"; + } + if (/FREQ=(WEEKLY|MONTHLY|YEARLY)/i.test(schedule.rrule ?? "")) { + return "weekly"; + } + return "daily"; +} + +// ──────────────────────────────────────────────────────────────────── +// Cadence detection + small helpers +// ──────────────────────────────────────────────────────────────────── + +/** + * True when the schedule describes ANY cadence the engine can expand — a + * one-shot, a rolling interval, an rrule, or a legacy `daysOfWeek`/`timesOfDay` + * shape. A schedule with none of these (a stray/empty row) is treated like PRN: + * no expected slots. Keeps a malformed row from minting a phantom daily band. + */ +function describesACadence( + schedule: CanonicalSchedule, + medication: BandMinterMedication, +): boolean { + if (medication.oneShot) return true; + if (schedule.rollingIntervalDays !== null) return true; + if (schedule.rrule !== null && schedule.rrule.trim() !== "") { + // A malformed rrule describes no usable cadence — `expandRrule` swallows + // the parse error and returns no slots, so without this gate the schedule + // would read `hasExpectedSlots: true` with an empty band list (audit + // CRITICAL-3: a bad rrule must fall to the no-slot-machinery path). + return isParseableRrule(schedule.rrule); + } + if (schedule.daysOfWeek !== null && schedule.daysOfWeek.trim() !== "") { + return true; + } + if (schedule.timesOfDay.length > 0) return true; + return false; +} + +/** True when the bare RRULE body parses (mirrors `expandRrule`'s try/catch). */ +function isParseableRrule(rrule: string): boolean { + try { + RRule.fromString(`RRULE:${rrule}`); + return true; + } catch { + return false; + } +} + +interface ResolvedWindows { + dailyOnTimeMinutes: number; + dailyOverdueMinutes: number; + weeklyOnTimeDays: number; + weeklyOverdueDays: number; +} + +/** Merge the caller's per-dose overrides over `DOSE_WINDOW_DEFAULTS`. */ +function resolveWindows(config: DoseWindowConfig | undefined): ResolvedWindows { + return { + dailyOnTimeMinutes: + config?.dailyOnTimeMinutes ?? DOSE_WINDOW_DEFAULTS.dailyOnTimeMinutes, + dailyOverdueMinutes: + config?.dailyOverdueMinutes ?? DOSE_WINDOW_DEFAULTS.dailyOverdueMinutes, + weeklyOnTimeDays: + config?.weeklyOnTimeDays ?? DOSE_WINDOW_DEFAULTS.weeklyOnTimeDays, + weeklyOverdueDays: + config?.weeklyOverdueDays ?? DOSE_WINDOW_DEFAULTS.weeklyOverdueDays, + }; +} + +/** + * Parse an "HH:mm" slot label into hour/minute, defaulting to the slot's own + * local time when the label is malformed. The engine always sets a parseable + * `timeOfDay`, but the day-scale bound minting must never crash the read path. + */ +function localHourMinute(timeOfDay: string): { hour: number; minute: number } { + const [hStr, mStr] = timeOfDay.split(":"); + const h = Number(hStr); + const m = Number(mStr); + if (!Number.isFinite(h) || !Number.isFinite(m)) return { hour: 0, minute: 0 }; + return { hour: h, minute: m }; +}