From efd77830f5826c3a8996b41bb5573a0ecec603dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 14 Jun 2026 13:11:59 +0200 Subject: [PATCH] fix(coach): quote medication adherence from the compliance authority MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The coach snapshot built its own medication-adherence figures: a per-day and per-week timeline tallied off a "countable ledger rows in window" denominator that differs from the cadence-aware `calculateCompliance(...).rate` every other surface — the medication card, the detail page, the dashboard pillar, the insights — reads. On an irregular, rolling, or partial-window schedule the two denominators diverge, so the coach could reason from a rate the card never shows the user. Route the coach's headline adherence % through `calculateCompliance(...).rate` with the medication context (the ledger path), aggregating taken / (taken + missed) across the user's scheduled medications over the coach window — exactly the arithmetic the card's rate uses. The snapshot now carries a single `compliance.rate` headline alongside the supplementary timeline, so the coach quotes the same number the card renders. --- src/lib/ai/coach/__tests__/snapshot.test.ts | 85 +++++++++++++++++++++ src/lib/ai/coach/snapshot.ts | 36 +++++++++ 2 files changed, 121 insertions(+) diff --git a/src/lib/ai/coach/__tests__/snapshot.test.ts b/src/lib/ai/coach/__tests__/snapshot.test.ts index f7725f414..ed0662da6 100644 --- a/src/lib/ai/coach/__tests__/snapshot.test.ts +++ b/src/lib/ai/coach/__tests__/snapshot.test.ts @@ -211,6 +211,91 @@ describe("buildCoachSnapshot", () => { expect(parsed.scope.sources).not.toContain("compliance"); }); + // v1.17 W1c — the coach's headline adherence % must route through the + // SAME `calculateCompliance(...).rate` authority the medication card + // shows, never a per-day / per-week denominator of its own. A daily med + // with 7 of 10 expected doses taken reads 70 % on the card; the coach + // snapshot must surface the identical figure on `compliance.rate`. + it("surfaces a headline compliance rate equal to calculateCompliance().rate", async () => { + const { calculateCompliance, buildComplianceMedicationContext } = + await import("@/lib/analytics/compliance"); + + const dayMs = 24 * 60 * 60 * 1000; + const createdAt = new Date(Date.now() - 40 * dayMs); + // Ten daily doses 1..10 days ago at 08:00 UTC; the 3 most recent + // confirmed-late and the rest taken on time, except days 4/7/9 missed. + const schedule = { + id: "sched-1", + windowStart: "08:00", + windowEnd: "09:00", + daysOfWeek: null, + timesOfDay: ["08:00"], + reminderGraceMinutes: null, + rrule: null, + rollingIntervalDays: null, + scheduleType: null, + cyclicOnWeeks: null, + cyclicOffWeeks: null, + doseWindows: null, + }; + const missedDays = new Set([4, 7, 9]); + const intakeEvents = []; + for (let n = 1; n <= 10; n++) { + const scheduledFor = new Date(Date.now() - n * dayMs); + scheduledFor.setUTCHours(8, 0, 0, 0); + const missed = missedDays.has(n); + intakeEvents.push({ + scheduledFor, + takenAt: missed ? null : scheduledFor, + skipped: false, + autoMissed: missed, + }); + } + const medication = { + id: "med-1", + name: "Testdrug", + startsOn: null, + endsOn: null, + oneShot: false, + createdAt, + schedules: [schedule], + scheduleRevisions: [], + intakeEvents, + }; + // The compliance query and the GLP-1 query share this mock; only the + // compliance one (no `treatmentClass` filter) gets the fixture so the + // GLP-1 block — which selects `doseChanges` etc. — sees no rows. + prismaMock.medication.findMany.mockImplementation((args?: { + where?: { treatmentClass?: string }; + }) => { + if (args?.where?.treatmentClass === "GLP1") return Promise.resolve([]); + return Promise.resolve([medication]); + }); + prismaMock.user.findUnique.mockResolvedValue({ + coachPrefsJson: null, + timezone: "Europe/Berlin", + }); + + const out = await buildCoachSnapshot("user-1"); + const parsed = JSON.parse(out.snapshotJson); + + expect(parsed.compliance).toBeDefined(); + expect(parsed.compliance.rate).not.toBeNull(); + + // Independent authority over the same window the coach uses (30-day + // default scope), same ledger context — the coach rate must equal it. + const now = new Date(); + const ctx = buildComplianceMedicationContext(medication, null, "Europe/Berlin"); + const authority = calculateCompliance( + intakeEvents, + [schedule], + 30, + createdAt, + { now, medicationContext: ctx }, + ); + expect(parsed.compliance.rate).toBe(authority.rate); + }); + // v1.4.36 W3 T2 — `anthropometrics` exclusion drops the profile // block even when features.context has populated fields. it("omits anthropometrics when excludeMetrics contains 'anthropometrics'", async () => { diff --git a/src/lib/ai/coach/snapshot.ts b/src/lib/ai/coach/snapshot.ts index 0f281d216..c594c688b 100644 --- a/src/lib/ai/coach/snapshot.ts +++ b/src/lib/ai/coach/snapshot.ts @@ -38,6 +38,7 @@ import { isCycleEnabled } from "@/lib/cycle/gate"; import { buildComplianceLedgerRows, buildComplianceMedicationContext, + calculateCompliance, lastNonSkippedTakenAt, SCHEDULE_COMPLIANCE_SELECT, } from "@/lib/analytics/compliance"; @@ -992,6 +993,20 @@ async function buildCoachSnapshotImpl( // taken", deliberate skips and ad-hoc takes stay out of the // denominator, and cross-source duplicate rows collapse onto one slot. const ledgerRows: DoseHistoryRow[] = []; + // v1.17 W1c — the coach's headline adherence figure routes through the + // SAME `calculateCompliance(...).rate` authority the medication card + // shows (the ledger path, `medicationContext` supplied), so the coach + // can never quote a denominator the card doesn't use. We tally + // numerator (on-time + late takes) and denominator (taken + missed) + // across every compliance medication over the coach window, exactly the + // ledger arithmetic the card's per-med rate uses; the per-day / per-week + // timeline below stays as supplementary context. + let complianceTaken = 0; + let complianceDenominator = 0; + const windowDaysForRate = Math.max( + 1, + Math.round((now.getTime() - cutoff.getTime()) / (24 * 60 * 60 * 1000)), + ); for (const med of complianceMeds) { if (med.schedules.length === 0) continue; const ctx = buildComplianceMedicationContext( @@ -1009,6 +1024,18 @@ async function buildCoachSnapshotImpl( now, ), ); + // The card's rate IS `calculateCompliance(...).rate` over the ledger; + // aggregate the same taken / (taken + missed) counts here so the + // coach's single headline % equals what the card renders. + const result = calculateCompliance( + med.intakeEvents, + med.schedules, + windowDaysForRate, + med.createdAt, + { now, medicationContext: ctx }, + ); + complianceTaken += result.taken; + complianceDenominator += result.taken + result.missed; } // Countable rows: taken (on-time or late) or genuinely missed. The // pending / upcoming / skipped / ad-hoc rows carry no adherence signal. @@ -1064,6 +1091,15 @@ async function buildCoachSnapshotImpl( })) .sort((a, b) => a.weekISO.localeCompare(b.weekISO)); snapshot.compliance = { + // v1.17 W1c — headline adherence % from the SAME ledger authority + // (`calculateCompliance(...).rate`) the medication card shows, so the + // coach quotes the exact figure on the card rather than a per-day / + // per-week rate built off a different denominator. Integer 0-100 to + // match the card's rounding. + rate: + complianceDenominator > 0 + ? Math.round((complianceTaken / complianceDenominator) * 100) + : null, timeline: { recent: recentRows, weekly: weeklyRows }, }; metrics.add("compliance");