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",