From fda0e504f069fcce1f826acc52363b2efe624d80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 2 Jul 2026 00:16:03 +0200 Subject: [PATCH] fix(insights): compute the adherence storyline from the schedule-anchored engine The Coach adherence storyline derived its rate from the raw coverage rollup, whose denominator counts logged slots rather than scheduled doses, so a user who logged only taken doses read ~100% and the dip gate suppressed a real decline. Extract the dashboard tile's schedule-anchored, cadence-aware compliance into one shared engine and route both surfaces through it, so Coach and the dashboard report the same number. --- src/app/api/medications/intake/route.ts | 168 +-------------- .../analytics/schedule-anchored-compliance.ts | 171 +++++++++++++++ .../adherence-storyline-engine-parity.test.ts | 196 ++++++++++++++++++ .../insights/derived/adherence-storyline.ts | 18 +- 4 files changed, 387 insertions(+), 166 deletions(-) create mode 100644 src/lib/analytics/schedule-anchored-compliance.ts create mode 100644 src/lib/insights/derived/__tests__/adherence-storyline-engine-parity.test.ts diff --git a/src/app/api/medications/intake/route.ts b/src/app/api/medications/intake/route.ts index d50e5766c..fb7f2bd4c 100644 --- a/src/app/api/medications/intake/route.ts +++ b/src/app/api/medications/intake/route.ts @@ -25,16 +25,15 @@ import { import { annotate } from "@/lib/logging/context"; import { prisma } from "@/lib/db"; import { auditLog } from "@/lib/auth/audit"; -import { userDayKey, DEFAULT_TIMEZONE } from "@/lib/tz/resolver"; +import { 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 { - buildComplianceMedicationContext, - expectedSlotCountForDay, - lastNonSkippedTakenAt, -} from "@/lib/analytics/compliance"; -import { getUserTodayBounds, startOfLocalDayInTz } from "@/lib/tz/local-day"; + buildScheduleAnchoredComplianceBuckets, + type ScheduleAnchoredComplianceBucket, +} from "@/lib/analytics/schedule-anchored-compliance"; +import { startOfLocalDayInTz } from "@/lib/tz/local-day"; import { projectTodayIntakesAndRecompute } from "@/lib/medications/scheduling/project-today-intakes"; import { applyCanonicalSlotWrite, @@ -204,7 +203,7 @@ export const GET = apiHandler(async (request: NextRequest) => { // 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, + caches.medicationsIntake as ServerCache, `${user.id}|compliance|${days}|${userTz}`, () => buildScheduleAnchoredComplianceBuckets(user.id, days, userTz), annotate, @@ -218,161 +217,6 @@ export const GET = apiHandler(async (request: NextRequest) => { return apiSuccess(result); }); -interface ComplianceBucket { - date: string; - scheduled: number; - taken: number; -} - -/** - * 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 buildScheduleAnchoredComplianceBuckets( - userId: string, - days: number, - userTz: string, -): Promise { - 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, - // v1.16.3 — archived schedule eras for era-aware expected counts. - scheduleRevisions: { orderBy: { validFrom: "asc" } }, - // v1.25 H-MED1 — pause eras so paused days drop out of the denominator. - pauseEras: { select: { pausedAt: true, resumedAt: 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: { - medicationId: true, - scheduledFor: true, - takenAt: true, - skipped: true, - autoMissed: true, - }, - }); - - // Pre-compute each local day's [start, end) bounds + key, oldest → newest. - // Each day's representative instant anchors on the user's LOCAL NOON of - // the current day and steps back in 24-hour increments: noon stays inside - // the intended local day across DST shifts (23/25-hour days), and — - // unlike the previous `now − 12 h` anchor — it cannot slip into - // yesterday when the user's local time is before noon, which silently - // dropped TODAY from the buckets for every pre-noon read. - const { start: todayStart } = getUserTodayBounds(now, userTz); - const todayNoonMs = todayStart.getTime() + 12 * 60 * 60 * 1000; - const dayKeys: string[] = []; - const dayBounds = new Map(); - for (let i = days - 1; i >= 0; i--) { - const representative = new Date(todayNoonMs - i * 86_400_000); - 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) - }); - } - - 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)); -} - export const POST = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); diff --git a/src/lib/analytics/schedule-anchored-compliance.ts b/src/lib/analytics/schedule-anchored-compliance.ts new file mode 100644 index 000000000..da736adab --- /dev/null +++ b/src/lib/analytics/schedule-anchored-compliance.ts @@ -0,0 +1,171 @@ +/** + * v1.26.0 SEAM-N3 — the ONE schedule-anchored, cadence-aware daily + * compliance computation. + * + * Extracted verbatim from the `GET /api/medications/intake?scope=compliance` + * dashboard-tile handler so every surface that surfaces a "taken of expected" + * adherence number reads through the SAME engine instead of the raw coverage + * rollup (`readMedicationCompliance`), whose `scheduled` counts LOGGED intake + * slots — a denominator that collapses to ~100% for a user who logs only the + * doses they took and never mints `takenAt:null` reminder rows for the ones + * they missed. See the adherence storyline (`adherence-storyline.ts`) and the + * 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%. + * + * Pure-ish over a single pinned `now` so a 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. + */ +import { prisma } from "@/lib/db"; +import { + buildComplianceMedicationContext, + expectedSlotCountForDay, + lastNonSkippedTakenAt, +} from "@/lib/analytics/compliance"; +import { getUserTodayBounds } from "@/lib/tz/local-day"; +import { userDayKey } from "@/lib/tz/resolver"; + +export interface ScheduleAnchoredComplianceBucket { + /** YYYY-MM-DD in the user's tz. */ + date: string; + scheduled: number; + taken: number; +} + +export async function buildScheduleAnchoredComplianceBuckets( + userId: string, + days: number, + userTz: string, + now: Date = new Date(), +): Promise { + 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, + // v1.16.3 — archived schedule eras for era-aware expected counts. + scheduleRevisions: { orderBy: { validFrom: "asc" } }, + // v1.25 H-MED1 — pause eras so paused days drop out of the denominator. + pauseEras: { select: { pausedAt: true, resumedAt: 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: { + medicationId: true, + scheduledFor: true, + takenAt: true, + skipped: true, + autoMissed: true, + }, + }); + + // Pre-compute each local day's [start, end) bounds + key, oldest → newest. + // Each day's representative instant anchors on the user's LOCAL NOON of + // the current day and steps back in 24-hour increments: noon stays inside + // the intended local day across DST shifts (23/25-hour days), and — + // unlike the previous `now − 12 h` anchor — it cannot slip into + // yesterday when the user's local time is before noon, which silently + // dropped TODAY from the buckets for every pre-noon read. + const { start: todayStart } = getUserTodayBounds(now, userTz); + const todayNoonMs = todayStart.getTime() + 12 * 60 * 60 * 1000; + const dayKeys: string[] = []; + const dayBounds = new Map(); + for (let i = days - 1; i >= 0; i--) { + const representative = new Date(todayNoonMs - i * 86_400_000); + 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) + }); + } + + 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/insights/derived/__tests__/adherence-storyline-engine-parity.test.ts b/src/lib/insights/derived/__tests__/adherence-storyline-engine-parity.test.ts new file mode 100644 index 000000000..ff5fc0e81 --- /dev/null +++ b/src/lib/insights/derived/__tests__/adherence-storyline-engine-parity.test.ts @@ -0,0 +1,196 @@ +/** + * v1.26.0 SEAM-N3 — the adherence storyline must read the SAME + * schedule-anchored, cadence-aware adherence number the dashboard tile does. + * + * The regression this pins: the storyline used to derive its adherence % from + * `readMedicationCompliance`, the RAW coverage rollup whose `scheduled` counts + * LOGGED intake slots. A user who logs only the doses they TOOK — never + * minting `takenAt:null` reminder rows for the doses they missed — therefore + * read ~100 % adherence (every logged row was both numerator and denominator), + * and the dip gate (`adherencePct > 80 → null`) wrongly suppressed the whole + * storyline. Anchoring on the recurrence engine's EXPECTED-dose count makes the + * missed days pull the rate below 100 %. + * + * The real compliance engine (`@/lib/analytics/compliance`) and the shared + * `buildScheduleAnchoredComplianceBuckets` run unmocked here — only the DB and + * the vital-series reader are stubbed — so the test exercises the actual + * expected-dose expansion, not a re-mock of it. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + medicationFindMany: vi.fn(), + intakeFindMany: vi.fn(), + probeRollupCoverage: vi.fn(), + readDayMeanSeries: vi.fn(), +})); + +vi.mock("@/lib/db", () => ({ + prisma: { + medication: { findMany: mocks.medicationFindMany }, + medicationIntakeEvent: { findMany: mocks.intakeFindMany }, + }, +})); + +vi.mock("@/lib/rollups/measurement-coverage", () => ({ + probeRollupCoverage: mocks.probeRollupCoverage, +})); + +vi.mock("@/lib/insights/derived/baseline", () => ({ + readDayMeanSeries: mocks.readDayMeanSeries, +})); + +import { buildAdherenceStoryline } from "@/lib/insights/derived/adherence-storyline"; +import { buildScheduleAnchoredComplianceBuckets } from "@/lib/analytics/schedule-anchored-compliance"; + +const DAY_MS = 86_400_000; +const NOW = new Date("2026-06-21T12:00:00.000Z"); +const TZ = "UTC"; + +/** A legacy daily schedule — one expected dose/day at 08:00, every day. */ +const dailySchedule = { + id: "sched-1", + windowStart: "08:00", + windowEnd: "09:00", + daysOfWeek: null, + timesOfDay: [], + rrule: null, + rollingIntervalDays: null, + reminderGraceMinutes: null, + scheduleType: "SCHEDULED", + cyclicOnWeeks: null, + cyclicOffWeeks: null, + doseWindows: null, +}; + +const medication = { + id: "med-1", + name: "ramipril", + treatmentClass: "GENERIC", + active: true, + oneShot: false, + startsOn: null, + endsOn: null, + // Created well before the window so no day is skipped for being pre-creation. + createdAt: new Date("2024-01-01T00:00:00.000Z"), + schedules: [dailySchedule], + scheduleRevisions: [], + pauseEras: [], +}; + +/** ISO 08:00 on a given YYYY-MM-DD. */ +const at8 = (day: string) => new Date(`${day}T08:00:00.000Z`); + +beforeEach(() => { + mocks.medicationFindMany.mockReset(); + mocks.intakeFindMany.mockReset(); + mocks.probeRollupCoverage.mockReset(); + mocks.readDayMeanSeries.mockReset(); + + // The same medication row satisfies both call sites: the storyline's + // `select: { name, treatmentClass }` and the engine's `include: {...}`. + mocks.medicationFindMany.mockResolvedValue([medication]); + + // Only-taken intakes: 7 taken doses across 14 scheduled days. The 7 missed + // days carry NO row at all (the user never minted a takenAt:null miss row). + const takenDays = [ + "2026-06-08", + "2026-06-10", + "2026-06-12", + "2026-06-14", + "2026-06-16", + "2026-06-18", + "2026-06-20", + ]; + mocks.intakeFindMany.mockResolvedValue( + takenDays.map((d) => ({ + medicationId: "med-1", + scheduledFor: at8(d), + takenAt: new Date(`${d}T08:05:00.000Z`), + skipped: false, + autoMissed: false, + })), + ); + + mocks.probeRollupCoverage.mockResolvedValue(new Map()); + + // Vital before/after: 8 prior days ~124, 8 recent days ~134 — a material + // move on a ~7.4 robust spread, so a storyline is produced once the dip + // gate lets it through. + const priorDays: { day: string; mean: number }[] = []; + for (let i = 0; i < 8; i++) { + const d = new Date( + new Date("2026-05-25T00:00:00.000Z").getTime() + i * DAY_MS, + ); + priorDays.push({ day: d.toISOString().slice(0, 10), mean: 124 }); + } + const recentDays: { day: string; mean: number }[] = []; + for (let i = 0; i < 8; i++) { + const d = new Date( + new Date("2026-06-08T00:00:00.000Z").getTime() + i * DAY_MS, + ); + recentDays.push({ day: d.toISOString().slice(0, 10), mean: 134 }); + } + mocks.readDayMeanSeries.mockResolvedValue({ + points: [...priorDays, ...recentDays], + source: "live", + }); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("adherence storyline ↔ dashboard-tile engine parity", () => { + it("derives adherence from the schedule-anchored engine (missed doses pull it below 100)", async () => { + // The dashboard-tile number, straight from the shared engine. + const buckets = await buildScheduleAnchoredComplianceBuckets( + "user-1", + 14, + TZ, + NOW, + ); + let scheduled = 0; + let taken = 0; + for (const b of buckets) { + if (b.scheduled > 0) { + scheduled += b.scheduled; + taken += b.taken; + } + } + // Sanity: 14 expected days, 7 taken → genuinely below 100 %. The raw + // coverage rollup would have read scheduled == taken == 7 → 100 %. + expect(scheduled).toBeGreaterThan(taken); + const dashboardPct = (100 * taken) / scheduled; + expect(dashboardPct).toBeLessThan(100); + + // The storyline must surface (the dip gate no longer wrongly suppresses it) + // and its adherence number must equal the dashboard-tile number. + const story = await buildAdherenceStoryline("user-1", TZ, NOW); + expect(story).not.toBeNull(); + expect(story!.adherencePct).toBeLessThan(100); + expect(story!.adherencePct).toBe(Math.round(dashboardPct)); + }); + + it("still suppresses the storyline when adherence is genuinely high (dip gate honoured)", async () => { + // Log a taken dose on EVERY one of the 14 scheduled days → ~100 %. + const allDays: { day: string }[] = []; + for (let i = 0; i < 14; i++) { + const d = new Date(NOW.getTime() - i * DAY_MS); + allDays.push({ day: d.toISOString().slice(0, 10) }); + } + mocks.intakeFindMany.mockResolvedValue( + allDays.map(({ day }) => ({ + medicationId: "med-1", + scheduledFor: at8(day), + takenAt: new Date(`${day}T08:05:00.000Z`), + skipped: false, + autoMissed: false, + })), + ); + + const story = await buildAdherenceStoryline("user-1", TZ, NOW); + // High adherence → the dip gate (adherencePct > 80) returns null. + expect(story).toBeNull(); + }); +}); diff --git a/src/lib/insights/derived/adherence-storyline.ts b/src/lib/insights/derived/adherence-storyline.ts index d2c4f0575..abc06010d 100644 --- a/src/lib/insights/derived/adherence-storyline.ts +++ b/src/lib/insights/derived/adherence-storyline.ts @@ -26,7 +26,8 @@ import type { MeasurementType } from "@/generated/prisma/client"; import { prisma } from "@/lib/db"; import { probeRollupCoverage } from "@/lib/rollups/measurement-coverage"; import { readDayMeanSeries } from "@/lib/insights/derived/baseline"; -import { readMedicationCompliance } from "@/lib/rollups/medication-compliance-rollups"; +import { buildScheduleAnchoredComplianceBuckets } from "@/lib/analytics/schedule-anchored-compliance"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/resolver"; import { inferMedTargetClass, MED_TARGET_MAP, @@ -171,11 +172,20 @@ export async function buildAdherenceStoryline( .filter((c): c is { label: string; cls: MedTargetClass } => c.cls !== null); if (candidates.length === 0) return null; - // 2. Overall recent adherence (slot-level, cadence-aware via the rollup). - const buckets = await readMedicationCompliance( + // 2. Overall recent adherence — schedule-anchored + cadence-aware, the SAME + // engine the dashboard tile reads (`buildScheduleAnchoredComplianceBuckets`). + // v1.26.0 SEAM-N3 — this replaced `readMedicationCompliance`, whose + // `scheduled` was a RAW coverage count of LOGGED intake slots: a user who + // logs only the doses they took (never minting `takenAt:null` reminder + // rows for missed ones) read ~100% adherence, and the dip gate below + // (adherencePct > 80 → null) wrongly suppressed the storyline. The + // schedule-anchored `scheduled` is the recurrence engine's EXPECTED-dose + // count, so genuinely missed doses pull the rate down. One engine, one + // number — the storyline's % now equals the dashboard tile's %. + const buckets = await buildScheduleAnchoredComplianceBuckets( userId, ADHERENCE_WINDOW_DAYS, - timezone ?? "Europe/Berlin", + timezone ?? DEFAULT_TIMEZONE, now, ); let scheduled = 0;