From e1d143d689cef21aedd7f0de3e6e2bf708a806d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 11 Nov 2026 21:55:27 +0100 Subject: [PATCH] fix(medications): read the dose history's window, not every intake The history tab asks for a window of at most a year, but the route read every intake the medication ever had: a medication taken three times a day for five years read about 5,500 rows on each open. The full read also served to find the last intake for rolling cadences. The route now reads the rows anchored or taken in the window, and of the rows before it only the latest take, which is the anchor a rolling grid re-starts from and the last intake when none falls in the window. Earlier takes place no slot in the window, so the ledger is the same. The read moves into its own function, which shortens the handler. --- .../baselines/source-function-lines.json | 4 +- .../[id]/dose-history/__tests__/route.test.ts | 73 ++++++++++++++++- .../medications/[id]/dose-history/route.ts | 82 ++++++++++++++----- 3 files changed, 135 insertions(+), 24 deletions(-) diff --git a/src/__tests__/baselines/source-function-lines.json b/src/__tests__/baselines/source-function-lines.json index a6c30e3f9..ba2210e20 100644 --- a/src/__tests__/baselines/source-function-lines.json +++ b/src/__tests__/baselines/source-function-lines.json @@ -1,7 +1,7 @@ { "$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.", "count": 339, - "total": 110080, + "total": 110065, "entries": { "app/achievements/page.tsx :: AchievementsPage": 206, "app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 275, @@ -27,7 +27,7 @@ "app/api/mcp/oauth/token/route.ts :: POST": 250, "app/api/mcp/oauth/token/route.ts :: POST > withBackgroundEvent(arg)": 248, "app/api/measurement-reminders/[id]/route.ts :: apiHandler(arg) #2": 239, - "app/api/medications/[id]/dose-history/route.ts :: apiHandler(arg)": 256, + "app/api/medications/[id]/dose-history/route.ts :: apiHandler(arg)": 241, "app/api/medications/[id]/inventory/[itemId]/route.ts :: apiHandler(arg)": 204, "app/api/medications/[id]/schedule-revisions/[revisionId]/route.ts :: apiHandler(arg)": 250, "app/api/mental-health/assessments/route.ts :: postAssessment": 249, diff --git a/src/app/api/medications/[id]/dose-history/__tests__/route.test.ts b/src/app/api/medications/[id]/dose-history/__tests__/route.test.ts index 5c55e140a..8edbfb47d 100644 --- a/src/app/api/medications/[id]/dose-history/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/dose-history/__tests__/route.test.ts @@ -19,7 +19,7 @@ import { NextRequest } from "next/server"; vi.mock("@/lib/db", () => ({ prisma: { medication: { findUnique: vi.fn() }, - medicationIntakeEvent: { findMany: vi.fn() }, + medicationIntakeEvent: { findMany: vi.fn(), findFirst: vi.fn() }, }, })); @@ -567,3 +567,74 @@ describe("GET /api/medications/[id]/dose-history — slots before creation (#102 }); } }); + +/** + * The history reads the window's rows, not every intake the medication ever + * had: for a medication taken for years each open of the tab read thousands + * of rows to show at most a year. Of the rows before the window only the + * latest take is read, because a rolling grid re-starts from it. + */ +describe("GET /api/medications/[id]/dose-history — the read is bounded to the window", () => { + const FROM = new Date("2026-06-01T00:00:00Z"); + const TO = new Date("2026-06-15T00:00:00Z"); + const windowQuery = `?from=${encodeURIComponent(FROM.toISOString())}&to=${encodeURIComponent(TO.toISOString())}`; + + it("asks only for rows anchored or taken in the window", async () => { + await GET(getReq(windowQuery), ROUTE_PARAMS); + + const where = vi.mocked(prisma.medicationIntakeEvent.findMany).mock + .calls[0][0]?.where; + expect(where).toMatchObject({ + OR: [{ scheduledFor: { gte: FROM } }, { takenAt: { gte: FROM } }], + }); + const before = vi.mocked(prisma.medicationIntakeEvent.findFirst).mock + .calls[0][0]; + expect(before?.where).toMatchObject({ + skipped: false, + scheduledFor: { lt: FROM }, + takenAt: { lt: FROM }, + }); + }); + + it("re-starts a rolling grid from the latest take before the window", async () => { + vi.mocked(prisma.medication.findUnique).mockResolvedValue({ + id: "med-1", + startsOn: null, + endsOn: null, + oneShot: false, + createdAt: new Date("2026-01-01T00:00:00Z"), + schedules: [ + { + id: "sched-1", + windowStart: "08:00", + windowEnd: "08:00", + daysOfWeek: null, + timesOfDay: ["08:00"], + reminderGraceMinutes: null, + rrule: null, + rollingIntervalDays: 7, + scheduleType: "SCHEDULED", + cyclicOnWeeks: null, + cyclicOffWeeks: null, + }, + ], + } as never); + // Taken four days before the window opens, nothing since: the next dose + // was due inside the window and reads as missed there. + vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValue({ + takenAt: new Date("2026-05-28T06:00:00Z"), + } as never); + + const res = await GET(getReq(windowQuery), ROUTE_PARAMS); + expect(res.status).toBe(200); + const rows = (await res.json()).data.rows as Array<{ + at: string; + kind: string; + status: string; + }>; + + expect(rows.map((r) => [r.at.slice(0, 10), r.kind, r.status])).toEqual([ + ["2026-06-04", "slot", "missed"], + ]); + }); +}); diff --git a/src/app/api/medications/[id]/dose-history/route.ts b/src/app/api/medications/[id]/dose-history/route.ts index 7d9f7cf5b..652ae6d06 100644 --- a/src/app/api/medications/[id]/dose-history/route.ts +++ b/src/app/api/medications/[id]/dose-history/route.ts @@ -101,6 +101,61 @@ interface SerializedDoseHistoryRow { } | null; } +/** + * The intakes a window needs: the rows anchored in it or taken in it (a late + * take on an older slot is an intake instant of the window), and of all the + * rows before it only the latest take, the anchor a rolling grid re-starts + * from and the last intake when none falls in the window. Earlier takes place + * no slot in the window. The whole history used to be read on every open, so + * a medication taken for years read thousands of rows to show at most a year. + */ +async function readWindowIntakes( + medicationId: string, + userId: string, + from: Date, +) { + const [events, takenBefore] = await Promise.all([ + prisma.medicationIntakeEvent.findMany({ + where: { + medicationId, + userId, + deletedAt: null, + OR: [{ scheduledFor: { gte: from } }, { takenAt: { gte: from } }], + }, + orderBy: [{ scheduledFor: "desc" }, { id: "desc" }], + select: { + id: true, + scheduledFor: true, + takenAt: true, + skipped: true, + autoMissed: true, + // v1.15.20 — USER_PIN rows bind by anchor in the read ledger. + attributionSource: true, + // v1.16.4 — per-intake dose override for the ledger's deviation hint. + doseTaken: true, + // v1.32.8 (iOS #64) — write provenance so the client can label how + // each dose was recorded (Web / API / Reminder / Import / AppleHealth). + source: true, + // v1.42.2 — the history labels an automatically recorded dose. + autoRecorded: true, + }, + }), + prisma.medicationIntakeEvent.findFirst({ + where: { + medicationId, + userId, + deletedAt: null, + skipped: false, + scheduledFor: { lt: from }, + takenAt: { lt: from }, + }, + orderBy: [{ takenAt: "desc" }, { id: "desc" }], + select: { takenAt: true }, + }), + ]); + return { events, anchorBefore: takenBefore?.takenAt ?? null }; +} + export const GET = apiHandler( async (request: NextRequest, { params }: RouteParams) => { const { user } = await requireAuth(); @@ -165,26 +220,7 @@ export const GET = apiHandler( Math.max(requestedFrom.getTime(), spanFloor.getTime()), ); - const events = await prisma.medicationIntakeEvent.findMany({ - where: { medicationId: id, userId: user.id, deletedAt: null }, - orderBy: [{ scheduledFor: "desc" }, { id: "desc" }], - select: { - id: true, - scheduledFor: true, - takenAt: true, - skipped: true, - autoMissed: true, - // v1.15.20 — USER_PIN rows bind by anchor in the read ledger. - attributionSource: true, - // v1.16.4 — per-intake dose override for the ledger's deviation hint. - doseTaken: true, - // v1.32.8 (iOS #64) — write provenance so the client can label how - // each dose was recorded (Web / API / Reminder / Import / AppleHealth). - source: true, - // v1.42.2 — the history labels an automatically recorded dose. - autoRecorded: true, - }, - }); + const { events, anchorBefore } = await readWindowIntakes(id, user.id, from); const mapped = events.map((e) => ({ id: e.id, @@ -198,7 +234,10 @@ export const GET = apiHandler( autoRecorded: e.autoRecorded, })); - const lastIntakeAt = lastNonSkippedTakenAt(mapped); + const lastIntakeAt = lastNonSkippedTakenAt([ + ...mapped, + ...(anchorBefore ? [{ takenAt: anchorBefore, skipped: false }] : []), + ]); // A dose may be recorded before its slot's own time: the dose window opens // up to two hours early, and a skip can be logged ahead. Such a row is @@ -263,6 +302,7 @@ export const GET = apiHandler( const intakeInstants = mapped .filter((e) => !e.skipped && e.takenAt !== null && e.takenAt <= to) .map((e) => e.takenAt as Date) + .concat(anchorBefore ? [anchorBefore] : []) .sort((a, b) => a.getTime() - b.getTime()); const groups = buildBandsForSchedulesWithEras({