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]) => ( ): 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,