From 7f7bf75e34b67d6b192358533e2f66253f6cc385 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 1 Jun 2026 07:26:47 +0200 Subject: [PATCH] fix(medications): classify each time-of-day slot in the compliance heatmap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../[id]/compliance/__tests__/route.test.ts | 241 ++++++++++++++++++ .../api/medications/[id]/compliance/route.ts | 102 +++++++- 2 files changed, 334 insertions(+), 9 deletions(-) create mode 100644 src/app/api/medications/[id]/compliance/__tests__/route.test.ts diff --git a/src/app/api/medications/[id]/compliance/__tests__/route.test.ts b/src/app/api/medications/[id]/compliance/__tests__/route.test.ts new file mode 100644 index 000000000..935f1b1f7 --- /dev/null +++ b/src/app/api/medications/[id]/compliance/__tests__/route.test.ts @@ -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> { + 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; +} + +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 | 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 | 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 | undefined; + expect(entry).toBeDefined(); + expect(entry!.veryLate).toBe(0); + expect(entry!.late).toBe(0); + expect(entry!.onTime).toBe(1); + }); +}); diff --git a/src/app/api/medications/[id]/compliance/route.ts b/src/app/api/medications/[id]/compliance/route.ts index d0e04bc0f..b46d41a35 100644 --- a/src/app/api/medications/[id]/compliance/route.ts +++ b/src/app/api/medications/[id]/compliance/route.ts @@ -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 );