diff --git a/src/lib/analytics/__tests__/compliance-bundle.test.ts b/src/lib/analytics/__tests__/compliance-bundle.test.ts index 519975a8f..0fae41d2d 100644 --- a/src/lib/analytics/__tests__/compliance-bundle.test.ts +++ b/src/lib/analytics/__tests__/compliance-bundle.test.ts @@ -17,6 +17,7 @@ import { buildMedicationComplianceBundle, buildComplianceDisplay, calculateCompliance, + dailyComplianceRatesFromLedger, lastNonSkippedTakenAt, tallyComplianceFromLedger, tallyLedgerRows, @@ -44,6 +45,21 @@ function dailySchedule(): ComplianceSchedule { }; } +function weeklyMondaySchedule(): ComplianceSchedule { + return { + windowStart: "08:00", + windowEnd: "09:00", + timesOfDay: ["08:00"], + daysOfWeek: null, + rrule: "FREQ=WEEKLY;BYDAY=MO", + rollingIntervalDays: null, + reminderGraceMinutes: null, + scheduleType: "SCHEDULED", + cyclicOnWeeks: null, + cyclicOffWeeks: null, + }; +} + function rollingSchedule(intervalDays: number): ComplianceSchedule { return { windowStart: "10:00", @@ -205,6 +221,104 @@ describe("buildMedicationComplianceBundle — parity with the per-window composi }); }); +describe("dailyComplianceRatesFromLedger — cadence-aware per-day series", () => { + it("weekly Monday-only med: per-day series agrees with compliance30 and never marks non-due days", () => { + // NOW (2026-06-01) is a Monday; build five trailing Mondays and take + // every one on time. A weekly-cadence med expects a dose ONLY on Mondays. + const createdAt = new Date(NOW.getTime() - 60 * DAY_MS); + const schedules = [weeklyMondaySchedule()]; + const ctx = buildComplianceMedicationContext( + { startsOn: null, endsOn: null, oneShot: false, createdAt }, + null, + TZ, + ); + + const events: FixtureEvent[] = []; + for (let week = 0; week < 5; week++) { + const daysAgo = week * 7; // every Monday back from NOW + events.push({ + takenAt: slotAt(daysAgo, "08:05"), + skipped: false, + scheduledFor: slotAt(daysAgo, "08:00"), + }); + } + ctx.lastIntakeAt = lastNonSkippedTakenAt(events); + + const bundle = buildMedicationComplianceBundle(events, schedules, ctx, NOW); + + // Took every expected Monday → 100% over 30 days. + expect(bundle.compliance30.rate).toBe(100); + + // The per-day series carves the same 30-day window out of the shared + // ledger and must agree with compliance30 — no off-cadence weekday is + // counted as a miss. + const from = new Date(NOW.getTime() - 30 * DAY_MS); + const windowRows = bundle.ledgerRows.filter( + (r) => r.at.getTime() >= from.getTime() && r.at.getTime() <= NOW.getTime(), + ); + const series = dailyComplianceRatesFromLedger(windowRows); + + // Only the Mondays in the window produce a point — Tuesday…Sunday emit + // nothing, so a 7×-denser daily denominator can never drag the rate down. + // NOW is itself a Monday, so the 30-day window holds five Mondays. + expect(series.length).toBe(5); + for (const day of series) { + expect(day.rate).toBe(100); + expect(day.missed).toBe(0); + } + + // The series' mean rate equals the canonical compliance30 rate: the two + // are computed from the SAME ledger and cannot contradict each other. + const meanRate = + series.reduce((sum, d) => sum + d.rate, 0) / series.length; + expect(Math.round(meanRate)).toBe(bundle.compliance30.rate); + }); + + it("weekly med with one missed Monday: the missed day reads 0%, due days only", () => { + const createdAt = new Date(NOW.getTime() - 60 * DAY_MS); + const schedules = [weeklyMondaySchedule()]; + const ctx = buildComplianceMedicationContext( + { startsOn: null, endsOn: null, oneShot: false, createdAt }, + null, + TZ, + ); + + // Four Mondays in the 30-day window; skip the take on the 2-weeks-ago one. + const events: FixtureEvent[] = []; + for (let week = 0; week < 5; week++) { + const daysAgo = week * 7; + if (week === 2) continue; // forgotten dose → an expected slot reads missed + events.push({ + takenAt: slotAt(daysAgo, "08:05"), + skipped: false, + scheduledFor: slotAt(daysAgo, "08:00"), + }); + } + ctx.lastIntakeAt = lastNonSkippedTakenAt(events); + + const bundle = buildMedicationComplianceBundle(events, schedules, ctx, NOW); + + const from = new Date(NOW.getTime() - 30 * DAY_MS); + const windowRows = bundle.ledgerRows.filter( + (r) => r.at.getTime() >= from.getTime() && r.at.getTime() <= NOW.getTime(), + ); + const series = dailyComplianceRatesFromLedger(windowRows); + + // Still one point per due Monday — never a point on a non-due weekday. + // Five Mondays in the window; the skipped take leaves one as a miss. + expect(series.length).toBe(5); + const zeroDays = series.filter((d) => d.rate === 0); + expect(zeroDays.length).toBe(1); + expect(zeroDays[0].missed).toBe(1); + + // Aggregate taken / (taken + missed) across due days equals compliance30. + const taken = series.reduce((sum, d) => sum + d.taken, 0); + const missed = series.reduce((sum, d) => sum + d.missed, 0); + const aggregateRate = Math.round((taken / (taken + missed)) * 100); + expect(aggregateRate).toBe(bundle.compliance30.rate); + }); +}); + describe("tallyLedgerRows — windowed tallies over one shared ledger", () => { it("the unwindowed tally equals tallyComplianceFromLedger byte-for-byte", () => { const createdAt = new Date(NOW.getTime() - 40 * DAY_MS); diff --git a/src/lib/analytics/compliance.ts b/src/lib/analytics/compliance.ts index 54b7a73aa..dc89a9dce 100644 --- a/src/lib/analytics/compliance.ts +++ b/src/lib/analytics/compliance.ts @@ -52,6 +52,7 @@ import { type RecurrenceContext, type ScheduleType, } from "@/lib/medications/scheduling/recurrence"; +import { toBerlinDayKey } from "@/lib/tz/resolver"; export interface IntakeEvent { takenAt: Date | null; @@ -1836,6 +1837,85 @@ export function buildMedicationComplianceBundle( return { compliance7, compliance30, complianceDisplay, ledgerRows, ledgerFrom }; } +/** + * One day of the cadence-aware compliance series: the day's compliance % + * over the doses the schedule actually expected that day, plus the raw + * taken / missed counts behind it. + */ +export interface DailyComplianceRate { + /** The day's anchor instant (the first expected slot of the day). */ + date: Date; + /** round(100 · taken / (taken + missed)), capped at 100. */ + rate: number; + /** Doses taken (on-time + late) on the day. */ + taken: number; + /** Expected doses never acted on past their miss cutoff, on the day. */ + missed: number; +} + +/** + * v1.18.0 — collapse unified dose-history ledger rows into a cadence-aware + * per-day compliance series. + * + * Only days the schedule's cadence actually expected a dose produce a point: + * the series is grouped over the ledger's scheduled `slot` rows, so an + * off-cadence weekday (a weekly Monday-only med on a Tuesday) or an off-week + * (the off-week of a bi-weekly schedule) emits no point at all and therefore + * can never be read as a 0% "miss". Each day's rate uses the SAME + * taken / (taken + missed) tally — and the SAME on-time-plus-late numerator, + * skip / ad-hoc / upcoming exclusions — that {@link tallyLedgerRows} applies + * to `compliance7` / `compliance30`, so the per-day series and the window + * rates are computed from one ledger and cannot contradict each other. + * + * `ad_hoc` rows (off-schedule top-ups, no defensible slot) and `upcoming` + * rows (the window hasn't opened) are dropped before grouping, exactly as the + * window tally excludes them. A day whose only ledger rows are skips yields no + * point (zero denominator) rather than a misleading 0%. + */ +export function dailyComplianceRatesFromLedger( + ledgerRows: DoseHistoryRow[], +): DailyComplianceRate[] { + const byDay = new Map< + string, + { taken: number; missed: number; date: Date } + >(); + + for (const row of ledgerRows) { + // Only scheduled slots have a defensible denominator; ad-hoc top-ups are + // excluded from the rate exactly as `tallyLedgerRows` excludes them. + if (row.kind !== "slot") continue; + const isTaken = + row.status === "taken_on_time" || row.status === "taken_late"; + const isMissed = row.status === "missed"; + // `skipped` (deliberate) and `upcoming` (window not open) advance neither + // counter — they never enter the day's denominator. + if (!isTaken && !isMissed) continue; + + const dayKey = toBerlinDayKey(row.at); + const bucket = byDay.get(dayKey) ?? { taken: 0, missed: 0, date: row.at }; + if (isTaken) bucket.taken += 1; + else bucket.missed += 1; + // Keep the earliest slot instant of the day as the point's anchor. + if (row.at.getTime() < bucket.date.getTime()) bucket.date = row.at; + byDay.set(dayKey, bucket); + } + + return Array.from(byDay.values()) + .sort((a, b) => a.date.getTime() - b.date.getTime()) + .map((day) => { + const denom = day.taken + day.missed; + return { + date: day.date, + rate: + denom > 0 + ? Math.min(100, Math.round((day.taken / denom) * 100)) + : 100, + taken: day.taken, + missed: day.missed, + }; + }); +} + /** * The cadence-timeline construction from {@link calculateCompliance}, * extracted so {@link buildMedicationComplianceBundle} can build it once diff --git a/src/lib/insights/medication-compliance-status.ts b/src/lib/insights/medication-compliance-status.ts index 69637f6e4..d8e7a8c14 100644 --- a/src/lib/insights/medication-compliance-status.ts +++ b/src/lib/insights/medication-compliance-status.ts @@ -5,7 +5,8 @@ import { } from "@/lib/ai/prompts/medication-compliance"; import { buildComplianceMedicationContext, - calculateCompliance, + buildMedicationComplianceBundle, + dailyComplianceRatesFromLedger, lastNonSkippedTakenAt, SCHEDULE_COMPLIANCE_SELECT, } from "@/lib/analytics/compliance"; @@ -224,50 +225,34 @@ export async function generateMedicationComplianceStatusForUser( lastNonSkippedTakenAt(events), userTz, ); - const compliance7 = calculateCompliance( + // v1.18.0 — one shared cadence-aware expansion pass yields BOTH the + // canonical compliance7 / compliance30 (the values shown in this same + // snapshot) AND the unified dose-history ledger the per-day series is + // built from, so the series can never disagree with compliance30. + const bundle = buildMedicationComplianceBundle( events, medication.schedules, - 7, - medication.createdAt, - { medicationContext }, + medicationContext, + now, ); - const compliance30 = calculateCompliance( - events, - medication.schedules, - 30, - medication.createdAt, - { medicationContext }, + const compliance7 = bundle.compliance7; + const compliance30 = bundle.compliance30; + + // v1.18.0 — the per-day series honours the medication's cadence + // (daysOfWeek + intervalWeeks + rolling / RRULE / cyclic): only days the + // schedule actually expected a dose produce a point, and each day's rate + // uses the SAME taken / (taken + missed) tally as compliance30. The old + // path divided every event-day by `schedules.length`, treating every + // calendar day as fully dose-due — which made a weekly-cadence med's + // per-day series contradict its own compliance30. Reusing the canonical + // ledger fixes that by construction. + const perDayRecords = dailyComplianceRatesFromLedger(bundle.ledgerRows).map( + (day) => ({ + measuredAt: day.date, + value: round(day.rate, 1), + }), ); - // Collapse the events into one rate-per-day record, then run them - // through the shared bucketing helper so the model receives the - // canonical 360 daily + 24 monthly view per medication. - const byDay = new Map< - string, - { expected: number; taken: number; skipped: number; date: Date } - >(); - const expectedPerDay = Math.max(1, medication.schedules.length); - - for (const event of events) { - const dayKey = toBerlinDayKey(event.scheduledFor); - const bucket = byDay.get(dayKey) ?? { - expected: medication.schedules.length, - taken: 0, - skipped: 0, - date: event.scheduledFor, - }; - if (event.takenAt && !event.skipped) bucket.taken += 1; - if (event.skipped) bucket.skipped += 1; - byDay.set(dayKey, bucket); - } - - const perDayRecords = Array.from(byDay.values()) - .sort((a, b) => a.date.getTime() - b.date.getTime()) - .map((stats) => ({ - measuredAt: stats.date, - value: round(Math.min(100, (stats.taken / expectedPerDay) * 100), 1), - })); - // `applyPayloadBudget` gives the latest-day focus; the compact // graded series is what reaches the prompt. const dailyBudgeted = applyPayloadBudget(perDayRecords, { now });