From 04361a9fe7f2a953e8064206794541c0a8e46286 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 3 Sep 2026 02:07:43 +0200 Subject: [PATCH] fix(medications): drop a medication with no schedule from every adherence surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `calculateCompliance` answers `rate: 100, totalExpected: 0` for a medication that expects no dose. Arithmetically honest, and a lie on a surface that reads the number as adherence — it also inflated the averages the AI prompts are built from. The doctor report has excluded both the PRN arm and the empty-schedule arm since it was written, with the reason beside it. The insights envelope, the blood-pressure gate and the medication-compliance snapshot filtered only PRN, and the empty schedule is reachable: `POST /api/medications` accepts `schedules ?? []` on a scheduled medication. Move both arms into one predicate, `expectsDoses`, and route all four surfaces through it so they cannot drift apart again. --- src/app/api/insights/comprehensive/route.ts | 9 +- src/lib/analytics/compliance/display.ts | 22 +++++ src/lib/doctor-report-helpers.ts | 7 +- .../__tests__/blood-pressure-status.test.ts | 73 +++++++++++++++++ .../medication-compliance-status.test.ts | 82 +++++++++++++++++++ src/lib/insights/blood-pressure-status.ts | 9 +- .../insights/medication-compliance-status.ts | 9 +- 7 files changed, 205 insertions(+), 6 deletions(-) diff --git a/src/app/api/insights/comprehensive/route.ts b/src/app/api/insights/comprehensive/route.ts index a3cf6e902..d73ba9d6f 100644 --- a/src/app/api/insights/comprehensive/route.ts +++ b/src/app/api/insights/comprehensive/route.ts @@ -17,6 +17,7 @@ import { import { buildComplianceMedicationContext, calculateCompliance, + expectsDoses, lastNonSkippedTakenAt, SCHEDULE_COMPLIANCE_SELECT, } from "@/lib/analytics/compliance"; @@ -365,7 +366,7 @@ export async function buildComprehensiveResponse(user: AuthedUser) { // ── Medication compliance ──────────────────────────────── // Bounded reads — left untouched per the directive. - const medications = await prisma.medication.findMany({ + const medicationRows = await prisma.medication.findMany({ // v1.16.11 — as-needed (PRN) medications never surface a compliance // rate (no expected doses). where: { userId, active: true, asNeeded: false }, @@ -379,6 +380,12 @@ export async function buildComprehensiveResponse(user: AuthedUser) { pauseEras: { select: { pausedAt: true, resumedAt: true } }, }, }); + // A medication with no schedule expects no dose, and `calculateCompliance` + // answers 100 % for that case. The `asNeeded: false` filter above catches + // only the PRN arm; the empty schedule is reachable on a scheduled + // medication and would otherwise report perfect adherence. + const medications = medicationRows.filter(expectsDoses); + const categoryMap = await getMedicationCategories( medications.map((m) => m.id), ); diff --git a/src/lib/analytics/compliance/display.ts b/src/lib/analytics/compliance/display.ts index fa379412f..47f7feb00 100644 --- a/src/lib/analytics/compliance/display.ts +++ b/src/lib/analytics/compliance/display.ts @@ -243,6 +243,28 @@ export function buildComplianceDisplay( * can be driven deterministically from a caller * that already pinned its own `now`. */ +/** + * Whether a medication expects any dose at all, and so has an adherence rate + * worth showing. + * + * `calculateCompliance` below answers `rate: 100, totalExpected: 0` for a + * medication with no schedule. That is arithmetically honest — nothing was + * expected, so nothing was missed — and it is a lie on every surface that + * reads the number as adherence. PRN is the obvious arm and every caller + * already filtered it; the empty schedule on a NON-PRN medication is the arm + * that got through, and it is reachable: `POST /api/medications` accepts + * `schedules ?? []` on a scheduled medication. The doctor report has excluded + * both arms since it was written, so the report never prints a fabricated + * 100 %; the same reason applies to every other surface, so the two arms live + * in one predicate rather than being re-derived at each call site. + */ +export function expectsDoses(medication: { + asNeeded: boolean; + schedules: readonly unknown[]; +}): boolean { + return !medication.asNeeded && medication.schedules.length > 0; +} + export function calculateCompliance( events: IntakeEvent[], schedules: ComplianceSchedule[], diff --git a/src/lib/doctor-report-helpers.ts b/src/lib/doctor-report-helpers.ts index c17e4aa6e..3c5e2c93d 100644 --- a/src/lib/doctor-report-helpers.ts +++ b/src/lib/doctor-report-helpers.ts @@ -21,6 +21,7 @@ import { userDayKey } from "@/lib/tz/resolver"; import { resolveCanonicalRecovery } from "@/lib/insights/derived/recovery-resolve"; import { buildComplianceMedicationContext, + expectsDoses, lastNonSkippedTakenAt, tallyComplianceFromLedger, } from "@/lib/analytics/compliance"; @@ -363,9 +364,9 @@ export function buildLedgerCompliance( const compliance: Record = {}; for (const med of medications) { // PRN / as-needed carry no schedule and no expected dose — excluded so - // the report never prints a fabricated 100 %. - if (med.asNeeded) continue; - if (med.schedules.length === 0) continue; + // the report never prints a fabricated 100 %. Both arms now live in the + // shared predicate, which is what the other adherence surfaces read. + if (!expectsDoses(med)) continue; const events = eventsByMedId.get(med.id) ?? []; const ctx = buildComplianceMedicationContext( diff --git a/src/lib/insights/__tests__/blood-pressure-status.test.ts b/src/lib/insights/__tests__/blood-pressure-status.test.ts index ec0017405..437c065da 100644 --- a/src/lib/insights/__tests__/blood-pressure-status.test.ts +++ b/src/lib/insights/__tests__/blood-pressure-status.test.ts @@ -260,3 +260,76 @@ describe("generateBloodPressureStatusForUser — token-leak hardening (v1.4.27 F expect(parsed.text).not.toContain("metric:"); }); }); + +// The BP-status gate reads its medications' adherence to decide what to say +// about a reading. `calculateCompliance` answers `rate: 100, totalExpected: 0` +// for a medication with no schedule, so a blood-pressure medication saved +// without one told the model the person was perfectly adherent — the exact +// fabrication the doctor report has excluded since it was written. +describe("generateBloodPressureStatusForUser — no rate without an expectation", () => { + const SCHEDULE = { + id: "sch-1", + windowStart: "07:00", + windowEnd: "08:00", + timesOfDay: ["07:00"], + daysOfWeek: null, + rrule: null, + rollingIntervalDays: null, + reminderGraceMinutes: null, + scheduleType: "SCHEDULED", + cyclicOnWeeks: null, + cyclicOffWeeks: null, + doseWindows: [], + }; + + function bpMed(id: string, name: string, schedules: unknown[]) { + return { + id, + name, + dose: "5mg", + active: true, + asNeeded: false, + createdAt: new Date(Date.now() - 60 * dayMs), + schedules, + scheduleRevisions: [], + pauseEras: [], + }; + } + + it("omits a blood-pressure medication that expects no dose", async () => { + const now = new Date(); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + dateOfBirth: null, + } as never); + vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null); + vi.mocked(prisma.measurement.findMany).mockResolvedValue([ + { type: "BLOOD_PRESSURE_SYS", value: 132, measuredAt: now }, + { type: "BLOOD_PRESSURE_DIA", value: 84, measuredAt: now }, + ] as never); + vi.mocked(prisma.medication.findMany).mockResolvedValue([ + bpMed("med-empty", "Unscheduled", []), + bpMed("med-real", "Amlodipine", [SCHEDULE]), + ] as never); + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue( + [] as never, + ); + vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.auditLog.create).mockResolvedValue({ + createdAt: new Date(), + } as never); + vi.mocked(getMedicationCategories).mockResolvedValue({ + "med-empty": "BLOOD_PRESSURE", + "med-real": "BLOOD_PRESSURE", + }); + + const captured: { userPrompt: string | null } = { userPrompt: null }; + stubCompletion('{"summary":"OK"}', captured); + + await generateBloodPressureStatusForUser("user-1", { locale: "en" }); + + const snapshot = JSON.parse(captured.userPrompt!.match(/\{[\s\S]*\}/)![0]); + const names = snapshot.bpMedications.map((m: { name: string }) => m.name); + expect(names).not.toContain("Unscheduled"); + expect(names).toContain("Amlodipine"); + }); +}); diff --git a/src/lib/insights/__tests__/medication-compliance-status.test.ts b/src/lib/insights/__tests__/medication-compliance-status.test.ts index 1e3a850b0..e480a3f65 100644 --- a/src/lib/insights/__tests__/medication-compliance-status.test.ts +++ b/src/lib/insights/__tests__/medication-compliance-status.test.ts @@ -360,3 +360,85 @@ describe("generateMedicationComplianceStatusForUser — token-leak hardening (v1 expect(parsed.medications[0].text).not.toContain("metric:"); }); }); + +// A medication with no schedule expects no dose. `calculateCompliance` answers +// `rate: 100, totalExpected: 0` for that case, and this surface averaged that +// 100 into `overall.averageCompliance30` and handed it to the model as the +// person's adherence. The doctor report has excluded the empty schedule +// alongside PRN since it was written — "so the report never prints a +// fabricated 100 %" — and the same reason applies wherever a rate is shown. +describe("generateMedicationComplianceStatusForUser — no rate without an expectation", () => { + function scheduleless(now: Date) { + return { + id: "med-empty", + name: "Unscheduled", + dose: "5mg", + active: true, + asNeeded: false, + createdAt: new Date(now.getTime() - 60 * dayMs), + schedules: [], + scheduleRevisions: [], + pauseEras: [], + }; + } + + it("keeps a schedule-less medication out of the snapshot and its average", async () => { + const now = new Date(); + const scheduled = { ...medFixture(now), asNeeded: false }; + const events = Array.from({ length: 30 }, (_, day) => { + const scheduledFor = new Date(now.getTime() - day * dayMs); + // Half the doses taken, so the real medication sits well below 100 %. + return { + medicationId: "med-1", + scheduledFor, + takenAt: day % 2 === 0 ? scheduledFor : null, + skipped: false, + }; + }); + + vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null); + vi.mocked(prisma.medication.findMany).mockResolvedValue([ + scheduled, + scheduleless(now), + ] as never); + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue( + events as never, + ); + vi.mocked(prisma.auditLog.create).mockResolvedValue({ + createdAt: new Date(), + } as never); + + const captured: { userPrompt: string | null } = { userPrompt: null }; + stubCompletion('{"summary":"OK","medications":[]}', captured); + + await generateMedicationComplianceStatusForUser("user-1", { locale: "en" }); + + const snapshot = JSON.parse(captured.userPrompt!.match(/\{[\s\S]*\}/)![0]); + const names = snapshot.medications.map((m: { name: string }) => m.name); + expect(names).not.toContain("Unscheduled"); + expect(names).toContain("Ramipril"); + expect(snapshot.overall.medicationCount).toBe(1); + // The fabricated 100 is no longer half of the average handed to the model. + expect(snapshot.overall.averageCompliance30).toBeLessThan(100); + }); + + it("reports no active medications when the only one has no schedule", async () => { + const now = new Date(); + vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null); + vi.mocked(prisma.medication.findMany).mockResolvedValue([ + scheduleless(now), + ] as never); + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue( + [] as never, + ); + + const outcome = (await generateMedicationComplianceStatusForUser("user-1", { + locale: "en", + })) as { summary: string | null; medications: unknown[] }; + + expect(outcome.medications).toEqual([]); + expect(outcome.summary).toContain("no active medications"); + // Nothing to assess, so no model call and no cache row either. + expect(runStatusCompletion).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/insights/blood-pressure-status.ts b/src/lib/insights/blood-pressure-status.ts index 0efd5f868..378988eea 100644 --- a/src/lib/insights/blood-pressure-status.ts +++ b/src/lib/insights/blood-pressure-status.ts @@ -24,6 +24,7 @@ import { import { buildComplianceMedicationContext, calculateCompliance, + expectsDoses, lastNonSkippedTakenAt, SCHEDULE_COMPLIANCE_SELECT, } from "@/lib/analytics/compliance"; @@ -324,7 +325,7 @@ export async function prepareBloodPressureStatusForUser( weightVsSystolicPairs, ); - const activeMedications = await prisma.medication.findMany({ + const activeMedicationRows = await prisma.medication.findMany({ // v1.16.11 — as-needed (PRN) medications never feed the BP-status // compliance gate (no expected doses, no rate). where: { userId, active: true, asNeeded: false }, @@ -339,6 +340,12 @@ export async function prepareBloodPressureStatusForUser( }, }); + // A medication with no schedule expects no dose, and `calculateCompliance` + // answers 100 % for that case. The `asNeeded: false` filter above catches + // only the PRN arm; the empty schedule is reachable on a scheduled + // medication and would otherwise report perfect adherence. + const activeMedications = activeMedicationRows.filter(expectsDoses); + const categoryMap = await getMedicationCategories( activeMedications.map((medication) => medication.id), ); diff --git a/src/lib/insights/medication-compliance-status.ts b/src/lib/insights/medication-compliance-status.ts index f7528dc2e..17236dd2d 100644 --- a/src/lib/insights/medication-compliance-status.ts +++ b/src/lib/insights/medication-compliance-status.ts @@ -9,6 +9,7 @@ import { buildComplianceMedicationContext, buildMedicationComplianceBundle, dailyComplianceRatesFromLedger, + expectsDoses, lastNonSkippedTakenAt, SCHEDULE_COMPLIANCE_SELECT, } from "@/lib/analytics/compliance"; @@ -208,7 +209,7 @@ export async function prepareMedicationComplianceStatusForUser( }; } - const medications = await prisma.medication.findMany({ + const medicationRows = await prisma.medication.findMany({ // v1.16.11 — as-needed (PRN) medications never surface a compliance // rate (no expected doses). where: { userId, active: true, asNeeded: false }, @@ -224,6 +225,12 @@ export async function prepareMedicationComplianceStatusForUser( orderBy: { name: "asc" }, }); + // A medication with no schedule expects no dose, and `calculateCompliance` + // answers 100 % for that case. The `asNeeded: false` filter above catches + // only the PRN arm; the empty schedule is reachable on a scheduled + // medication and would otherwise report perfect adherence. + const medications = medicationRows.filter(expectsDoses); + if (medications.length === 0) { return { phase: "served",