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