fix(insights): compute the adherence storyline from the schedule-anchored engine

The Coach adherence storyline derived its rate from the raw coverage
rollup, whose denominator counts logged slots rather than scheduled
doses, so a user who logged only taken doses read ~100% and the dip gate
suppressed a real decline. Extract the dashboard tile's schedule-anchored,
cadence-aware compliance into one shared engine and route both surfaces
through it, so Coach and the dashboard report the same number.
This commit is contained in:
Marc-André Bombeck committed 2026-07-02 00:16:03 +02:00
1 parent 40859613aa
commit fda0e504f0
4 files changed
+387 -166

No files matched your search

+6 -162
View File
@@ -25,16 +25,15 @@ import {
import { annotate } from "@/lib/logging/context";
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import { userDayKey, DEFAULT_TIMEZONE } from "@/lib/tz/resolver";
import { DEFAULT_TIMEZONE } from "@/lib/tz/resolver";
import { cached, caches, type ServerCache } from "@/lib/cache/server-cache";
import { invalidateUserMedications } from "@/lib/cache/invalidate";
import { recomputeMedicationComplianceForEvent } from "@/lib/rollups/medication-compliance-rollups";
import {
buildComplianceMedicationContext,
expectedSlotCountForDay,
lastNonSkippedTakenAt,
} from "@/lib/analytics/compliance";
import { getUserTodayBounds, startOfLocalDayInTz } from "@/lib/tz/local-day";
buildScheduleAnchoredComplianceBuckets,
type ScheduleAnchoredComplianceBucket,
} from "@/lib/analytics/schedule-anchored-compliance";
import { startOfLocalDayInTz } from "@/lib/tz/local-day";
import { projectTodayIntakesAndRecompute } from "@/lib/medications/scheduling/project-today-intakes";
import {
applyCanonicalSlotWrite,
@@ -204,7 +203,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
// makes the rate genuinely reflect taken-of-expected and lets the 7/30/90
// windows diverge with partial adherence.
const result = await cached(
caches.medicationsIntake as ServerCache<ComplianceBucket[]>,
caches.medicationsIntake as ServerCache<ScheduleAnchoredComplianceBucket[]>,
`${user.id}|compliance|${days}|${userTz}`,
() => buildScheduleAnchoredComplianceBuckets(user.id, days, userTz),
annotate,
@@ -218,161 +217,6 @@ export const GET = apiHandler(async (request: NextRequest) => {
return apiSuccess(result);
});
interface ComplianceBucket {
date: string;
scheduled: number;
taken: number;
}
/**
* v1.15.9 — schedule-anchored per-day compliance buckets for the dashboard
* tile. Replaces the rollup/legacy path whose `scheduled` was the count of
* logged intake rows (BUG #1: rate `taken / scheduled` ≈ 100% across every
* window because every logged row was both numerator and denominator).
*
* `scheduled` is now the canonical recurrence engine's expected-dose count
* for the day, summed across the user's active medications, so days the
* schedule expected a dose that the user missed pull the rate down — and the
* 7/30/90 windows genuinely diverge with partial adherence. `taken` stays the
* count of taken (non-skipped, non-auto-missed) doses that day, capped at the
* day's expected count so a duplicate log can't push a day above 100%.
*
* Pure-ish over a single pinned `now` so a 15-minute cached row is internally
* consistent. Bounded: one active-medications query + one window-events query,
* then per-medication-per-day engine expansion over the trailing window.
*/
async function buildScheduleAnchoredComplianceBuckets(
userId: string,
days: number,
userTz: string,
): Promise<ComplianceBucket[]> {
const now = new Date();
const nowMs = now.getTime();
const start = new Date(nowMs - days * 86_400_000);
const medications = await prisma.medication.findMany({
where: { userId, active: true },
include: {
schedules: true,
// v1.16.3 — archived schedule eras for era-aware expected counts.
scheduleRevisions: { orderBy: { validFrom: "asc" } },
// v1.25 H-MED1 — pause eras so paused days drop out of the denominator.
pauseEras: { select: { pausedAt: true, resumedAt: true } },
},
});
const events = await prisma.medicationIntakeEvent.findMany({
// v1.7.0 sync — exclude tombstoned rows from the compliance buckets.
where: { userId, deletedAt: null, scheduledFor: { gte: start } },
select: {
medicationId: true,
scheduledFor: true,
takenAt: true,
skipped: true,
autoMissed: true,
},
});
// Pre-compute each local day's [start, end) bounds + key, oldest → newest.
// Each day's representative instant anchors on the user's LOCAL NOON of
// the current day and steps back in 24-hour increments: noon stays inside
// the intended local day across DST shifts (23/25-hour days), and —
// unlike the previous `now − 12 h` anchor — it cannot slip into
// yesterday when the user's local time is before noon, which silently
// dropped TODAY from the buckets for every pre-noon read.
const { start: todayStart } = getUserTodayBounds(now, userTz);
const todayNoonMs = todayStart.getTime() + 12 * 60 * 60 * 1000;
const dayKeys: string[] = [];
const dayBounds = new Map<string, { start: Date; end: Date }>();
for (let i = days - 1; i >= 0; i--) {
const representative = new Date(todayNoonMs - i * 86_400_000);
const { start: dayStart, end: dayEndInclusive } = getUserTodayBounds(
representative,
userTz,
);
const key = userDayKey(dayStart, userTz);
if (dayBounds.has(key)) continue;
dayKeys.push(key);
dayBounds.set(key, {
start: dayStart,
end: new Date(dayEndInclusive.getTime() + 1), // half-open [start, end)
});
}
const totals = new Map<string, { scheduled: number; taken: number }>();
for (const key of dayKeys) totals.set(key, { scheduled: 0, taken: 0 });
// Group events by medication so each med's engine context is built once.
const eventsByMed = new Map<
string,
{
scheduledFor: Date;
takenAt: Date | null;
skipped: boolean;
autoMissed: boolean;
}[]
>();
for (const e of events) {
const list = eventsByMed.get(e.medicationId) ?? [];
list.push({
scheduledFor: e.scheduledFor,
takenAt: e.takenAt,
skipped: e.skipped,
autoMissed: e.autoMissed,
});
eventsByMed.set(e.medicationId, list);
}
for (const med of medications) {
if (med.schedules.length === 0) continue;
const medEvents = eventsByMed.get(med.id) ?? [];
const ctx = buildComplianceMedicationContext(
med,
lastNonSkippedTakenAt(medEvents),
userTz,
);
for (const key of dayKeys) {
const bounds = dayBounds.get(key)!;
// Skip days before the medication existed so a young med doesn't paint
// missed-denominator days it could not have been dosed on.
if (bounds.end <= med.createdAt) continue;
const scheduled = expectedSlotCountForDay(
med.schedules,
bounds.start,
bounds.end,
ctx,
medEvents,
);
if (scheduled === 0) continue;
// Taken doses that landed in this day's window (non-skipped, non-auto-
// missed), capped at the expected count so a duplicate log can't push a
// single day above 100%.
const takenThisDay = medEvents.filter(
(e) =>
e.takenAt !== null &&
!e.skipped &&
!e.autoMissed &&
e.scheduledFor >= bounds.start &&
e.scheduledFor < bounds.end,
).length;
const bucket = totals.get(key)!;
bucket.scheduled += scheduled;
bucket.taken += Math.min(takenThisDay, scheduled);
}
}
return dayKeys
.map((date) => {
const v = totals.get(date)!;
return { date, scheduled: v.scheduled, taken: v.taken };
})
.sort((a, b) => a.date.localeCompare(b.date));
}
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
@@ -0,0 +1,171 @@
/**
* v1.26.0 SEAM-N3 — the ONE schedule-anchored, cadence-aware daily
* compliance computation.
*
* Extracted verbatim from the `GET /api/medications/intake?scope=compliance`
* dashboard-tile handler so every surface that surfaces a "taken of expected"
* adherence number reads through the SAME engine instead of the raw coverage
* rollup (`readMedicationCompliance`), whose `scheduled` counts LOGGED intake
* slots — a denominator that collapses to ~100% for a user who logs only the
* doses they took and never mints `takenAt:null` reminder rows for the ones
* they missed. See the adherence storyline (`adherence-storyline.ts`) and the
* dashboard tile: both now consume this.
*
* `scheduled` is the canonical recurrence engine's expected-dose count for the
* day, summed across the user's active medications, so days the schedule
* expected a dose that the user missed pull the rate down — and the 7/30/90
* windows genuinely diverge with partial adherence. `taken` stays the count of
* taken (non-skipped, non-auto-missed) doses that day, capped at the day's
* expected count so a duplicate log can't push a day above 100%.
*
* Pure-ish over a single pinned `now` so a cached row is internally
* consistent. Bounded: one active-medications query + one window-events query,
* then per-medication-per-day engine expansion over the trailing window.
*/
import { prisma } from "@/lib/db";
import {
buildComplianceMedicationContext,
expectedSlotCountForDay,
lastNonSkippedTakenAt,
} from "@/lib/analytics/compliance";
import { getUserTodayBounds } from "@/lib/tz/local-day";
import { userDayKey } from "@/lib/tz/resolver";
export interface ScheduleAnchoredComplianceBucket {
/** YYYY-MM-DD in the user's tz. */
date: string;
scheduled: number;
taken: number;
}
export async function buildScheduleAnchoredComplianceBuckets(
userId: string,
days: number,
userTz: string,
now: Date = new Date(),
): Promise<ScheduleAnchoredComplianceBucket[]> {
const nowMs = now.getTime();
const start = new Date(nowMs - days * 86_400_000);
const medications = await prisma.medication.findMany({
where: { userId, active: true },
include: {
schedules: true,
// v1.16.3 — archived schedule eras for era-aware expected counts.
scheduleRevisions: { orderBy: { validFrom: "asc" } },
// v1.25 H-MED1 — pause eras so paused days drop out of the denominator.
pauseEras: { select: { pausedAt: true, resumedAt: true } },
},
});
const events = await prisma.medicationIntakeEvent.findMany({
// v1.7.0 sync — exclude tombstoned rows from the compliance buckets.
where: { userId, deletedAt: null, scheduledFor: { gte: start } },
select: {
medicationId: true,
scheduledFor: true,
takenAt: true,
skipped: true,
autoMissed: true,
},
});
// Pre-compute each local day's [start, end) bounds + key, oldest → newest.
// Each day's representative instant anchors on the user's LOCAL NOON of
// the current day and steps back in 24-hour increments: noon stays inside
// the intended local day across DST shifts (23/25-hour days), and —
// unlike the previous `now − 12 h` anchor — it cannot slip into
// yesterday when the user's local time is before noon, which silently
// dropped TODAY from the buckets for every pre-noon read.
const { start: todayStart } = getUserTodayBounds(now, userTz);
const todayNoonMs = todayStart.getTime() + 12 * 60 * 60 * 1000;
const dayKeys: string[] = [];
const dayBounds = new Map<string, { start: Date; end: Date }>();
for (let i = days - 1; i >= 0; i--) {
const representative = new Date(todayNoonMs - i * 86_400_000);
const { start: dayStart, end: dayEndInclusive } = getUserTodayBounds(
representative,
userTz,
);
const key = userDayKey(dayStart, userTz);
if (dayBounds.has(key)) continue;
dayKeys.push(key);
dayBounds.set(key, {
start: dayStart,
end: new Date(dayEndInclusive.getTime() + 1), // half-open [start, end)
});
}
const totals = new Map<string, { scheduled: number; taken: number }>();
for (const key of dayKeys) totals.set(key, { scheduled: 0, taken: 0 });
// Group events by medication so each med's engine context is built once.
const eventsByMed = new Map<
string,
{
scheduledFor: Date;
takenAt: Date | null;
skipped: boolean;
autoMissed: boolean;
}[]
>();
for (const e of events) {
const list = eventsByMed.get(e.medicationId) ?? [];
list.push({
scheduledFor: e.scheduledFor,
takenAt: e.takenAt,
skipped: e.skipped,
autoMissed: e.autoMissed,
});
eventsByMed.set(e.medicationId, list);
}
for (const med of medications) {
if (med.schedules.length === 0) continue;
const medEvents = eventsByMed.get(med.id) ?? [];
const ctx = buildComplianceMedicationContext(
med,
lastNonSkippedTakenAt(medEvents),
userTz,
);
for (const key of dayKeys) {
const bounds = dayBounds.get(key)!;
// Skip days before the medication existed so a young med doesn't paint
// missed-denominator days it could not have been dosed on.
if (bounds.end <= med.createdAt) continue;
const scheduled = expectedSlotCountForDay(
med.schedules,
bounds.start,
bounds.end,
ctx,
medEvents,
);
if (scheduled === 0) continue;
// Taken doses that landed in this day's window (non-skipped, non-auto-
// missed), capped at the expected count so a duplicate log can't push a
// single day above 100%.
const takenThisDay = medEvents.filter(
(e) =>
e.takenAt !== null &&
!e.skipped &&
!e.autoMissed &&
e.scheduledFor >= bounds.start &&
e.scheduledFor < bounds.end,
).length;
const bucket = totals.get(key)!;
bucket.scheduled += scheduled;
bucket.taken += Math.min(takenThisDay, scheduled);
}
}
return dayKeys
.map((date) => {
const v = totals.get(date)!;
return { date, scheduled: v.scheduled, taken: v.taken };
})
.sort((a, b) => a.date.localeCompare(b.date));
}
@@ -0,0 +1,196 @@
/**
* v1.26.0 SEAM-N3 — the adherence storyline must read the SAME
* schedule-anchored, cadence-aware adherence number the dashboard tile does.
*
* The regression this pins: the storyline used to derive its adherence % from
* `readMedicationCompliance`, the RAW coverage rollup whose `scheduled` counts
* LOGGED intake slots. A user who logs only the doses they TOOK — never
* minting `takenAt:null` reminder rows for the doses they missed — therefore
* read ~100 % adherence (every logged row was both numerator and denominator),
* and the dip gate (`adherencePct > 80 → null`) wrongly suppressed the whole
* storyline. Anchoring on the recurrence engine's EXPECTED-dose count makes the
* missed days pull the rate below 100 %.
*
* The real compliance engine (`@/lib/analytics/compliance`) and the shared
* `buildScheduleAnchoredComplianceBuckets` run unmocked here — only the DB and
* the vital-series reader are stubbed — so the test exercises the actual
* expected-dose expansion, not a re-mock of it.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
medicationFindMany: vi.fn(),
intakeFindMany: vi.fn(),
probeRollupCoverage: vi.fn(),
readDayMeanSeries: vi.fn(),
}));
vi.mock("@/lib/db", () => ({
prisma: {
medication: { findMany: mocks.medicationFindMany },
medicationIntakeEvent: { findMany: mocks.intakeFindMany },
},
}));
vi.mock("@/lib/rollups/measurement-coverage", () => ({
probeRollupCoverage: mocks.probeRollupCoverage,
}));
vi.mock("@/lib/insights/derived/baseline", () => ({
readDayMeanSeries: mocks.readDayMeanSeries,
}));
import { buildAdherenceStoryline } from "@/lib/insights/derived/adherence-storyline";
import { buildScheduleAnchoredComplianceBuckets } from "@/lib/analytics/schedule-anchored-compliance";
const DAY_MS = 86_400_000;
const NOW = new Date("2026-06-21T12:00:00.000Z");
const TZ = "UTC";
/** A legacy daily schedule — one expected dose/day at 08:00, every day. */
const dailySchedule = {
id: "sched-1",
windowStart: "08:00",
windowEnd: "09:00",
daysOfWeek: null,
timesOfDay: [],
rrule: null,
rollingIntervalDays: null,
reminderGraceMinutes: null,
scheduleType: "SCHEDULED",
cyclicOnWeeks: null,
cyclicOffWeeks: null,
doseWindows: null,
};
const medication = {
id: "med-1",
name: "ramipril",
treatmentClass: "GENERIC",
active: true,
oneShot: false,
startsOn: null,
endsOn: null,
// Created well before the window so no day is skipped for being pre-creation.
createdAt: new Date("2024-01-01T00:00:00.000Z"),
schedules: [dailySchedule],
scheduleRevisions: [],
pauseEras: [],
};
/** ISO 08:00 on a given YYYY-MM-DD. */
const at8 = (day: string) => new Date(`${day}T08:00:00.000Z`);
beforeEach(() => {
mocks.medicationFindMany.mockReset();
mocks.intakeFindMany.mockReset();
mocks.probeRollupCoverage.mockReset();
mocks.readDayMeanSeries.mockReset();
// The same medication row satisfies both call sites: the storyline's
// `select: { name, treatmentClass }` and the engine's `include: {...}`.
mocks.medicationFindMany.mockResolvedValue([medication]);
// Only-taken intakes: 7 taken doses across 14 scheduled days. The 7 missed
// days carry NO row at all (the user never minted a takenAt:null miss row).
const takenDays = [
"2026-06-08",
"2026-06-10",
"2026-06-12",
"2026-06-14",
"2026-06-16",
"2026-06-18",
"2026-06-20",
];
mocks.intakeFindMany.mockResolvedValue(
takenDays.map((d) => ({
medicationId: "med-1",
scheduledFor: at8(d),
takenAt: new Date(`${d}T08:05:00.000Z`),
skipped: false,
autoMissed: false,
})),
);
mocks.probeRollupCoverage.mockResolvedValue(new Map());
// Vital before/after: 8 prior days ~124, 8 recent days ~134 — a material
// move on a ~7.4 robust spread, so a storyline is produced once the dip
// gate lets it through.
const priorDays: { day: string; mean: number }[] = [];
for (let i = 0; i < 8; i++) {
const d = new Date(
new Date("2026-05-25T00:00:00.000Z").getTime() + i * DAY_MS,
);
priorDays.push({ day: d.toISOString().slice(0, 10), mean: 124 });
}
const recentDays: { day: string; mean: number }[] = [];
for (let i = 0; i < 8; i++) {
const d = new Date(
new Date("2026-06-08T00:00:00.000Z").getTime() + i * DAY_MS,
);
recentDays.push({ day: d.toISOString().slice(0, 10), mean: 134 });
}
mocks.readDayMeanSeries.mockResolvedValue({
points: [...priorDays, ...recentDays],
source: "live",
});
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("adherence storyline ↔ dashboard-tile engine parity", () => {
it("derives adherence from the schedule-anchored engine (missed doses pull it below 100)", async () => {
// The dashboard-tile number, straight from the shared engine.
const buckets = await buildScheduleAnchoredComplianceBuckets(
"user-1",
14,
TZ,
NOW,
);
let scheduled = 0;
let taken = 0;
for (const b of buckets) {
if (b.scheduled > 0) {
scheduled += b.scheduled;
taken += b.taken;
}
}
// Sanity: 14 expected days, 7 taken → genuinely below 100 %. The raw
// coverage rollup would have read scheduled == taken == 7 → 100 %.
expect(scheduled).toBeGreaterThan(taken);
const dashboardPct = (100 * taken) / scheduled;
expect(dashboardPct).toBeLessThan(100);
// The storyline must surface (the dip gate no longer wrongly suppresses it)
// and its adherence number must equal the dashboard-tile number.
const story = await buildAdherenceStoryline("user-1", TZ, NOW);
expect(story).not.toBeNull();
expect(story!.adherencePct).toBeLessThan(100);
expect(story!.adherencePct).toBe(Math.round(dashboardPct));
});
it("still suppresses the storyline when adherence is genuinely high (dip gate honoured)", async () => {
// Log a taken dose on EVERY one of the 14 scheduled days → ~100 %.
const allDays: { day: string }[] = [];
for (let i = 0; i < 14; i++) {
const d = new Date(NOW.getTime() - i * DAY_MS);
allDays.push({ day: d.toISOString().slice(0, 10) });
}
mocks.intakeFindMany.mockResolvedValue(
allDays.map(({ day }) => ({
medicationId: "med-1",
scheduledFor: at8(day),
takenAt: new Date(`${day}T08:05:00.000Z`),
skipped: false,
autoMissed: false,
})),
);
const story = await buildAdherenceStoryline("user-1", TZ, NOW);
// High adherence → the dip gate (adherencePct > 80) returns null.
expect(story).toBeNull();
});
});
@@ -26,7 +26,8 @@ import type { MeasurementType } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { probeRollupCoverage } from "@/lib/rollups/measurement-coverage";
import { readDayMeanSeries } from "@/lib/insights/derived/baseline";
import { readMedicationCompliance } from "@/lib/rollups/medication-compliance-rollups";
import { buildScheduleAnchoredComplianceBuckets } from "@/lib/analytics/schedule-anchored-compliance";
import { DEFAULT_TIMEZONE } from "@/lib/tz/resolver";
import {
inferMedTargetClass,
MED_TARGET_MAP,
@@ -171,11 +172,20 @@ export async function buildAdherenceStoryline(
.filter((c): c is { label: string; cls: MedTargetClass } => c.cls !== null);
if (candidates.length === 0) return null;
// 2. Overall recent adherence (slot-level, cadence-aware via the rollup).
const buckets = await readMedicationCompliance(
// 2. Overall recent adherence — schedule-anchored + cadence-aware, the SAME
// engine the dashboard tile reads (`buildScheduleAnchoredComplianceBuckets`).
// v1.26.0 SEAM-N3 — this replaced `readMedicationCompliance`, whose
// `scheduled` was a RAW coverage count of LOGGED intake slots: a user who
// logs only the doses they took (never minting `takenAt:null` reminder
// rows for missed ones) read ~100% adherence, and the dip gate below
// (adherencePct > 80 → null) wrongly suppressed the storyline. The
// schedule-anchored `scheduled` is the recurrence engine's EXPECTED-dose
// count, so genuinely missed doses pull the rate down. One engine, one
// number — the storyline's % now equals the dashboard tile's %.
const buckets = await buildScheduleAnchoredComplianceBuckets(
userId,
ADHERENCE_WINDOW_DAYS,
timezone ?? "Europe/Berlin",
timezone ?? DEFAULT_TIMEZONE,
now,
);
let scheduled = 0;