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.
This commit is contained in:
Marc-André Bombeck committed 2026-09-26 15:38:20 +02:00
1 parent 91571d4674
commit 62cef974bf
15 files changed
+162 -29

No files matched your search

@@ -225,7 +225,9 @@ const DISPOSITIONS: Record<StructuredLeafId, Disposition> = {
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<StructuredLeafId, Disposition> = {
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",
+6 -6
View File
@@ -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 (
<StatRow
key={med.name}
key={med.id}
label={med.dose ? `${med.name} — ${med.dose}` : med.name}
value={
rate
@@ -327,11 +327,11 @@ export function MedicationsSection({
);
})}
{complianceEntries
.filter(([name]) => !medications.some((m) => m.name === name))
.map(([name, c]) => (
.filter(([id]) => !medications.some((m) => m.id === id))
.map(([id, c]) => (
<StatRow
key={name}
label={name}
key={id}
label={c.name}
value={t("clinicianView.adherence", {
rate: `${Math.round((c.taken / c.total) * 100)}%`,
})}
Binary file not shown.
@@ -71,10 +71,17 @@ function makeData(overrides?: Partial<DoctorReportData>): 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,
+4 -2
View File
@@ -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,
@@ -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),
+10
View File
@@ -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<string, DoctorReportCompliance>;
medications: Array<{
/** The medication id, the key into `compliance`. */
id: string;
name: string;
dose: string;
// v1.9.0 — optional user/clinician-asserted drug-classification
+1
View File
@@ -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,
+1 -1
View File
@@ -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,
+26 -3
View File
@@ -111,10 +111,17 @@ function makeData(overrides?: Partial<DoctorReportData>): 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,
+10 -2
View File
@@ -65,9 +65,17 @@ function makeData(overrides?: Partial<DoctorReportData>): 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",
+2 -2
View File
@@ -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,
@@ -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<string, unknown> {
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();
});
});
@@ -141,6 +141,24 @@ export async function cleanupScheduledTelegramDeletions(): Promise<void> {
}
}
/**
* 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;
}
+2 -2
View File
@@ -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,