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({