From 7bbb473070936d7a1d3bc911a55f516b008e594c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 7 Jun 2026 13:26:58 +0200 Subject: [PATCH] fix(medications): rebase dashboard compliance denominator onto the schedule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dashboard compliance tile set scheduled = COUNT(*) of logged intake rows, so its rate (taken / scheduled) sat at ~100% across every window regardless of real adherence — every logged row was both numerator and denominator, and the 7/30/90 windows never diverged. Anchor scheduled to the canonical recurrence engine's expected-dose count per day, summed across active medications, so days the schedule expected a dose the user missed pull the rate down and the windows genuinely diverge with partial adherence. taken is capped per day at the expected count so a duplicate log can't push a day above 100%. The response shape stays {date, scheduled, taken} for the existing chart consumer. Regenerate the OpenAPI contract for the new expected/missed count fields. --- docs/api/openapi.yaml | 26 +++ .../intake/__tests__/route.test.ts | 140 ++++++----- src/app/api/medications/intake/route.ts | 219 +++++++++++------- src/lib/openapi/routes.ts | 24 ++ 4 files changed, 275 insertions(+), 134 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index c3073b2a3..6307da425 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -8460,6 +8460,18 @@ components: type: integer minimum: 0 maximum: 9007199254740991 + expected: + type: integer + minimum: 0 + maximum: 9007199254740991 + description: v1.15.9 rate denominator over the short window (`taken + missed`); user skips are excluded. Render `taken / + expected · rate%`. + missed: + type: integer + minimum: 0 + maximum: 9007199254740991 + description: v1.15.9 doses counted against the rate over the short window — includes forgotten doses the auto-miss cron + flipped. streak: type: integer minimum: 0 @@ -8467,6 +8479,8 @@ components: required: - rate - taken + - expected + - missed - streak additionalProperties: false long: @@ -8480,9 +8494,21 @@ components: type: integer minimum: 0 maximum: 9007199254740991 + expected: + type: integer + minimum: 0 + maximum: 9007199254740991 + description: v1.15.9 rate denominator over the long window (`taken + missed`). + missed: + type: integer + minimum: 0 + maximum: 9007199254740991 + description: v1.15.9 doses counted against the rate over the long window. required: - rate - taken + - expected + - missed additionalProperties: false currentCycle: type: object diff --git a/src/app/api/medications/intake/__tests__/route.test.ts b/src/app/api/medications/intake/__tests__/route.test.ts index c257ee57e..520646f98 100644 --- a/src/app/api/medications/intake/__tests__/route.test.ts +++ b/src/app/api/medications/intake/__tests__/route.test.ts @@ -369,43 +369,59 @@ describe("POST /api/medications/intake", () => { }); }); -describe("v1.4.39 W-MED — compliance rollup read swap", () => { - it("reads the rollup tier when coverage is present", async () => { +describe("v1.15.9 — schedule-anchored compliance buckets (BUG #1)", () => { + // The dashboard tile's `scheduled` is now the canonical recurrence + // engine's expected-dose count per day (not the count of logged intake + // rows). With partial adherence the per-day rate `taken / scheduled` + // genuinely reflects taken-of-expected — it is NOT pinned at ~100%. + function activeDailyMed(createdAt: Date) { + return { + id: "m1", + userId: "user-1", + active: true, + startsOn: null, + endsOn: null, + oneShot: false, + createdAt, + schedules: [ + { + windowStart: "08:00", + windowEnd: "09:00", + daysOfWeek: null, + rrule: "FREQ=DAILY", + rollingIntervalDays: null, + timesOfDay: ["08:00"], + reminderGraceMinutes: null, + scheduleType: "SCHEDULED", + cyclicOnWeeks: null, + cyclicOffWeeks: null, + }, + ], + }; + } + + it("anchors `scheduled` to the schedule — a day with a dose due but none taken reads < 100%", async () => { vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - // QA F-H-01 (v1.4.39): coverage probe returns - // `{ rolled_days >= event_days }` so the route lands on the - // rollup tier. Match the trailing-7-day window. - vi.mocked(prisma.$queryRaw).mockResolvedValue([ - { rolled_days: BigInt(7), event_days: BigInt(7) }, + const createdAt = new Date(Date.now() - 60 * 86_400_000); + vi.mocked(prisma.medication.findMany).mockResolvedValue([ + activeDailyMed(createdAt), ] as never); - // v1.4.39.1 — anchor the seed row on the runtime's "today" so the - // test stays green across the calendar regardless of when it - // runs. The pre-fix shape hard-coded `2026-05-18`, which fell out - // of the trailing-7-day window on every subsequent wall-clock - // day and silently shifted the body.data tail off the seed. - // - // v1.4.49.4 — compute todayKey in the SAME zone the route uses - // (`readMedicationCompliance` → `userDayKey(now, safeTz)`), not in - // UTC. SESSION_OK carries no `timezone`, so the route falls - // through to `DEFAULT_TIMEZONE = "Europe/Berlin"`. The pre-fix - // UTC computation diverged from the route's Berlin computation by - // one calendar day during the 22:00-24:00 UTC window every night, - // causing the test to fail deterministically once the clock - // crossed midnight Berlin while still on the previous UTC day. - const todayKey = (() => { - const now = new Date(); - const fmt = new Intl.DateTimeFormat("en-CA", { - timeZone: "Europe/Berlin", - year: "numeric", - month: "2-digit", - day: "2-digit", - }); - // `en-CA` short-date format is `YYYY-MM-DD`, matching the - // route's `userDayKey` output (see `src/lib/tz/resolver.ts`). - return fmt.format(now); - })(); - vi.mocked(prisma.medicationComplianceRollup.findMany).mockResolvedValue([ - { day: todayKey, scheduled: 3, taken: 2, skipped: 1 }, + // Only ONE taken dose across the whole 7-day window. A logged-row + // denominator would read 1 scheduled / 1 taken = 100% on that one day + // and nothing elsewhere; the engine instead expects ~7 doses (one per + // day) and counts the six un-taken days as missed → an aggregate rate + // well below 100%. + const yesterday8 = new Date(); + yesterday8.setUTCDate(yesterday8.getUTCDate() - 1); + yesterday8.setUTCHours(8, 30, 0, 0); + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([ + { + medicationId: "m1", + scheduledFor: yesterday8, + takenAt: yesterday8, + skipped: false, + autoMissed: false, + }, ] as never); const res = await GET( @@ -418,35 +434,53 @@ describe("v1.4.39 W-MED — compliance rollup read swap", () => { data: Array<{ date: string; scheduled: number; taken: number }>; }; expect(body.data).toHaveLength(7); - const today = body.data[body.data.length - 1]; - expect(today.scheduled).toBe(3); - expect(today.taken).toBe(2); - // The legacy live aggregator must not have run when coverage is hot. - expect(prisma.medicationIntakeEvent.findMany).not.toHaveBeenCalled(); + // Engine expects a dose on (nearly) every day → total scheduled ≫ the + // single logged row, and total taken is just the one. + const totalScheduled = body.data.reduce((s, d) => s + d.scheduled, 0); + const totalTaken = body.data.reduce((s, d) => s + d.taken, 0); + expect(totalScheduled).toBeGreaterThanOrEqual(5); + expect(totalTaken).toBe(1); + // Aggregate rate is far from the degenerate ~100% the logged-row + // denominator produced. + expect(totalTaken / totalScheduled).toBeLessThan(0.5); }); - it("falls back to the live aggregator on coverage miss", async () => { + it("a fully-adherent daily med reads ~100% on every dosed day", async () => { vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - // QA F-H-01 (v1.4.39): partial coverage — events present but - // rollups missing — forces fall-through to the live aggregator. - vi.mocked(prisma.$queryRaw).mockResolvedValue([ - { rolled_days: BigInt(0), event_days: BigInt(7) }, + const createdAt = new Date(Date.now() - 60 * 86_400_000); + vi.mocked(prisma.medication.findMany).mockResolvedValue([ + activeDailyMed(createdAt), ] as never); - vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([ - { - scheduledFor: new Date(), - takenAt: new Date(), + // One taken dose per day across the window. + const events = []; + for (let d = 0; d < 8; d++) { + const at = new Date(); + at.setUTCDate(at.getUTCDate() - d); + at.setUTCHours(8, 10, 0, 0); + events.push({ + medicationId: "m1", + scheduledFor: at, + takenAt: at, skipped: false, - }, - ] as never); + autoMissed: false, + }); + } + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue( + events as never, + ); const res = await GET( new NextRequest( "http://localhost/api/medications/intake?scope=compliance&days=7", ), ); - expect(res.status).toBe(200); - expect(prisma.medicationIntakeEvent.findMany).toHaveBeenCalled(); + const body = (await res.json()) as { + data: Array<{ date: string; scheduled: number; taken: number }>; + }; + // Every dosed day has taken === scheduled → 100% throughout. + for (const day of body.data) { + if (day.scheduled > 0) expect(day.taken).toBe(day.scheduled); + } }); }); diff --git a/src/app/api/medications/intake/route.ts b/src/app/api/medications/intake/route.ts index 7e0792d2e..0bd2d72fc 100644 --- a/src/app/api/medications/intake/route.ts +++ b/src/app/api/medications/intake/route.ts @@ -28,13 +28,13 @@ import { auditLog } from "@/lib/auth/audit"; import { userDayKey, DEFAULT_TIMEZONE } from "@/lib/tz/resolver"; import { cached, caches, type ServerCache } from "@/lib/cache/server-cache"; import { invalidateUserMedications } from "@/lib/cache/invalidate"; +import { recomputeMedicationComplianceForEvent } from "@/lib/rollups/medication-compliance-rollups"; import { - readMedicationCompliance, - hasMedicationComplianceCoverage, - recomputeMedicationComplianceForEvent, - enqueueUserMedicationComplianceBackfill, - type ComplianceBucket as RollupComplianceBucket, -} from "@/lib/rollups/medication-compliance-rollups"; + buildComplianceMedicationContext, + expectedSlotCountForDay, + lastNonSkippedTakenAt, +} from "@/lib/analytics/compliance"; +import { getUserTodayBounds } from "@/lib/timezone"; import { projectTodayIntakesAndRecompute } from "@/lib/medications/scheduling/project-today-intakes"; import { resolveInjectionSiteForWrite } from "@/lib/medications/injection-site-write"; import { @@ -167,10 +167,16 @@ export const GET = apiHandler(async (request: NextRequest) => { ? "skipped" : e.takenAt ? "taken" - : e.medication.snoozedUntil && - e.medication.snoozedUntil > new Date() - ? "snoozed" - : "pending", + : // v1.15.9 — a never-acted dose the auto-miss cron flipped is a + // terminal MISS, not a perpetual "pending". (Today's freshly + // projected rows are < 24 h old so this rarely fires here, but + // a stale row surfacing in the window reads honestly.) + e.autoMissed + ? "missed" + : e.medication.snoozedUntil && + e.medication.snoozedUntil > new Date() + ? "snoozed" + : "pending", snoozedUntil: e.medication.snoozedUntil?.toISOString() ?? null, })), ); @@ -182,15 +188,18 @@ export const GET = apiHandler(async (request: NextRequest) => { // move). Cache key carries the userTz so a user who changes timezone // doesn't read another tz's bucketing. // - // v1.4.39 W-MED — read path now consumes the persistent - // `medication_compliance_rollups` tier. A coverage probe falls back - // to the legacy live aggregator when the user has intake events but - // zero rollup rows; the fallback fires a boot-backfill enqueue in the - // background so the next request hits the rollup tier. + // v1.15.9 — `scheduled` is now the SCHEDULE-ANCHORED expected-dose count + // per day (the canonical recurrence engine), NOT the count of logged + // intake rows. The old rollup-backed path set `scheduled = COUNT(*)` of + // intake rows, so the dashboard tile's rate (`taken / scheduled`) was ~100% + // across every window regardless of real adherence — every logged row was + // both numerator and denominator. Anchoring `scheduled` to the schedule + // makes the rate genuinely reflect taken-of-expected and lets the 7/30/90 + // windows diverge with partial adherence. const result = await cached( caches.medicationsIntake as ServerCache, `${user.id}|compliance|${days}|${userTz}`, - () => readComplianceBucketsWithFallback(user.id, days, userTz), + () => buildScheduleAnchoredComplianceBuckets(user.id, days, userTz), annotate, ); @@ -202,50 +211,6 @@ export const GET = apiHandler(async (request: NextRequest) => { return apiSuccess(result); }); -/** - * v1.4.39 W-MED — read from the persistent rollup tier when covered, - * fall through to the legacy live aggregator on coverage miss. The - * fallback fires the boot backfill in the background so subsequent - * requests hit the rollup path; the current request still returns a - * correct response built from the raw events. - */ -async function readComplianceBucketsWithFallback( - userId: string, - days: number, - userTz: string, -): Promise { - const covered = await hasMedicationComplianceCoverage(userId, days, userTz); - if (covered) { - const buckets = await readMedicationCompliance(userId, days, userTz); - annotate({ - meta: { - medication_compliance_path: "rollup", - medication_compliance_days: days, - }, - }); - return buckets satisfies RollupComplianceBucket[]; - } - - // Coverage miss — the legacy aggregator stays correct over an empty - // rollup window. Fire a user-scoped backfill in the background so - // the next request lands on the rollup tier; the current request - // still returns the live-derived buckets. - // - // QA F-SEC-M-01 (v1.4.39): pre-fix this fired the cluster-wide - // `enqueueBootTimeMedicationComplianceBackfill` on every coverage- - // miss request, opening a soft-DoS amplifier (every authenticated - // user could drive a multi-tenant `LEFT JOIN` scan on each hit). - // The user-scoped helper enqueues exactly the caller's account. - void enqueueUserMedicationComplianceBackfill(userId); - annotate({ - meta: { - medication_compliance_path: "live-fallback", - medication_compliance_days: days, - }, - }); - return buildComplianceBuckets(userId, days, userTz); -} - interface ComplianceBucket { date: string; scheduled: number; @@ -253,40 +218,132 @@ interface ComplianceBucket { } /** - * v1.4.34 IW-G — pulled out of the GET handler so `cached()` can wrap - * the per-user compliance aggregation. Pure function over the user's - * intake events + the requested window; deterministic given a fixed - * "now" wall-clock (we anchor on `Date.now()` once at call time so a - * 15-minute cached row stays internally consistent). + * v1.15.9 — schedule-anchored per-day compliance buckets for the dashboard + * tile. Replaces the rollup/legacy path whose `scheduled` was the count of + * logged intake rows (BUG #1: rate `taken / scheduled` ≈ 100% across every + * window because every logged row was both numerator and denominator). + * + * `scheduled` is now 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%. + * + * Pure-ish over a single pinned `now` so a 15-minute cached row is internally + * consistent. Bounded: one active-medications query + one window-events query, + * then per-medication-per-day engine expansion over the trailing window. */ -async function buildComplianceBuckets( +async function buildScheduleAnchoredComplianceBuckets( userId: string, days: number, userTz: string, ): Promise { - const nowMs = Date.now(); + const now = new Date(); + const nowMs = now.getTime(); const start = new Date(nowMs - days * 86_400_000); + + const medications = await prisma.medication.findMany({ + where: { userId, active: true }, + include: { schedules: true }, + }); + const events = await prisma.medicationIntakeEvent.findMany({ // v1.7.0 sync — exclude tombstoned rows from the compliance buckets. where: { userId, deletedAt: null, scheduledFor: { gte: start } }, - select: { scheduledFor: true, takenAt: true, skipped: true }, + select: { + medicationId: true, + scheduledFor: true, + takenAt: true, + skipped: true, + autoMissed: true, + }, }); - const buckets = new Map(); - for (let i = 0; i < days; i++) { - const d = new Date(nowMs - i * 86_400_000); - buckets.set(userDayKey(d, userTz), { scheduled: 0, taken: 0 }); - } - for (const e of events) { - const key = userDayKey(e.scheduledFor, userTz); - const bucket = buckets.get(key); - if (!bucket) continue; - bucket.scheduled += 1; - if (e.takenAt && !e.skipped) bucket.taken += 1; + // Pre-compute each local day's [start, end) bounds + key, oldest → newest. + const dayKeys: string[] = []; + const dayBounds = new Map(); + for (let i = days - 1; i >= 0; i--) { + const representative = new Date(nowMs - i * 86_400_000 - 12 * 60 * 60 * 1000); + const { start: dayStart, end: dayEndInclusive } = getUserTodayBounds( + representative, + userTz, + ); + const key = userDayKey(dayStart, userTz); + if (dayBounds.has(key)) continue; + dayKeys.push(key); + dayBounds.set(key, { + start: dayStart, + end: new Date(dayEndInclusive.getTime() + 1), // half-open [start, end) + }); } - return [...buckets.entries()] - .map(([date, v]) => ({ date, scheduled: v.scheduled, taken: v.taken })) + const totals = new Map(); + for (const key of dayKeys) totals.set(key, { scheduled: 0, taken: 0 }); + + // Group events by medication so each med's engine context is built once. + const eventsByMed = new Map< + string, + { scheduledFor: Date; takenAt: Date | null; skipped: boolean; autoMissed: boolean }[] + >(); + for (const e of events) { + const list = eventsByMed.get(e.medicationId) ?? []; + list.push({ + scheduledFor: e.scheduledFor, + takenAt: e.takenAt, + skipped: e.skipped, + autoMissed: e.autoMissed, + }); + eventsByMed.set(e.medicationId, list); + } + + for (const med of medications) { + if (med.schedules.length === 0) continue; + const medEvents = eventsByMed.get(med.id) ?? []; + const ctx = buildComplianceMedicationContext( + med, + lastNonSkippedTakenAt(medEvents), + userTz, + ); + + for (const key of dayKeys) { + const bounds = dayBounds.get(key)!; + // Skip days before the medication existed so a young med doesn't paint + // missed-denominator days it could not have been dosed on. + if (bounds.end <= med.createdAt) continue; + + const scheduled = expectedSlotCountForDay( + med.schedules, + bounds.start, + bounds.end, + ctx, + medEvents, + ); + if (scheduled === 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%. + const takenThisDay = medEvents.filter( + (e) => + e.takenAt !== null && + !e.skipped && + !e.autoMissed && + e.scheduledFor >= bounds.start && + e.scheduledFor < bounds.end, + ).length; + + const bucket = totals.get(key)!; + bucket.scheduled += scheduled; + bucket.taken += Math.min(takenThisDay, scheduled); + } + } + + return dayKeys + .map((date) => { + const v = totals.get(date)!; + return { date, scheduled: v.scheduled, taken: v.taken }; + }) .sort((a, b) => a.date.localeCompare(b.date)); } diff --git a/src/lib/openapi/routes.ts b/src/lib/openapi/routes.ts index f33c09e93..f6e44b31a 100644 --- a/src/lib/openapi/routes.ts +++ b/src/lib/openapi/routes.ts @@ -1274,11 +1274,35 @@ const complianceDisplay = z short: z.object({ rate: z.number().int().min(0).max(100), taken: z.number().int().nonnegative(), + expected: z + .number() + .int() + .nonnegative() + .describe( + "v1.15.9 rate denominator over the short window (`taken + missed`); user skips are excluded. Render `taken / expected · rate%`.", + ), + missed: z + .number() + .int() + .nonnegative() + .describe( + "v1.15.9 doses counted against the rate over the short window — includes forgotten doses the auto-miss cron flipped.", + ), streak: z.number().int().nonnegative(), }), long: z.object({ rate: z.number().int().min(0).max(100), taken: z.number().int().nonnegative(), + expected: z + .number() + .int() + .nonnegative() + .describe("v1.15.9 rate denominator over the long window (`taken + missed`)."), + missed: z + .number() + .int() + .nonnegative() + .describe("v1.15.9 doses counted against the rate over the long window."), }), currentCycle: z .object({