From 9f865a9456c18cb589b91a3b8a2bb3006e032995 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 3 Oct 2026 23:24:23 +0200 Subject: [PATCH] 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. --- .../medications/[id]/dose-history/route.ts | 26 +++++++++-- src/lib/analytics/compliance/ledger.ts | 22 ++++----- src/lib/analytics/compliance/types.ts | 6 ++- src/lib/medications/scheduling/cadence.ts | 11 +++-- tests/integration/medication-courses.test.ts | 45 +++++++++++++++++++ 5 files changed, 85 insertions(+), 25 deletions(-) diff --git a/src/app/api/medications/[id]/dose-history/route.ts b/src/app/api/medications/[id]/dose-history/route.ts index 0159323a1..d0cc378f6 100644 --- a/src/app/api/medications/[id]/dose-history/route.ts +++ b/src/app/api/medications/[id]/dose-history/route.ts @@ -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; } } diff --git a/src/lib/analytics/compliance/ledger.ts b/src/lib/analytics/compliance/ledger.ts index 02ea6610a..baffb1123 100644 --- a/src/lib/analytics/compliance/ledger.ts +++ b/src/lib/analytics/compliance/ledger.ts @@ -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 diff --git a/src/lib/analytics/compliance/types.ts b/src/lib/analytics/compliance/types.ts index 198d52605..67d93ac6a 100644 --- a/src/lib/analytics/compliance/types.ts +++ b/src/lib/analytics/compliance/types.ts @@ -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, +): { 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, at: Date, ): boolean { const courses = ctx.courses; diff --git a/src/lib/medications/scheduling/cadence.ts b/src/lib/medications/scheduling/cadence.ts index 4ae7301ad..17e880f95 100644 --- a/src/lib/medications/scheduling/cadence.ts +++ b/src/lib/medications/scheduling/cadence.ts @@ -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; } diff --git a/tests/integration/medication-courses.test.ts b/tests/integration/medication-courses.test.ts index 54fd4190d..e99209ffa 100644 --- a/tests/integration/medication-courses.test.ts +++ b/tests/integration/medication-courses.test.ts @@ -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",