feat(medications): shared cadence-aware band minter

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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-08 17:28:55 +02:00
1 parent e5221b45e6
commit 75bf9829a8
2 files changed
+895

No files matched your search

@@ -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> = {}): 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> = {}): 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
});
});
@@ -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 };
}