From 0761ce2346b6446161a29927cdb3ff23f24b2bb9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 10 Jun 2026 11:17:38 +0200 Subject: [PATCH] fix(medications): widen write-path band minting to cover day-scale tails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../__tests__/attribute-intake.test.ts | 74 +++++++++++++++++++ .../scheduling/attribute-intake.ts | 67 +++++++++++------ 2 files changed, 119 insertions(+), 22 deletions(-) diff --git a/src/lib/medications/scheduling/__tests__/attribute-intake.test.ts b/src/lib/medications/scheduling/__tests__/attribute-intake.test.ts index 84f2e9ba9..9b7319e82 100644 --- a/src/lib/medications/scheduling/__tests__/attribute-intake.test.ts +++ b/src/lib/medications/scheduling/__tests__/attribute-intake.test.ts @@ -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 diff --git a/src/lib/medications/scheduling/attribute-intake.ts b/src/lib/medications/scheduling/attribute-intake.ts index 9d329562e..11c849230 100644 --- a/src/lib/medications/scheduling/attribute-intake.ts +++ b/src/lib/medications/scheduling/attribute-intake.ts @@ -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 };