fix(coach): quote medication adherence from the compliance authority

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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-14 13:11:59 +02:00
1 parent f4f1c0c8b0
commit efd77830f5
2 files changed
+121

No files matched your search

@@ -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 () => {
+36
View File
@@ -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");