mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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:
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);
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in new issue
Block a user