fix(medications): tally the cadence chips from the compliance ledger

`/api/medications/[id]/cadence` computed its adherence chip from a
private copy of the compliance ledger that ignored pause eras, courses
and auto-missed doses, and read only the chart's window of intakes. A
medication paused for a week read 77 % there and 100 % on the detail
page. The chips now tally `trailingComplianceLedger`, the ledger the
compliance payload builds, over the same window, with the medication's
pauses and courses; the copy is removed and a guard freezes the set of
files that build dose rows.
This commit is contained in:
Marc-André Bombeck committed 2026-10-20 04:37:44 +02:00
1 parent e355c6120f
commit feb8058233
6 files changed
+225 -251

No files matched your search

+22 -17
View File
@@ -24,10 +24,12 @@ import { apiError, apiSuccess, returnAllZodIssues } from "@/lib/api-response";
import {
buildCadenceTimeline,
computeNextDose,
type CadenceEngineContext,
} from "@/lib/medications/scheduling/cadence";
import { complianceChips } from "@/lib/medications/scheduling/compliance";
import {
buildComplianceMedicationContext,
COMPLIANCE_LEDGER_WINDOW_DAYS,
COURSES_COMPLIANCE_SELECT,
lastNonSkippedTakenAt,
SCHEDULE_COMPLIANCE_SELECT,
} from "@/lib/analytics/compliance";
@@ -58,6 +60,11 @@ export const GET = apiHandler(
schedules: { select: SCHEDULE_COMPLIANCE_SELECT },
// v1.16.3 — archived schedule eras for era-aware chips/tallies.
scheduleRevisions: { orderBy: { validFrom: "asc" } },
// The pause eras and courses, so the chips tally the ledger the
// detail page's rate reads: a paused day or a gap between two
// courses expects nothing.
pauseEras: { select: { pausedAt: true, resumedAt: true } },
courses: COURSES_COMPLIANCE_SELECT,
},
});
if (!med) {
@@ -75,11 +82,13 @@ export const GET = apiHandler(
const windowDays = parsed.data.days ?? 30;
const asOf = new Date();
// Pull intake events covering the window + a small buffer so a dose
// logged at the edge can still pair with its slot.
const bufferMs = 24 * 60 * 60 * 1000;
// The events the compliance ledger reads (its full trailing year plus a
// day): the chips tally the same rows as the detail page, and a rolling
// cadence re-anchors on the latest intake even when it predates the
// chart's window.
const from = new Date(
asOf.getTime() - windowDays * 24 * 60 * 60 * 1000 - bufferMs,
asOf.getTime() -
(COMPLIANCE_LEDGER_WINDOW_DAYS + 1) * 24 * 60 * 60 * 1000,
);
const events = await prisma.medicationIntakeEvent.findMany({
where: {
@@ -93,6 +102,8 @@ export const GET = apiHandler(
scheduledFor: true,
takenAt: true,
skipped: true,
// A dose the auto-miss job closed counts as missed, as in the rate.
autoMissed: true,
// v1.15.20 — pinned takes bind by anchor in the chip tally so the
// chips agree with the % + the history on a "zugeordnet" dose.
attributionSource: true,
@@ -121,16 +132,11 @@ export const GET = apiHandler(
// legacy weekday walker (e.g. `FREQ=WEEKLY;BYDAY=MO` expands daily,
// PRN counts as a real cadence) and disagrees with the dashboard.
// Mirrors `analytics/compliance.ts`'s context construction.
const lastIntakeAt = lastNonSkippedTakenAt(events);
const engineCtx: CadenceEngineContext = {
startsOn: med.startsOn,
endsOn: med.endsOn,
oneShot: med.oneShot,
createdAt: med.createdAt,
lastIntakeAt,
timeZone: userTz,
scheduleRevisions: med.scheduleRevisions,
};
const engineCtx = buildComplianceMedicationContext(
med,
lastNonSkippedTakenAt(events),
userTz,
);
// v1.39.1 (#1033) — intake tracking off: no chip, no next dose.
const liveSchedules = dueSchedules(med);
@@ -146,11 +152,10 @@ export const GET = apiHandler(
const chips = complianceChips(
liveSchedules,
events,
engineCtx,
asOf,
windowDays,
anchor,
userTz,
engineCtx,
);
const next = computeNextDose(
liveSchedules,