mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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:
1 parent
40859613aa
commit
fda0e504f0
4 files changed
+387
-166
No files matched your search
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user