fix(insights): make AI medication-compliance per-day series cadence-aware

The per-day compliance series in the medication-compliance insight snapshot
divided every event-day by the schedule count, treating each calendar day as
fully dose-due. A weekly-cadence medication (a Monday-only schedule, a
bi-weekly interval) therefore had its series read off-cadence weekdays as
misses, contradicting the canonical compliance30 value shown in the same
snapshot.

Route the per-day series through the unified dose-history ledger that already
backs compliance7 / compliance30: add dailyComplianceRatesFromLedger, which
groups the ledger's scheduled slot rows by day and scores each day with the
same taken / (taken + missed) tally — on-time-plus-late numerator, skip /
ad-hoc / upcoming exclusions — that tallyLedgerRows applies to the window
rates. Only days the cadence actually expected a dose produce a point, so a
non-due day can no longer be counted as a miss, and the per-day series and
compliance30 are computed from one ledger and cannot disagree.

The insight generator now derives both the canonical compliance results and
the ledger from a single buildMedicationComplianceBundle pass instead of two
calculateCompliance calls plus a hand-rolled per-day map.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.8 committed 2026-06-16 00:59:04 +02:00
1 parent 9a5e562001
commit 79f2762468
3 files changed
+219 -40

No files matched your search

@@ -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);
+80
View File
@@ -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
@@ -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 });