Merge the skip parity between the two compliance engines

This commit is contained in:
Marc-André Bombeck committed 2026-09-10 04:23:01 +02:00
commit 18302c64f8
3 files changed
+272 -9

No files matched your search

@@ -704,6 +704,49 @@ describe("v1.15.9 — schedule-anchored compliance buckets (BUG #1)", () => {
if (day.scheduled > 0) expect(day.taken).toBe(day.scheduled);
}
});
it("a deliberately skipped dose leaves the denominator — a skip is a pause, not a miss, so the tile matches the ledger rate", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const twiceDaily = activeDailyMed(new Date(Date.now() - 60 * 86_400_000));
twiceDaily.schedules[0].timesOfDay = ["08:00", "20:00"];
vi.mocked(prisma.medication.findMany).mockResolvedValue([
twiceDaily,
] as never);
// Yesterday: the morning dose taken, the evening dose deliberately
// skipped. The skipped slot must drop out of `scheduled` entirely, so the
// day reads 1 of 1 (100%) — the figure the medication card, the dose
// history and the doctor report already showed. Counting it as
// expected-and-missed made the same day read 1 of 2 (50%) here.
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([
{
medicationId: "m1",
scheduledFor: new Date("2026-06-09T08:00:00.000Z"),
takenAt: new Date("2026-06-09T08:05:00.000Z"),
skipped: false,
autoMissed: false,
},
{
medicationId: "m1",
scheduledFor: new Date("2026-06-09T20:00:00.000Z"),
takenAt: null,
skipped: true,
autoMissed: false,
},
] as never);
const res = await GET(
new NextRequest(
"http://localhost/api/medications/intake?scope=compliance&days=7",
),
);
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: Array<{ date: string; scheduled: number; taken: number }>;
};
const skipDay = body.data.find((d) => d.date === "2026-06-09");
expect(skipDay).toEqual({ date: "2026-06-09", scheduled: 1, taken: 1 });
});
});
describe("v1.4.43 W6 — multi-issue 422 envelope", () => {
@@ -0,0 +1,191 @@
/**
* The two compliance engines must report the SAME rate for the same day.
*
* `tallyComplianceFromLedger` (the dose-history ledger behind the medication
* card, the history view, the doctor report and the Health Score pillar) has
* excluded a deliberately skipped dose from the denominator since v1.15.9 —
* a skip is a pause, not a miss. `buildScheduleAnchoredComplianceBuckets`
* (the dashboard tile and the adherence storyline) counted the same slot as
* expected-and-missed, because its denominator was the raw expected-slot
* count with no skip term. One day, two figures: 67 % on the card, 50 % on
* the tile.
*
* This test feeds ONE fixture to BOTH engines and pins them to one number, so
* the divergence cannot come back through either side.
*/
import { describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
medicationFindMany: vi.fn(),
intakeFindMany: vi.fn(),
}));
vi.mock("@/lib/db", () => ({
prisma: {
medication: { findMany: mocks.medicationFindMany },
medicationIntakeEvent: { findMany: mocks.intakeFindMany },
},
}));
import {
buildComplianceMedicationContext,
tallyComplianceFromLedger,
type ComplianceSchedule,
type IntakeEvent,
} from "@/lib/analytics/compliance";
import { buildScheduleAnchoredComplianceBuckets } from "@/lib/analytics/schedule-anchored-compliance";
const TZ = "UTC";
/** Late enough that every slot in the two-day window is past its cutoff. */
const NOW = new Date("2026-06-10T23:00:00.000Z");
const WINDOW_FROM = new Date("2026-06-09T00:00:00.000Z");
/** A twice-daily plan: 08:00 and 20:00, every day. */
const twiceDaily: ComplianceSchedule = {
windowStart: "08:00",
windowEnd: "09:00",
daysOfWeek: null,
rrule: "FREQ=DAILY",
rollingIntervalDays: null,
timesOfDay: ["08:00", "20:00"],
reminderGraceMinutes: null,
scheduleType: "SCHEDULED",
cyclicOnWeeks: null,
cyclicOffWeeks: null,
doseWindows: null,
};
const medication = {
id: "med-1",
userId: "user-1",
active: true,
startsOn: null,
endsOn: null,
oneShot: false,
createdAt: new Date("2026-01-01T00:00:00.000Z"),
schedules: [twiceDaily],
scheduleRevisions: [],
pauseEras: [],
};
/**
* Four expected slots across two days:
* 09.06. 08:00 — taken
* 09.06. 20:00 — SKIPPED (deliberate)
* 10.06. 08:00 — missed (no row at all)
* 10.06. 20:00 — taken
*
* Both engines must read 2 taken of 3 expected → 67 %. The pre-fix tile
* counted the skip as expected-and-missed: 2 of 4 → 50 %.
*/
const events: IntakeEvent[] = [
{
scheduledFor: new Date("2026-06-09T08:00:00.000Z"),
takenAt: new Date("2026-06-09T08:05:00.000Z"),
skipped: false,
autoMissed: false,
},
{
scheduledFor: new Date("2026-06-09T20:00:00.000Z"),
takenAt: null,
skipped: true,
autoMissed: false,
},
{
scheduledFor: new Date("2026-06-10T20:00:00.000Z"),
takenAt: new Date("2026-06-10T20:05:00.000Z"),
skipped: false,
autoMissed: false,
},
];
const EXPECTED_RATE = 67;
describe("compliance engines agree on a deliberately skipped dose", () => {
it("the ledger tally leaves the skip out of the denominator (2 taken of 3 expected)", () => {
const ctx = buildComplianceMedicationContext(medication, null, TZ);
const tally = tallyComplianceFromLedger(
events,
[twiceDaily],
ctx,
WINDOW_FROM,
NOW,
NOW,
);
expect(tally.taken).toBe(2);
expect(tally.skipped).toBe(1);
expect(tally.missed).toBe(1);
expect(tally.denominator).toBe(3);
expect(tally.rate).toBe(EXPECTED_RATE);
});
it("the schedule-anchored buckets read the same rate on the same fixture", async () => {
mocks.medicationFindMany.mockResolvedValue([medication]);
mocks.intakeFindMany.mockResolvedValue(
events.map((e) => ({ ...e, medicationId: medication.id })),
);
const buckets = await buildScheduleAnchoredComplianceBuckets(
"user-1",
2,
TZ,
NOW,
);
const scheduled = buckets.reduce((s, b) => s + b.scheduled, 0);
const taken = buckets.reduce((s, b) => s + b.taken, 0);
// The skipped slot is gone from the denominator, exactly as in the ledger.
expect(scheduled).toBe(3);
expect(taken).toBe(2);
expect(Math.min(100, Math.round((taken / scheduled) * 100))).toBe(
EXPECTED_RATE,
);
// The day carrying the skip reads 1 of 1, not 1 of 2.
const skipDay = buckets.find((b) => b.date === "2026-06-09");
expect(skipDay).toEqual({ date: "2026-06-09", scheduled: 1, taken: 1 });
});
it("a day whose every dose was skipped drops out of both denominators", async () => {
const allSkipped: IntakeEvent[] = [
{
scheduledFor: new Date("2026-06-10T08:00:00.000Z"),
takenAt: null,
skipped: true,
autoMissed: false,
},
{
scheduledFor: new Date("2026-06-10T20:00:00.000Z"),
takenAt: null,
skipped: true,
autoMissed: false,
},
];
const ctx = buildComplianceMedicationContext(medication, null, TZ);
const tally = tallyComplianceFromLedger(
allSkipped,
[twiceDaily],
ctx,
new Date("2026-06-10T00:00:00.000Z"),
NOW,
NOW,
);
expect(tally.denominator).toBe(0);
mocks.medicationFindMany.mockResolvedValue([medication]);
mocks.intakeFindMany.mockResolvedValue(
allSkipped.map((e) => ({ ...e, medicationId: medication.id })),
);
const buckets = await buildScheduleAnchoredComplianceBuckets(
"user-1",
1,
TZ,
NOW,
);
// `scheduled: 0` is what the chart filters out — no bar, not a 0 % bar.
expect(buckets).toEqual([{ date: "2026-06-10", scheduled: 0, taken: 0 }]);
});
});
@@ -12,11 +12,17 @@
* dashboard tile: both now consume this.
*
* `scheduled` is the canonical recurrence engine's expected-dose count for the
* day, summed across the user's active medications, so days the schedule
* expected a dose that the user missed pull the rate down — and the 7/30/90
* windows genuinely diverge with partial adherence. `taken` stays the count of
* taken (non-skipped, non-auto-missed) doses that day, capped at the day's
* expected count so a duplicate log can't push a day above 100%.
* day, summed across the user's active medications and MINUS the doses the
* user deliberately skipped, so days the schedule expected a dose that the
* user missed pull the rate down — and the 7/30/90 windows genuinely diverge
* with partial adherence. `taken` stays the count of taken (non-skipped,
* non-auto-missed) doses that day, capped at the day's expected count so a
* duplicate log can't push a day above 100%.
*
* The skip exclusion is what keeps this engine and the ledger tally
* (`tallyComplianceFromLedger`, the source of the rate on the medication card,
* the dose history and the doctor report) reporting ONE number: both treat a
* skip as a deliberate pause outside the denominator, never as a miss.
*
* Pure-ish over a single pinned `now` so a cached row is internally
* consistent. Bounded: one active-medications query + one window-events query,
@@ -147,6 +153,30 @@ export async function buildScheduleAnchoredComplianceBuckets(
);
if (scheduled === 0) continue;
const inDay = (at: Date) => at >= bounds.start && at < bounds.end;
// A deliberate skip is a pause, not a miss (the semantics the ledger
// tally has carried since v1.15.9 and the only reading the "consistent
// skip handling" contract admits). The skipped slot leaves the
// denominator entirely instead of being counted as expected-and-missed
// — otherwise the dashboard tile reported a lower rate than the card,
// the history view and the doctor report for the very same day. The
// count is capped at the expected slots so a stray skip row outside
// the minted grid cannot drive the denominator negative; a day whose
// every expected dose was skipped drops out of the buckets, exactly as
// it drops out of the ledger denominator.
//
// `skipped` outranks `autoMissed` here for the same reason it does in
// `reconstructDoseHistory`: the cron marks a forgotten dose
// `autoMissed`, so a row still carrying `skipped` is a real user
// decision.
const skippedThisDay = Math.min(
medEvents.filter((e) => e.skipped && inDay(e.scheduledFor)).length,
scheduled,
);
const expected = scheduled - skippedThisDay;
if (expected === 0) continue;
// Taken doses that landed in this day's window (non-skipped, non-auto-
// missed), capped at the expected count so a duplicate log can't push a
// single day above 100%.
@@ -155,13 +185,12 @@ export async function buildScheduleAnchoredComplianceBuckets(
e.takenAt !== null &&
!e.skipped &&
!e.autoMissed &&
e.scheduledFor >= bounds.start &&
e.scheduledFor < bounds.end,
inDay(e.scheduledFor),
).length;
const bucket = totals.get(key)!;
bucket.scheduled += scheduled;
bucket.taken += Math.min(takenThisDay, scheduled);
bucket.scheduled += expected;
bucket.taken += Math.min(takenThisDay, expected);
}
}