Show every course in the dose history

The history minted bands only from the latest course, so misses in an
earlier course counted in the rate and appeared nowhere in the history.
It now mints over the span of every course and keeps the slots on a
course day. The compliance ledger and the cadence missed tally drop the
gap slots before the reconstruction instead of after it, so a dose
logged between courses stays visible as an off-schedule take in both.
This commit is contained in:
Marc-André Bombeck committed 2026-10-03 23:24:23 +02:00
1 parent 8aa2e33435
commit 9f865a9456
5 files changed
+85 -25

No files matched your search

@@ -14,6 +14,11 @@
* Ownership-scoped via `assertMedicationOwnership`; rate-limited; reads only
* `deletedAt: null` rows.
*/
import {
complianceMintWindow,
COURSES_COMPLIANCE_SELECT,
slotInsideCourses,
} from "@/lib/analytics/compliance";
import { NextRequest } from "next/server";
import { z } from "zod/v4";
@@ -130,6 +135,9 @@ export const GET = apiHandler(
// v1.16.3 — archived schedule eras: past days mint against the
// schedule that was live THEN.
scheduleRevisions: { orderBy: { validFrom: "asc" } },
// v1.40 (#1024) — the courses, so the history shows every course's
// slots, the same ones the compliance rate counts.
courses: COURSES_COMPLIANCE_SELECT,
},
});
if (!medication) {
@@ -204,10 +212,20 @@ export const GET = apiHandler(
}, to.getTime()),
);
const bandMedication: BandMinterMedication = {
id: medication.id,
// v1.40 (#1024) — mint over the span of every course and keep only the
// slots on a course day, the rule the compliance ledger applies, so the
// history lists exactly the misses the rate counts.
const courseCtx = {
startsOn: medication.startsOn,
endsOn: medication.endsOn,
timeZone: userTz,
courses: medication.courses,
};
const mintWindow = complianceMintWindow(courseCtx);
const bandMedication: BandMinterMedication = {
id: medication.id,
startsOn: mintWindow.startsOn,
endsOn: mintWindow.endsOn,
oneShot: medication.oneShot,
createdAt: medication.createdAt,
};
@@ -256,7 +274,9 @@ export const GET = apiHandler(
let family: BandFamily = "none";
for (const g of groups) {
if (g.hasExpectedSlots) {
bands.push(...g.bands);
bands.push(
...g.bands.filter((b) => slotInsideCourses(courseCtx, b.at)),
);
if (family === "none") family = g.family;
}
}
+8 -14
View File
@@ -175,7 +175,13 @@ export function buildComplianceLedgerRows(
// excluded from the denominator, exactly as the literature requires.
const bands: SlotBand[] = [];
for (const g of groups) {
if (g.hasExpectedSlots) bands.push(...g.bands);
// v1.40 (#1024) — a slot on a day between two courses was never
// expected. Dropped before the reconstruction, so a dose logged in the
// gap stays in the ledger as the off-schedule take it is (outside the
// denominator), which is also how the dose history shows it.
if (g.hasExpectedSlots) {
bands.push(...g.bands.filter((b) => slotInsideCourses(ctx, b.at)));
}
}
const intakes: HistoryIntake[] = events
@@ -193,19 +199,7 @@ export function buildComplianceLedgerRows(
// Slots before the medication's creation count only when a recorded dose
// claims them, so a caller's window reaching past the creation never
// mints phantom misses (#1028).
const reconstructed = reconstructDoseHistory(
bands,
intakes,
now,
ctx.createdAt,
);
// v1.40 (#1024) — an expected slot on a day between two courses was never
// expected: drop it. Only slot rows go; a dose logged in the gap stays as
// the recorded intake it is.
const rows = reconstructed.filter(
(row) => row.kind !== "slot" || slotInsideCourses(ctx, row.at),
);
const rows = reconstructDoseHistory(bands, intakes, now, ctx.createdAt);
// v1.25 H-MED1 — drop expected dose slots whose anchor falls inside a
// pause interval. While a medication is paused no dose is expected, so a
+4 -2
View File
@@ -192,7 +192,9 @@ export interface ComplianceMedicationContext {
* v1.40 (#1024) — the window the retrospective minters expand over: the span
* of every course when there are several, else the medication's own window.
*/
export function complianceMintWindow(ctx: ComplianceMedicationContext): {
export function complianceMintWindow(
ctx: Pick<ComplianceMedicationContext, "startsOn" | "endsOn" | "courses">,
): {
startsOn: Date | null;
endsOn: Date | null;
} {
@@ -215,7 +217,7 @@ export function complianceMintWindow(ctx: ComplianceMedicationContext): {
* projection window already bounds the expansion).
*/
export function slotInsideCourses(
ctx: ComplianceMedicationContext,
ctx: Pick<ComplianceMedicationContext, "timeZone" | "courses">,
at: Date,
): boolean {
const courses = ctx.courses;
+5 -6
View File
@@ -951,7 +951,10 @@ function missedFromLedger(
});
const bands: SlotBand[] = [];
for (const g of groups) {
if (g.hasExpectedSlots) bands.push(...g.bands);
// v1.40 (#1024) — only slots on a course day, as the compliance ledger.
if (g.hasExpectedSlots) {
bands.push(...g.bands.filter((b) => onCourseDay(engineCtx, b.at)));
}
}
const intakes: HistoryIntake[] = events
.filter((e) => e.scheduledFor >= from && e.scheduledFor <= asOf)
@@ -967,9 +970,5 @@ function missedFromLedger(
asOf,
engineCtx.createdAt,
);
return rows.filter(
(r) =>
r.status === "missed" &&
(r.kind !== "slot" || onCourseDay(engineCtx, r.at)),
).length;
return rows.filter((r) => r.status === "missed").length;
}
@@ -308,6 +308,51 @@ describe("course writes keep the projection", () => {
]);
});
it("lists every course's slots in the dose history, none in the gap", async () => {
// Daily 08:00 UTC (the account is on UTC). Course A five days, the third
// missed; a gap; course B five days, all taken. Both ended before today.
const shift = (days: number) =>
new Date(Date.now() + days * 86_400_000).toISOString().slice(0, 10);
const med = await makeMedication({
startsOn: shift(-20),
endsOn: shift(-16),
});
await postCourse(med.id, { startsOn: shift(-10), endsOn: shift(-6) });
const taken = [-20, -19, -17, -16, -10, -9, -8, -7, -6].map(shift);
await getPrismaClient().medicationIntakeEvent.createMany({
data: taken.map((day) => ({
userId: OWNER,
medicationId: med.id,
scheduledFor: new Date(`${day}T08:00:00.000Z`),
takenAt: new Date(`${day}T08:02:00.000Z`),
})),
});
// The medication existed long before course A.
await getPrismaClient().medication.update({
where: { id: med.id },
data: { createdAt: new Date(Date.now() - 40 * 86_400_000) },
});
const { GET } =
await import("@/app/api/medications/[id]/dose-history/route");
const body = await (
await GET(req("GET", `/api/medications/${med.id}/dose-history`), {
params: Promise.resolve({ id: med.id }),
})
).json();
const slots = (
body.data.rows as Array<{ kind: string; at: string; status: string }>
).filter((r) => r.kind === "slot");
const days = slots.map((r) => r.at.slice(0, 10)).sort();
const courseDays = [-20, -19, -18, -17, -16, -10, -9, -8, -7, -6].map(
shift,
);
expect(days).toEqual(courseDays.sort());
expect(
slots.filter((r) => r.status === "missed").map((r) => r.at.slice(0, 10)),
).toEqual([shift(-18)]);
});
it("lets the medication's own PUT edit the latest course", async () => {
const med = await makeMedication({
startsOn: "2026-03-01",