fix(medications): drop a medication with no schedule from every adherence surface

`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.
This commit is contained in:
Marc-André Bombeck committed 2026-09-03 02:07:43 +02:00
1 parent f72e236b8a
commit 04361a9fe7
7 files changed
+205 -6

No files matched your search

+8 -1
View File
@@ -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),
);
+22
View File
@@ -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[],
+4 -3
View File
@@ -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<string, DoctorReportCompliance> = {};
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(
@@ -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");
});
});
@@ -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();
});
});
+8 -1
View File
@@ -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),
);
@@ -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",