From 3e063f48ae4530e2d00866ba012c76ea9f7db508 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 10 Sep 2026 04:01:32 +0200 Subject: [PATCH] fix(medications): a skipped dose leaves the dashboard tile's denominator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two compliance engines disagreed about a deliberate skip. The dose-history ledger (`tallyComplianceFromLedger`, behind the medication card, the dose history, the doctor report and the Health Score pillar) has excluded a skipped dose from the denominator since v1.15.9 — a skip is a pause, not a miss. The schedule-anchored engine behind the dashboard tile and the adherence storyline built its denominator from the raw expected-slot count with no skip term, so it counted the same slot as expected-and-missed. A twice-daily day with one dose taken and one skipped read 100 % on the card and 50 % on the tile. The skip term now lands in the engine, not at the call sites: the day's expected count drops the doses the user skipped, capped at the minted slots, and a day whose every expected dose was skipped falls out of the buckets the same way it falls out of the ledger denominator. The schedule anchoring, the per-day buckets and the duplicate-log cap are untouched — only the skip treatment moved. `skipped` outranks `autoMissed`, mirroring `reconstructDoseHistory`, so a dose the auto-miss cron marked forgotten still counts against the rate. A new parity test feeds one fixture (twice daily, one taken, one skipped, one missed) to both engines and pins them to the same figure, so the class cannot come back through either side; both new cases were confirmed to fail against the previous behaviour. The tile-engine suite gains the corrected figure. --- .../intake/__tests__/route.test.ts | 43 ++++ .../__tests__/compliance-skip-parity.test.ts | 191 ++++++++++++++++++ .../analytics/schedule-anchored-compliance.ts | 47 ++++- 3 files changed, 272 insertions(+), 9 deletions(-) create mode 100644 src/lib/analytics/__tests__/compliance-skip-parity.test.ts diff --git a/src/app/api/medications/intake/__tests__/route.test.ts b/src/app/api/medications/intake/__tests__/route.test.ts index 5ca9c9025..1b63f0ed8 100644 --- a/src/app/api/medications/intake/__tests__/route.test.ts +++ b/src/app/api/medications/intake/__tests__/route.test.ts @@ -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", () => { diff --git a/src/lib/analytics/__tests__/compliance-skip-parity.test.ts b/src/lib/analytics/__tests__/compliance-skip-parity.test.ts new file mode 100644 index 000000000..21a9c57de --- /dev/null +++ b/src/lib/analytics/__tests__/compliance-skip-parity.test.ts @@ -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 }]); + }); +}); diff --git a/src/lib/analytics/schedule-anchored-compliance.ts b/src/lib/analytics/schedule-anchored-compliance.ts index 2801f0613..d78406369 100644 --- a/src/lib/analytics/schedule-anchored-compliance.ts +++ b/src/lib/analytics/schedule-anchored-compliance.ts @@ -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); } }