diff --git a/src/__tests__/dose-ledger-single-source-guard.test.ts b/src/__tests__/dose-ledger-single-source-guard.test.ts new file mode 100644 index 000000000..aff5974b4 --- /dev/null +++ b/src/__tests__/dose-ledger-single-source-guard.test.ts @@ -0,0 +1,55 @@ +/** + * Structural guard: medication compliance is tallied from one ledger. + * + * The cadence chips carried a private copy of the ledger build that + * `lib/analytics/compliance/ledger.ts` owns, minus the pause eras and the + * courses, so `/api/medications/[id]/cadence` could report 77 % for a month + * the detail page called 100 %. The chips now tally the shared ledger. + * + * `reconstructDoseHistory` is the step that turns minted bands and intakes + * into dose rows. The set of files that call it is frozen: the ledger, the + * cadence timeline (the chart and the streaks) and the dose-history view. A + * new caller is a new ledger and has to be added here, next to a reason. + * Comments are stripped first; the count floor proves the matcher reads. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +import { walkSourceFiles } from "./helpers/source-files"; + +const SRC = join(process.cwd(), "src"); + +const CALLERS: Record = { + "lib/analytics/compliance/ledger.ts": + "The compliance ledger every rate and the chips tally.", + "lib/medications/scheduling/cadence.ts": + "The cadence timeline the chart draws and the streaks walk.", + "app/api/medications/[id]/dose-history/route.ts": + "The dose-history view, row by row over the same bands.", +}; + +const CALL = /\breconstructDoseHistory\s*\(/; + +function stripComments(text: string): string { + return text.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, ""); +} + +describe("one compliance ledger", () => { + it("the dose rows are built only where this file says", () => { + const callers = walkSourceFiles(SRC, { floor: 3000 }) + .filter( + (file) => + !file.startsWith("generated/") && + !file.includes("__tests__/") && + !/\.test\.tsx?$/.test(file) && + file !== "lib/medications/scheduling/dose-history.ts", + ) + .filter((file) => + CALL.test(stripComments(readFileSync(join(SRC, file), "utf8"))), + ) + .sort(); + expect(callers.length).toBeGreaterThanOrEqual(3); + expect(callers).toEqual(Object.keys(CALLERS).sort()); + }); +}); diff --git a/src/app/api/medications/[id]/cadence/route.ts b/src/app/api/medications/[id]/cadence/route.ts index 2660a2773..96daeb9b1 100644 --- a/src/app/api/medications/[id]/cadence/route.ts +++ b/src/app/api/medications/[id]/cadence/route.ts @@ -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, diff --git a/src/lib/analytics/compliance/display.ts b/src/lib/analytics/compliance/display.ts index ac2f8cea2..7073eab46 100644 --- a/src/lib/analytics/compliance/display.ts +++ b/src/lib/analytics/compliance/display.ts @@ -29,9 +29,9 @@ import { } from "./windows"; import { COMPLIANCE_LEDGER_WINDOW_DAYS, - buildComplianceLedgerRows, tallyComplianceFromLedger, - tallyLedgerRows, + trailingComplianceLedger, + trailingComplianceTally, } from "./ledger"; import { buildCurrentCycle, type CurrentCycle } from "./cycle"; import { rollingIntakeInstants } from "./adapters"; @@ -564,9 +564,7 @@ export function buildMedicationComplianceBundle( : ledgerPeriodStart; const hasSchedules = schedules.length > 0; - const ledgerRows = hasSchedules - ? buildComplianceLedgerRows(events, schedules, ctx, ledgerFrom, now, now) - : []; + const ledgerRows = trailingComplianceLedger(events, schedules, ctx, now); // ONE cadence timeline over the full ledger window. Each per-window // streak below walks only its own trailing `effectiveDays`, so sharing @@ -607,10 +605,7 @@ export function buildMedicationComplianceBundle( 1, Math.ceil((now.getTime() - effectiveStart.getTime()) / ONE_DAY_MS), ); - const tally = tallyLedgerRows(ledgerRows, { - from: periodStart, - to: now, - }); + const tally = trailingComplianceTally(ledgerRows, days, now); const { current: streak } = streaksFromTimeline( timeline, now, diff --git a/src/lib/analytics/compliance/ledger.ts b/src/lib/analytics/compliance/ledger.ts index baffb1123..230b3dfb0 100644 --- a/src/lib/analytics/compliance/ledger.ts +++ b/src/lib/analytics/compliance/ledger.ts @@ -306,6 +306,41 @@ export function tallyLedgerRows( */ export const COMPLIANCE_LEDGER_WINDOW_DAYS = 365; +/** + * The ledger over the trailing {@link COMPLIANCE_LEDGER_WINDOW_DAYS} ending at + * `now`: the one set of dose rows every per-medication compliance figure is + * tallied from. A slot before the medication's creation stays in it only when + * a recorded dose claims it (#1028). + */ +export function trailingComplianceLedger( + events: IntakeEvent[], + schedules: ComplianceSchedule[], + ctx: ComplianceMedicationContext, + now: Date, +): DoseHistoryRow[] { + if (schedules.length === 0) return []; + const from = new Date( + now.getTime() - COMPLIANCE_LEDGER_WINDOW_DAYS * 24 * 60 * 60 * 1000, + ); + return buildComplianceLedgerRows(events, schedules, ctx, from, now, now); +} + +/** + * The tally of the trailing `days` ending at `now`, carved out of + * {@link trailingComplianceLedger}: the same rows, so the same window reads + * the same rate on every surface. + */ +export function trailingComplianceTally( + ledger: DoseHistoryRow[], + days: number, + now: Date, +): LedgerComplianceTally { + return tallyLedgerRows(ledger, { + from: new Date(now.getTime() - days * 24 * 60 * 60 * 1000), + to: now, + }); +} + /** * One day of the cadence-aware compliance series: the day's compliance % * over the doses the schedule actually expected that day, plus the raw diff --git a/src/lib/medications/scheduling/__tests__/compliance.test.ts b/src/lib/medications/scheduling/__tests__/compliance.test.ts index 6291df5ad..dae3cd298 100644 --- a/src/lib/medications/scheduling/__tests__/compliance.test.ts +++ b/src/lib/medications/scheduling/__tests__/compliance.test.ts @@ -8,16 +8,29 @@ import { describe, expect, it } from "vitest"; import { complianceChips, streaksFromTimeline } from "../compliance"; import { buildCadenceTimeline } from "../cadence"; -import type { - CadenceEngineContext, - IntakeEventLike, - ScheduleLike, -} from "../cadence"; +import type { IntakeEventLike, ScheduleLike } from "../cadence"; +import type { ComplianceMedicationContext } from "@/lib/analytics/compliance/types"; +import { buildMedicationComplianceBundle } from "@/lib/analytics/compliance/display"; function d(iso: string): Date { return new Date(iso); } +/** The medication context the chips read; UTC unless a test says otherwise. */ +function ctx( + over?: Partial, +): ComplianceMedicationContext { + return { + startsOn: null, + endsOn: null, + oneShot: false, + createdAt: d("2025-05-01T00:00:00Z"), + lastIntakeAt: null, + timeZone: "UTC", + ...over, + }; +} + const DAILY_8AM: ScheduleLike = { windowStart: "08:00", windowEnd: "09:00", @@ -27,7 +40,7 @@ const DAILY_8AM: ScheduleLike = { describe("complianceChips", () => { it("returns null adherence when no doses are expected in the window", () => { const NOW = d("2025-06-10T12:00:00"); - const result = complianceChips([], [], NOW, 30); + const result = complianceChips([], [], ctx(), NOW, 30); expect(result.adherenceRate).toBeNull(); expect(result.missedLast30).toBe(0); }); @@ -44,14 +57,14 @@ describe("complianceChips", () => { } // 5-day window: 5 expected, 5 taken, 0 missed (today's slot is upcoming // because NOW is 12:00 and window is 08:00-09:00... actually past today) - const result = complianceChips([DAILY_8AM], events, NOW, 5); + const result = complianceChips([DAILY_8AM], events, ctx(), NOW, 5); expect(result.adherenceRate).toBeGreaterThanOrEqual(80); expect(result.missedLast30).toBeLessThanOrEqual(1); }); it("computes 0% adherence when no events match any past slot", () => { const NOW = d("2025-06-10T12:00:00"); - const result = complianceChips([DAILY_8AM], [], NOW, 5); + const result = complianceChips([DAILY_8AM], [], ctx(), NOW, 5); expect(result.adherenceRate).toBe(0); expect(result.missedLast30).toBeGreaterThan(0); }); @@ -60,25 +73,25 @@ describe("complianceChips", () => { const NOW = d("2025-06-10T12:00:00"); // Window: NOW - 3d = 2025-06-07T12:00 → past slots: 08, 09, 10 // Provide one taken + one skipped + one taken for the three slots so - // the denominator excludes the skipped one. + // the denominator excludes the skipped one. A skip names its slot. const events: IntakeEventLike[] = [ { - scheduledFor: d("2025-06-08T08:30:00"), + scheduledFor: d("2025-06-08T08:00:00"), takenAt: d("2025-06-08T08:35:00"), skipped: false, }, { - scheduledFor: d("2025-06-09T08:30:00"), + scheduledFor: d("2025-06-09T08:00:00"), takenAt: null, skipped: true, }, { - scheduledFor: d("2025-06-10T08:30:00"), + scheduledFor: d("2025-06-10T08:00:00"), takenAt: d("2025-06-10T08:35:00"), skipped: false, }, ]; - const result = complianceChips([DAILY_8AM], events, NOW, 3); + const result = complianceChips([DAILY_8AM], events, ctx(), NOW, 3); // 2 taken / (2 taken + 0 missed) = 100, skipped excluded. expect(result.adherenceRate).toBe(100); }); @@ -97,7 +110,7 @@ describe("complianceChips", () => { skipped: false, }); } - const result = complianceChips([DAILY_8AM], events, NOW, 14); + const result = complianceChips([DAILY_8AM], events, ctx(), NOW, 14); expect(result.currentStreak).toBeGreaterThanOrEqual(4); }); @@ -132,7 +145,7 @@ describe("complianceChips", () => { skipped: false, }, ]; - const result = complianceChips([DAILY_8AM], events, NOW, 7); + const result = complianceChips([DAILY_8AM], events, ctx(), NOW, 7); // Streak ending at today = day 9 + day 10 = 2 (day 8 missed broke it). expect(result.currentStreak).toBeLessThanOrEqual(2); // Longest streak in window = max(3 from 5..7, 2 from 9..10) = 3. @@ -158,7 +171,7 @@ describe("complianceChips", () => { skipped: false, }, ]; - const result = complianceChips([weekly], events, NOW, 14); + const result = complianceChips([weekly], events, ctx(), NOW, 14); expect(result.adherenceRate).toBe(100); expect(result.missedLast30).toBe(0); }); @@ -170,7 +183,7 @@ describe("complianceChips", () => { daysOfWeek: "1", // Monday only }; const NOW = d("2025-06-10T12:00:00"); // Tuesday - const result = complianceChips([weekly], [], NOW, 14); + const result = complianceChips([weekly], [], ctx(), NOW, 14); expect(result.adherenceRate).toBe(0); expect(result.missedLast30).toBeGreaterThan(0); }); @@ -199,18 +212,16 @@ describe("complianceChips", () => { const berlin = complianceChips( [sched], events, + ctx({ timeZone: "Europe/Berlin" }), NOW, 7, - undefined, - "Europe/Berlin", ); const tokyo = complianceChips( [sched], events, + ctx({ timeZone: "Asia/Tokyo" }), NOW, 7, - undefined, - "Asia/Tokyo", ); // Both must compute a finite adherence rate (the schedule has @@ -226,23 +237,10 @@ describe("complianceChips", () => { }); describe("complianceChips — canonical-engine delegation (v1.7.0 SB-SCHED-2)", () => { - function engineCtx( - over?: Partial, - ): CadenceEngineContext { - return { - startsOn: null, - endsOn: null, - oneShot: false, - createdAt: d("2025-05-01T00:00:00Z"), - lastIntakeAt: null, - timeZone: "Europe/Berlin", - ...over, - }; - } + const engineCtx = (over?: Partial) => + ctx({ timeZone: "Europe/Berlin", ...over }); - it("RRULE weekly Monday: legacy walker counts every day missed, engine counts only Mondays", () => { - // `daysOfWeek = null` reads as every-day to the legacy walker, so an - // RRULE-weekly schedule over-counts expected doses without the engine. + it("RRULE weekly Monday: only the Mondays in the window are expected", () => { const rruleWeekly: ScheduleLike = { windowStart: "08:00", windowEnd: "09:00", @@ -250,25 +248,15 @@ describe("complianceChips — canonical-engine delegation (v1.7.0 SB-SCHED-2)", rrule: "FREQ=WEEKLY;BYDAY=MO", timesOfDay: ["08:00"], }; - const NOW = d("2025-06-10T12:00:00Z"); // Tuesday + // Saturday: both Mondays in the window are past a weekly dose's late tail. + const NOW = d("2025-06-14T12:00:00Z"); - // Legacy path (no engineCtx) expands daily → many missed days. - const legacy = complianceChips([rruleWeekly], [], NOW, 14); - // Canonical path (engineCtx) expands only Mondays in the window. - const engine = complianceChips( - [rruleWeekly], - [], - NOW, - 14, - undefined, - "Europe/Berlin", - engineCtx(), - ); + const engine = complianceChips([rruleWeekly], [], engineCtx(), NOW, 14); - // The engine path expects far fewer doses (only the Mondays in a - // 14-day window: 2) than the daily legacy expansion (~13). - expect(engine.missedLast30).toBeLessThan(legacy.missedLast30); - expect(engine.missedLast30).toBeLessThanOrEqual(2); + // Two Mondays in a 14-day window, both missed; `daysOfWeek = null` + // must not read as every day. + expect(engine.missedLast30).toBe(2); + expect(engine.adherenceRate).toBe(0); }); it("PRN: engine short-circuits to zero expected doses (no adherence, no missed)", () => { @@ -281,15 +269,7 @@ describe("complianceChips — canonical-engine delegation (v1.7.0 SB-SCHED-2)", }; const NOW = d("2025-06-10T12:00:00Z"); - const engine = complianceChips( - [prn], - [], - NOW, - 30, - undefined, - "Europe/Berlin", - engineCtx(), - ); + const engine = complianceChips([prn], [], engineCtx(), NOW, 30); // PRN is as-needed — never projected, reminded, or counted in // compliance-expected. No expected slots → null adherence, zero missed. @@ -315,20 +295,16 @@ describe("complianceChips — canonical-engine delegation (v1.7.0 SB-SCHED-2)", const engine = complianceChips( [cyclic], [], + engineCtx({ startsOn: d("2025-05-01T00:00:00Z") }), NOW, 14, - undefined, - "Europe/Berlin", - engineCtx({ startsOn: d("2025-05-01T00:00:00Z") }), ); const everyDay = complianceChips( [{ ...cyclic, scheduleType: "SCHEDULED" }], [], + engineCtx({ startsOn: d("2025-05-01T00:00:00Z") }), NOW, 14, - undefined, - "Europe/Berlin", - engineCtx({ startsOn: d("2025-05-01T00:00:00Z") }), ); // The cyclic gate must drop the off-week's doses, so fewer missed @@ -445,3 +421,43 @@ describe("streaksFromTimeline — DST-correct day walk", () => { expect(current).toBe(7); }); }); + +describe("complianceChips — the detail page's rate", () => { + it("drops a pause from the chips exactly as the compliance payload does", () => { + // Daily 08:00, paused from 10 days ago to 3 days ago, every other dose + // taken. The chips used to tally the paused week as seven missed doses + // (77 %) while the detail page, reading the compliance ledger, said 100. + const NOW = d("2025-06-30T12:00:00Z"); + const day = (n: number) => new Date(NOW.getTime() - n * 86_400_000); + const pausedAt = day(10); + const resumedAt = day(3); + const events: IntakeEventLike[] = []; + for (let n = 1; n <= 30; n++) { + const slot = new Date(day(n).setUTCHours(8, 0, 0, 0)); + if (slot >= pausedAt && slot < resumedAt) continue; + events.push({ scheduledFor: slot, takenAt: slot, skipped: false }); + } + const daily: ScheduleLike = { + windowStart: "08:00", + windowEnd: "09:00", + daysOfWeek: null, + timesOfDay: ["08:00"], + }; + const context = ctx({ + createdAt: d("2025-01-01T00:00:00Z"), + pauseEras: [{ pausedAt, resumedAt }], + }); + + const chips = complianceChips([daily], events, context, NOW, 30); + const bundle = buildMedicationComplianceBundle( + events, + [daily], + context, + NOW, + ); + + expect(chips.missedLast30).toBe(0); + expect(chips.adherenceRate).toBe(100); + expect(chips.adherenceRate).toBe(bundle.compliance30.rate); + }); +}); diff --git a/src/lib/medications/scheduling/compliance.ts b/src/lib/medications/scheduling/compliance.ts index 8a8157db6..2eff7aee1 100644 --- a/src/lib/medications/scheduling/compliance.ts +++ b/src/lib/medications/scheduling/compliance.ts @@ -6,33 +6,28 @@ * rate, current streak (days), longest streak (days), missed in last * 30 days. Each chip is monochrome — maintainer note: no gamified badges. * - * Distinct from `src/lib/analytics/compliance.ts.calculateCompliance`: - * that helper computes against expected counts (schedules-per-day × - * days) and is used by the existing /api/medications/[id]/compliance - * route. This module computes against the pair-matched timeline so - * the chips and the cadence chart agree on every single dose. + * Adherence and missed doses come from the compliance ledger the + * /api/medications/[id]/compliance route reads, so the chips and the + * detail page's rate agree; the streaks walk the pair-matched timeline + * the cadence chart draws. * - * v1.4.25 W21 Fix-O — accepts an optional IANA `timeZone` argument - * forwarded into the timeline + streak math so a user in Tokyo gets - * the same chip values as a user in Berlin even when the host's - * system time is set differently. Omitting the argument keeps the - * legacy system-local behaviour the W19e tests pin. + * The timeline and streak math run in the context's IANA zone, so a user + * in Tokyo gets the same chip values as a user in Berlin whatever the + * host's system time is. */ -import type { SlotBand } from "./attribution"; -import { type BandMinterMedication } from "./band-minter"; -import { buildBandsForSchedulesWithEras } from "./schedule-eras"; import { buildCadenceTimeline, startOfLocalDay, - type CadenceEngineContext, type IntakeEventLike, type PairedDose, type ScheduleLike, } from "./cadence"; -import { reconstructDoseHistory, type HistoryIntake } from "./dose-history"; -import { type CanonicalSchedule, type RecurrenceContext } from "./recurrence"; -import { normaliseDoseWindows } from "./worker-helpers"; +import { + trailingComplianceLedger, + trailingComplianceTally, +} from "@/lib/analytics/compliance/ledger"; +import type { ComplianceMedicationContext } from "@/lib/analytics/compliance/types"; import { userDayKey } from "@/lib/tz/resolver"; export interface ComplianceChips { @@ -146,134 +141,19 @@ export function streaksFromTimeline( } /** - * 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. + * The four chips: adherence and missed doses from the compliance ledger the + * detail page's rate reads (`trailingComplianceTally`, with the medication's + * pause eras and courses), the streaks from the cadence timeline (a + * day-grain "every dose taken or skipped" rule, orthogonal to per-dose + * attribution). */ -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, - // v1.40 (#1024) — a medication: `startsOn` is a course start. - rollingAnchor: "courseStart", - }; - 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, - doseWindows: normaliseDoseWindows(s.doseWindows), - }; - // 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 = buildBandsForSchedulesWithEras({ - medication, - schedules: canonicalSchedules, - revisions: engineCtx.scheduleRevisions ?? [], - 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, - // v1.15.20 — pinned takes bind by anchor (taken-late, slot served). - pinned: e.attributionSource === "USER_PIN", - })); - - const rows = reconstructDoseHistory( - bands, - intakes, - asOf, - engineCtx.createdAt, - ); - 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[], + ctx: ComplianceMedicationContext, asOf: Date, windowDays = 30, anchor?: Date, - timeZone?: string, - engineCtx?: CadenceEngineContext, ): ComplianceChips { const timeline = buildCadenceTimeline( schedules, @@ -281,33 +161,21 @@ export function complianceChips( asOf, windowDays, anchor, - timeZone, - engineCtx, + ctx.timeZone, + ctx, ); - // 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, + const { taken, missed } = trailingComplianceTally( + trailingComplianceLedger(events, schedules, ctx, asOf), windowDays, - timeZone, - engineCtx, + asOf, ); - 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( timeline, asOf, windowDays, - timeZone, + ctx.timeZone, ); return { adherenceRate,