From 62cef974bff23293ed55aef2459ff83330cafff1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 26 Sep 2026 15:38:20 +0200 Subject: [PATCH] fix(medications): scope a snooze to its slot and key report adherence by id A snooze lives on the medication row, and the reminder tick applied it to every slot of that medication. A Telegram "skip" of the morning dose snoozes to the end of the day, so the evening dose of the same medication was neither reminded nor marked missed by the reminder path. A snooze now holds only slots whose reminder window had opened when it was set, dated by the row's updatedAt that the snooze write stamps (the same reading the skip and pin suppression already uses). A later edit to the row can only widen the hold back to the old behaviour. The doctor report kept adherence in a map keyed by medication name, so two medications with the same name (an ended course and a new one, two strengths) collapsed into one row and the second overwrote the first. The map is keyed by medication id now and carries the name as the label; the report's medication list carries the id. The PDF, the FHIR adherence observations, the MCP visit summary, the clinician view and the GLP-1 section read it that way. --- .../share-view-leaf-render-guard.test.ts | 12 ++++- src/components/clinician/report-sections.tsx | 12 ++--- src/lib/__tests__/doctor-report-data.test.ts | Bin 17515 -> 18677 bytes .../__tests__/doctor-report-pdf-core.test.ts | 15 +++++-- src/lib/doctor-report-helpers.ts | 6 ++- .../medication-mood-wellness-section.ts | 4 +- src/lib/doctor-report-types.ts | 10 +++++ src/lib/doctor-report/collect.ts | 1 + src/lib/doctor-report/medications.ts | 2 +- src/lib/fhir/__tests__/build-bundle.test.ts | 29 +++++++++++-- src/lib/fhir/__tests__/conformance.test.ts | 12 ++++- src/lib/fhir/resources/observation.ts | 4 +- .../medication-reminder-check.test.ts | 41 ++++++++++++++++++ .../reminder/medication-reminder-check.ts | 39 +++++++++++++++-- src/lib/mcp/doctor-visit-summary.ts | 4 +- 15 files changed, 162 insertions(+), 29 deletions(-) diff --git a/src/__tests__/share-view-leaf-render-guard.test.ts b/src/__tests__/share-view-leaf-render-guard.test.ts index 5f073d4f5..50b591b21 100644 --- a/src/__tests__/share-view-leaf-render-guard.test.ts +++ b/src/__tests__/share-view-leaf-render-guard.test.ts @@ -225,7 +225,9 @@ const DISPOSITIONS: Record = { MEDICATION_LIST: { kind: "rendered", data: { - medications: [{ name: "Ramipril", dose: "5 mg", schedules: [] }], + medications: [ + { id: "med-ramipril", name: "Ramipril", dose: "5 mg", schedules: [] }, + ], }, marker: "Ramipril", }, @@ -252,7 +254,13 @@ const DISPOSITIONS: Record = { kind: "rendered", data: { compliance: { - Metformin: { total: 60, taken: 57, skipped: 1, missed: 2 }, + "med-metformin": { + name: "Metformin", + total: 60, + taken: 57, + skipped: 1, + missed: 2, + }, }, }, marker: "Metformin", diff --git a/src/components/clinician/report-sections.tsx b/src/components/clinician/report-sections.tsx index 10efc951c..bf41f8535 100644 --- a/src/components/clinician/report-sections.tsx +++ b/src/components/clinician/report-sections.tsx @@ -309,14 +309,14 @@ export function MedicationsSection({ empty={medications.length === 0 && complianceEntries.length === 0} > {medications.map((med) => { - const comp = report.compliance[med.name]; + const comp = report.compliance[med.id]; const rate = complianceOn && comp && comp.total > 0 ? `${Math.round((comp.taken / comp.total) * 100)}%` : null; return ( !medications.some((m) => m.name === name)) - .map(([name, c]) => ( + .filter(([id]) => !medications.some((m) => m.id === id)) + .map(([id, c]) => ( 1gh5SEdMAQXruAS9yErMZLi+F-?zOaclLqCgf8fGpU%8{@*JnT{u*;z6kCncd^C8w#%2(>L?|%*@aCjbG=DFW-{0G2+w$%{5H0D04}r zR*XlIz>cIrkf7v*e^Y5FN+C|*-~pU$0)MR*94bInU;(+F8OtWuD@;L~U~S2u zdPO74l_8;mTpF|B6np`e-D@|WB;YA7*f3j!;MB9gVy@X)zduy9Xy?`>3>?mWGDs}A>PBRNKONImcd zzTsXrx7}8^fA%bUjX8-iE^Mr4lnBc^#YrMaKR%wxqxg9kgnjFY21zv$zMZnti)zY1 z_-lIUBaG{9OBMY?kVOO0{V#=NMOekj&8?lk$=)cG|3tS#;3>b$ugQ;#m#g{iMg#Yu f^>Gb#+MP9|+szePer}r!E9juJzd}pwY+U;d^Kan@ delta 100 zcmex5k@0m0): DoctorReportData { glucoseUnit: "mg/dL", bmi: 24.1, compliance: { - Ramipril: { total: 90, taken: 85, skipped: 3, missed: 2 }, + "med-ramipril": { + name: "Ramipril", + total: 90, + taken: 85, + skipped: 3, + missed: 2, + }, }, medications: [ { + id: "med-ramipril", name: "Ramipril", dose: "5mg", schedules: [ @@ -324,7 +331,8 @@ describe("doctor-report pagination", () => { it("paginates a long compliance table across pages without throwing", () => { const compliance: DoctorReportData["compliance"] = {}; for (let i = 0; i < 60; i++) { - compliance[`Medication ${i}`] = { + compliance[`med-${i}`] = { + name: `Medication ${i}`, total: 90, taken: 80, skipped: 5, @@ -899,7 +907,8 @@ describe("extracted doctor-report section boundaries", () => { it("footer is drawn once on every dynamically added page", async () => { const compliance: DoctorReportData["compliance"] = {}; for (let i = 0; i < 60; i += 1) { - compliance[`Medication ${i}`] = { + compliance[`med-${i}`] = { + name: `Medication ${i}`, total: 90, taken: 80, skipped: 5, diff --git a/src/lib/doctor-report-helpers.ts b/src/lib/doctor-report-helpers.ts index 3c5e2c93d..305fe5e70 100644 --- a/src/lib/doctor-report-helpers.ts +++ b/src/lib/doctor-report-helpers.ts @@ -344,7 +344,8 @@ export function normaliseDateRange( * 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. + * instants. Keyed by medication id: two medications can share a name, and + * a name key let the second overwrite the first. The name rides in the value. */ export function buildLedgerCompliance( medications: DoctorReportComplianceMedication[], @@ -385,7 +386,8 @@ export function buildLedgerCompliance( // `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] = { + compliance[med.id] = { + name: med.name, total: tally.denominator, taken: tally.taken, skipped: tally.skipped, diff --git a/src/lib/doctor-report-pdf/medication-mood-wellness-section.ts b/src/lib/doctor-report-pdf/medication-mood-wellness-section.ts index ce7929457..61b436cff 100644 --- a/src/lib/doctor-report-pdf/medication-mood-wellness-section.ts +++ b/src/lib/doctor-report-pdf/medication-mood-wellness-section.ts @@ -67,7 +67,7 @@ export function buildMedicationMoodWellnessSection( doc.text(t("doctorReport.complianceTitle"), margin, y); y += 6; - const compRows = complianceEntries.map(([name, c]) => { + const compRows = complianceEntries.map(([, c]) => { // v1.17 W1a — `total` is the ledger rate denominator (taken + missed, // deliberate skips excluded), so `taken / total` matches the app's // detail-page adherence %. The column is labelled "Expected" rather @@ -77,7 +77,7 @@ export function buildMedicationMoodWellnessSection( const ratePct = adherenceRatePercent(c.taken, c.total); const rate = ratePct !== null ? `${num(ratePct, 0)}%` : "—"; return [ - name, + c.name, String(c.taken), String(c.skipped), String(c.missed), diff --git a/src/lib/doctor-report-types.ts b/src/lib/doctor-report-types.ts index 708b4de54..c37bab2c4 100644 --- a/src/lib/doctor-report-types.ts +++ b/src/lib/doctor-report-types.ts @@ -40,7 +40,14 @@ export interface DoctorReportStats { latest: number; } +/** + * One medication's adherence over the report window. The report's + * `compliance` map is keyed by medication id, because two medications may + * share a name (an ended course and a new one, two strengths); `name` is the + * label every renderer prints. + */ export interface DoctorReportCompliance { + name: string; total: number; taken: number; skipped: number; @@ -102,8 +109,11 @@ export interface DoctorReportData { */ glucoseClinical: GlucoseClinicalMetrics; bmi: number | null; + /** Keyed by medication id; see `DoctorReportCompliance`. */ compliance: Record; medications: Array<{ + /** The medication id, the key into `compliance`. */ + id: string; name: string; dose: string; // v1.9.0 — optional user/clinician-asserted drug-classification diff --git a/src/lib/doctor-report/collect.ts b/src/lib/doctor-report/collect.ts index 502480dba..6dc45b463 100644 --- a/src/lib/doctor-report/collect.ts +++ b/src/lib/doctor-report/collect.ts @@ -583,6 +583,7 @@ export async function collectDoctorReportData( compliance: gate.admits("MEDICATION_COMPLIANCE") ? compliance : {}, medications: gate.admits("MEDICATION_LIST") ? medications.map((m) => ({ + id: m.id, name: m.name, dose: m.dose, atcCode: m.atcCode, diff --git a/src/lib/doctor-report/medications.ts b/src/lib/doctor-report/medications.ts index fa1def120..d6e420291 100644 --- a/src/lib/doctor-report/medications.ts +++ b/src/lib/doctor-report/medications.ts @@ -186,7 +186,7 @@ export function buildGlp1Block(params: { medications: glp1Meds.map((m) => { const latest = m.doseChanges[m.doseChanges.length - 1] ?? null; const lastIntake = m.intakeEvents[0] ?? null; - const comp = params.compliance[m.name] ?? { + const comp = params.compliance[m.id] ?? { taken: 0, total: 0, skipped: 0, diff --git a/src/lib/fhir/__tests__/build-bundle.test.ts b/src/lib/fhir/__tests__/build-bundle.test.ts index a0c2f5f07..c119dc128 100644 --- a/src/lib/fhir/__tests__/build-bundle.test.ts +++ b/src/lib/fhir/__tests__/build-bundle.test.ts @@ -111,10 +111,17 @@ function makeData(overrides?: Partial): DoctorReportData { glucoseUnit: "mg/dL", bmi: 24.1, compliance: { - "Example Drug": { total: 90, taken: 85, skipped: 3, missed: 2 }, + "med-example": { + name: "Example Drug", + total: 90, + taken: 85, + skipped: 3, + missed: 2, + }, }, medications: [ { + id: "med-example-drug", name: "Example Drug", dose: "5mg", schedules: [ @@ -472,6 +479,7 @@ describe("buildFhirDocumentBundle", () => { makeData({ medications: [ { + id: "med-mounjaro", name: "Mounjaro", dose: "10mg", atcCode: "A10BX10", @@ -509,7 +517,13 @@ describe("buildFhirDocumentBundle", () => { const bundle = buildFhirDocumentBundle( makeData({ medications: [ - { name: "Ramipril", dose: "5mg", atcCode: "C09AA05", schedules: [] }, + { + id: "med-ramipril", + name: "Ramipril", + dose: "5mg", + atcCode: "C09AA05", + schedules: [], + }, ], }), { insuranceNumber: null }, @@ -781,6 +795,7 @@ describe("buildFhirDocumentBundle", () => { makeData({ medications: [ { + id: "med-empagliflozin", name: "Empagliflozin", dose: "10mg", atcCode: "A10BK03", @@ -810,6 +825,7 @@ describe("buildFhirDocumentBundle", () => { makeData({ medications: [ { + id: "med-empagliflozin", name: "Empagliflozin", dose: "10mg", atcCode: "A10BK03", @@ -867,7 +883,14 @@ describe("buildFhirDocumentBundle", () => { it("never invents a BfArM coding when no ATC code is stored, even with germanAtc on", () => { const bundle = buildFhirDocumentBundle( makeData({ - medications: [{ name: "Herbal mix", dose: "1 tab", schedules: [] }], + medications: [ + { + id: "med-herbal", + name: "Herbal mix", + dose: "1 tab", + schedules: [], + }, + ], }), { insuranceNumber: null }, FIXED_NOW, diff --git a/src/lib/fhir/__tests__/conformance.test.ts b/src/lib/fhir/__tests__/conformance.test.ts index 8877f7dad..2cdc9cd53 100644 --- a/src/lib/fhir/__tests__/conformance.test.ts +++ b/src/lib/fhir/__tests__/conformance.test.ts @@ -65,9 +65,17 @@ function makeData(overrides?: Partial): DoctorReportData { glucoseUnit: "mg/dL", bmi: 24.1, compliance: { - "Example Drug": { total: 90, taken: 85, skipped: 3, missed: 2 }, + "med-example": { + name: "Example Drug", + total: 90, + taken: 85, + skipped: 3, + missed: 2, + }, }, - medications: [{ name: "Example Drug", dose: "5mg", schedules: [] }], + medications: [ + { id: "med-example", name: "Example Drug", dose: "5mg", schedules: [] }, + ], medicationAdministrations: [ { medicationName: "Example Drug", diff --git a/src/lib/fhir/resources/observation.ts b/src/lib/fhir/resources/observation.ts index 45a8bc4a8..fc4c7a5be 100644 --- a/src/lib/fhir/resources/observation.ts +++ b/src/lib/fhir/resources/observation.ts @@ -174,7 +174,7 @@ function adherenceObservations( ): FhirObservation[] { const observations: FhirObservation[] = []; const period = reportingPeriod(data); - for (const [name, comp] of Object.entries(data.compliance)) { + for (const comp of Object.values(data.compliance)) { if (comp.total <= 0) continue; // Integer percent — the one canonical rounding the app card + PDF use, so // a clinician sees the same adherence figure on every surface. The helper @@ -197,7 +197,7 @@ function adherenceObservations( display: "Medication adherence", }, ], - text: `Medication adherence — ${name}`, + text: `Medication adherence — ${comp.name}`, }, subject: patientRef, effectivePeriod: period, diff --git a/src/lib/jobs/__tests__/medication-reminder-check.test.ts b/src/lib/jobs/__tests__/medication-reminder-check.test.ts index a6b259481..7bc66a553 100644 --- a/src/lib/jobs/__tests__/medication-reminder-check.test.ts +++ b/src/lib/jobs/__tests__/medication-reminder-check.test.ts @@ -851,3 +851,44 @@ describe("issue #664 — exact occurrence identity survives local midnight", () expect(dispatchNotification).not.toHaveBeenCalled(); }); }); + +describe("handleReminderCheck — a snooze holds only the slot it answered", () => { + function snoozed(at: string): Record { + return { + ...medicationWithSchedule({ timesOfDay: ["08:00", "18:00"] }), + // A Telegram "skip" of the morning dose snoozes to the end of the day; + // the snooze write stamps the row's updatedAt. + snoozedUntil: new Date("2026-07-28T23:59:59.000Z"), + updatedAt: new Date(at), + }; + } + + it("still reminds the evening dose after the morning one was snoozed", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-28T18:40:00.000Z")); + prismaMock.medication.findMany.mockResolvedValue([ + snoozed("2026-07-28T08:30:00.000Z"), + ] as never); + + await handleReminderCheck([]); + + expect(dispatchNotification).toHaveBeenCalledTimes(1); + expect(dispatchNotification).toHaveBeenCalledWith( + expect.objectContaining({ + metadata: expect.objectContaining({ timeOfDay: "18:00" }), + }), + ); + }); + + it("holds the slot whose reminder was open when the snooze was set", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-28T08:40:00.000Z")); + prismaMock.medication.findMany.mockResolvedValue([ + snoozed("2026-07-28T08:30:00.000Z"), + ] as never); + + await handleReminderCheck([]); + + expect(dispatchNotification).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/jobs/reminder/medication-reminder-check.ts b/src/lib/jobs/reminder/medication-reminder-check.ts index fd46f6a15..c913a3af4 100644 --- a/src/lib/jobs/reminder/medication-reminder-check.ts +++ b/src/lib/jobs/reminder/medication-reminder-check.ts @@ -141,6 +141,24 @@ export async function cleanupScheduledTelegramDeletions(): Promise { } } +/** + * Whether a medication-level snooze holds the reminder of one slot. It + * holds only while it runs (`now < snoozedUntil`) and only for a slot whose + * reminder window had opened when the snooze was set (`snoozedAt`, the + * medication row's @updatedAt of the snooze write). A later edit to the + * row moves `snoozedAt` forward, which can only widen the hold back to the + * old medication-wide behaviour, never lose a snooze. + */ +export function snoozeHoldsSlot( + snoozedUntil: Date | null, + snoozedAt: Date, + slotWindowStart: Date, + now: Date, +): boolean { + if (!snoozedUntil || now.getTime() >= snoozedUntil.getTime()) return false; + return slotWindowStart.getTime() <= snoozedAt.getTime(); +} + /** * Check all active medications for each user and determine reminder phases. * Uses phase-based logic (GREEN/YELLOW/ORANGE/RED) to send one notification @@ -477,6 +495,7 @@ export async function handleReminderCheck( return currentPhase ? { slotScheduledFor: occurrence.at, + windowStart: phaseWindow.start, minutesToEnd, currentPhase, } @@ -489,8 +508,12 @@ export async function handleReminderCheck( )[0]; if (!phaseCandidate) continue; - const { slotScheduledFor, minutesToEnd, currentPhase } = - phaseCandidate; + const { + slotScheduledFor, + windowStart, + minutesToEnd, + currentPhase, + } = phaseCandidate; const slotInstant = slotScheduledFor.getTime(); if (liveEraStart !== null && slotInstant < liveEraStart) { continue; @@ -544,8 +567,16 @@ export async function handleReminderCheck( continue; } - // Skip if medication is snoozed - if (med.snoozedUntil && now < med.snoozedUntil) { + // A snooze answers the reminder of the slot that was open when it + // was set, so it holds only slots whose window had opened by then. + // `snoozedUntil` lives on the medication row and the snooze write + // stamps the row's @updatedAt, which dates the decision (the same + // reading the skip / pin suppression above uses). A later slot of + // the same medication, such as the evening dose after a morning + // "skip" that snoozes to the end of the day, is still reminded. + if ( + snoozeHoldsSlot(med.snoozedUntil, med.updatedAt, windowStart, now) + ) { continue; } diff --git a/src/lib/mcp/doctor-visit-summary.ts b/src/lib/mcp/doctor-visit-summary.ts index f647cc071..6b00444d6 100644 --- a/src/lib/mcp/doctor-visit-summary.ts +++ b/src/lib/mcp/doctor-visit-summary.ts @@ -73,8 +73,8 @@ export function summariseForVisit( }; }); - const compliance = Object.entries(data.compliance).map(([name, c]) => ({ - medication: name, + const compliance = Object.values(data.compliance).map((c) => ({ + medication: c.name, takenDoses: c.taken, expectedDoses: c.total, missedDoses: c.missed,