fix(medications): the schedule flip cannot rewrite history and the doctor report stops inventing a rate

Switching an as-needed medication onto a fixed schedule archived no
revision (there were no prior rows), so the era segmentation attributed
the entire schedule-less stretch to the new schedules — every as-needed
day would have read as missed across the era-aware consumers. The flip
now archives an empty-payload era covering the stretch, which expects
nothing. The doctor report's compliance table excludes as-needed
medications: with no schedule every logged intake is taken by
definition, and a fabricated 100% has no place on a clinical document;
the medication list itself still carries them.
This commit is contained in:
Marc-André Bombeck committed 2026-06-12 23:47:43 +02:00
1 parent 6fef2081d0
commit 4ced89553d
3 files changed
+97 -1

No files matched your search

@@ -700,6 +700,61 @@ describe("PUT /api/medications/[id] — as-needed (v1.16.11, #316)", () => {
expect(updateData.data.asNeeded).toBe(false);
});
it("archives an EMPTY era for the as-needed stretch on the flip back", async () => {
// Without the empty revision the live era would start at the previous
// revision boundary (or createdAt) and the NEW schedule's slots would
// retro-paint every schedule-less as-needed day as missed.
mockExisting({ asNeeded: true, scheduleCount: 0 });
vi.mocked(getMedicationCategories).mockResolvedValue({} as never);
const previousBoundary = new Date("2026-05-01T08:00:00Z");
vi.mocked(prisma.medicationScheduleRevision.findFirst).mockResolvedValue({
validUntil: previousBoundary,
} as never);
vi.mocked(prisma.medication.update).mockResolvedValue({
id: "m1",
userId: "user-1",
asNeeded: false,
schedules: [{ id: "s1" }],
} as never);
const res = await PUT(
putReq({
asNeeded: false,
schedules: [{ windowStart: "08:00", windowEnd: "09:00" }],
}),
ROUTE_CTX,
);
expect(res.status).toBe(200);
expect(prisma.medicationScheduleRevision.create).toHaveBeenCalledTimes(1);
const createArg = vi.mocked(prisma.medicationScheduleRevision.create).mock
.calls[0][0] as {
data: { payload: unknown; validFrom: Date; validUntil: Date };
};
expect(createArg.data.payload).toEqual([]);
expect(createArg.data.validFrom).toEqual(previousBoundary);
expect(createArg.data.validUntil).toBeInstanceOf(Date);
});
it("does NOT archive an era when a scheduled medication replaces schedules from zero rows", async () => {
// A non-as-needed medication with no previous rows (fresh create path)
// keeps the v1.16.3 behaviour: nothing to archive.
mockExisting({ asNeeded: false, scheduleCount: 0 });
vi.mocked(getMedicationCategories).mockResolvedValue({} as never);
vi.mocked(prisma.medication.update).mockResolvedValue({
id: "m1",
userId: "user-1",
asNeeded: false,
schedules: [{ id: "s1" }],
} as never);
const res = await PUT(
putReq({
schedules: [{ windowStart: "08:00", windowEnd: "09:00" }],
}),
ROUTE_CTX,
);
expect(res.status).toBe(200);
expect(prisma.medicationScheduleRevision.create).not.toHaveBeenCalled();
});
it("422s an empty schedules array WITHOUT asNeeded:true (Zod)", async () => {
mockExisting({ scheduleCount: 1 });
const res = await PUT(putReq({ schedules: [] }), ROUTE_CTX);
+36
View File
@@ -494,6 +494,42 @@ export const PUT = apiHandler(
},
meta: { schedule_revision_rows: previousEntries.length },
});
} else if (
previousRows.length === 0 &&
existing.asNeeded &&
asNeeded === false &&
normalisedSchedules.length > 0
) {
// v1.16.11 — flipping as-needed OFF. The medication carried ZERO
// schedule rows while as-needed, so the wholesale-replace archive
// above never fires — and without a revision the live era would
// start at the PREVIOUS revision's `validUntil` (or `createdAt`),
// retro-painting the schedule-less as-needed stretch with the NEW
// schedule's expected slots: every PRN day would read as missed.
// Archive an EMPTY era covering the as-needed stretch instead; an
// empty payload expands to zero schedules, so era-aware compliance
// expects nothing there — exactly the as-needed contract.
const lastRevision = await prisma.medicationScheduleRevision.findFirst({
where: { medicationId: id, supersededByRevisionId: null },
orderBy: { validUntil: "desc" },
select: { validUntil: true },
});
await prisma.medicationScheduleRevision.create({
data: {
medicationId: id,
validFrom: lastRevision?.validUntil ?? existing.createdAt,
validUntil: new Date(),
payload: [] as unknown as Prisma.InputJsonValue,
},
});
annotate({
action: {
name: "medication.schedule.revision_archived",
entity_type: "medication",
entity_id: id,
},
meta: { schedule_revision_rows: 0 },
});
}
// A schedule replace invalidates the open slot anchors the projector /
// reminder worker minted for the OLD times: a pending 08:00 row for a
+6 -1
View File
@@ -441,6 +441,7 @@ export async function collectDoctorReportData(
atcCode: true,
rxNormCode: true,
deliveryForm: true,
asNeeded: true,
},
},
},
@@ -502,9 +503,13 @@ export async function collectDoctorReportData(
};
}
// Medication compliance.
// 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<string, DoctorReportCompliance> = {};
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 };