fix(medications): clamp compliance expected slots to creation date

`expectedSlotsBetween` walked the full requested window even for a
medication younger than that window. The legacy weekday expander floors
on `startsOn` but not on `createdAt`, so a two-day-old daily med
reported seven expected doses over a seven-day window and thirty over a
thirty-day window — counting days before it existed. Clamp the window
lower bound to `createdAt` so the expected-dose counts, and the window
selection that reads them, reflect the medication's real age. The
displayed rates already clamp independently via `calculateCompliance`,
so only the slot counts change.
This commit is contained in:
Marc-André Bombeck committed 2026-06-01 23:43:39 +02:00
1 parent 38867439f3
commit 068cb168d9
2 files changed
+92 -1

No files matched your search

@@ -1221,6 +1221,33 @@ describe("buildComplianceDisplay — two rows, cadence-scaled windows", () => {
expect(display.short).toBeDefined();
expect(display.long).toBeDefined();
});
it("a 2-day-old daily med's expected counts reflect its real age", () => {
// Created two days ago: the expected-dose counts must reflect ~2 days
// of daily doses, not the 7 / 30 a full-window walk would report. No
// rung clears the floor, so the selection falls back to the widest
// rung while keeping the counts age-accurate.
const schedules: ComplianceSchedule[] = [
{
windowStart: "08:00",
windowEnd: "09:00",
daysOfWeek: null,
rrule: "FREQ=DAILY",
timesOfDay: ["08:00"],
},
];
const createdAt = new Date(NOW.getTime() - 2 * DAY_MS);
const display = buildComplianceDisplay([], schedules, ctx({ createdAt }), {
now: NOW,
});
// ~2 days of daily doses — nowhere near the 7 a 7-day window or the 30
// a 30-day window would report before the createdAt clamp.
expect(display.expectedShort).toBeLessThanOrEqual(3);
expect(display.expectedLong).toBeLessThanOrEqual(3);
// No rung clears the floor → widest windows still render two rows.
expect(display.shortDays).toBe(90);
expect(display.longDays).toBe(365);
});
});
describe("expectedSlotsBetween", () => {
@@ -1283,4 +1310,57 @@ describe("expectedSlotsBetween", () => {
);
expect(slots.length).toBe(0);
});
it("clamps the window lower bound to createdAt for a young med", () => {
// A daily med created two days before the window's upper bound: the
// legacy weekday walker floors on `startsOn` (none here) but not on
// `createdAt`, so without the clamp this 30-day window would count
// ~30 days of pre-creation slots. With the clamp only the ~2 days the
// med actually existed emit a slot.
const to = new Date("2025-06-15T00:00:00Z");
const from = new Date(to.getTime() - 30 * DAY_MS);
const createdAt = new Date(to.getTime() - 2 * DAY_MS);
const schedules: ComplianceSchedule[] = [
{
windowStart: "08:00",
windowEnd: "09:00",
daysOfWeek: null,
rrule: "FREQ=DAILY",
timesOfDay: ["08:00"],
},
];
const slots = expectedSlotsBetween(schedules, from, to, ctx({ createdAt }));
// Daily cadence over ~2 days → at most 3 slots, never the ~30 a full
// 30-day window would otherwise emit.
expect(slots.length).toBeLessThanOrEqual(3);
expect(slots.length).toBeGreaterThanOrEqual(1);
// Every emitted slot lands at or after the creation instant.
for (const s of slots) {
expect(s.at.getTime()).toBeGreaterThanOrEqual(createdAt.getTime());
}
});
it("leaves the window untouched when createdAt predates it", () => {
// createdAt well before `from` → the clamp is a no-op and the full
// window's slots survive.
const to = new Date("2025-06-15T00:00:00Z");
const from = new Date(to.getTime() - 7 * DAY_MS);
const schedules: ComplianceSchedule[] = [
{
windowStart: "08:00",
windowEnd: "09:00",
daysOfWeek: null,
rrule: "FREQ=DAILY",
timesOfDay: ["08:00"],
},
];
const slots = expectedSlotsBetween(
schedules,
from,
to,
ctx({ createdAt: new Date("2025-01-01T00:00:00Z") }),
);
// A full week of daily doses → ~7 slots.
expect(slots.length).toBeGreaterThanOrEqual(7);
});
});
+12 -1
View File
@@ -340,6 +340,15 @@ export function expectedSlotCountForDay(
* `occurrencesBetween` is inclusive of both ends; the caller passes a
* `[from, to]` window and we sort the union of every schedule's slots so
* a multi-schedule medication interleaves its slots in time order.
*
* v1.8.6 QA — the window lower bound is clamped to `ctx.createdAt`. The
* legacy weekday walker floors on `startsOn` but not on `createdAt`, so a
* brand-new daily med queried over a 30-day window would otherwise emit
* slots for every day before it existed (7/30 expected on a 2-day-old med).
* Clamping here keeps the expected-dose denominator — and the window
* selection that reads it — honest about the medication's real age. The
* displayed rates clamp independently via `calculateCompliance`'s
* `medicationCreatedAt` argument, so this only fixes the slot counts.
*/
export function expectedSlotsBetween(
schedules: ComplianceSchedule[],
@@ -348,12 +357,14 @@ export function expectedSlotsBetween(
ctx: ComplianceMedicationContext,
): Occurrence[] {
const recurrenceCtx = toRecurrenceCtx(ctx, "compliance-slots");
const effectiveFrom =
ctx.createdAt.getTime() > from.getTime() ? ctx.createdAt : from;
const all: Occurrence[] = [];
for (let i = 0; i < schedules.length; i++) {
all.push(
...occurrencesBetween(
toCanonicalSchedule(schedules[i], `compliance-slots-${i}`),
from,
effectiveFrom,
to,
recurrenceCtx,
),