mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
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:
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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in new issue
Block a user