fix(medications): widen write-path band minting to cover day-scale tails

The write path minted attribution bands over a fixed ±1 day around the
take, so a weekly or rolling dose taken two to four days late — inside
its own overdue tail — never found its anchor and was stored ad hoc
while the slot stayed missed. Day-scale schedules now mint over the full
on-time-plus-overdue reach derived from the configured dose windows;
minute-scale schedules keep the ±1 day probe.
This commit is contained in:
Marc-André Bombeck committed 2026-06-10 11:17:38 +02:00
1 parent 76012bf8e2
commit 0761ce2346
2 files changed
+119 -22

No files matched your search

@@ -123,6 +123,80 @@ describe("attributeTakenToSlot — PRN / unscheduled", () => {
});
});
describe("attributeTakenToSlot — weekly day-scale late tail", () => {
// Weekly Monday 08:00 (legacy daysOfWeek shape). DAY (2026-06-08) is a
// Monday, so the anchor is Monday 08:00 and the band spans Sun 08:00 →
// Tue 08:00 on-time plus the 4-day overdue tail to Sat 08:00.
const weekly = med([schedule({ daysOfWeek: "1", timesOfDay: ["08:00"] })]);
it("binds a take 3 days after the anchor to the slot as late, not ad-hoc", () => {
// Thursday 08:00 — inside the 4-day overdue tail, but 3 days past the
// anchor: a ±1-day mint range never surfaces the Monday band.
const takenAt = at(new Date("2026-06-11T12:00:00Z"), 8, 0);
const out = attributeTakenToSlot({
medication: weekly,
userTz: TZ,
takenAt,
});
expect(out.slotInstant?.getTime()).toBe(at(DAY, 8, 0).getTime());
expect(out.status).toBe("late");
});
it("treats a take past the overdue tail as ad-hoc (bi-weekly, +6 days)", () => {
// Bi-weekly Monday anchored at startsOn = Mon 2026-06-08; the next
// anchor (Jun 22) is two weeks out, so day +6 sits in the dead zone
// past the tail (Sat Jun 13 08:00) and before the next on-time band.
const biWeekly = med(
[schedule({ daysOfWeek: "i2;1", timesOfDay: ["08:00"] })],
{ startsOn: new Date("2026-06-08T00:00:00Z") },
);
const takenAt = at(new Date("2026-06-14T12:00:00Z"), 8, 0);
const out = attributeTakenToSlot({
medication: biWeekly,
userTz: TZ,
takenAt,
});
expect(out.slotInstant).toBeNull();
expect(out.status).toBeNull();
expect(out.hasExpectedSlots).toBe(true);
});
it("binds a rolling take 4 days after the projected next-due as late", () => {
// 7-day rolling, last shot Mon Jun 1 08:00 → next due Mon Jun 8 08:00.
// Take Friday Jun 12 08:00: 4 days past the anchor (inside the tail,
// and past the half-cycle gate that emits the overdue forward slot).
const rolling = med([
schedule({ rollingIntervalDays: 7, timesOfDay: ["08:00"] }),
]);
const lastShot = at(new Date("2026-06-01T12:00:00Z"), 8, 0);
const takenAt = at(new Date("2026-06-12T12:00:00Z"), 8, 0);
const out = attributeTakenToSlot({
medication: rolling,
userTz: TZ,
takenAt,
lastIntakeAt: lastShot,
intakeInstants: [lastShot],
});
expect(out.slotInstant?.getTime()).toBe(at(DAY, 8, 0).getTime());
expect(out.status).toBe("late");
});
it("binds a 3-days-late weekly take across the spring DST transition", () => {
// Friday 2026-03-27 08:00 CET anchor; the take lands Monday 2026-03-30
// 08:00 CEST — 3 days later across the Mar 29 spring-forward shift.
const friday = med([schedule({ daysOfWeek: "5", timesOfDay: ["08:00"] })]);
const anchorDay = new Date("2026-03-27T12:00:00Z");
const takenAt = at(new Date("2026-03-30T12:00:00Z"), 8, 0);
const out = attributeTakenToSlot({
medication: friday,
userTz: TZ,
takenAt,
});
expect(out.slotInstant?.getTime()).toBe(at(anchorDay, 8, 0).getTime());
expect(out.status).toBe("late");
});
});
describe("attributeTakenToSlot — DST correctness", () => {
it("snaps a winter (CET) on-time take to its slot", () => {
const winterDay = new Date("2026-01-15T12:00:00Z"); // CET
@@ -29,10 +29,11 @@ import {
type SlotBand,
} from "@/lib/medications/scheduling/attribution";
import {
buildBandsForSchedules,
buildBandsForMedication,
type BandMinterMedication,
type DoseWindowConfig,
} from "@/lib/medications/scheduling/band-minter";
import { DOSE_WINDOW_DEFAULTS } from "@/lib/medications/scheduling/dose-window-defaults";
import {
buildCanonicalSchedule,
buildRecurrenceContext,
@@ -116,31 +117,53 @@ export function buildMedicationDayBands(input: {
});
const canonicalSchedules = schedules.map(buildCanonicalSchedule);
// Mint over the take's local day padded a day each side so a slot near the
// local-midnight boundary (and any DST shift) is still captured — mirrors
// `resolve-slot-instant.ts`. A weekly/rolling band reaches further; its
// minter widens the probe internally, so the padded day window is enough to
// surface the anchor that the take could pair to.
const from = new Date(input.around.getTime() - ONE_DAY_MS);
const to = new Date(input.around.getTime() + ONE_DAY_MS);
const groups = buildBandsForSchedules({
medication: bandMinterMedication,
schedules: canonicalSchedules,
ctx,
userTz: input.userTz,
range: { from, to },
now: input.now ?? input.around,
windowConfig: input.windowConfig,
intakeInstants: input.intakeInstants,
});
// Minute-scale cadences: mint over the take's local day padded a day each
// side so a slot near the local-midnight boundary (and any DST shift) is
// still captured — mirrors `resolve-slot-instant.ts`.
//
// Day-scale cadences (weekly / rolling) need a wider mint range: a band's
// late tail reaches `weeklyOnTimeDays + weeklyOverdueDays` days past its
// anchor, so a take inside that tail sits several days AFTER the anchor —
// a ±1-day range would never mint the anchor's band and the take would be
// misfiled as ad-hoc. The minter widens its probe internally only for
// family CLASSIFICATION, not for minting, so the range itself must cover
// the band's full reach (derived from the configured / default windows,
// plus a day of midnight/DST padding). Each schedule is minted narrow
// first; a day-scale family is then re-minted over the wide range.
const minuteScaleRange = {
from: new Date(input.around.getTime() - ONE_DAY_MS),
to: new Date(input.around.getTime() + ONE_DAY_MS),
};
const dayScaleReachDays =
(input.windowConfig?.weeklyOnTimeDays ??
DOSE_WINDOW_DEFAULTS.weeklyOnTimeDays) +
(input.windowConfig?.weeklyOverdueDays ??
DOSE_WINDOW_DEFAULTS.weeklyOverdueDays) +
1;
const dayScaleRange = {
from: new Date(input.around.getTime() - dayScaleReachDays * ONE_DAY_MS),
to: new Date(input.around.getTime() + dayScaleReachDays * ONE_DAY_MS),
};
const bands: SlotBand[] = [];
let hasExpectedSlots = false;
for (const g of groups) {
if (g.hasExpectedSlots) {
for (const schedule of canonicalSchedules) {
const common = {
medication: bandMinterMedication,
schedule,
ctx,
userTz: input.userTz,
now: input.now ?? input.around,
windowConfig: input.windowConfig,
intakeInstants: input.intakeInstants,
};
let result = buildBandsForMedication({ ...common, range: minuteScaleRange });
if (result.family === "weekly") {
result = buildBandsForMedication({ ...common, range: dayScaleRange });
}
if (result.hasExpectedSlots) {
hasExpectedSlots = true;
bands.push(...g.bands);
bands.push(...result.bands);
}
}
return { bands, hasExpectedSlots };