mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
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 };
|
||||
}
|
||||
Reference in new issue
Block a user