mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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:
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
|
||||
);
|
||||
|
||||
|
||||
Reference in new issue
Block a user