fix(medications): rebase dashboard compliance denominator onto the schedule

The dashboard compliance tile set scheduled = COUNT(*) of logged intake
rows, so its rate (taken / scheduled) sat at ~100% across every window
regardless of real adherence — every logged row was both numerator and
denominator, and the 7/30/90 windows never diverged.

Anchor scheduled to the canonical recurrence engine's expected-dose count
per day, summed across active medications, so days the schedule expected a
dose the user missed pull the rate down and the windows genuinely diverge
with partial adherence. taken is capped per day at the expected count so a
duplicate log can't push a day above 100%. The response shape stays
{date, scheduled, taken} for the existing chart consumer.

Regenerate the OpenAPI contract for the new expected/missed count fields.
This commit is contained in:
Marc-André Bombeck committed 2026-06-07 13:26:58 +02:00
1 parent cb239c2803
commit 7bbb473070
4 files changed
+275 -134

No files matched your search

+26
View File
@@ -8460,6 +8460,18 @@ components:
type: integer
minimum: 0
maximum: 9007199254740991
expected:
type: integer
minimum: 0
maximum: 9007199254740991
description: v1.15.9 rate denominator over the short window (`taken + missed`); user skips are excluded. Render `taken /
expected · rate%`.
missed:
type: integer
minimum: 0
maximum: 9007199254740991
description: v1.15.9 doses counted against the rate over the short window — includes forgotten doses the auto-miss cron
flipped.
streak:
type: integer
minimum: 0
@@ -8467,6 +8479,8 @@ components:
required:
- rate
- taken
- expected
- missed
- streak
additionalProperties: false
long:
@@ -8480,9 +8494,21 @@ components:
type: integer
minimum: 0
maximum: 9007199254740991
expected:
type: integer
minimum: 0
maximum: 9007199254740991
description: v1.15.9 rate denominator over the long window (`taken + missed`).
missed:
type: integer
minimum: 0
maximum: 9007199254740991
description: v1.15.9 doses counted against the rate over the long window.
required:
- rate
- taken
- expected
- missed
additionalProperties: false
currentCycle:
type: object
@@ -369,43 +369,59 @@ describe("POST /api/medications/intake", () => {
});
});
describe("v1.4.39 W-MED — compliance rollup read swap", () => {
it("reads the rollup tier when coverage is present", async () => {
describe("v1.15.9 — schedule-anchored compliance buckets (BUG #1)", () => {
// The dashboard tile's `scheduled` is now the canonical recurrence
// engine's expected-dose count per day (not the count of logged intake
// rows). With partial adherence the per-day rate `taken / scheduled`
// genuinely reflects taken-of-expected — it is NOT pinned at ~100%.
function activeDailyMed(createdAt: Date) {
return {
id: "m1",
userId: "user-1",
active: true,
startsOn: null,
endsOn: null,
oneShot: false,
createdAt,
schedules: [
{
windowStart: "08:00",
windowEnd: "09:00",
daysOfWeek: null,
rrule: "FREQ=DAILY",
rollingIntervalDays: null,
timesOfDay: ["08:00"],
reminderGraceMinutes: null,
scheduleType: "SCHEDULED",
cyclicOnWeeks: null,
cyclicOffWeeks: null,
},
],
};
}
it("anchors `scheduled` to the schedule — a day with a dose due but none taken reads < 100%", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
// QA F-H-01 (v1.4.39): coverage probe returns
// `{ rolled_days >= event_days }` so the route lands on the
// rollup tier. Match the trailing-7-day window.
vi.mocked(prisma.$queryRaw).mockResolvedValue([
{ rolled_days: BigInt(7), event_days: BigInt(7) },
const createdAt = new Date(Date.now() - 60 * 86_400_000);
vi.mocked(prisma.medication.findMany).mockResolvedValue([
activeDailyMed(createdAt),
] as never);
// v1.4.39.1 — anchor the seed row on the runtime's "today" so the
// test stays green across the calendar regardless of when it
// runs. The pre-fix shape hard-coded `2026-05-18`, which fell out
// of the trailing-7-day window on every subsequent wall-clock
// day and silently shifted the body.data tail off the seed.
//
// v1.4.49.4 — compute todayKey in the SAME zone the route uses
// (`readMedicationCompliance` → `userDayKey(now, safeTz)`), not in
// UTC. SESSION_OK carries no `timezone`, so the route falls
// through to `DEFAULT_TIMEZONE = "Europe/Berlin"`. The pre-fix
// UTC computation diverged from the route's Berlin computation by
// one calendar day during the 22:00-24:00 UTC window every night,
// causing the test to fail deterministically once the clock
// crossed midnight Berlin while still on the previous UTC day.
const todayKey = (() => {
const now = new Date();
const fmt = new Intl.DateTimeFormat("en-CA", {
timeZone: "Europe/Berlin",
year: "numeric",
month: "2-digit",
day: "2-digit",
});
// `en-CA` short-date format is `YYYY-MM-DD`, matching the
// route's `userDayKey` output (see `src/lib/tz/resolver.ts`).
return fmt.format(now);
})();
vi.mocked(prisma.medicationComplianceRollup.findMany).mockResolvedValue([
{ day: todayKey, scheduled: 3, taken: 2, skipped: 1 },
// Only ONE taken dose across the whole 7-day window. A logged-row
// denominator would read 1 scheduled / 1 taken = 100% on that one day
// and nothing elsewhere; the engine instead expects ~7 doses (one per
// day) and counts the six un-taken days as missed → an aggregate rate
// well below 100%.
const yesterday8 = new Date();
yesterday8.setUTCDate(yesterday8.getUTCDate() - 1);
yesterday8.setUTCHours(8, 30, 0, 0);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([
{
medicationId: "m1",
scheduledFor: yesterday8,
takenAt: yesterday8,
skipped: false,
autoMissed: false,
},
] as never);
const res = await GET(
@@ -418,35 +434,53 @@ describe("v1.4.39 W-MED — compliance rollup read swap", () => {
data: Array<{ date: string; scheduled: number; taken: number }>;
};
expect(body.data).toHaveLength(7);
const today = body.data[body.data.length - 1];
expect(today.scheduled).toBe(3);
expect(today.taken).toBe(2);
// The legacy live aggregator must not have run when coverage is hot.
expect(prisma.medicationIntakeEvent.findMany).not.toHaveBeenCalled();
// Engine expects a dose on (nearly) every day → total scheduled ≫ the
// single logged row, and total taken is just the one.
const totalScheduled = body.data.reduce((s, d) => s + d.scheduled, 0);
const totalTaken = body.data.reduce((s, d) => s + d.taken, 0);
expect(totalScheduled).toBeGreaterThanOrEqual(5);
expect(totalTaken).toBe(1);
// Aggregate rate is far from the degenerate ~100% the logged-row
// denominator produced.
expect(totalTaken / totalScheduled).toBeLessThan(0.5);
});
it("falls back to the live aggregator on coverage miss", async () => {
it("a fully-adherent daily med reads ~100% on every dosed day", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
// QA F-H-01 (v1.4.39): partial coverage — events present but
// rollups missing — forces fall-through to the live aggregator.
vi.mocked(prisma.$queryRaw).mockResolvedValue([
{ rolled_days: BigInt(0), event_days: BigInt(7) },
const createdAt = new Date(Date.now() - 60 * 86_400_000);
vi.mocked(prisma.medication.findMany).mockResolvedValue([
activeDailyMed(createdAt),
] as never);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([
{
scheduledFor: new Date(),
takenAt: new Date(),
// One taken dose per day across the window.
const events = [];
for (let d = 0; d < 8; d++) {
const at = new Date();
at.setUTCDate(at.getUTCDate() - d);
at.setUTCHours(8, 10, 0, 0);
events.push({
medicationId: "m1",
scheduledFor: at,
takenAt: at,
skipped: false,
},
] as never);
autoMissed: false,
});
}
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
events as never,
);
const res = await GET(
new NextRequest(
"http://localhost/api/medications/intake?scope=compliance&days=7",
),
);
expect(res.status).toBe(200);
expect(prisma.medicationIntakeEvent.findMany).toHaveBeenCalled();
const body = (await res.json()) as {
data: Array<{ date: string; scheduled: number; taken: number }>;
};
// Every dosed day has taken === scheduled → 100% throughout.
for (const day of body.data) {
if (day.scheduled > 0) expect(day.taken).toBe(day.scheduled);
}
});
});
+138 -81
View File
@@ -28,13 +28,13 @@ import { auditLog } from "@/lib/auth/audit";
import { userDayKey, 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 {
readMedicationCompliance,
hasMedicationComplianceCoverage,
recomputeMedicationComplianceForEvent,
enqueueUserMedicationComplianceBackfill,
type ComplianceBucket as RollupComplianceBucket,
} from "@/lib/rollups/medication-compliance-rollups";
buildComplianceMedicationContext,
expectedSlotCountForDay,
lastNonSkippedTakenAt,
} from "@/lib/analytics/compliance";
import { getUserTodayBounds } from "@/lib/timezone";
import { projectTodayIntakesAndRecompute } from "@/lib/medications/scheduling/project-today-intakes";
import { resolveInjectionSiteForWrite } from "@/lib/medications/injection-site-write";
import {
@@ -167,10 +167,16 @@ export const GET = apiHandler(async (request: NextRequest) => {
? "skipped"
: e.takenAt
? "taken"
: e.medication.snoozedUntil &&
e.medication.snoozedUntil > new Date()
? "snoozed"
: "pending",
: // v1.15.9 — a never-acted dose the auto-miss cron flipped is a
// terminal MISS, not a perpetual "pending". (Today's freshly
// projected rows are < 24 h old so this rarely fires here, but
// a stale row surfacing in the window reads honestly.)
e.autoMissed
? "missed"
: e.medication.snoozedUntil &&
e.medication.snoozedUntil > new Date()
? "snoozed"
: "pending",
snoozedUntil: e.medication.snoozedUntil?.toISOString() ?? null,
})),
);
@@ -182,15 +188,18 @@ export const GET = apiHandler(async (request: NextRequest) => {
// move). Cache key carries the userTz so a user who changes timezone
// doesn't read another tz's bucketing.
//
// v1.4.39 W-MED — read path now consumes the persistent
// `medication_compliance_rollups` tier. A coverage probe falls back
// to the legacy live aggregator when the user has intake events but
// zero rollup rows; the fallback fires a boot-backfill enqueue in the
// background so the next request hits the rollup tier.
// v1.15.9 — `scheduled` is now the SCHEDULE-ANCHORED expected-dose count
// per day (the canonical recurrence engine), NOT the count of logged
// intake rows. The old rollup-backed path set `scheduled = COUNT(*)` of
// intake rows, so the dashboard tile's rate (`taken / scheduled`) was ~100%
// across every window regardless of real adherence — every logged row was
// both numerator and denominator. Anchoring `scheduled` to the schedule
// 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[]>,
`${user.id}|compliance|${days}|${userTz}`,
() => readComplianceBucketsWithFallback(user.id, days, userTz),
() => buildScheduleAnchoredComplianceBuckets(user.id, days, userTz),
annotate,
);
@@ -202,50 +211,6 @@ export const GET = apiHandler(async (request: NextRequest) => {
return apiSuccess(result);
});
/**
* v1.4.39 W-MED — read from the persistent rollup tier when covered,
* fall through to the legacy live aggregator on coverage miss. The
* fallback fires the boot backfill in the background so subsequent
* requests hit the rollup path; the current request still returns a
* correct response built from the raw events.
*/
async function readComplianceBucketsWithFallback(
userId: string,
days: number,
userTz: string,
): Promise<ComplianceBucket[]> {
const covered = await hasMedicationComplianceCoverage(userId, days, userTz);
if (covered) {
const buckets = await readMedicationCompliance(userId, days, userTz);
annotate({
meta: {
medication_compliance_path: "rollup",
medication_compliance_days: days,
},
});
return buckets satisfies RollupComplianceBucket[];
}
// Coverage miss — the legacy aggregator stays correct over an empty
// rollup window. Fire a user-scoped backfill in the background so
// the next request lands on the rollup tier; the current request
// still returns the live-derived buckets.
//
// QA F-SEC-M-01 (v1.4.39): pre-fix this fired the cluster-wide
// `enqueueBootTimeMedicationComplianceBackfill` on every coverage-
// miss request, opening a soft-DoS amplifier (every authenticated
// user could drive a multi-tenant `LEFT JOIN` scan on each hit).
// The user-scoped helper enqueues exactly the caller's account.
void enqueueUserMedicationComplianceBackfill(userId);
annotate({
meta: {
medication_compliance_path: "live-fallback",
medication_compliance_days: days,
},
});
return buildComplianceBuckets(userId, days, userTz);
}
interface ComplianceBucket {
date: string;
scheduled: number;
@@ -253,40 +218,132 @@ interface ComplianceBucket {
}
/**
* v1.4.34 IW-G — pulled out of the GET handler so `cached()` can wrap
* the per-user compliance aggregation. Pure function over the user's
* intake events + the requested window; deterministic given a fixed
* "now" wall-clock (we anchor on `Date.now()` once at call time so a
* 15-minute cached row stays internally consistent).
* 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 buildComplianceBuckets(
async function buildScheduleAnchoredComplianceBuckets(
userId: string,
days: number,
userTz: string,
): Promise<ComplianceBucket[]> {
const nowMs = Date.now();
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 },
});
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: { scheduledFor: true, takenAt: true, skipped: true },
select: {
medicationId: true,
scheduledFor: true,
takenAt: true,
skipped: true,
autoMissed: true,
},
});
const buckets = new Map<string, { scheduled: number; taken: number }>();
for (let i = 0; i < days; i++) {
const d = new Date(nowMs - i * 86_400_000);
buckets.set(userDayKey(d, userTz), { scheduled: 0, taken: 0 });
}
for (const e of events) {
const key = userDayKey(e.scheduledFor, userTz);
const bucket = buckets.get(key);
if (!bucket) continue;
bucket.scheduled += 1;
if (e.takenAt && !e.skipped) bucket.taken += 1;
// Pre-compute each local day's [start, end) bounds + key, oldest → newest.
const dayKeys: string[] = [];
const dayBounds = new Map<string, { start: Date; end: Date }>();
for (let i = days - 1; i >= 0; i--) {
const representative = new Date(nowMs - i * 86_400_000 - 12 * 60 * 60 * 1000);
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)
});
}
return [...buckets.entries()]
.map(([date, v]) => ({ date, scheduled: v.scheduled, taken: v.taken }))
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));
}
+24
View File
@@ -1274,11 +1274,35 @@ const complianceDisplay = z
short: z.object({
rate: z.number().int().min(0).max(100),
taken: z.number().int().nonnegative(),
expected: z
.number()
.int()
.nonnegative()
.describe(
"v1.15.9 rate denominator over the short window (`taken + missed`); user skips are excluded. Render `taken / expected · rate%`.",
),
missed: z
.number()
.int()
.nonnegative()
.describe(
"v1.15.9 doses counted against the rate over the short window — includes forgotten doses the auto-miss cron flipped.",
),
streak: z.number().int().nonnegative(),
}),
long: z.object({
rate: z.number().int().min(0).max(100),
taken: z.number().int().nonnegative(),
expected: z
.number()
.int()
.nonnegative()
.describe("v1.15.9 rate denominator over the long window (`taken + missed`)."),
missed: z
.number()
.int()
.nonnegative()
.describe("v1.15.9 doses counted against the rate over the long window."),
}),
currentCycle: z
.object({