From 8e50a9b4cd0d4bff2fe2e328e46241efdd2218d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 14 Jun 2026 13:17:48 +0200 Subject: [PATCH] fix(doctor-report): compute medication adherence from the dose ledger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The doctor-report PDF and FHIR export tallied raw intake rows — taken when takenAt was set, else skipped, else missed, with every row counting toward the denominator. That double-counted cross-source duplicate rows (a reminder row and an API row for the same slot both read as taken), ignored band timing (a pre-window or ad-hoc take counted as a plain take, a pending row counted as a miss regardless of the cutoff), and honoured no cadence, so off-week rolling or cyclic days could enter the tally. A clinician comparing the printed adherence % against the app's medication detail page could see two different numbers for the same drug. Route the report through the same dose-ledger compliance the detail page uses: per scheduled medication, mint the cadence-aware bands over the report window and tally taken / missed / skipped from the unified dose-history ledger, with the denominator being taken + missed so the renderers' taken/total rate equals the ledger rate. PRN / as-needed medications stay excluded. The compliance assembly is extracted into a pure, unit-tested `buildLedgerCompliance` helper; the medication query now loads the archived schedule eras and the report reads the user timezone so past days attribute against the schedule that was live then. --- src/lib/__tests__/doctor-report-data.test.ts | Bin 5805 -> 10248 bytes src/lib/analytics/compliance.ts | 2 +- src/lib/doctor-report-data.ts | 173 ++++++++++++++++--- 3 files changed, 154 insertions(+), 21 deletions(-) diff --git a/src/lib/__tests__/doctor-report-data.test.ts b/src/lib/__tests__/doctor-report-data.test.ts index 7047219ad2a93fc92017400ee627a90a0120d1d4..a1fae17148cb5765f054afeb688a737d98baa530 100644 GIT binary patch literal 10248 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 } +/** + * v1.17 W1a — the medication row shape the ledger compliance builder needs. + * Carries the cadence-engine context fields (course window + creation + + * archived schedule eras) so the same band minter the detail page uses can + * reconstruct expected slots. + */ +export interface DoctorReportComplianceMedication { + id: string; + name: string; + asNeeded: boolean; + startsOn: Date | null; + endsOn: Date | null; + oneShot: boolean; + createdAt: Date; + schedules: ComplianceSchedule[]; + scheduleRevisions?: ScheduleRevisionLike[]; +} + +/** A window-bounded intake row keyed to its medication. */ +export interface DoctorReportComplianceIntake extends IntakeEvent { + medicationId: string; +} + +/** + * v1.17 W1a — doctor-report medication adherence through the dose-ledger + * authority. + * + * The previous implementation tallied RAW intake rows: `taken = takenAt != null`, + * else `skipped`, else `missed`, with the denominator being every row. That + * double-counted cross-source duplicate rows (a REMINDER row + an API row for + * the same slot both counted as taken), ignored band timing (a pre-window or + * ad-hoc take counted as a plain "taken", a pending row counted as "missed" + * regardless of the miss cutoff), and honoured no cadence (rolling / cyclic / + * RRULE off-weeks could enter the tally). A clinician comparing the PDF % to + * the app detail page could see two different adherence numbers for one drug. + * + * Route the report through the SAME ledger the detail page uses: per + * medication, mint the cadence-aware bands over the report window + * `[start, end]` and tally `taken` (on-time + late), `missed` and `skipped` + * from the unified dose-history ledger. `total = taken + missed` so the + * renderers' `taken / total` rate equals the ledger rate the detail page + * shows. PRN / as-needed medications are excluded exactly as before (no + * schedule, no expected dose — a fabricated 100 % on a clinical report). + * + * Pure / synchronous — bands come from pre-fetched schedules + intake + * instants. Keyed by medication name to match the renderer contract. + */ +export function buildLedgerCompliance( + medications: DoctorReportComplianceMedication[], + intakeEvents: DoctorReportComplianceIntake[], + userTz: string, + start: Date, + end: Date, + now: Date, +): Record { + const eventsByMedId = new Map(); + for (const event of intakeEvents) { + const list = eventsByMedId.get(event.medicationId); + if (list) list.push(event); + else eventsByMedId.set(event.medicationId, [event]); + } + + 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; + + const events = eventsByMedId.get(med.id) ?? []; + const ctx = buildComplianceMedicationContext( + med, + lastNonSkippedTakenAt(events), + userTz, + ); + const tally = tallyComplianceFromLedger( + events, + med.schedules, + ctx, + start, + end, + now, + ); + // `total = taken + missed` (the ledger denominator) so the renderer's + // `taken / total` equals `tally.rate` — the exact number the detail page + // shows. Deliberate skips are reported separately but stay out of `total`. + compliance[med.name] = { + total: tally.denominator, + taken: tally.taken, + skipped: tally.skipped, + missed: tally.missed, + }; + } + return compliance; +} + /** * Aggregate the doctor-report payload for a user over a `[start, end]` range. * Pure data assembly — no auth, no rate-limit, no audit. Idempotent. @@ -414,6 +518,19 @@ export async function collectDoctorReportData( where: { userId, active: true }, include: { schedules: true, + // v1.17 W1a — archived schedule eras so the ledger compliance + // builder segments expected-slot expansion against the schedule + // that was live on each past day (matches the detail page). + scheduleRevisions: { + orderBy: { validFrom: "asc" }, + select: { + id: true, + validFrom: true, + validUntil: true, + payload: true, + supersededByRevisionId: true, + }, + }, // v1.4.25 W4d — eager-load dose history + recent intake site // for any active medication. Generic meds carry empty arrays // so the legacy data path is byte-identical. @@ -467,6 +584,9 @@ export async function collectDoctorReportData( heightCm: true, glucoseUnit: true, thresholdsJson: true, + // v1.17 W1a — the user timezone anchors the ledger compliance + // band minter (matches the detail page's dose-day attribution). + timezone: true, // v1.7.0 — patient-identity fields for the export cover + FHIR // Patient. KVNR is encrypted (and not selected here) — the route // decrypts it and hands it to the builders. @@ -503,26 +623,39 @@ export async function collectDoctorReportData( }; } - // Medication compliance. As-needed medications are excluded: they - // carry no schedule, so every logged intake is "taken" by definition - // and a 100% rate would be a fabricated number on a clinical report. - // The medication itself stays on the report's medication list. - const compliance: Record = {}; - for (const event of intakeEvents) { - if (event.medication.asNeeded) continue; - const name = event.medication.name; - if (!compliance[name]) { - compliance[name] = { total: 0, taken: 0, skipped: 0, missed: 0 }; - } - compliance[name].total++; - if (event.takenAt) { - compliance[name].taken++; - } else if (event.skipped) { - compliance[name].skipped++; - } else { - compliance[name].missed++; - } - } + // v1.17 W1a — medication compliance through the dose-ledger authority (the + // same engine the detail page uses), NOT a raw-row tally. Routes each + // scheduled medication's intake rows through the cadence-aware band minter + // over the report window so the PDF / FHIR adherence % matches the app's + // detail page (slot dedup, band timing, cadence honoured). As-needed / PRN + // medications are excluded — no schedule, no expected dose, no fabricated + // 100 % on a clinical report. The medication itself stays on the list. + const reportTz = userProfile?.timezone ?? "Europe/Berlin"; + const compliance = buildLedgerCompliance( + medications.map((m) => ({ + id: m.id, + name: m.name, + asNeeded: m.asNeeded, + startsOn: m.startsOn, + endsOn: m.endsOn, + oneShot: m.oneShot, + createdAt: m.createdAt, + schedules: m.schedules, + scheduleRevisions: m.scheduleRevisions, + })), + intakeEvents.map((e) => ({ + medicationId: e.medicationId, + scheduledFor: e.scheduledFor, + takenAt: e.takenAt, + skipped: e.skipped, + autoMissed: e.autoMissed ?? false, + attributionSource: e.attributionSource ?? undefined, + })), + reportTz, + start, + end, + end, + ); // v1.9.0 — MedicationAdministration source rows. One entry per acted // intake (taken OR explicitly skipped); pending / missed rows are