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); } }