From 79e7c866e47d3561fdfa10f4af4e2af99556eb65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 8 Jun 2026 17:50:06 +0200 Subject: [PATCH] fix(medications): unify compliance % and per-dose status onto one dose-history ledger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The medication compliance percentage and the history view drew their per-dose attribution from four different matchers — the ±12h pairDoses proximity used by calculateCompliance, the deriveDoseStatus bands (open dose only), the 3h+120min classifyIntakeTiming heatmap heuristic, and a raw takenAt-IS-NOT-NULL per-day rollup. They could and did disagree: a dose could read "taken late" in the rate while the history called it an off-schedule ad-hoc take. Route the rate through the shared cadence-aware band minter + reconstructDoseHistory so the percentage is a tally over the SAME ledger the history view renders. The same intake can no longer be taken in the % and ad-hoc in the ledger. - calculateCompliance: when a medication context is supplied, the count fields (taken / skipped / missed / rate) come from tallyComplianceFromLedger. Numerator = taken_on_time + taken_late (a late dose still counts as taken — the taking-vs-timing split is kept available); denominator = taken + missed; skipped, ad-hoc, upcoming and whole PRN groups are excluded; rate capped at 100. The streak still walks the cadence timeline. Context-less pure-math callers keep the legacy timeline tally byte-stable. - complianceChips + missedDoses: adherence rate + missed count come from the same ledger when an engine context is threaded. - /api/medications/[id]/compliance heatmap: the per-day timing split and missed marks are bucketed from the one ledger instead of the per-day classifyIntakeTiming + expectedSlotCountForDay walk, so the cells agree with the headline rate. - medication_compliance_rollups stays a raw cross-medication daily coverage count, documented as distinct from the band-attributed % (the band minter is per-cadence and cannot run inside a single SQL tally). Downstream readers (health score, insight compliance status) keep calling calculateCompliance; their numbers shift to the honest band-attributed values. Some currently-counted off-time takes (e.g. an 11:29 take of a 07:00 dose) now read ad-hoc with the slot missed rather than taken-late — the configurable per-dose window is the lever to widen the on-time band. Tests: the irregular twice-daily, single-time-late heatmap, and legacy parity fixtures are updated with rationale for the intended number changes; new tally tests pin the taking/timing split, the skip / ad-hoc / upcoming / PRN exclusions, auto-missed=missed, the 100% cap, rolling retrospective families, and the cross-consistency invariant. --- .../[id]/compliance/__tests__/route.test.ts | 23 +- .../api/medications/[id]/compliance/route.ts | 378 ++++++++------- .../analytics/__tests__/compliance.test.ts | 443 ++++++++++++++++-- src/lib/analytics/compliance.ts | 257 +++++++++- src/lib/medications/scheduling/cadence.ts | 99 ++++ src/lib/medications/scheduling/compliance.ts | 142 +++++- .../rollups/medication-compliance-rollups.ts | 19 + 7 files changed, 1128 insertions(+), 233 deletions(-) diff --git a/src/app/api/medications/[id]/compliance/__tests__/route.test.ts b/src/app/api/medications/[id]/compliance/__tests__/route.test.ts index ae9e21ed9..4541af588 100644 --- a/src/app/api/medications/[id]/compliance/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/compliance/__tests__/route.test.ts @@ -169,7 +169,15 @@ describe("GET /api/medications/[id]/compliance — per-slot timing", () => { expect(entry!.onTime).toBe(2); }); - it("keeps single-time schedules byte-identical: a 12h-late dose is still very_late", async () => { + it("a 12h-late dose is an ad-hoc take + a missed 07:00 slot (band model)", async () => { + // v1.15.18 — the heatmap now buckets the unified dose-history ledger. A + // single 07:00 daily dose logged at 19:00 is 12h outside the 07:00 slot's + // on-time band (±60min) AND its late tail (to ~10:00), so it is an AD-HOC + // off-schedule take — a real logged dose (taken / on-time tone), NOT a + // "very late" attribution onto the 07:00 slot. The 07:00 slot itself reads + // missed (no intake inside its window past its cutoff). This is the audit's + // intent: the cell no longer mislabels an off-schedule take as a punctuality + // grade against a slot it never belonged to. const { start: dayStart } = dayBoundsForOffset(DAY_OFFSET); const dateKey = userDayKey(dayStart, TZ); @@ -179,7 +187,7 @@ describe("GET /api/medications/[id]/compliance — per-slot timing", () => { id: "sched-1", windowStart: "07:00", windowEnd: "08:00", - timesOfDay: [], // single-time: candidate = [windowStart] + timesOfDay: [], // single-time daily: the minter mints a 07:00 band daysOfWeek: null, rrule: null, rollingIntervalDays: null, @@ -191,7 +199,7 @@ describe("GET /api/medications/[id]/compliance — per-slot timing", () => { ]) as never, ); - // Single 07:00 schedule, dose taken at 19:00 → ~12h late → very_late. + // Single 07:00 schedule, dose logged at 19:00 → outside every band → ad-hoc. vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([ { takenAt: atSlotUTC(dayStart, "19:00"), @@ -203,9 +211,14 @@ describe("GET /api/medications/[id]/compliance — per-slot timing", () => { const daily = await callRoute(); const entry = daily[dateKey] as Record | undefined; expect(entry).toBeDefined(); - expect(entry!.veryLate).toBe(1); - expect(entry!.onTime).toBe(0); + // The off-schedule take reads as a logged dose; no late-attribution. + expect(entry!.veryLate).toBe(0); expect(entry!.late).toBe(0); + expect(entry!.onTime).toBe(1); + expect(entry!.taken).toBe(1); + // The 07:00 slot itself is missed (uncovered, past its cutoff). + // expected = the missed slot + the ad-hoc take's own slot. + expect(entry!.expected).toBe(2); }); it("handles an overnight per-slot window (23:00 slot, 1h span) as on_time near the slot", async () => { diff --git a/src/app/api/medications/[id]/compliance/route.ts b/src/app/api/medications/[id]/compliance/route.ts index 4a1cc2e85..ef845778a 100644 --- a/src/app/api/medications/[id]/compliance/route.ts +++ b/src/app/api/medications/[id]/compliance/route.ts @@ -6,83 +6,128 @@ import { buildComplianceDisplay, buildComplianceMedicationContext, calculateCompliance, - classifyIntakeTiming, - expectedSlotCountForDay, lastNonSkippedTakenAt, } from "@/lib/analytics/compliance"; import type { DailyComplianceEntry } from "@/lib/analytics/compliance"; +import type { SlotBand } from "@/lib/medications/scheduling/attribution"; +import { + buildBandsForSchedules, + type BandMinterMedication, +} from "@/lib/medications/scheduling/band-minter"; +import { + reconstructDoseHistory, + type DoseHistoryRow, + type HistoryIntake, +} from "@/lib/medications/scheduling/dose-history"; +import { + type CanonicalSchedule, + type RecurrenceContext, +} from "@/lib/medications/scheduling/recurrence"; import { assertMedicationOwnership } from "@/lib/medications/route-guards"; -import { getUserTodayBounds } from "@/lib/timezone"; import { userDayKey } from "@/lib/tz/format"; type RouteParams = { params: Promise<{ id: string }> }; -/** A single effective dose slot with its derived punctuality window. */ -interface SlotWindow { - /** Minute-of-day of the slot's start time (used for nearest-slot match). */ - startMinutes: number; - /** "HH:mm" slot start — fed to `classifyIntakeTiming` as `windowStart`. */ +type MedicationScheduleRow = { + id: string; + rrule: string | null; + rollingIntervalDays: number | null; + timesOfDay: string[]; + daysOfWeek: string | null; windowStart: string; - /** "HH:mm" slot end = slot start + the schedule's window span. */ windowEnd: string; -} + reminderGraceMinutes: number | null; + scheduleType: "SCHEDULED" | "PRN" | "CYCLIC"; + cyclicOnWeeks: number | null; + cyclicOffWeeks: number | null; +}; -const MINUTES_PER_DAY = 24 * 60; - -/** Parse "HH:mm" into minute-of-day. */ -function hhmmToMinutes(time: string): number { - const [h, m] = time.split(":").map(Number); - return h * 60 + m; -} - -/** Format a minute-of-day (wrapped into 0..1439) back to "HH:mm". */ -function minutesToHHmm(minutes: number): string { - const wrapped = ((minutes % MINUTES_PER_DAY) + MINUTES_PER_DAY) % - MINUTES_PER_DAY; - const h = Math.floor(wrapped / 60); - const m = wrapped % 60; - return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`; +/** + * v1.15.18 — adapt a Prisma schedule row to the canonical engine shape the + * band minter consumes. A legacy daily row carrying only `windowStart` + * surfaces it as the single `timeOfDay` so the minter mints its daily band. + */ +function toCanonicalForBands( + s: MedicationScheduleRow, + oneShot: boolean, +): CanonicalSchedule { + const base: CanonicalSchedule = { + id: s.id, + rrule: s.rrule, + rollingIntervalDays: s.rollingIntervalDays, + timesOfDay: s.timesOfDay, + daysOfWeek: s.daysOfWeek, + windowStart: s.windowStart, + windowEnd: s.windowEnd, + reminderGraceMinutes: s.reminderGraceMinutes, + scheduleType: s.scheduleType, + cyclicOnWeeks: s.cyclicOnWeeks, + cyclicOffWeeks: s.cyclicOffWeeks, + }; + if ( + base.timesOfDay.length === 0 && + base.rrule === null && + base.rollingIntervalDays === null && + base.scheduleType !== "PRN" && + !oneShot + ) { + return { ...base, timesOfDay: [base.windowStart] }; + } + return base; } /** - * v1.8.1 B15 follow-on — expand every schedule into its effective dose - * slots so timing classification matches against the right time-of-day. + * v1.15.18 — fold one dose-history ledger row into a per-day heatmap entry. * - * Candidate slot times for a schedule are its `timesOfDay` when non-empty, - * else `[windowStart]` (so single-time schedules collapse to one slot at - * `windowStart` and stay byte-identical to the pre-fix matcher). Each - * slot's punctuality window is anchored at the slot time and spans the - * schedule's `windowEnd − windowStart` duration; overnight windows - * (`windowEnd ≤ windowStart`) add a day to the span before deriving the - * per-slot end. Multi-schedule meds union their slots — the matcher then - * picks the single closest slot across all of them. + * Slot rows contribute to `expected` / `expectedCount` (the day's due-slot + * count): a taken slot adds to `taken` and its timing bucket; a missed slot + * stays uncounted in `taken`; a skipped slot lands in `skipped`; an upcoming + * slot is still due but not yet acted on. An ad-hoc row is a real off-schedule + * take — it counts as `taken` AND adds its own `expected` slot (so the + * heatmap's `missed = expected − taken − skipped` math stays non-negative) and + * reads on-time (a logged dose colours green). */ -function buildSlotCandidates( - schedules: { windowStart: string; windowEnd: string; timesOfDay: string[] }[], -): SlotWindow[] { - const slots: SlotWindow[] = []; - for (const sched of schedules) { - const startMin = hhmmToMinutes(sched.windowStart); - let endMin = hhmmToMinutes(sched.windowEnd); - // Overnight window: the end is on the following day. - if (endMin <= startMin) endMin += MINUTES_PER_DAY; - const spanMinutes = endMin - startMin; - - const times = - sched.timesOfDay.length > 0 ? sched.timesOfDay : [sched.windowStart]; - - for (const time of times) { - const slotStartMin = hhmmToMinutes(time); - slots.push({ - startMinutes: slotStartMin, - windowStart: minutesToHHmm(slotStartMin), - // `classifyIntakeTiming` re-detects the overnight wrap from the - // HH:mm pair, so a wrapped end formats back into 0..1439 cleanly. - windowEnd: minutesToHHmm(slotStartMin + spanMinutes), - }); - } +function bucketLedgerRow( + entry: DailyComplianceEntry, + row: DoseHistoryRow, +): void { + switch (row.status) { + case "taken_on_time": + entry.expected++; + entry.expectedCount++; + entry.taken++; + entry.onTime++; + break; + case "taken_late": + entry.expected++; + entry.expectedCount++; + entry.taken++; + entry.late++; + break; + case "missed": + entry.expected++; + entry.expectedCount++; + break; + case "skipped": + entry.expected++; + entry.expectedCount++; + entry.skipped++; + break; + case "upcoming": + // A future / still-takeable slot is due but not yet acted on. It counts + // toward the day's expected/due grid but not toward taken or missed. + entry.expected++; + entry.expectedCount++; + break; + case "ad_hoc": + // An off-schedule take: a real taken dose with no scheduled slot. Count + // it as taken + its own expected slot so the heatmap missed math holds. + entry.expected++; + entry.expectedCount++; + entry.taken++; + entry.onTime++; + break; } - return slots; } export const GET = apiHandler( @@ -165,134 +210,103 @@ export const GET = apiHandler( { now }, ); - // Build daily compliance map for heatmap/line chart (90 days) + // v1.15.18 — the heatmap / line-chart daily map is now bucketed from the + // ONE unified dose-history ledger (the same bands the % + the history view + // read), so the per-day timing split (on-time / late) and the per-day + // missed marks can never disagree with the headline rate. The legacy + // per-day `classifyIntakeTiming` 3h-grace heuristic + `expectedSlotCount + // ForDay` walk are retired here in favour of band membership: a take inside + // a slot's on-time band reads on-time, inside its late tail reads late, + // outside every band reads ad-hoc; an unfilled slot past its miss cutoff + // reads missed; a future slot is upcoming (not yet due). const dailyCompliance: Record = {}; - for (let d = 0; d < 90; d++) { - // v1.7.0 code-correctness M1 — anchor each day's [start,end) on the - // user-tz local-day boundary, not a UTC-midnight slice. The engine - // applies `timesOfDay` in the user timezone, so for tz-distant users - // a UTC slice could attach `due` / `expectedCount` to the adjacent - // calendar cell. Anchor on a noon-local representative instant - // (dodges the DST midnight-ambiguity edge) and key off the user-tz - // day so the heatmap cell, the engine frame, and `dateKey` agree. - const representative = new Date( - now.getTime() - d * 24 * 60 * 60 * 1000 - 12 * 60 * 60 * 1000, - ); - const { start: dayStart, end: dayEndInclusive } = getUserTodayBounds( - representative, - userTz, - ); - // `getUserTodayBounds` returns an inclusive end (local 23:59:59.999); - // the slicing loops below use a half-open `[dayStart, dayEnd)`. - const dayEnd = new Date(dayEndInclusive.getTime() + 1); + // Build the bands across the whole 90-day heatmap window once. The window + // floor clamps to the medication's creation so pre-existence days never + // mint phantom slots. + const DAY_MS = 24 * 60 * 60 * 1000; + const windowFrom = new Date( + Math.max(now.getTime() - 90 * DAY_MS, createdAt.getTime()), + ); + const bandMedication: BandMinterMedication = { + id: medication.id, + startsOn: medication.startsOn, + endsOn: medication.endsOn, + oneShot: medication.oneShot, + createdAt: medication.createdAt, + }; + const bandCtx: RecurrenceContext = { + medication: { + id: medication.id, + startsOn: medication.startsOn, + endsOn: medication.endsOn, + oneShot: medication.oneShot, + createdAt: medication.createdAt, + }, + timeZone: userTz, + lastIntakeAt, + }; + const canonicalSchedules: CanonicalSchedule[] = medication.schedules.map( + (s) => toCanonicalForBands(s, medication.oneShot), + ); + const intakeInstants = mapped + .filter((e) => !e.skipped && e.takenAt !== null && e.takenAt <= now) + .map((e) => e.takenAt as Date) + .sort((a, b) => a.getTime() - b.getTime()); + const groups = buildBandsForSchedules({ + medication: bandMedication, + schedules: canonicalSchedules, + ctx: bandCtx, + userTz, + range: { from: windowFrom, to: now }, + now, + intakeInstants, + }); + const bands: SlotBand[] = []; + for (const g of groups) { + if (g.hasExpectedSlots) bands.push(...g.bands); + } + const historyIntakes: HistoryIntake[] = mapped + .filter((e) => e.scheduledFor >= windowFrom && e.scheduledFor <= now) + .map((e) => ({ + scheduledFor: e.scheduledFor, + takenAt: e.takenAt, + skipped: e.skipped, + autoMissed: e.autoMissed, + })); + const ledgerRows = reconstructDoseHistory(bands, historyIntakes, now); - // Skip days before medication was created - if (dayEnd <= createdAt) continue; - - const dateKey = userDayKey(dayStart, userTz); - - const dayEvents = mapped.filter( - (e) => e.scheduledFor >= dayStart && e.scheduledFor < dayEnd, - ); - - const takenEvents = dayEvents.filter( - (e) => e.takenAt !== null && !e.skipped, - ); - - // Classify timing for each taken event against the best-matching schedule - let onTime = 0; - let late = 0; - let veryLate = 0; - let early = 0; - - // v1.8.1 B15 follow-on — match each taken event to the closest - // effective time-of-day SLOT, not merely `windowStart`. A single - // schedule row carrying `timesOfDay = ["07:00","19:00"]` is two - // distinct dose slots; matching the evening dose against the lone - // `windowStart` "07:00" mis-classified it as `very_late` (~12h late) - // and painted the heatmap cell orange even when both doses were - // taken on time. Candidate slots for a schedule are its `timesOfDay` - // when non-empty, else `[windowStart]` — so single-time schedules - // collapse to exactly one candidate at `windowStart` and stay - // byte-identical to the pre-fix behaviour. The per-slot window - // mirrors the projector / cadence anchor: effective windowStart is - // the slot time, effective windowEnd is the slot time plus the - // schedule's `windowEnd − windowStart` span (overnight spans where - // `windowEnd ≤ windowStart` add a day). - const slotCandidates = buildSlotCandidates(medication.schedules); - - for (const evt of takenEvents) { - if (slotCandidates.length === 0) { - // No schedule info: treat all taken as on_time - onTime++; - continue; - } - - // Match event to the closest slot window by scheduledFor time. - const evtHour = evt.scheduledFor.getUTCHours(); - const evtMin = evt.scheduledFor.getUTCMinutes(); - const evtMinutes = evtHour * 60 + evtMin; - - let bestSlot = slotCandidates[0]; - let bestDist = Infinity; - - for (const slot of slotCandidates) { - const dist = Math.abs(evtMinutes - slot.startMinutes); - if (dist < bestDist) { - bestDist = dist; - bestSlot = slot; - } - } - - const timing = classifyIntakeTiming( - evt.takenAt, - bestSlot.windowStart, - bestSlot.windowEnd, - dayStart, // the scheduled date - ); - - // v1.4.34 IW-C — `early` is the new compliant bucket; it counts - // alongside `onTime` for the heatmap so a proactive logger reads - // green. The classifier still emits a distinct `"early"` value - // for downstream consumers that want to differentiate; the - // separate counter is surfaced on the daily entry below. - if (timing === "on_time") onTime++; - else if (timing === "early") early++; - else if (timing === "late") late++; - else veryLate++; + // Bucket each ledger row into its user-tz day. A slot row buckets on its + // anchor; an ad-hoc row on its real take time. + const byDay = new Map(); + const ensureDay = (key: string): DailyComplianceEntry => { + let entry = byDay.get(key); + if (!entry) { + entry = { + expected: 0, + expectedCount: 0, + due: false, + taken: 0, + skipped: 0, + onTime: 0, + late: 0, + veryLate: 0, + early: 0, + }; + byDay.set(key, entry); } + return entry; + }; - // v1.7.0 item 5 — the per-day expected count is the engine's actual - // due-slot count for THIS day, not the static schedule count. iOS - // history paints a "missed" mark only when `due === true`, so - // off-weeks / non-matching weekdays / PRN days no longer show a - // false miss. `expected` is kept populated (= expectedCount) for - // existing web consumers that read it; `due` + `expectedCount` are - // the explicit additive fields iOS keys off. - const expectedCount = expectedSlotCountForDay( - medication.schedules, - dayStart, - dayEnd, - medicationContext, - // v1.13.x — pass the intake history so a ROLLING schedule's per-day - // `due` flags come from the retrospective grid (each logged dose is a - // due day) instead of the engine's single forward slot — keeping the - // heatmap, the denominator, and the displayed rate in agreement. - mapped, - ); + for (const row of ledgerRows) { + const key = userDayKey(row.at, userTz); + const entry = ensureDay(key); + bucketLedgerRow(entry, row); + } - dailyCompliance[dateKey] = { - expected: expectedCount, - expectedCount, - due: expectedCount > 0, - taken: takenEvents.length, - skipped: dayEvents.filter((e) => e.skipped).length, - onTime: onTime + early, - late, - veryLate, - early, - }; + for (const [key, entry] of byDay) { + entry.due = entry.expectedCount > 0; + dailyCompliance[key] = entry; } annotate({ diff --git a/src/lib/analytics/__tests__/compliance.test.ts b/src/lib/analytics/__tests__/compliance.test.ts index 6e1527cce..95ee18abd 100644 --- a/src/lib/analytics/__tests__/compliance.test.ts +++ b/src/lib/analytics/__tests__/compliance.test.ts @@ -25,6 +25,7 @@ import { expectedSlotCountForDay, expectedSlotsBetween, lastNonSkippedTakenAt, + tallyComplianceFromLedger, type ComplianceMedicationContext, type ComplianceSchedule, } from "../compliance"; @@ -447,20 +448,67 @@ describe("calculateCompliance — engine-routed (medicationContext)", () => { }; } - it("parity: a legacy daysOfWeek-only schedule yields identical numbers with no context", () => { + it("on-time takes: the ledger scores every logged 08:00 dose as taken (100%)", () => { + // v1.15.18 — the context path now tallies the unified dose-history + // ledger (band membership) instead of the legacy ±12h proximity timeline. + // An 08:00 daily dose taken at 08:00 sits inside the ±60min on-time band → + // taken_on_time. The seven logged days all score taken; the current-day + // 08:00 slot is still inside its takeable window at NOW (12:00, the late + // tail ends at 12:00) so it reads upcoming, not missed → a clean 100%. + // (The off-time / stricter behaviour is pinned in the next test.) const schedules: ComplianceSchedule[] = [ - { windowStart: "08:00", windowEnd: "09:00", daysOfWeek: null }, + { windowStart: "08:00", windowEnd: "09:00", daysOfWeek: null, timesOfDay: ["08:00"] }, ]; - const events = Array.from({ length: 7 }, (_, i) => - eventAt(new Date(NOW.getTime() - (i + 1) * DAY_MS), true), - ); - const legacy = calculateCompliance(events, schedules, 7); + const onTime = (d: Date) => ({ + scheduledFor: d, + takenAt: d, + skipped: false, + }); + const events = Array.from({ length: 7 }, (_, i) => { + const day = new Date(NOW.getTime() - (i + 1) * DAY_MS); + day.setUTCHours(8, 0, 0, 0); + return onTime(day); + }); const withCtx = calculateCompliance(events, schedules, 7, undefined, { medicationContext: ctx(), }); - // Legacy daysOfWeek-only schedule has no canonical fields → both - // paths run the legacy walker → identical numbers. - expect(withCtx).toEqual(legacy); + // The window floors at NOW − 7d (Jan-8 12:00), so Jan-8's 08:00 dose falls + // just before it; the six in-window logged doses (Jan-9..Jan-14) all score + // taken and the current-day slot is still upcoming → 100%. + expect(withCtx.taken).toBe(6); + expect(withCtx.missed).toBe(0); + expect(withCtx.rate).toBe(100); + }); + + it("off-time take: the ledger is stricter than the legacy ±12h proximity matcher", () => { + // v1.15.18 — the keystone behaviour change. A daily 08:00 dose logged at + // 12:30 (4.5h late) USED to count as taken under the ±12h `pairDoses` + // proximity matcher. The unified band model attributes it honestly: 12:30 + // is outside the 08:00 slot's on-time band (07:00–09:00) AND its late tail + // (to ~12:00), so it is an AD-HOC take and the slot reads MISSED. The + // percentage now agrees with the history view (the dose cannot read + // "taken" in the % while the ledger calls it ad-hoc). + const schedules: ComplianceSchedule[] = [ + { windowStart: "08:00", windowEnd: "09:00", daysOfWeek: null, timesOfDay: ["08:00"] }, + ]; + // takenAt = 08:00 slot + 4.5h = 12:30 (eventAt adds 30min onto a 12:00 + // scheduledFor; here we pin the off-time take explicitly). + const events = Array.from({ length: 7 }, (_, i) => { + const day = new Date(NOW.getTime() - (i + 1) * DAY_MS); + day.setUTCHours(8, 0, 0, 0); + return { + scheduledFor: day, + takenAt: new Date(day.getTime() + 4.5 * 60 * 60 * 1000), + skipped: false, + }; + }); + const withCtx = calculateCompliance(events, schedules, 7, undefined, { + medicationContext: ctx(), + }); + // Every off-time take is ad-hoc → its slot is missed → 0% taken. + expect(withCtx.taken).toBe(0); + expect(withCtx.missed).toBeGreaterThanOrEqual(6); + expect(withCtx.rate).toBe(0); }); it("FREQ=WEEKLY;BYDAY=MO — took every Monday → 100%, denominator counts only Mondays", () => { @@ -2163,39 +2211,40 @@ describe("calculateCompliance — irregular twice-daily taker (BUG 3 verify)", ( return new Date(`2025-01-${String(day).padStart(2, "0")}T${hhmm}:00Z`); } - it("snaps two off-time same-day takes to BOTH slots → 2 taken, not 1 taken + 1 missed", () => { - // The crux of BUG 3: a day with a late-morning take (09:13) and an - // evening take (19:00) must read as two taken doses. The morning take - // pairs to the 07:00 slot (2h13m away, well inside the ±6h half-gap) and - // the evening to the 19:00 slot — NOT the morning take landing on 19:00 - // and the 07:00 slot reading falsely missed. + it("attributes two same-day takes to BOTH slots — a near-on-time morning take is taken_late, not orphaned", () => { + // v1.15.18 — the band model attributes each take to its OWN slot. A + // morning take at 08:13 (07:00 slot + 1h13m) is inside the 07:00 slot's + // late tail (07:00 + 60min on-time + 180min tail → to 10:00), so it reads + // taken_late and does NOT orphan the 07:00 slot onto the 19:00 row. The + // evening 19:00 take is on-time. The crux of the original BUG-3 fix + // survives the move off the ±6h snap: a same-day morning + evening take + // never collapse into one taken + one falsely-missed. // - // A 2-day window so BOTH of Jan-14's slots fall inside [NOW-2d, NOW] - // (a 1-day window starts at noon Jan-14, which is after the 07:00 slot). + // Cover EVERY slot the window emits so there is no stray uncovered slot: + // a 3-day window (NOW − 3d = Jan-12 12:00 .. Jan-15 12:00) holds Jan-12 + // 19:00, Jan-13 07:00+19:00, Jan-14 07:00+19:00, Jan-15 07:00 = 6 slots. + const onTime = (s: Date) => ({ scheduledFor: s, takenAt: s, skipped: false }); const events = [ - // 07:00 slot, logged at 09:13 (off-time, snapped onto the 07:00 row). + onTime(slot(12, "19:00")), + onTime(slot(13, "07:00")), + onTime(slot(13, "19:00")), + // 07:00 slot, logged at 08:13 (within the late tail → taken_late). { scheduledFor: slot(14, "07:00"), - takenAt: new Date(slot(14, "07:00").getTime() + (2 * 60 + 13) * 60 * 1000), + takenAt: new Date(slot(14, "07:00").getTime() + (1 * 60 + 13) * 60 * 1000), skipped: false, }, - // 19:00 slot, logged on time. - { scheduledFor: slot(14, "19:00"), takenAt: slot(14, "19:00"), skipped: false }, - // Jan-13's slots are covered too so the window has no stray miss. - { scheduledFor: slot(13, "07:00"), takenAt: slot(13, "07:00"), skipped: false }, - { scheduledFor: slot(13, "19:00"), takenAt: slot(13, "19:00"), skipped: false }, + onTime(slot(14, "19:00")), + onTime(slot(15, "07:00")), ]; - const result = calculateCompliance(events, twiceDaily, 2, undefined, { + const result = calculateCompliance(events, twiceDaily, 3, undefined, { now: NOW, medicationContext: ctxVal, }); - // Both of Jan-14's off-time/on-time takes pair to their own slot → the - // morning slot is NOT falsely missed. No missed slot anywhere → 100%. + // Every slot is covered by its own take → no miss, 100%. expect(result.missed).toBe(0); expect(result.rate).toBe(100); - // The morning take did not orphan the 07:00 slot: at least Jan-14's two - // doses both register as taken. - expect(result.taken).toBeGreaterThanOrEqual(3); + expect(result.taken).toBe(6); }); it("computes the rate as taken/(taken+missed) over a 7-day irregular pattern", () => { @@ -2437,3 +2486,337 @@ describe("doseCadenceFamily", () => { ).toBe("daily"); }); }); + +// ──────────────────────────────────────────────────────────────────── +// v1.15.18 — the UNIFIED ledger tally. `tallyComplianceFromLedger` is the +// ONE authority behind the compliance %: it builds the cadence-aware bands +// per schedule, reconstructs the dose-history ledger, and tallies it so the +// % and the history view can never contradict. These tests pin the +// taking-vs-timing split, the exclusions (skip / ad-hoc / upcoming / PRN), +// auto-missed=missed, and the cross-consistency invariant that the SAME +// intake can't be "taken" in the % while the ledger calls it "ad_hoc". +// ──────────────────────────────────────────────────────────────────── + +import { localHmAsUtc } from "@/lib/timezone"; +import { + buildBandsForSchedules, + type BandMinterMedication, +} from "@/lib/medications/scheduling/band-minter"; +import { + reconstructDoseHistory, + type HistoryIntake, +} from "@/lib/medications/scheduling/dose-history"; +import type { CanonicalSchedule, RecurrenceContext } from "@/lib/medications/scheduling/recurrence"; + +describe("tallyComplianceFromLedger — the unified % keystone", () => { + const TZ = "Europe/Berlin"; + const dayRef = new Date("2026-06-08T12:00:00Z"); + function at(h: number, m: number): Date { + return localHmAsUtc(dayRef, TZ, h, m); + } + function ctxFor( + over: Partial = {}, + ): ComplianceMedicationContext { + return { + startsOn: new Date("2026-06-01T00:00:00Z"), + endsOn: null, + oneShot: false, + createdAt: new Date("2026-06-01T00:00:00Z"), + lastIntakeAt: null, + timeZone: TZ, + ...over, + }; + } + // Twice-daily 07:00 / 19:00, single window covering the whole day. + const twiceDaily: ComplianceSchedule[] = [ + { + windowStart: "07:00", + windowEnd: "19:00", + daysOfWeek: null, + timesOfDay: ["07:00", "19:00"], + }, + ]; + // Late evening "now" so both of the reference day's slots are past cutoff. + const nowEvening = at(23, 59); + // Window covers just the reference day. + const from = at(0, 0); + + it("a late take counts as taken (taking adherence), with the timing split exposed", () => { + // 07:00 on-time + 19:00 logged at 20:30 (inside the 180min late tail). + const events = [ + { scheduledFor: at(7, 0), takenAt: at(7, 0), skipped: false }, + { scheduledFor: at(19, 0), takenAt: at(20, 30), skipped: false }, + ]; + const tally = tallyComplianceFromLedger( + events, + twiceDaily, + ctxFor(), + from, + nowEvening, + nowEvening, + ); + expect(tally.taken).toBe(2); + expect(tally.takenOnTime).toBe(1); + expect(tally.takenLate).toBe(1); + expect(tally.missed).toBe(0); + expect(tally.rate).toBe(100); + }); + + it("a deliberate user-skip is EXCLUDED from the denominator", () => { + const events = [ + { scheduledFor: at(7, 0), takenAt: at(7, 0), skipped: false }, + { scheduledFor: at(19, 0), takenAt: null, skipped: true }, + ]; + const tally = tallyComplianceFromLedger( + events, + twiceDaily, + ctxFor(), + from, + nowEvening, + nowEvening, + ); + expect(tally.taken).toBe(1); + expect(tally.skipped).toBe(1); + expect(tally.missed).toBe(0); + expect(tally.denominator).toBe(1); // taken + missed, skip excluded + expect(tally.rate).toBe(100); + }); + + it("an auto-missed forgotten dose IS a miss (counts against the rate)", () => { + const events = [ + { scheduledFor: at(7, 0), takenAt: at(7, 0), skipped: false }, + { + scheduledFor: at(19, 0), + takenAt: null, + skipped: false, + autoMissed: true, + }, + ]; + const tally = tallyComplianceFromLedger( + events, + twiceDaily, + ctxFor(), + from, + nowEvening, + nowEvening, + ); + expect(tally.taken).toBe(1); + expect(tally.missed).toBe(1); + expect(tally.skipped).toBe(0); + expect(tally.rate).toBe(50); + }); + + it("an off-schedule (ad-hoc) take is EXCLUDED + leaves its slot missed", () => { + // Marc's case: 07:00 dose logged at 11:29 → outside the band → ad-hoc; + // the 07:00 slot is missed; the 19:00 slot is also missed (untouched). + const events = [ + { scheduledFor: at(7, 0), takenAt: at(11, 29), skipped: false }, + ]; + const tally = tallyComplianceFromLedger( + events, + twiceDaily, + ctxFor(), + from, + nowEvening, + nowEvening, + ); + expect(tally.adHoc).toBe(1); + // The ad-hoc take is NOT in taken; both slots are missed. + expect(tally.taken).toBe(0); + expect(tally.missed).toBe(2); + // Denominator = taken + missed; ad-hoc never enters it. + expect(tally.denominator).toBe(2); + expect(tally.rate).toBe(0); + }); + + it("a future slot is upcoming — excluded, never a premature miss", () => { + // Early morning now: both slots still ahead of their miss cutoff. + const earlyNow = at(5, 0); + const tally = tallyComplianceFromLedger( + [], + twiceDaily, + ctxFor(), + from, + earlyNow, + earlyNow, + ); + expect(tally.taken).toBe(0); + expect(tally.missed).toBe(0); + expect(tally.denominator).toBe(0); + expect(tally.rate).toBe(100); // empty-window contract + }); + + it("a PRN schedule contributes NO denominator (every intake excluded)", () => { + const prn: ComplianceSchedule[] = [ + { + windowStart: "07:00", + windowEnd: "19:00", + daysOfWeek: null, + timesOfDay: ["07:00"], + scheduleType: "PRN", + }, + ]; + const events = [ + { scheduledFor: at(7, 0), takenAt: at(7, 0), skipped: false }, + { scheduledFor: at(14, 0), takenAt: at(14, 0), skipped: false }, + ]; + const tally = tallyComplianceFromLedger( + events, + prn, + ctxFor(), + from, + nowEvening, + nowEvening, + ); + // PRN → hasExpectedSlots false → no bands → every take ad-hoc, no slots. + expect(tally.taken).toBe(0); + expect(tally.missed).toBe(0); + expect(tally.adHoc).toBe(2); + expect(tally.denominator).toBe(0); + expect(tally.rate).toBe(100); + }); + + it("the rate is capped at 100% (extra doses never inflate it)", () => { + // Both slots taken + an ad-hoc extra. Ad-hoc is excluded so the rate is a + // clean 100, never >100. + const events = [ + { scheduledFor: at(7, 0), takenAt: at(7, 0), skipped: false }, + { scheduledFor: at(19, 0), takenAt: at(19, 0), skipped: false }, + { scheduledFor: at(13, 0), takenAt: at(13, 0), skipped: false }, // extra + ]; + const tally = tallyComplianceFromLedger( + events, + twiceDaily, + ctxFor(), + from, + nowEvening, + nowEvening, + ); + expect(tally.taken).toBe(2); + expect(tally.adHoc).toBe(1); + expect(tally.rate).toBe(100); + }); + + it("CROSS-CONSISTENCY: the same intake cannot be taken in the % and ad_hoc in the ledger", () => { + // The keystone invariant. Build the exact bands the tally builds, run the + // SAME reconstructDoseHistory, and assert the tally's per-status counts + // equal the ledger's per-status counts — so a dose graded "taken" by the % + // is a "taken_*" row in the history view, and an "ad_hoc" row never leaks + // into the taken numerator. + const events = [ + { scheduledFor: at(7, 0), takenAt: at(7, 0), skipped: false }, // on-time + { scheduledFor: at(19, 0), takenAt: at(20, 30), skipped: false }, // late + { scheduledFor: at(7, 0), takenAt: at(11, 29), skipped: false }, // ad-hoc + ]; + const tally = tallyComplianceFromLedger( + events, + twiceDaily, + ctxFor(), + from, + nowEvening, + nowEvening, + ); + + // Re-derive the ledger independently the same way the tally does. + const medication: BandMinterMedication = { + id: "x", + startsOn: ctxFor().startsOn, + endsOn: null, + oneShot: false, + createdAt: ctxFor().createdAt, + }; + const recurrenceCtx: RecurrenceContext = { + medication: { + id: "x", + startsOn: ctxFor().startsOn, + endsOn: null, + oneShot: false, + createdAt: ctxFor().createdAt, + }, + timeZone: TZ, + lastIntakeAt: null, + }; + const canonical: CanonicalSchedule = { + id: "s", + rrule: null, + rollingIntervalDays: null, + timesOfDay: ["07:00", "19:00"], + daysOfWeek: null, + windowStart: "07:00", + windowEnd: "19:00", + reminderGraceMinutes: null, + scheduleType: "SCHEDULED", + cyclicOnWeeks: null, + cyclicOffWeeks: null, + }; + const groups = buildBandsForSchedules({ + medication, + schedules: [canonical], + ctx: recurrenceCtx, + userTz: TZ, + range: { from, to: nowEvening }, + now: nowEvening, + intakeInstants: events + .filter((e) => !e.skipped && e.takenAt) + .map((e) => e.takenAt as Date), + }); + const bands = groups.flatMap((g) => (g.hasExpectedSlots ? g.bands : [])); + const intakes: HistoryIntake[] = events.map((e) => ({ + scheduledFor: e.scheduledFor, + takenAt: e.takenAt, + skipped: e.skipped, + autoMissed: false, + })); + const rows = reconstructDoseHistory(bands, intakes, nowEvening); + + const fromLedger = { + onTime: rows.filter((r) => r.status === "taken_on_time").length, + late: rows.filter((r) => r.status === "taken_late").length, + missed: rows.filter((r) => r.status === "missed").length, + adHoc: rows.filter((r) => r.status === "ad_hoc").length, + }; + expect(tally.takenOnTime).toBe(fromLedger.onTime); + expect(tally.takenLate).toBe(fromLedger.late); + expect(tally.missed).toBe(fromLedger.missed); + expect(tally.adHoc).toBe(fromLedger.adHoc); + // And the load-bearing claim: the ad-hoc take is NOT counted as taken. + expect(tally.taken).toBe(fromLedger.onTime + fromLedger.late); + expect(tally.adHoc).toBe(1); + }); + + it("rolling weekly (GLP-1) — each logged shot is a taken slot (retrospective family)", () => { + const rolling: ComplianceSchedule[] = [ + { + windowStart: "08:00", + windowEnd: "08:00", + daysOfWeek: null, + rollingIntervalDays: 7, + timesOfDay: ["08:00"], + }, + ]; + const shot1 = new Date("2026-05-04T09:12:00Z"); + const shot2 = new Date("2026-05-11T18:40:00Z"); + const shot3 = new Date("2026-05-18T07:55:00Z"); + const now = new Date("2026-05-20T12:00:00Z"); + const events = [shot1, shot2, shot3].map((d) => ({ + scheduledFor: d, + takenAt: d, + skipped: false, + })); + const tally = tallyComplianceFromLedger( + events, + rolling, + ctxFor({ + startsOn: new Date("2026-05-01T00:00:00Z"), + createdAt: new Date("2026-05-01T00:00:00Z"), + lastIntakeAt: shot3, + }), + new Date("2026-05-01T00:00:00Z"), + now, + now, + ); + // Each irregular shot anchors its own retrospective band → all taken. + expect(tally.taken).toBeGreaterThanOrEqual(3); + expect(tally.rate).toBeGreaterThanOrEqual(90); + }); +}); diff --git a/src/lib/analytics/compliance.ts b/src/lib/analytics/compliance.ts index de336999b..d6f5c0d23 100644 --- a/src/lib/analytics/compliance.ts +++ b/src/lib/analytics/compliance.ts @@ -19,6 +19,12 @@ * daily compliance heatmap on `/api/medications/[id]/compliance`. */ +import type { SlotBand } from "@/lib/medications/scheduling/attribution"; +import { + buildBandsForSchedules, + type BandMinterMedication, + type DoseWindowConfig, +} from "@/lib/medications/scheduling/band-minter"; import { buildCadenceTimeline, type CadenceEngineContext, @@ -26,6 +32,10 @@ import { type ScheduleLike, } from "@/lib/medications/scheduling/cadence"; import { streaksFromTimeline } from "@/lib/medications/scheduling/compliance"; +import { + reconstructDoseHistory, + type HistoryIntake, +} from "@/lib/medications/scheduling/dose-history"; import { expandRollingRetrospective, nextOccurrenceAfter, @@ -1094,6 +1104,177 @@ function soonestCadenceFamily( return "daily"; } +/** + * v1.15.18 — the unified compliance tally over the dose-history ledger. + * + * THE single source of the medication compliance %. It builds the + * cadence-aware `SlotBand[]` per schedule (the shared band minter — every + * cadence: daily, fixed-weekday, rolling-retrospective, one-shot, cyclic, + * PRN), reconstructs the ONE dose-history ledger + * (`reconstructDoseHistory`), and tallies it so the percentage and the + * history view are mathematically incapable of contradicting each other + * (audit CRITICAL-2). It replaces the ±12h `pairDoses` proximity matcher + * that `calculateCompliance` used for the engine-routed path. + * + * The tally follows the adherence literature's TAKING-vs-TIMING split: + * - numerator (taken) = `taken_on_time` + `taken_late` — a late dose is + * still a taken dose; "late" is NOT collapsed into "missed"; + * - denominator = taken + `missed`; + * - EXCLUDED from the denominator: `skipped` (deliberate user decision), + * `ad_hoc` (off-schedule top-up — no defensible slot), `upcoming` (the + * window hasn't opened), and ENTIRE PRN groups (`hasExpectedSlots:false` + * — PRN has no defensible denominator per the literature); + * - the rate is capped at 100% (extra doses never inflate it). + * + * The on-time / late split is surfaced separately so a caller can show both + * the TAKING rate (the headline) and the TIMING quality. + * + * Pure / synchronous: the bands are minted from pre-fetched schedules + + * intake instants; no DB access. + */ +export interface LedgerComplianceTally { + /** Doses taken (on-time + late). The TAKING-adherence numerator. */ + taken: number; + /** Of `taken`, the count inside the on-time band. */ + takenOnTime: number; + /** Of `taken`, the count in the late tail (still counts as taken). */ + takenLate: number; + /** Expected doses never acted on past their miss cutoff. */ + missed: number; + /** Deliberate user skips — excluded from the denominator. */ + skipped: number; + /** Off-schedule intakes (PRN groups + ad-hoc rows) — excluded. */ + adHoc: number; + /** taken + missed (the rate denominator). */ + denominator: number; + /** round(100 · taken / denominator), capped at 100. 100 on empty. */ + rate: number; +} + +export function tallyComplianceFromLedger( + events: IntakeEvent[], + schedules: ComplianceSchedule[], + ctx: ComplianceMedicationContext, + from: Date, + to: Date, + now: Date, + windowConfig?: DoseWindowConfig, +): LedgerComplianceTally { + const medication: BandMinterMedication = { + id: "compliance-tally", + startsOn: ctx.startsOn, + endsOn: ctx.endsOn, + oneShot: ctx.oneShot, + createdAt: ctx.createdAt, + }; + const recurrenceCtx = toRecurrenceCtx(ctx, "compliance-tally"); + const canonicalSchedules = schedules.map((s, i) => { + const canonical = toCanonicalSchedule(s, `compliance-tally-${i}`); + // A legacy daily schedule carries only `windowStart` (no `timesOfDay`, + // no rrule, no rolling, no `daysOfWeek`). The engine's `expandLegacy` + // reads that as "every day at windowStart", but the band minter's + // cadence-detection gate needs an explicit time signal — surface + // `windowStart` as the single time-of-day so the daily band is minted. + if ( + canonical.timesOfDay.length === 0 && + canonical.rrule === null && + canonical.rollingIntervalDays === null && + canonical.scheduleType !== "PRN" && + !ctx.oneShot + ) { + return { ...canonical, timesOfDay: [canonical.windowStart] }; + } + return canonical; + }); + // Rolling cadences anchor their retrospective grid AT each logged intake; + // the bands need every non-skipped take in (or before) the window. Reuse + // the same instant-extraction the legacy rolling path uses so the + // numerator and denominator are built from one expansion. + const intakeInstants = intakeInstantsAtOrBefore( + events.map((e) => ({ takenAt: e.takenAt, skipped: e.skipped })), + to, + ); + + const groups = buildBandsForSchedules({ + medication, + schedules: canonicalSchedules, + ctx: recurrenceCtx, + userTz: ctx.timeZone, + range: { from, to }, + now, + windowConfig, + intakeInstants, + }); + + // The ledger reads `scheduledFor` + `takenAt` + skip/auto-miss flags. The + // bands already partition the slot space, so the union of every + // non-PRN schedule's bands is fed to ONE reconstruction — `reconstruct + // DoseHistory` claims each slot by at most one intake, so pooling the + // (already correctly-minted) bands is safe. PRN groups (no expected + // slots) contribute no bands; their intakes surface as ad-hoc and are + // excluded from the denominator, exactly as the literature requires. + const bands: SlotBand[] = []; + for (const g of groups) { + if (g.hasExpectedSlots) bands.push(...g.bands); + } + + const intakes: HistoryIntake[] = events + .filter((e) => e.scheduledFor >= from && e.scheduledFor <= to) + .map((e) => ({ + scheduledFor: e.scheduledFor, + takenAt: e.takenAt, + skipped: e.skipped, + autoMissed: e.autoMissed ?? false, + })); + + const rows = reconstructDoseHistory(bands, intakes, now); + + let takenOnTime = 0; + let takenLate = 0; + let missed = 0; + let skipped = 0; + let adHoc = 0; + for (const row of rows) { + switch (row.status) { + case "taken_on_time": + takenOnTime++; + break; + case "taken_late": + takenLate++; + break; + case "missed": + missed++; + break; + case "skipped": + skipped++; + break; + case "ad_hoc": + adHoc++; + break; + // `upcoming` slots are future / still-takeable → excluded from every + // counter so a partial head-of-window day never pollutes the rate. + } + } + + const taken = takenOnTime + takenLate; + const denominator = taken + missed; + const rate = + denominator > 0 + ? Math.min(100, Math.round((taken / denominator) * 100)) + : 100; + + return { + taken, + takenOnTime, + takenLate, + missed, + skipped, + adHoc, + denominator, + rate, + }; +} + /** * Calculate compliance for a medication over a given period. * @@ -1168,6 +1349,41 @@ export function calculateCompliance( Math.ceil((now.getTime() - effectiveStart.getTime()) / DAY_MS), ); + // v1.15.18 — when a medication context is supplied, the count fields + // (taken / skipped / missed / rate) come from the UNIFIED dose-history + // ledger tally, NOT the ±12h `pairDoses` proximity matcher. This is the + // keystone unification: the percentage is a tally over the exact same + // ledger the history view renders, so the two can never contradict (a + // dose can't read "taken late" in the % while the ledger calls it + // "ad-hoc"). The numerator is on-time + late takes (a late dose still + // counts as taken); user skips + ad-hoc top-ups + PRN groups are excluded + // from the denominator. The streak still walks the cadence timeline below + // (its day-grain "every dose taken or skipped" rule is unchanged). + // + // Context-less callers (pure-math fixtures, pre-v1.7 surfaces) keep the + // legacy timeline tally byte-stable — they have no engine context to mint + // bands from. + const ledgerCtx = options?.medicationContext; + let ledgerCounts: + | { taken: number; skipped: number; missed: number; rate: number } + | null = null; + if (ledgerCtx) { + const tally = tallyComplianceFromLedger( + events, + schedules, + ledgerCtx, + effectiveStart, + now, + now, + ); + ledgerCounts = { + taken: tally.taken, + skipped: tally.skipped, + missed: tally.missed, + rate: tally.rate, + }; + } + // Normalise the schedule shape so legacy callers that pass only // `{ windowStart, windowEnd }` still produce a usable `daysOfWeek` // field (treated as daily by the cadence parser). @@ -1252,23 +1468,36 @@ export function calculateCompliance( retro, ); - let taken = 0; - let skipped = 0; - let missed = 0; - for (const slot of timeline) { - if (slot.status === "taken") taken++; - else if (slot.status === "skipped") skipped++; - else if (slot.status === "missed") missed++; - // `upcoming` slots (future window) are excluded from every counter - // so a partial day at the head of the window doesn't pollute the rate. + // v1.15.18 — the count fields come from the unified ledger tally when a + // medication context was supplied (the keystone unification), and from the + // legacy timeline tally otherwise (context-less pure-math callers). The + // streak below always walks the timeline — its day-grain rule is orthogonal + // to the per-dose attribution and stays byte-stable for every caller. + let taken: number; + let skipped: number; + let missed: number; + let rate: number; + if (ledgerCounts) { + ({ taken, skipped, missed, rate } = ledgerCounts); + } else { + taken = 0; + skipped = 0; + missed = 0; + for (const slot of timeline) { + if (slot.status === "taken") taken++; + else if (slot.status === "skipped") skipped++; + else if (slot.status === "missed") missed++; + // `upcoming` slots (future window) are excluded from every counter + // so a partial day at the head of the window doesn't pollute the rate. + } + // Skipped doses are excluded from the denominator — they represent a + // deliberate user decision rather than a missed dose. + const denom = taken + missed; + rate = + denom > 0 ? Math.min(100, Math.round((taken / denom) * 100)) : 100; } const totalExpected = taken + skipped + missed; - // Skipped doses are excluded from the denominator — they represent a - // deliberate user decision rather than a missed dose. - const denom = taken + missed; - const rate = - denom > 0 ? Math.min(100, Math.round((taken / denom) * 100)) : 100; // Streak: consecutive days, ending today, where every expected dose // was taken or skipped. Days with no expected dose advance the diff --git a/src/lib/medications/scheduling/cadence.ts b/src/lib/medications/scheduling/cadence.ts index 5288000a2..63b6bff73 100644 --- a/src/lib/medications/scheduling/cadence.ts +++ b/src/lib/medications/scheduling/cadence.ts @@ -28,6 +28,15 @@ */ import { parseScheduleRecurrence } from "@/lib/medication-schedule"; +import type { SlotBand } from "@/lib/medications/scheduling/attribution"; +import { + buildBandsForSchedules, + type BandMinterMedication, +} from "@/lib/medications/scheduling/band-minter"; +import { + reconstructDoseHistory, + type HistoryIntake, +} from "@/lib/medications/scheduling/dose-history"; import { type CanonicalSchedule, type RecurrenceContext, @@ -790,6 +799,19 @@ export function missedDoses( timeZone?: string, engineCtx?: CadenceEngineContext, ): number { + // v1.15.18 — when an engine context is threaded the missed count comes from + // the unified band ledger (the same one the % and the history view read), so + // every "missed" surface agrees. Pure-math callers (no context) keep the + // legacy timeline-status count. + const ledger = missedFromLedger( + schedules, + events, + asOf, + windowDays, + timeZone, + engineCtx, + ); + if (ledger !== null) return ledger; const timeline = buildCadenceTimeline( schedules, events, @@ -801,3 +823,80 @@ export function missedDoses( ); return timeline.filter((d) => d.status === "missed").length; } + +/** + * v1.15.18 — the count of `missed` rows in the unified dose-history ledger + * over the trailing window. Returns null when no engine context is supplied + * (the caller then uses the legacy timeline tally). Shares the band minter + + * `reconstructDoseHistory` with the compliance % so the numbers agree. + */ +function missedFromLedger( + schedules: ScheduleLike[], + events: IntakeEventLike[], + asOf: Date, + windowDays: number, + timeZone: string | undefined, + engineCtx: CadenceEngineContext | undefined, +): number | null { + if (!engineCtx) return null; + const from = new Date(asOf.getTime() - windowDays * DAY_MS); + const userTz = engineCtx.timeZone || timeZone || "UTC"; + const medication: BandMinterMedication = { + id: "missed-tally", + startsOn: engineCtx.startsOn, + endsOn: engineCtx.endsOn, + oneShot: engineCtx.oneShot, + createdAt: engineCtx.createdAt, + }; + const recurrenceCtx: RecurrenceContext = { + medication: { + id: "missed-tally", + startsOn: engineCtx.startsOn, + endsOn: engineCtx.endsOn, + oneShot: engineCtx.oneShot, + createdAt: engineCtx.createdAt, + }, + timeZone: userTz, + lastIntakeAt: engineCtx.lastIntakeAt, + }; + const canonicalSchedules: CanonicalSchedule[] = schedules.map((s, i) => { + const base = toCanonical(s, i); + if ( + base.timesOfDay.length === 0 && + base.rrule === null && + base.rollingIntervalDays === null && + base.scheduleType !== "PRN" && + !engineCtx.oneShot + ) { + return { ...base, timesOfDay: [base.windowStart] }; + } + return base; + }); + const intakeInstants = events + .filter((e) => !e.skipped && e.takenAt !== null && e.takenAt <= asOf) + .map((e) => e.takenAt as Date) + .sort((a, b) => a.getTime() - b.getTime()); + const groups = buildBandsForSchedules({ + medication, + schedules: canonicalSchedules, + ctx: recurrenceCtx, + userTz, + range: { from, to: asOf }, + now: asOf, + intakeInstants, + }); + const bands: SlotBand[] = []; + for (const g of groups) { + if (g.hasExpectedSlots) bands.push(...g.bands); + } + const intakes: HistoryIntake[] = events + .filter((e) => e.scheduledFor >= from && e.scheduledFor <= asOf) + .map((e) => ({ + scheduledFor: e.scheduledFor, + takenAt: e.takenAt, + skipped: e.skipped, + autoMissed: e.autoMissed ?? false, + })); + const rows = reconstructDoseHistory(bands, intakes, asOf); + return rows.filter((r) => r.status === "missed").length; +} diff --git a/src/lib/medications/scheduling/compliance.ts b/src/lib/medications/scheduling/compliance.ts index cb816f44c..e06061609 100644 --- a/src/lib/medications/scheduling/compliance.ts +++ b/src/lib/medications/scheduling/compliance.ts @@ -19,6 +19,11 @@ * legacy system-local behaviour the W19e tests pin. */ +import type { SlotBand } from "./attribution"; +import { + buildBandsForSchedules, + type BandMinterMedication, +} from "./band-minter"; import { buildCadenceTimeline, startOfLocalDay, @@ -27,6 +32,14 @@ import { type PairedDose, type ScheduleLike, } from "./cadence"; +import { + reconstructDoseHistory, + type HistoryIntake, +} from "./dose-history"; +import { + type CanonicalSchedule, + type RecurrenceContext, +} from "./recurrence"; export interface ComplianceChips { /** 0-100, taken / (taken + missed). Skipped doses are excluded from @@ -142,6 +155,116 @@ export function streaksFromTimeline( return { current, longest }; } +/** + * v1.15.18 — band-ledger tally counts for the detail-page chips. + * + * When the route threads an engine context (RRULE / rolling / cyclic / + * one-shot / PRN / legacy) the adherence rate + missed count come from the + * SAME unified dose-history ledger the compliance % and the history view + * read — so the chips can never contradict either. The streak still walks + * the cadence timeline (its day-grain "every dose taken or skipped" rule is + * orthogonal to per-dose attribution). Returns null when there is no engine + * context (a pure-math caller) — the caller then falls back to the legacy + * timeline tally. + */ +function ledgerChipCounts( + schedules: ScheduleLike[], + events: IntakeEventLike[], + asOf: Date, + windowDays: number, + timeZone: string | undefined, + engineCtx: CadenceEngineContext | undefined, +): { taken: number; missed: number } | null { + if (!engineCtx) return null; + const DAY_MS = 24 * 60 * 60 * 1000; + const from = new Date(asOf.getTime() - windowDays * DAY_MS); + const userTz = engineCtx.timeZone || timeZone || "UTC"; + + const medication: BandMinterMedication = { + id: "chips-tally", + startsOn: engineCtx.startsOn, + endsOn: engineCtx.endsOn, + oneShot: engineCtx.oneShot, + createdAt: engineCtx.createdAt, + }; + const recurrenceCtx: RecurrenceContext = { + medication: { + id: "chips-tally", + startsOn: engineCtx.startsOn, + endsOn: engineCtx.endsOn, + oneShot: engineCtx.oneShot, + createdAt: engineCtx.createdAt, + }, + timeZone: userTz, + lastIntakeAt: engineCtx.lastIntakeAt, + }; + const canonicalSchedules: CanonicalSchedule[] = schedules.map((s, i) => { + const base: CanonicalSchedule = { + id: s.id ?? `chips-${i}`, + rrule: s.rrule ?? null, + rollingIntervalDays: s.rollingIntervalDays ?? null, + timesOfDay: s.timesOfDay ?? [], + daysOfWeek: s.daysOfWeek ?? null, + windowStart: s.windowStart, + windowEnd: s.windowEnd, + reminderGraceMinutes: s.reminderGraceMinutes ?? null, + scheduleType: s.scheduleType ?? "SCHEDULED", + cyclicOnWeeks: s.cyclicOnWeeks ?? null, + cyclicOffWeeks: s.cyclicOffWeeks ?? null, + }; + // Surface windowStart as the single time-of-day for a legacy daily row + // so the band minter's cadence gate mints its daily band (see the + // analytics/compliance tally for the same normalisation). + if ( + base.timesOfDay.length === 0 && + base.rrule === null && + base.rollingIntervalDays === null && + base.scheduleType !== "PRN" && + !engineCtx.oneShot + ) { + return { ...base, timesOfDay: [base.windowStart] }; + } + return base; + }); + + const intakeInstants = events + .filter((e) => !e.skipped && e.takenAt !== null && e.takenAt <= asOf) + .map((e) => e.takenAt as Date) + .sort((a, b) => a.getTime() - b.getTime()); + + const groups = buildBandsForSchedules({ + medication, + schedules: canonicalSchedules, + ctx: recurrenceCtx, + userTz, + range: { from, to: asOf }, + now: asOf, + intakeInstants, + }); + const bands: SlotBand[] = []; + for (const g of groups) { + if (g.hasExpectedSlots) bands.push(...g.bands); + } + + const intakes: HistoryIntake[] = events + .filter((e) => e.scheduledFor >= from && e.scheduledFor <= asOf) + .map((e) => ({ + scheduledFor: e.scheduledFor, + takenAt: e.takenAt, + skipped: e.skipped, + autoMissed: e.autoMissed ?? false, + })); + + const rows = reconstructDoseHistory(bands, intakes, asOf); + let taken = 0; + let missed = 0; + for (const row of rows) { + if (row.status === "taken_on_time" || row.status === "taken_late") taken++; + else if (row.status === "missed") missed++; + } + return { taken, missed }; +} + export function complianceChips( schedules: ScheduleLike[], events: IntakeEventLike[], @@ -160,8 +283,23 @@ export function complianceChips( timeZone, engineCtx, ); - const taken = timeline.filter((d) => d.status === "taken").length; - const missed = timeline.filter((d) => d.status === "missed").length; + // v1.15.18 — adherence + missed come from the unified band ledger when an + // engine context is present (so the chips agree with the % and the history + // view); the legacy timeline tally is the fallback for pure-math callers. + const ledger = ledgerChipCounts( + schedules, + events, + asOf, + windowDays, + timeZone, + engineCtx, + ); + const taken = ledger + ? ledger.taken + : timeline.filter((d) => d.status === "taken").length; + const missed = ledger + ? ledger.missed + : timeline.filter((d) => d.status === "missed").length; const denom = taken + missed; const adherenceRate = denom === 0 ? null : Math.round((taken / denom) * 100); const { current, longest } = streaksFromTimeline( diff --git a/src/lib/rollups/medication-compliance-rollups.ts b/src/lib/rollups/medication-compliance-rollups.ts index 8127197be..7abbec4cb 100644 --- a/src/lib/rollups/medication-compliance-rollups.ts +++ b/src/lib/rollups/medication-compliance-rollups.ts @@ -14,6 +14,25 @@ * coverage probe falls back to live + fires a boot backfill when a * user has intake events but zero rollup rows. * + * v1.15.18 DESIGN DECISION — this tier stays a RAW per-day COVERAGE count, + * NOT the band-attributed compliance %. It aggregates, across ALL of a + * user's medications, "of the intake rows whose `scheduled_for` lands on + * this day, how many carry a non-null `taken_at`" — a coarse daily + * cross-medication coverage strip for the dashboard mini-tiles. The + * unified band-membership attribution (the `tallyComplianceFromLedger` + * keystone) is per-medication and per-cadence: it depends on the slot + * bands of each schedule and the user timezone, which cannot be expressed + * in a single cross-medication SQL `SUM(CASE …)`. Re-attributing every + * intake against its cadence's bands before the tally would mean running + * the band minter per `(medication, day)` inside the rollup, which defeats + * the rollup's whole purpose (bounded SQL, no per-row Node walk). The + * detail page + the medication card already read the band-attributed % via + * `calculateCompliance` / `complianceChips`; this tier deliberately remains + * the cheaper coverage signal and is documented as such so the two are not + * confused. The `scheduled` / `taken` column names reflect this: a day's + * `taken` is "intake rows logged taken", not "doses inside their on-time + * band". + * * Audit anchor: `.planning/round-v1438-perf-analysis.md` §2.5 + §5 P4. */ import { prisma } from "@/lib/db";