fix(medications): classify each time-of-day slot in the compliance heatmap

The History compliance heatmap matched each taken intake event to the
closest schedule by `windowStart` alone, ignoring `timesOfDay`. For a
twice-daily medication stored as one schedule row with
`timesOfDay = ["07:00","19:00"]` and `windowStart = "07:00"`, the evening
dose matched the lone 07:00 window and classified as very-late (~12h),
painting the day's cell orange even when both doses were taken on time —
so the heatmap never recovered to green for single-row multi-time
schedules.

Expand every schedule into its effective dose slots (its `timesOfDay`,
else `[windowStart]`) and classify each event against the nearest slot's
window — the slot time anchoring a window that spans the schedule's
`windowEnd − windowStart` duration, overnight spans included. Single-time
schedules collapse to one slot at `windowStart` and stay byte-identical.
The denominator already routed through the canonical engine; only the
numerator's timing classification is corrected.
This commit is contained in:
Marc-André Bombeck committed 2026-06-01 07:26:47 +02:00
1 parent fcb517df87
commit 7f7bf75e34
2 files changed
+334 -9

No files matched your search

@@ -0,0 +1,241 @@
/**
* v1.8.1 B15 follow-on — per-slot timing classification for the History
* compliance heatmap.
*
* The `dailyCompliance` builder in `GET /api/medications/[id]/compliance`
* classifies each taken intake into early / on_time / late / very_late
* buckets and paints the heatmap cell off those counts. The bug: a
* twice-daily medication stored as ONE schedule row
* (`timesOfDay = ["07:00","19:00"]`, `windowStart = "07:00"`) matched the
* 19:00 dose against the single `windowStart` "07:00" — classifying a
* perfectly on-time evening dose as `very_late` (~12h late) and painting
* the cell orange even though both doses were taken on time.
*
* The fix matches each taken event to the closest effective time-of-day
* SLOT across every schedule (candidate slots = `timesOfDay` when
* non-empty, else `[windowStart]`) and classifies against that slot's
* window (slot..slot+span, span = windowEnd − windowStart). Single-time
* schedules stay byte-identical to the pre-fix behaviour.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
medication: { findUnique: vi.fn() },
medicationIntakeEvent: { findMany: vi.fn() },
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/medications/route-guards", () => ({
assertMedicationOwnership: vi.fn().mockResolvedValue(null),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { GET } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { getUserTodayBounds } from "@/lib/timezone";
import { userDayKey } from "@/lib/tz/format";
// UTC keeps the half-open day slice (`[dayStart, dayEnd)`) and the
// classifier's window anchor (`setUTCHours` on the day-start date) in the
// same reference frame, so the fixture instants below land deterministically
// in range regardless of the host timezone.
const TZ = "UTC";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const, timezone: TZ },
};
const ROUTE_PARAMS = { params: Promise.resolve({ id: "med-1" }) };
// A representative "yesterday" so the day is fully in the past and the
// medication's createdAt clearly precedes it. The route walks 90 days
// back from `new Date()`; this instant lands well inside that window.
const DAY_OFFSET = 1;
function dayBoundsForOffset(offset: number) {
const now = new Date();
const representative = new Date(
now.getTime() - offset * 24 * 60 * 60 * 1000 - 12 * 60 * 60 * 1000,
);
const { start, end } = getUserTodayBounds(representative, TZ);
return { start, endInclusive: end };
}
// Build a UTC instant for a given "HH:mm" anchored on the day-start date,
// mirroring how `classifyIntakeTiming` interprets the window via
// `setUTCHours` on the scheduled date. Keeps the fixture's takenAt aligned
// with the classifier's reference frame regardless of host timezone.
function atSlotUTC(dayStart: Date, hhmm: string): Date {
const [h, m] = hhmm.split(":").map(Number);
const d = new Date(dayStart);
d.setUTCHours(h, m, 0, 0);
return d;
}
function medication(schedules: unknown[]) {
return {
id: "med-1",
userId: "user-1",
createdAt: new Date(Date.now() - 200 * 24 * 60 * 60 * 1000),
startsOn: null,
endsOn: null,
oneShot: false,
schedules,
};
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
});
async function callRoute(): Promise<Record<string, unknown>> {
const res = await GET(new Request("http://localhost"), ROUTE_PARAMS);
expect(res.status).toBe(200);
const body = await res.json();
return body.data.dailyCompliance as Record<string, unknown>;
}
describe("GET /api/medications/[id]/compliance — per-slot timing", () => {
it("classifies both doses of a single-row twice-daily schedule as on_time (green cell)", async () => {
const { start: dayStart } = dayBoundsForOffset(DAY_OFFSET);
const dateKey = userDayKey(dayStart, TZ);
// ONE schedule row, two times-of-day, a 1h window per slot.
vi.mocked(prisma.medication.findUnique).mockResolvedValue(
medication([
{
id: "sched-1",
windowStart: "07:00",
windowEnd: "08:00",
timesOfDay: ["07:00", "19:00"],
daysOfWeek: null,
rrule: null,
rollingIntervalDays: null,
reminderGraceMinutes: null,
scheduleType: "SCHEDULED",
cyclicOnWeeks: null,
cyclicOffWeeks: null,
},
]) as never,
);
// Both doses logged right at their slot times.
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([
{
takenAt: atSlotUTC(dayStart, "07:05"),
skipped: false,
scheduledFor: atSlotUTC(dayStart, "07:00"),
},
{
takenAt: atSlotUTC(dayStart, "19:05"),
skipped: false,
scheduledFor: atSlotUTC(dayStart, "19:00"),
},
] as never);
const daily = await callRoute();
const entry = daily[dateKey] as Record<string, number> | undefined;
expect(entry).toBeDefined();
expect(entry!.veryLate).toBe(0);
expect(entry!.late).toBe(0);
// Both doses compliant → green cell.
expect(entry!.onTime).toBe(2);
});
it("keeps single-time schedules byte-identical: a 12h-late dose is still very_late", async () => {
const { start: dayStart } = dayBoundsForOffset(DAY_OFFSET);
const dateKey = userDayKey(dayStart, TZ);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(
medication([
{
id: "sched-1",
windowStart: "07:00",
windowEnd: "08:00",
timesOfDay: [], // single-time: candidate = [windowStart]
daysOfWeek: null,
rrule: null,
rollingIntervalDays: null,
reminderGraceMinutes: null,
scheduleType: "SCHEDULED",
cyclicOnWeeks: null,
cyclicOffWeeks: null,
},
]) as never,
);
// Single 07:00 schedule, dose taken at 19:00 → ~12h late → very_late.
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([
{
takenAt: atSlotUTC(dayStart, "19:00"),
skipped: false,
scheduledFor: atSlotUTC(dayStart, "07:00"),
},
] as never);
const daily = await callRoute();
const entry = daily[dateKey] as Record<string, number> | undefined;
expect(entry).toBeDefined();
expect(entry!.veryLate).toBe(1);
expect(entry!.onTime).toBe(0);
expect(entry!.late).toBe(0);
});
it("handles an overnight per-slot window (23:00 slot, 1h span) as on_time near the slot", async () => {
const { start: dayStart } = dayBoundsForOffset(DAY_OFFSET);
const dateKey = userDayKey(dayStart, TZ);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(
medication([
{
id: "sched-1",
windowStart: "23:00",
windowEnd: "00:00", // overnight: end <= start
timesOfDay: ["09:00", "23:00"],
daysOfWeek: null,
rrule: null,
rollingIntervalDays: null,
reminderGraceMinutes: null,
scheduleType: "SCHEDULED",
cyclicOnWeeks: null,
cyclicOffWeeks: null,
},
]) as never,
);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([
{
takenAt: atSlotUTC(dayStart, "23:10"),
skipped: false,
scheduledFor: atSlotUTC(dayStart, "23:00"),
},
] as never);
const daily = await callRoute();
const entry = daily[dateKey] as Record<string, number> | undefined;
expect(entry).toBeDefined();
expect(entry!.veryLate).toBe(0);
expect(entry!.late).toBe(0);
expect(entry!.onTime).toBe(1);
});
});
@@ -16,6 +16,74 @@ import { userDayKey } from "@/lib/tz/format";
type RouteParams = { params: Promise<{ id: string }> };
/** A single effective dose slot with its derived punctuality window. */
interface SlotWindow {
/** Minute-of-day of the slot's start time (used for nearest-slot match). */
startMinutes: number;
/** "HH:mm" slot start — fed to `classifyIntakeTiming` as `windowStart`. */
windowStart: string;
/** "HH:mm" slot end = slot start + the schedule's window span. */
windowEnd: string;
}
const MINUTES_PER_DAY = 24 * 60;
/** Parse "HH:mm" into minute-of-day. */
function hhmmToMinutes(time: string): number {
const [h, m] = time.split(":").map(Number);
return h * 60 + m;
}
/** Format a minute-of-day (wrapped into 0..1439) back to "HH:mm". */
function minutesToHHmm(minutes: number): string {
const wrapped = ((minutes % MINUTES_PER_DAY) + MINUTES_PER_DAY) %
MINUTES_PER_DAY;
const h = Math.floor(wrapped / 60);
const m = wrapped % 60;
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
}
/**
* v1.8.1 B15 follow-on — expand every schedule into its effective dose
* slots so timing classification matches against the right time-of-day.
*
* Candidate slot times for a schedule are its `timesOfDay` when non-empty,
* else `[windowStart]` (so single-time schedules collapse to one slot at
* `windowStart` and stay byte-identical to the pre-fix matcher). Each
* slot's punctuality window is anchored at the slot time and spans the
* schedule's `windowEnd − windowStart` duration; overnight windows
* (`windowEnd ≤ windowStart`) add a day to the span before deriving the
* per-slot end. Multi-schedule meds union their slots — the matcher then
* picks the single closest slot across all of them.
*/
function buildSlotCandidates(
schedules: { windowStart: string; windowEnd: string; timesOfDay: string[] }[],
): SlotWindow[] {
const slots: SlotWindow[] = [];
for (const sched of schedules) {
const startMin = hhmmToMinutes(sched.windowStart);
let endMin = hhmmToMinutes(sched.windowEnd);
// Overnight window: the end is on the following day.
if (endMin <= startMin) endMin += MINUTES_PER_DAY;
const spanMinutes = endMin - startMin;
const times =
sched.timesOfDay.length > 0 ? sched.timesOfDay : [sched.windowStart];
for (const time of times) {
const slotStartMin = hhmmToMinutes(time);
slots.push({
startMinutes: slotStartMin,
windowStart: minutesToHHmm(slotStartMin),
// `classifyIntakeTiming` re-detects the overnight wrap from the
// HH:mm pair, so a wrapped end formats back into 0..1439 cleanly.
windowEnd: minutesToHHmm(slotStartMin + spanMinutes),
});
}
}
return slots;
}
export const GET = apiHandler(
async (_request: Request, { params }: RouteParams) => {
const { user } = await requireAuth();
@@ -115,33 +183,49 @@ export const GET = apiHandler(
let veryLate = 0;
let early = 0;
// v1.8.1 B15 follow-on — match each taken event to the closest
// effective time-of-day SLOT, not merely `windowStart`. A single
// schedule row carrying `timesOfDay = ["07:00","19:00"]` is two
// distinct dose slots; matching the evening dose against the lone
// `windowStart` "07:00" mis-classified it as `very_late` (~12h late)
// and painted the heatmap cell orange even when both doses were
// taken on time. Candidate slots for a schedule are its `timesOfDay`
// when non-empty, else `[windowStart]` — so single-time schedules
// collapse to exactly one candidate at `windowStart` and stay
// byte-identical to the pre-fix behaviour. The per-slot window
// mirrors the projector / cadence anchor: effective windowStart is
// the slot time, effective windowEnd is the slot time plus the
// schedule's `windowEnd − windowStart` span (overnight spans where
// `windowEnd ≤ windowStart` add a day).
const slotCandidates = buildSlotCandidates(medication.schedules);
for (const evt of takenEvents) {
if (medication.schedules.length === 0) {
if (slotCandidates.length === 0) {
// No schedule info: treat all taken as on_time
onTime++;
continue;
}
// Match event to the closest schedule window by scheduledFor time
// Match event to the closest slot window by scheduledFor time.
const evtHour = evt.scheduledFor.getUTCHours();
const evtMin = evt.scheduledFor.getUTCMinutes();
const evtMinutes = evtHour * 60 + evtMin;
let bestSchedule = medication.schedules[0];
let bestSlot = slotCandidates[0];
let bestDist = Infinity;
for (const sched of medication.schedules) {
const [sh, sm] = sched.windowStart.split(":").map(Number);
const dist = Math.abs(evtHour * 60 + evtMin - (sh * 60 + sm));
for (const slot of slotCandidates) {
const dist = Math.abs(evtMinutes - slot.startMinutes);
if (dist < bestDist) {
bestDist = dist;
bestSchedule = sched;
bestSlot = slot;
}
}
const timing = classifyIntakeTiming(
evt.takenAt,
bestSchedule.windowStart,
bestSchedule.windowEnd,
bestSlot.windowStart,
bestSlot.windowEnd,
dayStart, // the scheduled date
);