mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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:
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 () => {
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in new issue
Block a user