From dad9a7778e84cbd7c45ab8b5d0c851fd6d643a8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 15 Oct 2026 11:02:38 +0200 Subject: [PATCH 1/3] fix(medications): keep an extra Apple Health dose off a slot that already holds one A HealthKit dose event binds to a slot by window band, and a weekly schedule's band reaches a day either side of its slot. An extra dose logged the day after a scheduled one landed on that slot and overwrote it last-write-wins: the slot's time changed and the extra dose was stored nowhere. An external dose record whose band slot already carries a different take is now recorded as its own unscheduled dose. The dose history now lets the take stored on a slot claim it before an extra dose stored on its own instant, instead of following the order the rows were read in, so the extra dose reads as off-schedule. --- .../intake/bulk/__tests__/route.test.ts | 6 +- src/app/api/medications/intake/bulk/route.ts | 29 ++ .../scheduling/__tests__/extra-dose.test.ts | 114 ++++++ .../medications/scheduling/dose-history.ts | 27 +- src/lib/medications/scheduling/slot-upsert.ts | 73 ++++ ...medication-extra-dose-apple-health.test.ts | 356 ++++++++++++++++++ 6 files changed, 602 insertions(+), 3 deletions(-) create mode 100644 src/lib/medications/scheduling/__tests__/extra-dose.test.ts create mode 100644 tests/integration/medication-extra-dose-apple-health.test.ts diff --git a/src/app/api/medications/intake/bulk/__tests__/route.test.ts b/src/app/api/medications/intake/bulk/__tests__/route.test.ts index 464d57691..c3ac9d2a3 100644 --- a/src/app/api/medications/intake/bulk/__tests__/route.test.ts +++ b/src/app/api/medications/intake/bulk/__tests__/route.test.ts @@ -1380,9 +1380,11 @@ describe("POST /api/medications/intake/bulk — v1.28 Apple Health dose events", }); it("stamps the externalId onto the pending slot row when the Apple dose converges by band", async () => { - // Dedup pre-check first (empty), then the canonical-slot find returns - // the worker-minted pending REMINDER row for the 19:00 slot. + // Dedup pre-check first (empty), then the held-dose probe (no take on + // the slot yet), then the canonical-slot find returns the worker-minted + // pending REMINDER row for the 19:00 slot. vi.mocked(prisma.medicationIntakeEvent.findMany) + .mockResolvedValueOnce([] as never) .mockResolvedValueOnce([] as never) .mockResolvedValueOnce([ { diff --git a/src/app/api/medications/intake/bulk/route.ts b/src/app/api/medications/intake/bulk/route.ts index 69fd01a1f..37202fac4 100644 --- a/src/app/api/medications/intake/bulk/route.ts +++ b/src/app/api/medications/intake/bulk/route.ts @@ -65,6 +65,7 @@ import { resolveForcedSlotForWrite, resolveSlotForWriteByBand, resolveSlotInstantForWrite, + slotHeldByAnotherDose, } from "@/lib/medications/scheduling/slot-upsert"; import { resolveInjectionSiteForWrite } from "@/lib/medications/injection-site-write"; import { @@ -338,6 +339,9 @@ async function postBulk(request: NextRequest): Promise { let inserted = 0; let updated = 0; let duplicates = 0; + // External dose records kept as their own unscheduled dose because their + // band slot already carried a different one. + let extraDosesUnscheduled = 0; const skipped: Array<{ index: number; reason: string }> = []; const unstableShapes: UnstableExternalIdShape[] = []; // v1.4.39 W-MED — collect distinct `(medicationId, dayKey)` pairs @@ -443,6 +447,9 @@ async function postBulk(request: NextRequest): Promise { // a band decision. Pending echoes / skips carry no decision // (undefined → the upsert leaves an existing USER_PIN untouched). let attributionSource: "AUTO" | "USER_PIN" | undefined; + // Set when the band slot is held by a different dose and this take is + // recorded unscheduled; it must not converge onto any slot row below. + let heldByAnotherDose = false; if (isExplicitTaken && entry.forceSlotInstant !== undefined) { canonicalSlot = await resolveForcedSlotForWrite({ userId: user.id, @@ -486,6 +493,26 @@ async function postBulk(request: NextRequest): Promise { }); canonicalSlot = attribution.slotInstant; attributionSource = "AUTO"; + // An external dose record (a HealthKit dose event) whose band slot + // already carries a different recorded dose is an extra dose, not a + // correction of that one. Converging would overwrite the slot's + // take last-write-wins and the extra dose would exist nowhere; it is + // recorded as its own unscheduled dose instead. + if ( + canonicalSlot !== null && + entry.externalId !== undefined && + (await slotHeldByAnotherDose({ + userId: user.id, + medicationId: entry.medicationId, + canonicalSlot, + takenAt: entry.takenAt as Date, + externalId: entry.externalId, + })) + ) { + canonicalSlot = null; + heldByAnotherDose = true; + extraDosesUnscheduled += 1; + } } else if (isExplicitSkip && entry.scheduledFor === undefined) { // v1.16.9 — a slot-less deliberate skip resolves by band // membership on the skip moment, exactly like a take does. The @@ -666,6 +693,7 @@ async function postBulk(request: NextRequest): Promise { // late-morning dose consuming the evening slot). It records // standalone (ad-hoc) instead. const existingSlotRow = + !heldByAnotherDose && entry.scheduledFor !== undefined && mayConvergeOntoSuppliedSlot({ skipped: entry.skipped, @@ -878,6 +906,7 @@ async function postBulk(request: NextRequest): Promise { updated, duplicates, skipped: skipped.length, + extra_doses_unscheduled: extraDosesUnscheduled, ...(unstableShapes.length > 0 ? unstableExternalIdMeta("medication.intake.bulk", unstableShapes) : {}), diff --git a/src/lib/medications/scheduling/__tests__/extra-dose.test.ts b/src/lib/medications/scheduling/__tests__/extra-dose.test.ts new file mode 100644 index 000000000..67457ce71 --- /dev/null +++ b/src/lib/medications/scheduling/__tests__/extra-dose.test.ts @@ -0,0 +1,114 @@ +/** + * An extra dose next to a slot that already carries one. + * + * Two ends: the write path keeps a different external dose record off a slot + * that is already taken (`isAnotherDose`), and the read ledger lets the dose + * stored on the slot claim it before an extra one stored on its own instant, + * whatever order the rows were read in. + */ +import { describe, expect, it } from "vitest"; + +import { buildSlotBands, type SlotWindowInput } from "../attribution"; +import { reconstructDoseHistory, type HistoryIntake } from "../dose-history"; +import { SAME_DOSE_TOLERANCE_MS, isAnotherDose } from "../slot-upsert"; + +const MIN = 60_000; +const t0 = new Date("2026-06-09T20:00:00Z"); +const plus = (minutes: number) => new Date(t0.getTime() + minutes * MIN); + +describe("isAnotherDose", () => { + it("a slot with nothing taken is never held by another dose", () => { + expect( + isAnotherDose( + { takenAt: null, externalId: null }, + { takenAt: plus(600), externalId: "hk-2" }, + ), + ).toBe(false); + }); + + it("the same external record is the same dose", () => { + expect( + isAnotherDose( + { takenAt: t0, externalId: "hk-1" }, + { takenAt: plus(600), externalId: "hk-1" }, + ), + ).toBe(false); + }); + + it("two external records a day apart are two doses", () => { + expect( + isAnotherDose( + { takenAt: t0, externalId: "hk-1" }, + { takenAt: plus(24 * 60 + 22), externalId: "hk-2" }, + ), + ).toBe(true); + }); + + it("two external records within a minute are one dose entered twice", () => { + expect( + isAnotherDose( + { takenAt: t0, externalId: "hk-1" }, + { takenAt: new Date(t0.getTime() + 30_000), externalId: "hk-2" }, + ), + ).toBe(false); + }); + + it("a take with no external record merges within the tolerance only", () => { + const held = { takenAt: t0, externalId: null }; + expect( + isAnotherDose(held, { + takenAt: new Date(t0.getTime() + SAME_DOSE_TOLERANCE_MS), + externalId: "hk-2", + }), + ).toBe(false); + expect(isAnotherDose(held, { takenAt: plus(30), externalId: "hk-2" })).toBe( + true, + ); + }); +}); + +describe("reconstructDoseHistory: the slot's own dose claims the slot", () => { + const slot: SlotWindowInput = { + at: t0, + timeOfDay: "22:00", + onTimeStart: plus(-60), + onTimeEnd: plus(60), + lateGraceMs: 180 * MIN, + }; + const bands = buildSlotBands([slot]); + const onSlot: HistoryIntake = { + id: "on-slot", + scheduledFor: t0, + takenAt: plus(5), + skipped: false, + }; + const extra: HistoryIntake = { + id: "extra", + scheduledFor: plus(30), + takenAt: plus(30), + skipped: false, + }; + + it.each([ + ["slot row first", [onSlot, extra]], + ["extra row first (newest scheduledFor first)", [extra, onSlot]], + ])("%s", (_label, intakes) => { + const rows = reconstructDoseHistory(bands, intakes, plus(600), null); + const slotRow = rows.find((row) => row.kind === "slot"); + const adHoc = rows.filter((row) => row.kind === "ad_hoc"); + expect(slotRow?.intake?.id).toBe("on-slot"); + expect(adHoc.map((row) => row.intake?.id)).toEqual(["extra"]); + }); + + it("among takes not stored on the slot, the nearer one claims it", () => { + const far: HistoryIntake = { ...extra, id: "far" }; + const near: HistoryIntake = { + id: "near", + scheduledFor: plus(10), + takenAt: plus(10), + skipped: false, + }; + const rows = reconstructDoseHistory(bands, [far, near], plus(600), null); + expect(rows.find((row) => row.kind === "slot")?.intake?.id).toBe("near"); + }); +}); diff --git a/src/lib/medications/scheduling/dose-history.ts b/src/lib/medications/scheduling/dose-history.ts index b4d31aef3..a126eff2c 100644 --- a/src/lib/medications/scheduling/dose-history.ts +++ b/src/lib/medications/scheduling/dose-history.ts @@ -273,8 +273,33 @@ export function reconstructDoseHistory( } } - for (const i of taken) { + // The best fit claims a slot first, whatever order the caller read the rows + // in. A take the write path stored ON the slot it attributes to goes before + // one stored on its own instant (an extra dose recorded unscheduled), then + // on-time before late, then the nearer take. Processing in the caller's + // order (newest `scheduledFor` first on the history route) let an extra dose + // logged half an hour after the slot claim it and pushed the slot's own + // dose out as the off-schedule one. + const ranked = taken.map((i) => { const matched = attributeIntakeToSlot(i.takenAt as Date, bands); + if (!matched) return { i, matched, key: [2, 0, 0] as const }; + const storedOnSlot = + Math.abs(i.scheduledFor.getTime() - matched.band.at.getTime()) <= + ANCHOR_EPSILON_MS; + return { + i, + matched, + key: [ + storedOnSlot ? 0 : 1, + matched.status === "on_time" ? 0 : 1, + Math.abs((i.takenAt as Date).getTime() - matched.band.at.getTime()), + ] as const, + }; + }); + ranked.sort( + (a, b) => a.key[0] - b.key[0] || a.key[1] - b.key[1] || a.key[2] - b.key[2], + ); + for (const { i, matched } of ranked) { if (matched && !claim.has(matched.band)) { claim.set(matched.band, { intake: i, diff --git a/src/lib/medications/scheduling/slot-upsert.ts b/src/lib/medications/scheduling/slot-upsert.ts index 55eef2a2b..fa1d63343 100644 --- a/src/lib/medications/scheduling/slot-upsert.ts +++ b/src/lib/medications/scheduling/slot-upsert.ts @@ -746,6 +746,79 @@ export async function findPinConflict(input: { }); } +/** + * How far apart two recorded takes of one slot may sit and still be the same + * dose logged twice (a tap in HealthLog and an entry in another app a few + * minutes later). Further apart, they are two doses. + */ +export const SAME_DOSE_TOLERANCE_MS = 15 * 60_000; + +/** + * Is `held` (the take a slot already carries) a different dose from the + * incoming external dose record? + * + * Band attribution maps a take to the slot whose band contains it, and a + * weekly schedule's band reaches a day either side of the slot. Without this + * check an extra dose logged the day after a Tuesday dose landed on the + * Tuesday slot and overwrote it last-write-wins: the Tuesday time was + * replaced and the extra dose existed nowhere. + * + * - Nothing taken on the slot yet (pending, skipped): not another dose, the + * incoming take is the slot's dose. + * - The same external record: the same dose. + * - Two different external records: different doses, unless they sit within + * a minute of each other, which is a double entry rather than two doses. + * - Otherwise (the held take has no external record): the same dose within + * {@link SAME_DOSE_TOLERANCE_MS}, a different one beyond it. + */ +export function isAnotherDose( + held: { takenAt: Date | null; externalId: string | null }, + incoming: { takenAt: Date; externalId: string | null }, +): boolean { + if (held.takenAt === null) return false; + if (held.externalId !== null && held.externalId === incoming.externalId) { + return false; + } + const gap = Math.abs(held.takenAt.getTime() - incoming.takenAt.getTime()); + if (held.externalId !== null && incoming.externalId !== null) { + return gap > 60_000; + } + return gap > SAME_DOSE_TOLERANCE_MS; +} + +/** + * Does the slot at `canonicalSlot` already carry a recorded take that is a + * different dose from the incoming one (see {@link isAnotherDose})? The + * caller records the incoming take as its own unscheduled dose instead of + * converging onto the slot. + */ +export async function slotHeldByAnotherDose(input: { + userId: string; + medicationId: string; + canonicalSlot: Date; + takenAt: Date; + externalId: string | null; + client?: Pick; +}): Promise { + const client = input.client ?? defaultPrisma; + const rows = await client.medicationIntakeEvent.findMany({ + where: { + userId: input.userId, + medicationId: input.medicationId, + scheduledFor: input.canonicalSlot, + deletedAt: null, + takenAt: { not: null }, + }, + select: { takenAt: true, externalId: true }, + }); + return rows.some((row) => + isAnotherDose(row, { + takenAt: input.takenAt, + externalId: input.externalId, + }), + ); +} + export interface ResolveForcedSlotInput { userId: string; medicationId: string; diff --git a/tests/integration/medication-extra-dose-apple-health.test.ts b/tests/integration/medication-extra-dose-apple-health.test.ts new file mode 100644 index 000000000..df8465703 --- /dev/null +++ b/tests/integration/medication-extra-dose-apple-health.test.ts @@ -0,0 +1,356 @@ +/** + * An extra (off-schedule) dose recorded in Apple Health must arrive as its own + * unscheduled dose. + * + * The phone posts every HealthKit dose event to the bulk route with the + * event's UUID as `externalId`, `scheduledFor` set to the event's scheduled + * date or, for an extra dose that has none, its own time. A taken write binds + * to a slot by window band, and a weekly schedule's band reaches a day either + * side of its slot. An extra dose on the Wednesday after a Tuesday dose + * therefore landed on the Tuesday slot and overwrote it: the Tuesday time was + * replaced by the Wednesday one and the extra dose existed nowhere. + * + * Three shapes, each through the shipped `POST` and `GET` exports: an extra + * dose far from any slot, one half an hour after a slot already taken, and one + * on a day the schedule has no slot. Each asserts the stored rows AND the dose + * history the app reads back, where the extra dose must show as off-schedule + * and the slot keep its own dose. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { NextRequest } from "next/server"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +import { localHmAsUtc } from "@/lib/tz/local-day"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => { + cookieJar.set(name, value); + }, + delete: (name: string) => { + cookieJar.delete(name); + }, + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +const TZ = "Europe/Berlin"; +const DAY_MS = 24 * 60 * 60 * 1000; +const MIN = 60_000; + +let counter = 0; + +async function signedInUser(): Promise { + const prisma = getPrismaClient(); + const suffix = `extra-dose-${counter++}`; + const user = await prisma.user.create({ + data: { + username: suffix, + email: `${suffix}@example.test`, + role: "USER", + timezone: TZ, + }, + }); + const session = await prisma.session.create({ + data: { userId: user.id, expiresAt: new Date(Date.now() + 3_600_000) }, + }); + cookieJar.set("healthlog_session", session.id); + return user.id; +} + +async function seedMedication( + userId: string, + schedule: { timesOfDay: string[]; daysOfWeek: string | null }, +): Promise { + const med = await getPrismaClient().medication.create({ + data: { + userId, + name: "Magnesium", + dose: "400mg", + active: true, + // Dose events from Apple Health only target a mirrored medication. + externalSource: "APPLE_HEALTH", + externalId: `hk-medication-${userId}`, + startsOn: new Date(Date.now() - 60 * DAY_MS), + // The ledger expects slots only from the medication's creation on. + createdAt: new Date(Date.now() - 60 * DAY_MS), + schedules: { + create: { + windowStart: schedule.timesOfDay[0], + windowEnd: schedule.timesOfDay[0], + timesOfDay: schedule.timesOfDay, + daysOfWeek: schedule.daysOfWeek, + scheduleType: "SCHEDULED", + }, + }, + }, + }); + return med.id; +} + +/** A local day at least two weeks back with the given weekday (0 = Sunday). */ +function pastLocalDay(weekday: number): Date { + const day = new Date(Date.now() - 21 * DAY_MS); + day.setUTCHours(12, 0, 0, 0); + while (day.getUTCDay() !== weekday) { + day.setTime(day.getTime() - DAY_MS); + } + return day; +} + +interface BulkEntry { + medicationId: string; + scheduledFor: string; + takenAt: string; + externalId: string; +} + +/** The phone's shape: one HealthKit dose event per entry. */ +function appleDose( + medicationId: string, + takenAt: Date, + externalId: string, + scheduledFor: Date = takenAt, +): BulkEntry { + return { + medicationId, + scheduledFor: scheduledFor.toISOString(), + takenAt: takenAt.toISOString(), + externalId, + }; +} + +async function postBulk(entries: BulkEntry[]) { + const { POST } = await import("@/app/api/medications/intake/bulk/route"); + const response = await POST( + new NextRequest("http://localhost/api/medications/intake/bulk", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + entries: entries.map((entry) => ({ + ...entry, + source: "APPLE_HEALTH", + })), + }), + }), + ); + expect(response.status).toBe(200); + const body = (await response.json()) as { + data: { entries: Array<{ status: string; id?: string }> }; + }; + return body.data.entries; +} + +type Handler = ( + request: NextRequest, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + context: { params: Promise }, +) => Promise; + +interface LedgerRow { + kind: "slot" | "ad_hoc"; + at: string; + status: string; + intake: { id: string | null; takenAt: string | null } | null; +} + +async function readLedger( + medicationId: string, + from: Date, + to: Date, +): Promise { + const { GET } = await import("@/app/api/medications/[id]/dose-history/route"); + const search = new URLSearchParams({ + from: from.toISOString(), + to: to.toISOString(), + }); + const response = await (GET as Handler)( + new NextRequest( + `http://localhost/api/medications/${medicationId}/dose-history?${search}`, + { method: "GET" }, + ), + { params: Promise.resolve({ id: medicationId }) }, + ); + expect(response.status).toBe(200); + const body = (await response.json()) as { data: { rows: LedgerRow[] } }; + return body.data.rows; +} + +async function rowsOf(medicationId: string) { + return getPrismaClient().medicationIntakeEvent.findMany({ + where: { medicationId, deletedAt: null }, + orderBy: { takenAt: "asc" }, + }); +} + +function takenAtOf(row: LedgerRow | undefined): number | null { + const taken = row?.intake?.takenAt; + return taken ? new Date(taken).getTime() : null; +} + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); +}); + +describe("an extra dose from Apple Health is stored as an unscheduled dose", () => { + it("(a) far from any slot", async () => { + const userId = await signedInUser(); + const medicationId = await seedMedication(userId, { + timesOfDay: ["08:00"], + daysOfWeek: null, + }); + const day = pastLocalDay(3); + const slot = localHmAsUtc(day, TZ, 8, 0); + const extraAt = localHmAsUtc(day, TZ, 15, 0); + + const results = await postBulk([ + appleDose(medicationId, new Date(slot.getTime() + 5 * MIN), "hk-a-1"), + appleDose(medicationId, extraAt, "hk-a-2"), + ]); + expect(results.map((r) => r.status)).toEqual(["inserted", "inserted"]); + + const rows = await rowsOf(medicationId); + expect(rows).toHaveLength(2); + const extra = rows.find((row) => row.externalId === "hk-a-2"); + expect(extra?.takenAt?.getTime()).toBe(extraAt.getTime()); + expect(extra?.scheduledFor.getTime()).toBe(extraAt.getTime()); + + const ledger = await readLedger( + medicationId, + new Date(slot.getTime() - 6 * 3_600_000), + new Date(slot.getTime() + 18 * 3_600_000), + ); + const adHoc = ledger.filter((row) => row.kind === "ad_hoc"); + expect(adHoc).toHaveLength(1); + expect(takenAtOf(adHoc[0])).toBe(extraAt.getTime()); + const slotRow = ledger.find((row) => row.kind === "slot"); + expect(slotRow?.status).toBe("taken_on_time"); + }); + + it("(b) 30 minutes after a slot already taken", async () => { + const userId = await signedInUser(); + const medicationId = await seedMedication(userId, { + timesOfDay: ["22:00"], + daysOfWeek: null, + }); + const day = pastLocalDay(2); + const slot = localHmAsUtc(day, TZ, 22, 0); + const ownAt = new Date(slot.getTime() + 2 * MIN); + const extraAt = new Date(slot.getTime() + 30 * MIN); + + // The slot's own dose first, then the extra one in a later sync. + expect( + (await postBulk([appleDose(medicationId, ownAt, "hk-b-1", slot)])).map( + (r) => r.status, + ), + ).toEqual(["inserted"]); + expect( + (await postBulk([appleDose(medicationId, extraAt, "hk-b-2")])).map( + (r) => r.status, + ), + ).toEqual(["inserted"]); + + const rows = await rowsOf(medicationId); + expect(rows).toHaveLength(2); + const own = rows.find((row) => row.externalId === "hk-b-1"); + const extra = rows.find((row) => row.externalId === "hk-b-2"); + // The slot keeps its own dose, time unchanged. + expect(own?.scheduledFor.getTime()).toBe(slot.getTime()); + expect(own?.takenAt?.getTime()).toBe(ownAt.getTime()); + // The extra dose sits on its own instant. + expect(extra?.scheduledFor.getTime()).toBe(extraAt.getTime()); + expect(extra?.takenAt?.getTime()).toBe(extraAt.getTime()); + + const ledger = await readLedger( + medicationId, + new Date(slot.getTime() - 6 * 3_600_000), + new Date(slot.getTime() + 6 * 3_600_000), + ); + const slotRow = ledger.find((row) => row.kind === "slot"); + expect(slotRow?.status).toBe("taken_on_time"); + expect(takenAtOf(slotRow)).toBe(ownAt.getTime()); + const adHoc = ledger.filter((row) => row.kind === "ad_hoc"); + expect(adHoc).toHaveLength(1); + expect(takenAtOf(adHoc[0])).toBe(extraAt.getTime()); + }); + + it("(b2) a re-sync of the same dose event stays one dose", async () => { + const userId = await signedInUser(); + const medicationId = await seedMedication(userId, { + timesOfDay: ["22:00"], + daysOfWeek: null, + }); + const slot = localHmAsUtc(pastLocalDay(2), TZ, 22, 0); + const ownAt = new Date(slot.getTime() + 2 * MIN); + + await postBulk([appleDose(medicationId, ownAt, "hk-b2-1", slot)]); + const again = await postBulk([ + appleDose(medicationId, ownAt, "hk-b2-1", slot), + ]); + expect(again.map((r) => r.status)).toEqual(["duplicate"]); + expect(await rowsOf(medicationId)).toHaveLength(1); + }); + + it("(c) on a day without a slot, next to the weekly slot the day before", async () => { + const userId = await signedInUser(); + // Tuesdays and Fridays at 22:00. + const medicationId = await seedMedication(userId, { + timesOfDay: ["22:00"], + daysOfWeek: "2,5", + }); + const tuesday = pastLocalDay(2); + const wednesday = new Date(tuesday.getTime() + DAY_MS); + const tuesdaySlot = localHmAsUtc(tuesday, TZ, 22, 0); + const tuesdayDose = localHmAsUtc(tuesday, TZ, 20, 23); + const wednesdayDose = localHmAsUtc(wednesday, TZ, 20, 45); + + await postBulk([appleDose(medicationId, tuesdayDose, "hk-c-tue")]); + const results = await postBulk([ + appleDose(medicationId, wednesdayDose, "hk-c-wed"), + ]); + expect(results.map((r) => r.status)).toEqual(["inserted"]); + + const rows = await rowsOf(medicationId); + expect(rows).toHaveLength(2); + const tue = rows.find((row) => row.externalId === "hk-c-tue"); + const wed = rows.find((row) => row.externalId === "hk-c-wed"); + // Tuesday keeps its own time. + expect(tue?.scheduledFor.getTime()).toBe(tuesdaySlot.getTime()); + expect(tue?.takenAt?.getTime()).toBe(tuesdayDose.getTime()); + // Wednesday is its own unscheduled dose. + expect(wed?.scheduledFor.getTime()).toBe(wednesdayDose.getTime()); + expect(wed?.takenAt?.getTime()).toBe(wednesdayDose.getTime()); + + const ledger = await readLedger( + medicationId, + new Date(tuesday.getTime() - 2 * DAY_MS), + new Date(tuesday.getTime() + 2 * DAY_MS), + ); + const tuesdayRow = ledger.find( + (row) => + row.kind === "slot" && + new Date(row.at).getTime() === tuesdaySlot.getTime(), + ); + expect(takenAtOf(tuesdayRow)).toBe(tuesdayDose.getTime()); + const adHoc = ledger.filter((row) => row.kind === "ad_hoc"); + expect(adHoc.map(takenAtOf)).toEqual([wednesdayDose.getTime()]); + }); +}); From 73c4dda2247a858eab7382e05abf968eba7f9dc8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 15 Oct 2026 11:24:40 +0200 Subject: [PATCH 2/3] fix(visits): reach every lab result, condition, document and visit from the link pickers The visit form's lab picker offered the newest 200 results, the condition picker the newest 100 conditions, the document pickers the newest thousand documents and the document sheet's visit picker the newest 100 visits. On a long record everything older could not be linked. Each picker now opens on the records around the date of the thing being linked, pages older and newer from there, and searches through the list route itself. Records already linked always show, from the labels the record's own read carries and from what was picked in the sheet. The lab list takes a q filter over analyte and panel, the condition list from/to/q/offset/sortDir, and the visit list an offset. --- docs/api/openapi.yaml | 43 +++ messages/de.json | 5 +- messages/en.json | 5 +- messages/es.json | 5 +- messages/fr.json | 5 +- messages/it.json | 5 +- messages/ko.json | 5 +- messages/pl.json | 5 +- src/app/api/encounters/route.ts | 8 +- src/app/api/illness/episodes/route.ts | 20 +- src/app/api/labs/route.ts | 8 +- .../__tests__/document-record-links.test.tsx | 26 +- .../documents/document-record-links.tsx | 90 ++++- .../encounter-link-picker-limits.test.ts | 32 -- src/components/encounters/encounter-form.tsx | 10 + .../encounters/encounter-link-pickers.tsx | 206 ++++++++---- .../entity-link-picker-paging.test.tsx | 199 +++++++++++ .../links/__tests__/picker-requests.test.ts | 122 +++++++ .../__tests__/vault-document-options.test.ts | 41 ++- src/components/links/entity-link-picker.tsx | 220 ++++++++++++- src/components/links/picker-requests.ts | 96 ++++++ src/components/links/use-link-picker-pages.ts | 235 +++++++++++++ .../links/vault-document-options.ts | 109 +++--- .../vaccination-document-picker.tsx | 35 +- .../vaccinations/vaccination-form.tsx | 7 + .../vaccinations/vaccination-sheet.tsx | 1 + .../__tests__/picker-list-keys.test.ts | 38 ++- src/lib/query-keys/documents.ts | 22 +- src/lib/query-keys/encounters.ts | 7 + src/lib/query-keys/illness.ts | 12 +- src/lib/query-keys/labs.ts | 11 +- src/lib/validations/encounters.ts | 5 + src/lib/validations/illness.ts | 11 + src/lib/validations/labs.ts | 5 + .../visit-link-picker-long-history.test.ts | 311 ++++++++++++++++++ 35 files changed, 1714 insertions(+), 251 deletions(-) delete mode 100644 src/components/encounters/__tests__/encounter-link-picker-limits.test.ts create mode 100644 src/components/links/__tests__/entity-link-picker-paging.test.tsx create mode 100644 src/components/links/__tests__/picker-requests.test.ts create mode 100644 src/components/links/picker-requests.ts create mode 100644 src/components/links/use-link-picker-pages.ts create mode 100644 tests/integration/visit-link-picker-long-history.test.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index c1dbd36c3..e87d57ba5 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -14833,6 +14833,12 @@ paths: type: string minLength: 1 maxLength: 120 + - in: query + name: q + schema: + type: string + minLength: 1 + maxLength: 120 - in: query name: from schema: @@ -15372,6 +15378,12 @@ paths: type: integer minimum: 1 maximum: 200 + - in: query + name: offset + schema: + type: integer + minimum: 0 + maximum: 100000 responses: "200": description: The caller's visits. @@ -16315,6 +16327,37 @@ paths: const: "true" - type: string const: "false" + - in: query + name: offset + schema: + type: integer + minimum: 0 + maximum: 100000 + - in: query + name: from + schema: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + - in: query + name: to + schema: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + - in: query + name: sortDir + schema: + type: string + enum: + - asc + - desc + - in: query + name: q + schema: + type: string + minLength: 1 + maxLength: 120 responses: "200": description: The caller's illness episodes. diff --git a/messages/de.json b/messages/de.json index 6a814002e..e7a8a1dd0 100644 --- a/messages/de.json +++ b/messages/de.json @@ -12174,7 +12174,10 @@ "suggested": "Um dieses Datum", "loadError": "Die Liste konnte nicht geladen werden.", "removeNamed": "{name} entfernen", - "removed": "Verknüpfung mit {name} gelöst" + "removed": "Verknüpfung mit {name} gelöst", + "linked": "Verknüpft", + "loadNewer": "Neuere laden", + "loadOlder": "Ältere laden" } }, "bodySites": { diff --git a/messages/en.json b/messages/en.json index 356521583..11a517a80 100644 --- a/messages/en.json +++ b/messages/en.json @@ -12174,7 +12174,10 @@ "suggested": "Around this date", "loadError": "The list could not be loaded.", "removeNamed": "Remove {name}", - "removed": "Unlinked {name}" + "removed": "Unlinked {name}", + "linked": "Linked", + "loadNewer": "Load newer", + "loadOlder": "Load older" } }, "bodySites": { diff --git a/messages/es.json b/messages/es.json index dfa682e77..43c808b32 100644 --- a/messages/es.json +++ b/messages/es.json @@ -12174,7 +12174,10 @@ "suggested": "En torno a esta fecha", "loadError": "No se pudo cargar la lista.", "removeNamed": "Quitar {name}", - "removed": "Se desvinculó {name}" + "removed": "Se desvinculó {name}", + "linked": "Vinculados", + "loadNewer": "Cargar más recientes", + "loadOlder": "Cargar anteriores" } }, "bodySites": { diff --git a/messages/fr.json b/messages/fr.json index 587297fd3..3f3de9ecd 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -12174,7 +12174,10 @@ "suggested": "Autour de cette date", "loadError": "Impossible de charger la liste.", "removeNamed": "Retirer {name}", - "removed": "{name} dissocié" + "removed": "{name} dissocié", + "linked": "Liés", + "loadNewer": "Charger les plus récents", + "loadOlder": "Charger les plus anciens" } }, "bodySites": { diff --git a/messages/it.json b/messages/it.json index 5302c2727..4853184ed 100644 --- a/messages/it.json +++ b/messages/it.json @@ -12174,7 +12174,10 @@ "suggested": "Intorno a questa data", "loadError": "Impossibile caricare l’elenco.", "removeNamed": "Rimuovi {name}", - "removed": "Collegamento a {name} rimosso" + "removed": "Collegamento a {name} rimosso", + "linked": "Collegati", + "loadNewer": "Carica più recenti", + "loadOlder": "Carica meno recenti" } }, "bodySites": { diff --git a/messages/ko.json b/messages/ko.json index fe1370a61..122c25d92 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -12174,7 +12174,10 @@ "suggested": "이 날짜 전후", "loadError": "목록을 불러오지 못했습니다.", "removeNamed": "{name} 제거", - "removed": "{name} 연결 해제됨" + "removed": "{name} 연결 해제됨", + "linked": "연결됨", + "loadNewer": "최근 항목 더 보기", + "loadOlder": "이전 항목 더 보기" } }, "bodySites": { diff --git a/messages/pl.json b/messages/pl.json index 7a3e8b972..5907e3c21 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -12174,7 +12174,10 @@ "suggested": "Wokół tej daty", "loadError": "Nie udało się wczytać listy.", "removeNamed": "Usuń {name}", - "removed": "Odłączono: {name}" + "removed": "Odłączono: {name}", + "linked": "Połączone", + "loadNewer": "Wczytaj nowsze", + "loadOlder": "Wczytaj starsze" } }, "bodySites": { diff --git a/src/app/api/encounters/route.ts b/src/app/api/encounters/route.ts index 43151980d..ef90cc80b 100644 --- a/src/app/api/encounters/route.ts +++ b/src/app/api/encounters/route.ts @@ -63,6 +63,7 @@ export const GET = apiHandler(async (request: NextRequest) => { practitionerId: params.get("practitionerId") ?? undefined, episodeId: params.get("episodeId") ?? undefined, limit: params.get("limit") ?? undefined, + offset: params.get("offset") ?? undefined, }); if (!parsed.success) { return returnAllZodIssues(parsed.error, 422, { @@ -72,6 +73,7 @@ export const GET = apiHandler(async (request: NextRequest) => { const query = parsed.data; const take = query.limit ?? 100; + const skip = query.offset ?? 0; const now = new Date(); const where = { @@ -112,8 +114,9 @@ export const GET = apiHandler(async (request: NextRequest) => { // `gt` and `gte` are different keys, so the caller's lower bound and this // arm's floor both survive the spread; only the `lte` pair collided. where: { ...where, occurredAt: { ...where.occurredAt, gt: now } }, - orderBy: { occurredAt: "asc" }, + orderBy: [{ occurredAt: "asc" }, { id: "asc" }], take, + skip, include: ENCOUNTER_INCLUDE, }), prisma.encounter.findMany({ @@ -121,8 +124,9 @@ export const GET = apiHandler(async (request: NextRequest) => { ...where, occurredAt: { ...where.occurredAt, lte: pastCeiling }, }, - orderBy: { occurredAt: "desc" }, + orderBy: [{ occurredAt: "desc" }, { id: "desc" }], take, + skip, include: ENCOUNTER_INCLUDE, }), ]); diff --git a/src/app/api/illness/episodes/route.ts b/src/app/api/illness/episodes/route.ts index 15425425f..255e0f8c0 100644 --- a/src/app/api/illness/episodes/route.ts +++ b/src/app/api/illness/episodes/route.ts @@ -43,6 +43,11 @@ export const GET = apiHandler(async (request: NextRequest) => { const parsed = illnessEpisodeListQuerySchema.safeParse({ limit: params.get("limit") ?? undefined, includeResolved: params.get("includeResolved") ?? undefined, + offset: params.get("offset") ?? undefined, + from: params.get("from") ?? undefined, + to: params.get("to") ?? undefined, + sortDir: params.get("sortDir") ?? undefined, + q: params.get("q") ?? undefined, }); if (!parsed.success) { return returnAllZodIssues(parsed.error, 422, { @@ -50,15 +55,28 @@ export const GET = apiHandler(async (request: NextRequest) => { }); } + const { from, to, q, offset, sortDir } = parsed.data; const limit = parsed.data.limit ?? 50; + const order = sortDir ?? "desc"; const rows = await prisma.illnessEpisode.findMany({ where: { userId: user.id, deletedAt: null, ...(parsed.data.includeResolved === "false" ? { resolvedAt: null } : {}), + ...(from || to + ? { + onsetAt: { + ...(from ? { gte: new Date(from) } : {}), + ...(to ? { lte: new Date(to) } : {}), + }, + } + : {}), + ...(q ? { label: { contains: q, mode: "insensitive" as const } } : {}), }, - orderBy: { onsetAt: "desc" }, + // `id` breaks ties so offset paging never repeats or drops a row. + orderBy: [{ onsetAt: order }, { id: order }], take: limit, + skip: offset ?? 0, }); annotate({ diff --git a/src/app/api/labs/route.ts b/src/app/api/labs/route.ts index e18282a76..7d1b8857e 100644 --- a/src/app/api/labs/route.ts +++ b/src/app/api/labs/route.ts @@ -85,7 +85,7 @@ export const GET = apiHandler(async (request: NextRequest) => { return returnAllZodIssues(parsed.error, 422); } - const { biomarkerId, analyte, panel, from, to, limit, offset, sortDir } = + const { biomarkerId, analyte, panel, q, from, to, limit, offset, sortDir } = parsed.data; const where = { @@ -94,6 +94,12 @@ export const GET = apiHandler(async (request: NextRequest) => { ...(biomarkerId && { biomarkerId }), ...(analyte && { analyte }), ...(panel && { panel }), + ...(q && { + OR: [ + { analyte: { contains: q, mode: "insensitive" as const } }, + { panel: { contains: q, mode: "insensitive" as const } }, + ], + }), ...(from || to ? { takenAt: { diff --git a/src/components/documents/__tests__/document-record-links.test.tsx b/src/components/documents/__tests__/document-record-links.test.tsx index e83374fcf..209e10413 100644 --- a/src/components/documents/__tests__/document-record-links.test.tsx +++ b/src/components/documents/__tests__/document-record-links.test.tsx @@ -4,12 +4,26 @@ import { renderToStaticMarkup } from "react-dom/server"; vi.mock("@/hooks/use-auth", () => ({ useAuth: () => ({ user: { modules: { vaccinations: true } } }), })); -vi.mock("@/hooks/use-encounters", () => ({ - useEncounters: () => ({ - data: { upcoming: [], past: [] }, - isPending: false, - isError: false, - refetch: vi.fn(), +vi.mock("@/components/links/use-link-picker-pages", async (importOriginal) => ({ + ...(await importOriginal< + typeof import("@/components/links/use-link-picker-pages") + >()), + // The visit picker's pages: none loaded, nothing more to page. + useLinkPickerPages: () => ({ + items: [], + pending: false, + error: false, + retry: vi.fn(), + term: "", + setTerm: vi.fn(), + searchPending: false, + filterLocally: true, + hasOlder: false, + hasNewer: false, + loadingOlder: false, + loadingNewer: false, + loadOlder: vi.fn(), + loadNewer: vi.fn(), }), })); vi.mock("@/components/vaccinations/use-vaccinations", () => ({ diff --git a/src/components/documents/document-record-links.tsx b/src/components/documents/document-record-links.tsx index b91b7fb0d..a80281ef9 100644 --- a/src/components/documents/document-record-links.tsx +++ b/src/components/documents/document-record-links.tsx @@ -23,18 +23,31 @@ * reach the doses (`vaccinationLinks: null`) shows no dose block rather than * an empty one that would claim the page records none. */ -import { useState } from "react"; +import { useMemo, useState } from "react"; import Link from "next/link"; import { CalendarDays, Syringe } from "lucide-react"; -import { EntityLinkPicker } from "@/components/links/entity-link-picker"; +import { + EntityLinkPicker, + serverPagingFrom, +} from "@/components/links/entity-link-picker"; import type { EntityLinkOption } from "@/components/links/entity-link-picker"; import { isNearAnchor } from "@/components/links/vault-document-options"; +import { encounterPickerUrl } from "@/components/links/picker-requests"; +import { + PICKER_PAGE_SIZE, + pickerPivot, + useLinkPickerPages, + type PickerPage, + type PickerPageRequest, +} from "@/components/links/use-link-picker-pages"; import { encounterKindText } from "@/components/encounters/encounter-labels"; import { useVaccinations } from "@/components/vaccinations/use-vaccinations"; -import { useEncounters } from "@/hooks/use-encounters"; import { useAuth } from "@/hooks/use-auth"; +import { apiGet } from "@/lib/api/api-fetch"; import { useFormatters, useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; +import type { EncounterDTO, EncounterListDTO } from "@/lib/encounters/dto"; import type { EncounterKind } from "@/generated/prisma/client"; import type { InboundDocumentDetailDto } from "@/lib/validations/inbound-documents"; @@ -75,6 +88,25 @@ export function recordOptions( return [...near, ...rest]; } +/** + * One picker page of visits. The route answers in two arms, each paged by the + * same offset; another page exists while either arm came back full. + */ +export async function fetchEncounterPage( + request: PickerPageRequest, +): Promise> { + const list = await apiGet(encounterPickerUrl(request)); + const full = + list.upcoming.length >= PICKER_PAGE_SIZE || + list.past.length >= PICKER_PAGE_SIZE; + return { + items: [...list.upcoming, ...list.past], + next: full + ? String(Number(request.cursor ?? "0") + PICKER_PAGE_SIZE) + : null, + }; +} + /** Where a visit opens: its sheet on the checkups page. */ function visitHref(encounterId: string): string { return `/checkups?visit=${encodeURIComponent(encounterId)}`; @@ -132,7 +164,16 @@ export function DocumentRecordLinks({ const vaccinationsOn = user?.modules?.vaccinations !== false; const anchor = doc.documentDate ?? doc.reportDate; - const encounters = useEncounters(canManage); + // The visits around the document's date, paged older and newer from + // there. The visit route has no text search (its free text is ciphertext + // at rest), so the term filters what is loaded. + const encounters = useLinkPickerPages({ + enabled: canManage, + pivot: pickerPivot(anchor), + queryKey: queryKeys.encounterPicker, + fetchPage: fetchEncounterPage, + serverSearch: false, + }); const vaccinations = useVaccinations( null, canManage && vaccinationsOn && doc.vaccinationLinks !== null, @@ -150,16 +191,16 @@ export function DocumentRecordLinks({ }; const visitOptions = recordOptions( - [ - ...(encounters.data?.upcoming ?? []), - ...(encounters.data?.past ?? []), - ].map((visit) => ({ - id: visit.id, - label: encounterKindText(t, visit.kind as EncounterKind), - meta: visit.practitioner?.name ?? null, - occurredAt: visit.occurredAt, - href: visitHref(visit.id), - })), + [...encounters.items] + // Two arms per page arrive in two orders; the list reads newest first. + .sort((a, b) => b.occurredAt.localeCompare(a.occurredAt)) + .map((visit) => ({ + id: visit.id, + label: encounterKindText(t, visit.kind as EncounterKind), + meta: visit.practitioner?.name ?? null, + occurredAt: visit.occurredAt, + href: visitHref(visit.id), + })), anchor, labels, ); @@ -176,6 +217,19 @@ export function DocumentRecordLinks({ const showDoses = vaccinationsOn && doc.vaccinationLinks !== null; + // The visits the document is filed against, as its own read names them, + // so their chips show whatever page of visits is loaded. + const knownVisits = useMemo( + () => + doc.encounterLinks.map((link) => ({ + id: link.encounterId, + label: encounterKindText(t, link.kind as EncounterKind), + dateLabel: link.occurredAt ? format.date(link.occurredAt) : null, + href: visitHref(link.encounterId), + })), + [doc.encounterLinks, t, format], + ); + return ( <> {canManage ? ( @@ -183,11 +237,11 @@ export function DocumentRecordLinks({ icon={CalendarDays} title={t("documents.detail.visitsLabel")} slot="document-visit-links" - pending={encounters.isPending || !seedFresh} - error={encounters.isError || seedError} + pending={encounters.pending || !seedFresh} + error={encounters.error || seedError} errorLabel={t("links.picker.loadError")} onRetry={() => { - if (encounters.isError) void encounters.refetch(); + if (encounters.error) encounters.retry(); if (seedError) onRetrySeed?.(); }} selected={visitIds ?? doc.encounterLinks.map((l) => l.encounterId)} @@ -196,6 +250,8 @@ export function DocumentRecordLinks({ onChange({ encounterIds: ids }); }} options={visitOptions} + server={serverPagingFrom(encounters)} + knownOptions={knownVisits} searchPlaceholder={t("links.picker.searchPlaceholder")} emptyLabel={t("documents.detail.noVisitsToLink")} /> diff --git a/src/components/encounters/__tests__/encounter-link-picker-limits.test.ts b/src/components/encounters/__tests__/encounter-link-picker-limits.test.ts deleted file mode 100644 index 9cae124b8..000000000 --- a/src/components/encounters/__tests__/encounter-link-picker-limits.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { illnessEpisodeListQuerySchema } from "@/lib/validations/illness"; -import { listLabResultsSchema } from "@/lib/validations/labs"; -import { - EPISODE_FETCH_LIMIT, - PICKER_FETCH_LIMIT, -} from "../encounter-link-pickers"; - -/** - * Each picker's page size must be one its list route accepts. A refused size - * comes back as a 422, and a picker that treats a failed read as an empty - * list tells the person there is nothing to link. That is how the visit - * form's condition picker and both document pickers showed nothing at all. - */ -describe("visit link picker page sizes", () => { - it("the condition picker asks for a page the episode route accepts", () => { - expect( - illnessEpisodeListQuerySchema.safeParse({ - limit: String(EPISODE_FETCH_LIMIT), - includeResolved: "true", - }).success, - ).toBe(true); - }); - - it("the lab picker asks for a page the lab route accepts", () => { - expect( - listLabResultsSchema.safeParse({ limit: String(PICKER_FETCH_LIMIT) }) - .success, - ).toBe(true); - }); -}); diff --git a/src/components/encounters/encounter-form.tsx b/src/components/encounters/encounter-form.tsx index 3b65668d5..9183aa7c9 100644 --- a/src/components/encounters/encounter-form.tsx +++ b/src/components/encounters/encounter-form.tsx @@ -33,6 +33,7 @@ import { useAuth } from "@/hooks/use-auth"; import { useTranslations } from "@/lib/i18n/context"; import type { Encounter, EncounterWriteBody } from "@/hooks/use-encounters"; import type { Practitioner } from "@/hooks/use-practitioners"; +import type { EncounterLinksDTO } from "@/lib/encounters/dto"; import { ENCOUNTER_KINDS, ENCOUNTER_STATUSES, @@ -57,6 +58,12 @@ export interface EncounterDraft { documentIds: string[]; labResultIds: string[]; episodeIds: string[]; + /** + * The links the visit carried when the sheet opened, as its read names + * them, so their chips show whatever page the pickers have loaded. Display + * only; the id arrays above are what a save writes. + */ + links: EncounterLinksDTO | null; } function localValue(instant: Date): string { @@ -91,6 +98,7 @@ export function emptyDraft( documentIds: [], labResultIds: [], episodeIds: [], + links: null, ...overrides, }; } @@ -108,6 +116,7 @@ export function draftFromEncounter(row: Encounter): EncounterDraft { documentIds: row.links?.documents.map((link) => link.id) ?? [], labResultIds: row.links?.labResults.map((link) => link.id) ?? [], episodeIds: row.links?.conditions.map((link) => link.id) ?? [], + links: row.links ?? null, }; } @@ -303,6 +312,7 @@ export function EncounterForm({ ; + results: LabPickerResult[]; +} + +/** One picker page of lab results. */ +export async function fetchLabPage( + request: PickerPageRequest, +): Promise> { + const page = await apiGet(labPickerUrl(request)); + return { + items: page.results, + next: nextOffset(request.cursor, page.results.length), + }; +} + +/** One picker page of condition episodes. */ +export async function fetchEpisodePage( + request: PickerPageRequest, +): Promise> { + const rows = await apiGet(episodePickerUrl(request)); + return { items: rows, next: nextOffset(request.cursor, rows.length) }; +} + +/** A visit's existing link as a chip option, or null when it is withheld. */ +function linkedOption( + link: { id: string; label: string | null; date: string | null }, + date: (iso: string) => string, + href?: string, +): EntityLinkOption | null { + if (link.label === null) return null; + return { + id: link.id, + label: link.label, + dateLabel: link.date ? date(link.date) : null, + ...(href ? { href } : {}), + }; } export function EncounterLinkPickers({ modules, anchor, + links, documentIds, labResultIds, episodeIds, onChange, }: { modules: Partial> | undefined; - /** The visit's own date, for the document suggestions on top. */ + /** The visit's own date: where every picker opens. */ anchor: string | null; + /** + * What the visit links already, as its own read names it (an edit). Their + * chips show whether or not a loaded page holds them. + */ + links?: EncounterLinksDTO | null; documentIds: string[]; labResultIds: string[]; episodeIds: string[]; @@ -79,64 +135,76 @@ export function EncounterLinkPickers({ const documentsOn = modules?.inboundDocuments === true; const labsOn = modules?.labs === true; const illnessOn = modules?.illness === true; + const pivot = pickerPivot(anchor); - // The whole vault, suggestions near the visit's date on top — see - // `useVaultDocumentOptions` for why this no longer asks for one oversized - // page. + // The vault around the visit's date, suggestions on top — see + // `useVaultDocumentOptions`. const documents = useVaultDocumentOptions({ enabled: documentsOn, anchor, }); - const labs = useQuery({ - queryKey: queryKeys.labResultsPicker(PICKER_FETCH_LIMIT), + const labs = useLinkPickerPages({ enabled: labsOn, - queryFn: () => - apiGet( - `/api/labs?limit=${PICKER_FETCH_LIMIT}&offset=0&sortDir=desc`, - ), + pivot, + queryKey: queryKeys.labResultsPicker, + fetchPage: fetchLabPage, }); - const episodes = useQuery({ - queryKey: queryKeys.illnessEpisodesPicker(EPISODE_FETCH_LIMIT), + const episodes = useLinkPickerPages({ enabled: illnessOn, - queryFn: () => - apiGet( - `/api/illness/episodes?includeResolved=true&limit=${EPISODE_FETCH_LIMIT}`, - ), + pivot, + queryKey: queryKeys.illnessEpisodesPicker, + fetchPage: fetchEpisodePage, }); + const known = useMemo(() => { + const date = (iso: string) => format.date(iso); + const keep = ( + option: EntityLinkOption | null, + ): option is EntityLinkOption => option !== null; + return { + documents: (links?.documents ?? []) + .map((link) => linkedDocumentOption(link, date)) + .filter(keep), + labResults: (links?.labResults ?? []) + .map((link) => linkedOption(link, date)) + .filter(keep), + conditions: (links?.conditions ?? []) + .map((link) => + linkedOption(link, date, `/illness/${encodeURIComponent(link.id)}`), + ) + .filter(keep), + }; + }, [links, format]); + if (!documentsOn && !labsOn && !illnessOn) return null; // Labs group by sample date + panel: "Blutbild · 12.05.2026", the analyte // underneath. A visit links the whole panel from a day in one "select all". - const labOptions: EntityLinkOption[] = (labs.data?.results ?? []).map( - (result) => { - const dayLabel = format.date(result.takenAt); - const groupLabel = result.panel - ? `${result.panel} · ${dayLabel}` - : dayLabel; - return { - id: result.id, - label: result.analyte, - dateLabel: dayLabel, - group: { - key: `${result.takenAt.slice(0, 10)}|${result.panel ?? ""}`, - label: groupLabel, - }, - }; - }, - ); + const labOptions: EntityLinkOption[] = labs.items.map((result) => { + const dayLabel = format.date(result.takenAt); + const groupLabel = result.panel + ? `${result.panel} · ${dayLabel}` + : dayLabel; + return { + id: result.id, + label: result.analyte, + dateLabel: dayLabel, + group: { + key: `${result.takenAt.slice(0, 10)}|${result.panel ?? ""}`, + label: groupLabel, + }, + }; + }); - // Episodes stay flat (a bounded list), newest onset first from the route. - const episodeOptions: EntityLinkOption[] = (episodes.data ?? []).map( - (episode) => ({ - id: episode.id, - label: episode.label, - dateLabel: episode.onsetAt ? format.date(episode.onsetAt) : null, - href: `/illness/${encodeURIComponent(episode.id)}`, - }), - ); + // Episodes stay flat, newest onset first. + const episodeOptions: EntityLinkOption[] = episodes.items.map((episode) => ({ + id: episode.id, + label: episode.label, + dateLabel: episode.onsetAt ? format.date(episode.onsetAt) : null, + href: `/illness/${encodeURIComponent(episode.id)}`, + })); return (
@@ -152,6 +220,8 @@ export function EncounterLinkPickers({ selected={documentIds} onChange={(ids) => onChange({ documentIds: ids })} options={documents.options} + server={documents.server} + knownOptions={known.documents} searchPlaceholder={t("links.picker.searchPlaceholder")} emptyLabel={t("encounters.form.linkNothingToOffer")} extraAction={ @@ -173,13 +243,15 @@ export function EncounterLinkPickers({ icon={FlaskConical} title={t("encounters.form.linkLabResults")} slot="encounter-link-labs" - pending={labs.isPending} - error={labs.isError} + pending={labs.pending} + error={labs.error} errorLabel={t("links.picker.loadError")} - onRetry={() => void labs.refetch()} + onRetry={labs.retry} selected={labResultIds} onChange={(ids) => onChange({ labResultIds: ids })} options={labOptions} + server={serverPagingFrom(labs)} + knownOptions={known.labResults} searchPlaceholder={t("links.picker.searchPlaceholder")} emptyLabel={t("encounters.form.linkNothingToOffer")} /> @@ -190,13 +262,15 @@ export function EncounterLinkPickers({ icon={Stethoscope} title={t("encounters.form.linkConditions")} slot="encounter-link-conditions" - pending={episodes.isPending} - error={episodes.isError} + pending={episodes.pending} + error={episodes.error} errorLabel={t("links.picker.loadError")} - onRetry={() => void episodes.refetch()} + onRetry={episodes.retry} selected={episodeIds} onChange={(ids) => onChange({ episodeIds: ids })} options={episodeOptions} + server={serverPagingFrom(episodes)} + knownOptions={known.conditions} searchPlaceholder={t("links.picker.searchPlaceholder")} emptyLabel={t("encounters.form.linkNothingToOffer")} /> diff --git a/src/components/links/__tests__/entity-link-picker-paging.test.tsx b/src/components/links/__tests__/entity-link-picker-paging.test.tsx new file mode 100644 index 000000000..8c7e67de3 --- /dev/null +++ b/src/components/links/__tests__/entity-link-picker-paging.test.tsx @@ -0,0 +1,199 @@ +/** + * The link picker over a list that pages on the server. + * + * A visit's link pickers used to offer one capped page and nothing past it. + * Paged, three things have to hold: what the record already links shows + * whether or not a loaded page holds it, the sheet offers the paging the + * server reports, and an empty first window is not read as "nothing to link". + * + * ResponsiveSheet portals its body, which static markup does not render, so it + * is mocked down to a wrapper that always shows its children. + */ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { FlaskConical } from "lucide-react"; + +vi.mock("@/components/ui/responsive-sheet", () => ({ + ResponsiveSheet: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import { + EntityLinkPicker, + resolvableOptions, + type EntityLinkOption, + type EntityLinkServerPaging, +} from "../entity-link-picker"; +import { mergeWindowPages, uniqueById } from "../use-link-picker-pages"; + +function render(node: React.ReactNode) { + return renderToStaticMarkup( + {node}, + ); +} + +const server = (over: Partial = {}) => ({ + term: "", + onTermChange: () => undefined, + searchPending: false, + hasOlder: false, + hasNewer: false, + loadingOlder: false, + loadingNewer: false, + onOlder: () => undefined, + onNewer: () => undefined, + ...over, +}); + +const base = { + icon: FlaskConical, + title: "Lab results", + slot: "labs", + searchPlaceholder: "Search", + emptyLabel: "Nothing to link", + pending: false, + onChange: () => undefined, +}; + +const loaded: EntityLinkOption[] = [ + { id: "recent", label: "Glucose", dateLabel: "12.05.2026" }, +]; +const linked2019: EntityLinkOption = { + id: "ferritin-2019", + label: "Ferritin", + dateLabel: "03.04.2019", +}; + +describe("paged link picker", () => { + it("a linked record outside every loaded page still shows as a chip and in the sheet", () => { + const html = render( + , + ); + expect(html).toContain('data-slot="labs-chip"'); + expect(html).toContain("Ferritin"); + expect(html).toContain("03.04.2019"); + // In the sheet, on top under its own heading. + expect(html).toContain("Linked"); + }); + + it("offers older and newer pages while the server reports them", () => { + const html = render( + , + ); + expect(html).toContain('data-slot="labs-load-older"'); + expect(html).toContain('data-slot="labs-load-newer"'); + expect(html).toContain("Load older"); + expect(html).toContain("Load newer"); + }); + + it("offers no paging once the server has no more", () => { + const html = render( + , + ); + expect(html).not.toContain('data-slot="labs-load-older"'); + expect(html).not.toContain('data-slot="labs-load-newer"'); + }); + + it("an empty first window with more to page is not 'nothing to link'", () => { + const html = render( + , + ); + expect(html).not.toContain("Nothing to link"); + expect(html).toContain('data-slot="labs-add"'); + }); + + it("does not filter a server answer again by the term", () => { + // The route matched on the panel name; the option does not render it. + const html = render( + , + ); + expect(html).toContain('data-slot="labs-option"'); + }); + + it("a route without search filters what is loaded", () => { + const html = render( + , + ); + expect(html).not.toContain('data-slot="labs-option"'); + }); +}); + +describe("resolvableOptions", () => { + it("prefers the loaded option, then the known one, then a remembered pick", () => { + const picked = new Map([ + ["a", { id: "a", label: "picked" }], + ["b", { id: "b", label: "picked" }], + ["c", { id: "c", label: "picked" }], + ]); + const map = resolvableOptions( + [{ id: "a", label: "loaded" }], + [ + { id: "a", label: "known" }, + { id: "b", label: "known" }, + ], + picked, + ); + expect(map.get("a")?.label).toBe("loaded"); + expect(map.get("b")?.label).toBe("known"); + expect(map.get("c")?.label).toBe("picked"); + }); +}); + +describe("mergeWindowPages", () => { + it("puts the newer window, newest first, above the older one", () => { + const merged = mergeWindowPages( + [ + { items: [{ id: "n1" }, { id: "n2" }], next: "2" }, + { items: [{ id: "n3" }], next: null }, + ], + [{ items: [{ id: "o1" }, { id: "o2" }], next: null }], + ); + expect(merged.map((item) => item.id)).toEqual([ + "n3", + "n2", + "n1", + "o1", + "o2", + ]); + }); + + it("drops a record the two windows both returned", () => { + expect(uniqueById([{ id: "x" }, { id: "y" }, { id: "x" }])).toEqual([ + { id: "x" }, + { id: "y" }, + ]); + }); +}); diff --git a/src/components/links/__tests__/picker-requests.test.ts b/src/components/links/__tests__/picker-requests.test.ts new file mode 100644 index 000000000..125939e4b --- /dev/null +++ b/src/components/links/__tests__/picker-requests.test.ts @@ -0,0 +1,122 @@ +/** + * Every request a link picker can send must be one its list route accepts. + * + * A refused parameter comes back as a 422, and a picker that reads a failed + * read as an empty list tells the person there is nothing to link. That is how + * the visit form's condition picker and both document pickers once showed + * nothing at all. Each builder is checked in every direction, with and without + * a date, against the very schema its route parses with. + */ +import { describe, expect, it } from "vitest"; + +import { documentListQuerySchema } from "@/lib/validations/inbound-documents"; +import { encounterListQuerySchema } from "@/lib/validations/encounters"; +import { illnessEpisodeListQuerySchema } from "@/lib/validations/illness"; +import { listLabResultsSchema } from "@/lib/validations/labs"; + +import { + documentPickerUrl, + encounterPickerUrl, + episodePickerUrl, + labPickerUrl, +} from "../picker-requests"; +import { + PICKER_PAGE_SIZE, + nextOffset, + pickerPivot, + type PickerPageRequest, +} from "../use-link-picker-pages"; + +const pivot = pickerPivot("2019-05-10T10:00:00.000Z"); + +const REQUESTS: PickerPageRequest[] = [ + { direction: "older", pivot, term: "", cursor: null }, + { direction: "older", pivot, term: "", cursor: "200" }, + { direction: "older", pivot: null, term: "", cursor: null }, + { direction: "newer", pivot, term: "", cursor: null }, + { direction: "newer", pivot, term: "", cursor: "100" }, + { direction: "search", pivot: null, term: "ferritin", cursor: null }, + { direction: "search", pivot: null, term: "ferritin", cursor: "100" }, +]; + +function params(url: string): Record { + return Object.fromEntries(new URL(url, "http://local").searchParams); +} + +describe.each([ + ["labs", labPickerUrl, listLabResultsSchema], + ["episodes", episodePickerUrl, illnessEpisodeListQuerySchema], + ["encounters", encounterPickerUrl, encounterListQuerySchema], +])("%s picker", (_name, build, schema) => { + it.each(REQUESTS)("$direction (cursor $cursor) parses", (request) => { + const result = schema.safeParse(params(build(request))); + expect(result.success, JSON.stringify(result.error?.issues)).toBe(true); + }); +}); + +describe("documents picker", () => { + it.each(REQUESTS)("$direction parses", (request) => { + // The document cursor is a row id, not an offset. + const url = documentPickerUrl({ + ...request, + cursor: request.cursor ? "doc-id" : null, + }); + const result = documentListQuerySchema.safeParse(params(url)); + expect(result.success, JSON.stringify(result.error?.issues)).toBe(true); + }); + + it("splits the day-granular window at the pivot's day", () => { + const older = params( + documentPickerUrl({ direction: "older", pivot, term: "", cursor: null }), + ); + const newer = params( + documentPickerUrl({ direction: "newer", pivot, term: "", cursor: null }), + ); + expect(older.to).toBe("2019-05-17"); + expect(newer.from).toBe("2019-05-18"); + expect(older.order).toBe("desc"); + expect(newer.order).toBe("asc"); + }); +}); + +describe("the windows around a record's date", () => { + it("splits a week after the date, at the end of that day", () => { + expect(pivot).toBe("2019-05-17T23:59:59.999Z"); + expect(pickerPivot(null)).toBeNull(); + expect(pickerPivot("not a date")).toBeNull(); + }); + + it("older reads up to the split newest first, newer after it oldest first", () => { + const older = params( + labPickerUrl({ direction: "older", pivot, term: "", cursor: null }), + ); + const newer = params( + labPickerUrl({ direction: "newer", pivot, term: "", cursor: null }), + ); + expect(older).toMatchObject({ to: pivot, sortDir: "desc", offset: "0" }); + expect(newer).toMatchObject({ + from: "2019-05-18T00:00:00.000Z", + sortDir: "asc", + }); + }); + + it("a search ignores the window and sends the term", () => { + const search = params( + labPickerUrl({ + direction: "search", + pivot: null, + term: "ferritin", + cursor: null, + }), + ); + expect(search.q).toBe("ferritin"); + expect(search.from).toBeUndefined(); + expect(search.to).toBeUndefined(); + }); + + it("an offset page that came back full has a next page, a short one has none", () => { + expect(nextOffset(null, PICKER_PAGE_SIZE)).toBe(String(PICKER_PAGE_SIZE)); + expect(nextOffset("100", PICKER_PAGE_SIZE)).toBe("200"); + expect(nextOffset("100", 3)).toBeNull(); + }); +}); diff --git a/src/components/links/__tests__/vault-document-options.test.ts b/src/components/links/__tests__/vault-document-options.test.ts index e0dbbace6..049c915d8 100644 --- a/src/components/links/__tests__/vault-document-options.test.ts +++ b/src/components/links/__tests__/vault-document-options.test.ts @@ -91,37 +91,36 @@ describe("documentOptionsWithSuggestions", () => { }); }); -describe("fetchWholeVault", () => { - it("asks only for pages the list route accepts, and walks every cursor", async () => { +describe("fetchVaultPage", () => { + it("asks for one page the list route accepts and hands back its cursor", async () => { const { documentListQuerySchema } = await import("@/lib/validations/inbound-documents"); const apiFetch = await import("@/lib/api/api-fetch"); const urls: string[] = []; - const pages = [ - { documents: [doc("a", "2026-05-01")], nextCursor: "a" }, - { documents: [doc("b", "1991-03-01")], nextCursor: null }, - ]; const spy = vi .spyOn(apiFetch, "apiGet") .mockImplementation(async (url: string) => { urls.push(url); - return pages.shift() as never; + return { + documents: [doc("a", "2026-05-01")], + nextCursor: "a", + } as never; }); try { - const { fetchWholeVault } = await import("../vault-document-options"); - const documents = await fetchWholeVault(); - expect(documents.map((d) => d.id)).toEqual(["a", "b"]); - expect(urls).toHaveLength(2); - for (const url of urls) { - // The route parses its query with this schema and answers a 422 to - // anything it refuses; the picker used to read that 422 as an empty - // vault. - const params = Object.fromEntries( - new URL(url, "http://local").searchParams, - ); - expect(documentListQuerySchema.safeParse(params).success).toBe(true); - } - expect(urls[1]).toContain("cursor=a"); + const { fetchVaultPage } = await import("../vault-document-options"); + const page = await fetchVaultPage({ + direction: "older", + pivot: "2026-05-17T23:59:59.999Z", + term: "", + cursor: "z", + }); + expect(page.items.map((d) => d.id)).toEqual(["a"]); + expect(page.next).toBe("a"); + const params = Object.fromEntries( + new URL(urls[0]!, "http://local").searchParams, + ); + expect(documentListQuerySchema.safeParse(params).success).toBe(true); + expect(params).toMatchObject({ cursor: "z", to: "2026-05-17" }); } finally { spy.mockRestore(); } diff --git a/src/components/links/entity-link-picker.tsx b/src/components/links/entity-link-picker.tsx index 8d70d10b0..919dfd9e7 100644 --- a/src/components/links/entity-link-picker.tsx +++ b/src/components/links/entity-link-picker.tsx @@ -30,10 +30,18 @@ * sheet where its button cannot be pressed while the sheet is open. The chip * carries the option's date, so two doses of one vaccine read as two * different records. + * + * **A long list pages on the server.** A caller over a list that can outgrow + * one page passes `server`: the search box then asks the list route instead + * of filtering what is loaded, and "Load newer" / "Load older" page through + * the windows around the record's date (see `useLinkPickerPages`). A record + * that is linked always shows as a chip, whether or not the page that holds + * it is loaded: the caller passes what it knows about the existing links as + * `knownOptions`, and every option picked in this session is remembered. */ import { useMemo, useState, type ReactNode } from "react"; import Link from "next/link"; -import { Check, Plus, Search, X } from "lucide-react"; +import { Check, Loader2, Plus, Search, X } from "lucide-react"; import type { LucideIcon } from "lucide-react"; import { Badge } from "@/components/ui/badge"; @@ -82,6 +90,74 @@ export function filterOptions( return options.filter((option) => haystack(option).includes(needle)); } +/** + * Server-side search and paging for a list that can outgrow one page. The + * caller owns the term (it is part of the list route's query) and the pages. + */ +export interface EntityLinkServerPaging { + term: string; + onTermChange: (term: string) => void; + /** The typed term has not been answered yet. */ + searchPending: boolean; + hasOlder: boolean; + hasNewer: boolean; + loadingOlder: boolean; + loadingNewer: boolean; + onOlder: () => void; + onNewer: () => void; + /** + * The list route has no text search: the term filters the loaded options + * here instead of being sent. + */ + filterLocally?: boolean; +} + +/** + * The paging props from a `useLinkPickerPages` result, so every caller + * wires the same fields the same way. + */ +export function serverPagingFrom(pages: { + term: string; + setTerm: (term: string) => void; + searchPending: boolean; + hasOlder: boolean; + hasNewer: boolean; + loadingOlder: boolean; + loadingNewer: boolean; + loadOlder: () => void; + loadNewer: () => void; + filterLocally: boolean; +}): EntityLinkServerPaging { + return { + term: pages.term, + onTermChange: pages.setTerm, + searchPending: pages.searchPending, + hasOlder: pages.hasOlder, + hasNewer: pages.hasNewer, + loadingOlder: pages.loadingOlder, + loadingNewer: pages.loadingNewer, + onOlder: pages.loadOlder, + onNewer: pages.loadNewer, + filterLocally: pages.filterLocally, + }; +} + +/** + * The options a chip can be resolved from: what is loaded, what the caller + * knows about the existing links, and what was picked in this session. The + * loaded option wins, then the known one, then the picked one. + */ +export function resolvableOptions( + options: readonly EntityLinkOption[], + knownOptions: readonly EntityLinkOption[], + picked: ReadonlyMap, +): Map { + const map = new Map(picked); + for (const option of knownOptions) map.set(option.id, option); + for (const option of options) map.set(option.id, option); + return map; +} + export interface EntityLinkGroup { /** Group key, or null for the flat (ungrouped) bucket. */ key: string | null; @@ -165,6 +241,8 @@ export function EntityLinkPicker({ errorLabel, onRetry, extraAction, + server, + knownOptions, }: { icon: LucideIcon; title: string; @@ -191,27 +269,68 @@ export function EntityLinkPicker({ * the way forward. The element renders nothing when it has nothing to offer. */ extraAction?: ReactNode; + /** Server-side search and paging; omit for a list that fits one read. */ + server?: EntityLinkServerPaging; + /** + * What the caller knows about records already linked (label and date from + * the record's own read), so their chips show even when no loaded page + * holds them. + */ + knownOptions?: EntityLinkOption[]; }) { const { t } = useTranslations(); const [open, setOpen] = useState(false); - const [term, setTerm] = useState(""); + const [localTerm, setLocalTerm] = useState(""); + const term = server ? server.term : localTerm; + const setTerm = server ? server.onTermChange : setLocalTerm; - const optionById = useMemo(() => { - const map = new Map(); - for (const option of options) map.set(option.id, option); - return map; - }, [options]); - - const groups = useMemo( - () => groupOptions(filterOptions(options, term)), - [options, term], + // Every option picked in this session, so its chip survives a search or a + // page change that takes it out of the loaded list. Written from the event + // handlers, never during render. + const [picked, setPicked] = useState>( + () => new Map(), ); + const remember = (more: readonly EntityLinkOption[]) => { + if (more.length === 0) return; + setPicked((prev) => { + const next = new Map(prev); + for (const option of more) next.set(option.id, option); + return next; + }); + }; + + const optionById = useMemo( + () => resolvableOptions(options, knownOptions ?? [], picked), + [options, knownOptions, picked], + ); + + // A server-paged list is already the answer to the term; filtering it + // again here would drop a match on a field the option does not render. + const groups = useMemo(() => { + const visible = + server && !server.filterLocally ? options : filterOptions(options, term); + // Linked records the loaded pages do not hold stay reachable in the + // sheet, on top, so they can be unticked where they are listed. + const loaded = new Set(options.map((option) => option.id)); + const linkedElsewhere = term.trim() + ? [] + : selected + .filter((id) => !loaded.has(id)) + .map((id) => optionById.get(id)) + .filter((option): option is EntityLinkOption => option != null) + .map((option) => ({ + ...option, + group: { key: "__linked__", label: t("links.picker.linked") }, + })); + return groupOptions([...linkedElsewhere, ...visible]); + }, [options, term, server, selected, optionById, t]); // The link the last X removed, offered back until the next change. const [lastRemoved, setLastRemoved] = useState(null); - const change = (ids: string[]) => { + const change = (ids: string[], touched: readonly EntityLinkOption[] = []) => { setLastRemoved(null); + remember(touched); onChange(ids); }; @@ -245,7 +364,9 @@ export function EntityLinkPicker({ onRetry={onRetry} slot={`${slot}-error`} /> - ) : options.length === 0 ? ( + ) : options.length === 0 && + selected.length === 0 && + !(server && (server.hasOlder || server.hasNewer)) ? (

{emptyLabel}

{extraAction} @@ -315,7 +436,9 @@ export function EntityLinkPicker({
- {groups.length === 0 ? ( + {server?.searchPending && groups.length === 0 ? ( +
+ +
+ ) : groups.length === 0 && + !(server && (server.hasOlder || server.hasNewer)) ? (

{t("links.picker.searchNoMatch")}

) : (
+ {server?.hasNewer ? ( + + ) : null} {groups.map((group) => { const groupIds = group.options.map((option) => option.id); return ( @@ -384,7 +526,9 @@ export function EntityLinkPicker({
); })} + {server?.hasOlder ? ( + + ) : null}
)} @@ -451,3 +603,37 @@ export function EntityLinkPicker({ ); } + +/** "Load newer" / "Load older": a full-width row inside the scrolling list. */ +function PageButton({ + slot, + loading, + onClick, + label, +}: { + slot: string; + loading: boolean; + onClick: () => void; + label: string; +}) { + return ( + + ); +} diff --git a/src/components/links/picker-requests.ts b/src/components/links/picker-requests.ts new file mode 100644 index 000000000..2d4be8381 --- /dev/null +++ b/src/components/links/picker-requests.ts @@ -0,0 +1,96 @@ +/** + * The list-route requests behind each link picker, one builder per family. + * + * Pure, so a test can parse every URL a picker can send against the route's + * own query schema: a page size or a parameter the route refuses answers 422, + * and a picker that reads a 422 as an empty list hides the record it exists to + * offer. That is exactly how a vault once read as empty. + * + * Every builder speaks one {@link PickerPageRequest}: `older` is the window up + * to the split newest first, `newer` the window after it oldest first, + * `search` the term across the whole history. The cursor is the family's own: + * an offset for the routes that page by offset, the last row's id for the + * document route's keyset. + */ +import { + PICKER_PAGE_SIZE, + afterPivot, + type PickerPageRequest, +} from "./use-link-picker-pages"; + +function withParams(path: string, params: URLSearchParams): string { + return `${path}?${params.toString()}`; +} + +/** `GET /api/labs` — offset paging, `from` / `to` on the draw instant. */ +export function labPickerUrl(request: PickerPageRequest): string { + const params = new URLSearchParams({ + limit: String(PICKER_PAGE_SIZE), + offset: request.cursor ?? "0", + sortDir: request.direction === "newer" ? "asc" : "desc", + }); + if (request.direction === "search") params.set("q", request.term); + else if (request.pivot !== null) { + if (request.direction === "older") params.set("to", request.pivot); + else params.set("from", afterPivot(request.pivot)); + } + return withParams("/api/labs", params); +} + +/** `GET /api/illness/episodes` — offset paging, the window on the onset. */ +export function episodePickerUrl(request: PickerPageRequest): string { + const params = new URLSearchParams({ + includeResolved: "true", + limit: String(PICKER_PAGE_SIZE), + offset: request.cursor ?? "0", + sortDir: request.direction === "newer" ? "asc" : "desc", + }); + if (request.direction === "search") params.set("q", request.term); + else if (request.pivot !== null) { + if (request.direction === "older") params.set("to", request.pivot); + else params.set("from", afterPivot(request.pivot)); + } + return withParams("/api/illness/episodes", params); +} + +/** + * `GET /api/documents/inbound` — keyset paging on the filing date. The route's + * window is day-granular and inclusive, so the newer window starts the day + * after the split's day. + */ +export function documentPickerUrl(request: PickerPageRequest): string { + const params = new URLSearchParams({ + sort: "documentDate", + order: request.direction === "newer" ? "asc" : "desc", + limit: String(PICKER_PAGE_SIZE), + }); + if (request.cursor) params.set("cursor", request.cursor); + if (request.direction === "search") params.set("q", request.term); + else if (request.pivot !== null) { + if (request.direction === "older") { + params.set("to", request.pivot.slice(0, 10)); + } else { + params.set("from", afterPivot(request.pivot).slice(0, 10)); + } + } + return withParams("/api/documents/inbound", params); +} + +/** + * `GET /api/encounters` — offset paging inside a window on the visit instant. + * The route answers in two arms (upcoming soonest first, past newest first), + * each paged by the same offset; the picker merges and orders them itself. + * The visit route has no text search, so `search` reads the whole history + * and the picker filters what it has loaded. + */ +export function encounterPickerUrl(request: PickerPageRequest): string { + const params = new URLSearchParams({ + limit: String(PICKER_PAGE_SIZE), + offset: request.cursor ?? "0", + }); + if (request.direction !== "search" && request.pivot !== null) { + if (request.direction === "older") params.set("to", request.pivot); + else params.set("from", afterPivot(request.pivot)); + } + return withParams("/api/encounters", params); +} diff --git a/src/components/links/use-link-picker-pages.ts b/src/components/links/use-link-picker-pages.ts new file mode 100644 index 000000000..28f9e1f41 --- /dev/null +++ b/src/components/links/use-link-picker-pages.ts @@ -0,0 +1,235 @@ +"use client"; + +/** + * The data behind a link picker over a list that can be long: the records + * around one date, paged older and newer from there, or a search across the + * whole history. + * + * The pickers used to fetch one capped page, newest first (200 lab results, + * 100 conditions, ten pages of documents, the last 100 visits), and offered + * only that. On a long record everything older than the cap could not be + * linked at all: a visit from 2019 could not reach its own 2019 lab panel. + * + * Now a picker opens on the records around the date of the thing being linked + * (the visit's, the dose's, the document's). The list is split a few days + * after that date (the same window the "belongs to a visit?" suggestion uses, + * so a result drawn two days after the visit is on the first page) into two + * server-side windows: + * + * - `older`: up to the split, newest first, so the record's own days lead + * the list, paged further back on request; + * - `newer`: after the split, oldest first, fetched only when asked for and + * paged further forward from there; + * + * and a typed term switches to `search`, which asks the list route itself + * (`q`) so a match is found wherever it sits in the history. With no date + * yet the picker opens on the newest records and pages back. + * + * Every page comes from the family's own list route through `fetchPage`, so + * paging, the filter and the ownership check stay server-side. + */ +import { useMemo, useState } from "react"; +import { useInfiniteQuery } from "@tanstack/react-query"; + +import { useDebouncedValue } from "@/hooks/use-debounced-value"; +import { ENCOUNTER_SUGGEST_WINDOW_DAYS } from "@/lib/encounters/suggest-window"; + +export type PickerDirection = "older" | "newer" | "search"; + +/** One page of a picker list, and the cursor for the next one (or null). */ +export interface PickerPage { + items: T[]; + next: string | null; +} + +export interface PickerPageRequest { + direction: PickerDirection; + /** The split instant (ISO), or null when the record has no date yet. */ + pivot: string | null; + /** The trimmed search term; empty outside `search`. */ + term: string; + /** The family's own cursor (an offset or a row id), null for page one. */ + cursor: string | null; +} + +/** Records per page, the same for every family. */ +export const PICKER_PAGE_SIZE = 100; + +/** How long typing settles before the search asks the server. */ +const SEARCH_DEBOUNCE_MS = 250; + +/** Drop repeats by id, keeping the first occurrence's position. */ +export function uniqueById(items: readonly T[]): T[] { + const seen = new Set(); + const out: T[] = []; + for (const item of items) { + if (seen.has(item.id)) continue; + seen.add(item.id); + out.push(item); + } + return out; +} + +/** + * The visible list, newest first: the newer pages (fetched oldest first, so + * reversed) above the older ones. + */ +export function mergeWindowPages( + newerPages: ReadonlyArray>, + olderPages: ReadonlyArray>, +): T[] { + const newer = newerPages.flatMap((page) => page.items).reverse(); + const older = olderPages.flatMap((page) => page.items); + return uniqueById([...newer, ...older]); +} + +export function useLinkPickerPages({ + enabled, + pivot, + queryKey, + fetchPage, + serverSearch = true, +}: { + enabled: boolean; + pivot: string | null; + /** The family's factory key for one direction, pivot and term. */ + queryKey: ( + direction: PickerDirection, + pivot: string | null, + term: string, + ) => readonly unknown[]; + fetchPage: (request: PickerPageRequest) => Promise>; + /** + * Whether the list route can search. A route without a text filter (the + * visit list: its free text is ciphertext at rest) leaves the term to the + * picker, which filters what is loaded. + */ + serverSearch?: boolean; +}) { + const [rawTerm, setTerm] = useState(""); + const debounced = useDebouncedValue(rawTerm.trim(), SEARCH_DEBOUNCE_MS); + const term = serverSearch ? debounced : ""; + const searching = term.length > 0; + + const older = useInfiniteQuery({ + queryKey: queryKey("older", pivot, ""), + enabled: enabled && !searching, + initialPageParam: null as string | null, + queryFn: ({ pageParam }) => + fetchPage({ direction: "older", pivot, term: "", cursor: pageParam }), + getNextPageParam: (last) => last.next ?? undefined, + }); + + // The newer window is read only once asked for, and only for the split it + // was asked for: a changed date starts over on the older window. + const [newerFor, setNewerFor] = useState(null); + const newerRequested = pivot !== null && newerFor === pivot; + + const newer = useInfiniteQuery({ + queryKey: queryKey("newer", pivot, ""), + // No date, no "after": the older window already starts at the newest. + enabled: enabled && !searching && newerRequested, + initialPageParam: null as string | null, + queryFn: ({ pageParam }) => + fetchPage({ direction: "newer", pivot, term: "", cursor: pageParam }), + getNextPageParam: (last) => last.next ?? undefined, + }); + + const search = useInfiniteQuery({ + queryKey: queryKey("search", null, term), + enabled: enabled && searching, + initialPageParam: null as string | null, + queryFn: ({ pageParam }) => + fetchPage({ direction: "search", pivot: null, term, cursor: pageParam }), + getNextPageParam: (last) => last.next ?? undefined, + }); + + const items = useMemo(() => { + if (searching) { + return uniqueById((search.data?.pages ?? []).flatMap((p) => p.items)); + } + return mergeWindowPages(newer.data?.pages ?? [], older.data?.pages ?? []); + }, [searching, search.data, newer.data, older.data]); + + const activeError = searching + ? search.isError + : older.isError || (newerRequested && newer.isError); + + return { + items, + /** The first window is still loading; the inline block shows a skeleton. */ + pending: older.isPending && !searching, + error: activeError, + retry: () => { + if (searching) void search.refetch(); + else { + void older.refetch(); + if (newerRequested) void newer.refetch(); + } + }, + term: rawTerm, + setTerm, + /** A search is in flight (typed, or its first page not back yet). */ + searchPending: + serverSearch && + (rawTerm.trim() !== term || (searching && search.isPending)), + /** The picker filters the loaded options by the term itself. */ + filterLocally: !serverSearch, + hasOlder: searching ? search.hasNextPage : older.hasNextPage, + // Unknown until asked: offered once for every dated record, then only + // while the server reports another page. + hasNewer: + !searching && + pivot !== null && + (!newerRequested || newer.isFetching || newer.hasNextPage), + loadingOlder: searching + ? search.isFetchingNextPage + : older.isFetchingNextPage, + loadingNewer: !searching && newerRequested && newer.isFetching, + loadOlder: () => { + if (searching) void search.fetchNextPage(); + else void older.fetchNextPage(); + }, + loadNewer: () => { + if (searching || pivot === null) return; + if (!newerRequested) setNewerFor(pivot); + else void newer.fetchNextPage(); + }, + }; +} + +export type LinkPickerPages = ReturnType< + typeof useLinkPickerPages +>; + +/** + * Where a record's date splits the list: the end of the UTC day + * {@link ENCOUNTER_SUGGEST_WINDOW_DAYS} days after it, so everything the + * suggestion window would offer is on the first page. Day-granular so typing + * the time of a visit does not refetch. Null when there is no date (or it does + * not parse). + */ +export function pickerPivot(anchor: string | null): string | null { + if (!anchor) return null; + const parsed = new Date(anchor); + if (Number.isNaN(parsed.getTime())) return null; + const split = new Date( + parsed.getTime() + ENCOUNTER_SUGGEST_WINDOW_DAYS * 86_400_000, + ); + split.setUTCHours(23, 59, 59, 999); + return split.toISOString(); +} + +/** The instant just after the split, where the newer window starts. */ +export function afterPivot(pivot: string): string { + return new Date(new Date(pivot).getTime() + 1).toISOString(); +} + +/** Offset cursors, for the list routes that page by `offset`. */ +export function nextOffset( + cursor: string | null, + received: number, +): string | null { + if (received < PICKER_PAGE_SIZE) return null; + return String(Number(cursor ?? "0") + received); +} diff --git a/src/components/links/vault-document-options.ts b/src/components/links/vault-document-options.ts index 91aee8604..3ac090efd 100644 --- a/src/components/links/vault-document-options.ts +++ b/src/components/links/vault-document-options.ts @@ -7,38 +7,41 @@ * The visit and vaccination forms used to fetch their document options with a * page size the list route refuses (`limit=200` against a ceiling of 100). The * 422 read as an empty list, so the picker said "No documents yet." to a - * person with a full vault, and the only way a scan ever got attached was the - * ±7-day suggestion offered at upload. A childhood record covering eight doses - * from one sheet of paper could therefore be filed against exactly one of - * them. + * person with a full vault. The next version walked the whole vault, ten + * pages deep, and anything past the thousandth document was out of reach. * - * Now the whole vault is offered, paged through at the route's own ceiling, - * and a read failure says so instead of pretending the vault is empty. The - * documents dated within the same ±{@link ENCOUNTER_SUGGEST_WINDOW_DAYS}-day - * window the upload suggestion uses stay on top under their own heading, so - * the common case is still one tap; everything else follows, grouped by month - * and searchable. + * Now the picker opens on the documents filed around the record's date and + * pages older and newer from there, and the search box asks the list route + * itself, so a document is reachable wherever it sits in a vault of any size + * (`useLinkPickerPages`). A read failure says so instead of pretending the + * vault is empty. The documents dated within the same + * ±{@link ENCOUNTER_SUGGEST_WINDOW_DAYS}-day window the upload suggestion uses + * stay on top under their own heading, so the common case is still one tap; + * everything else follows, grouped by month. + * + * A document whose filing date was cleared falls outside every date window; + * the search still finds it by title, file name or content. */ import { useMemo } from "react"; -import { useQuery } from "@tanstack/react-query"; -import type { EntityLinkOption } from "@/components/links/entity-link-picker"; +import { + serverPagingFrom, + type EntityLinkOption, +} from "@/components/links/entity-link-picker"; +import { documentPickerUrl } from "@/components/links/picker-requests"; +import { + pickerPivot, + useLinkPickerPages, + type PickerPage, + type PickerPageRequest, +} from "@/components/links/use-link-picker-pages"; import { apiGet } from "@/lib/api/api-fetch"; import { ENCOUNTER_SUGGEST_WINDOW_DAYS } from "@/lib/encounters/suggest-window"; import { useFormatters, useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; -import { - DOCUMENT_LIST_MAX_LIMIT, - type InboundDocumentDto, -} from "@/lib/validations/inbound-documents"; +import type { InboundDocumentDto } from "@/lib/validations/inbound-documents"; import { daysBetweenDateKeys } from "@/lib/tz/format"; -/** - * How many pages the picker walks. At the route's ceiling of 100 that is a - * thousand documents, past which the vault's own search is the better tool. - */ -const VAULT_PICKER_MAX_PAGES = 10; - /** The fields the option list reads off a document. */ export type VaultDocument = Pick< InboundDocumentDto, @@ -50,25 +53,12 @@ interface ListPage { nextCursor: string | null; } -/** Walk the vault newest filing date first, at the route's own page ceiling. */ -export async function fetchWholeVault(): Promise { - const documents: VaultDocument[] = []; - let cursor: string | null = null; - for (let page = 0; page < VAULT_PICKER_MAX_PAGES; page++) { - const params = new URLSearchParams({ - sort: "documentDate", - order: "desc", - limit: String(DOCUMENT_LIST_MAX_LIMIT), - }); - if (cursor) params.set("cursor", cursor); - const result: ListPage = await apiGet( - `/api/documents/inbound?${params.toString()}`, - ); - documents.push(...result.documents); - cursor = result.nextCursor; - if (!cursor) break; - } - return documents; +/** One picker page of the vault. */ +export async function fetchVaultPage( + request: PickerPageRequest, +): Promise> { + const result = await apiGet(documentPickerUrl(request)); + return { items: result.documents, next: result.nextCursor }; } /** @@ -135,8 +125,9 @@ export function documentOptionsWithSuggestions( } /** - * The picker's data: the whole vault as options, the suggestion group on top - * when the record has a date, and the query state an error row needs. + * The picker's data: the vault around the record's date as options, the + * suggestion group on top when the record has a date, the paging the sheet + * offers, and the query state an error row needs. */ export function useVaultDocumentOptions({ enabled, @@ -148,26 +139,42 @@ export function useVaultDocumentOptions({ }) { const { t } = useTranslations(); const format = useFormatters(); - const query = useQuery({ - queryKey: queryKeys.inboundDocumentVaultPicker(), + const pages = useLinkPickerPages({ enabled, - queryFn: fetchWholeVault, + pivot: pickerPivot(anchor), + queryKey: queryKeys.inboundDocumentVaultPicker, + fetchPage: fetchVaultPage, }); const options = useMemo( () => - documentOptionsWithSuggestions(query.data ?? [], anchor, { + documentOptionsWithSuggestions(pages.items, anchor, { suggested: t("links.picker.suggested"), month: (iso) => `${format.monthShort(iso)} ${iso.slice(0, 4)}`, date: (iso) => format.date(iso), }), - [query.data, anchor, t, format], + [pages.items, anchor, t, format], ); return { options, - pending: query.isPending, - error: query.isError, - retry: () => void query.refetch(), + pending: pages.pending, + error: pages.error, + retry: pages.retry, + server: serverPagingFrom(pages), + }; +} + +/** A linked document as the record's own read names it, for its chip. */ +export function linkedDocumentOption( + link: { id: string; label: string | null; date: string | null }, + date: (iso: string) => string, +): EntityLinkOption | null { + if (link.label === null) return null; + return { + id: link.id, + label: link.label, + dateLabel: link.date ? date(link.date) : null, + href: `/documents?doc=${encodeURIComponent(link.id)}`, }; } diff --git a/src/components/vaccinations/vaccination-document-picker.tsx b/src/components/vaccinations/vaccination-document-picker.tsx index 6fb195261..da8f17a0e 100644 --- a/src/components/vaccinations/vaccination-document-picker.tsx +++ b/src/components/vaccinations/vaccination-document-picker.tsx @@ -5,10 +5,11 @@ * transcribed from. The shared {@link EntityLinkPicker} — an inline summary * (removable chips + an add button) over a searchable, month-grouped sheet. * - * The whole vault is offered, not only the pages dated near the dose: one + * The whole vault is reachable, not only the pages dated near the dose: one * childhood record commonly covers a dozen doses given years apart, and it - * belongs on every one of them. The pages dated within the upload - * suggestion's window of this dose sit on top as suggestions. + * belongs on every one of them. The picker opens on the pages around the + * dose's date, the ones within the upload suggestion's window on top as + * suggestions, and pages older and newer or searches the vault from there. * * **The gate blanks the block, it does not post-filter it.** When the * `inboundDocuments` module is off, this renders nothing — no heading, no empty @@ -23,11 +24,15 @@ * than showing an empty set, which would both hide the links and offer to * save their removal. */ +import { useMemo } from "react"; import { FolderOpen } from "lucide-react"; -import { useTranslations } from "@/lib/i18n/context"; +import { useFormatters, useTranslations } from "@/lib/i18n/context"; import { EntityLinkPicker } from "@/components/links/entity-link-picker"; -import { useVaultDocumentOptions } from "@/components/links/vault-document-options"; +import { + linkedDocumentOption, + useVaultDocumentOptions, +} from "@/components/links/vault-document-options"; import { ImportFromSourceButton } from "@/components/documents/sources/import-from-source-button"; import { mergeIds } from "@/components/documents/sources/merge-ids"; @@ -35,6 +40,7 @@ export function VaccinationDocumentPicker({ enabled, anchor, documentIds, + linkedDocuments, seedPending = false, seedError = false, onRetrySeed, @@ -45,6 +51,15 @@ export function VaccinationDocumentPicker({ /** The dose's own date (ISO), for the suggestions on top. */ anchor: string | null; documentIds: string[]; + /** + * The pages the dose is filed against, as its own read names them, so their + * chips show whatever part of the vault is loaded. + */ + linkedDocuments?: ReadonlyArray<{ + id: string; + label: string | null; + date: string | null; + }>; /** The dose's own links are still loading. */ seedPending?: boolean; /** Reading the dose's own links failed. */ @@ -53,7 +68,15 @@ export function VaccinationDocumentPicker({ onChange: (documentIds: string[]) => void; }) { const { t } = useTranslations(); + const format = useFormatters(); const vault = useVaultDocumentOptions({ enabled, anchor }); + const known = useMemo( + () => + (linkedDocuments ?? []) + .map((link) => linkedDocumentOption(link, (iso) => format.date(iso))) + .filter((option) => option !== null), + [linkedDocuments, format], + ); if (!enabled) return null; @@ -73,6 +96,8 @@ export function VaccinationDocumentPicker({ selected={documentIds} onChange={onChange} options={vault.options} + server={vault.server} + knownOptions={known} searchPlaceholder={t("links.picker.searchPlaceholder")} emptyLabel={t("vaccinations.form.linkNothingToOffer")} extraAction={ diff --git a/src/components/vaccinations/vaccination-form.tsx b/src/components/vaccinations/vaccination-form.tsx index aff06ca44..bdb30c955 100644 --- a/src/components/vaccinations/vaccination-form.tsx +++ b/src/components/vaccinations/vaccination-form.tsx @@ -191,6 +191,12 @@ export function VaccinationForm({ */ savedDocuments?: { ids: string[] | null; + /** The same links with the label and date the detail read carries. */ + links?: ReadonlyArray<{ + id: string; + label: string | null; + date: string | null; + }>; pending: boolean; error: boolean; retry: () => void; @@ -376,6 +382,7 @@ export function VaccinationForm({ enabled={user?.modules?.inboundDocuments === true} anchor={anchor} documentIds={draft.documentIds ?? savedDocuments?.ids ?? []} + linkedDocuments={savedDocuments?.links} seedPending={ draft.documentIds === null && (savedDocuments?.pending ?? false) } diff --git a/src/components/vaccinations/vaccination-sheet.tsx b/src/components/vaccinations/vaccination-sheet.tsx index 3f76cc21b..3cb0e8130 100644 --- a/src/components/vaccinations/vaccination-sheet.tsx +++ b/src/components/vaccinations/vaccination-sheet.tsx @@ -141,6 +141,7 @@ export function VaccinationSheet({ ids: detail.data?.documents?.map((document) => document.id) ?? null, + links: detail.data?.documents, pending: detail.isPending, error: detail.isError, retry: () => void detail.refetch(), diff --git a/src/lib/query-keys/__tests__/picker-list-keys.test.ts b/src/lib/query-keys/__tests__/picker-list-keys.test.ts index ec41804a9..f22bfd8be 100644 --- a/src/lib/query-keys/__tests__/picker-list-keys.test.ts +++ b/src/lib/query-keys/__tests__/picker-list-keys.test.ts @@ -1,11 +1,11 @@ /** * One key, one fetch. The visit form's link pickers read labs and illness - * episodes with their own page size (200 results, 100 episodes), the Labs - * list and the illness hook with theirs (500, the route's default 50). Under - * a shared key whichever read landed first served the other, so the picker - * could offer 50 episodes or the Labs list could render a 200-row page as - * the whole list. The picker reads carry their own keys, still under the - * domain roots so a lab or episode write evicts them. + * episodes in their own windows and pages, the Labs list and the illness hook + * with theirs. Under a shared key whichever read landed first served the + * other, so the picker could offer 50 episodes or the Labs list could render + * a picker page as the whole list. The picker reads carry their own keys, + * still under the domain roots so a lab or episode write evicts them, and + * each direction, split and term is its own key. */ import { describe, expect, it } from "vitest"; @@ -22,20 +22,36 @@ const LIST_PARAMS = { describe("the link pickers' reads have keys of their own", () => { it("labs: differs from the Labs list and sits under the lab root", () => { - const key = queryKeys.labResultsPicker(200); + const key = queryKeys.labResultsPicker("older", null, ""); expect(key).not.toEqual(queryKeys.labResultsList(LIST_PARAMS)); expect(key.slice(0, 1)).toEqual(queryKeys.labResults()); }); it("episodes: differs from the illness list and sits under the illness root", () => { - const key = queryKeys.illnessEpisodesPicker(100); + const key = queryKeys.illnessEpisodesPicker("older", null, ""); expect(key).not.toEqual(queryKeys.illnessEpisodes(true)); expect(key.slice(0, 1)).toEqual(queryKeys.illness()); }); - it("a different page size is a different key", () => { - expect(queryKeys.labResultsPicker(200)).not.toEqual( - queryKeys.labResultsPicker(500), + it("a different direction, split or term is a different key", () => { + const base = queryKeys.labResultsPicker("older", "2019-05-17", ""); + expect(base).not.toEqual( + queryKeys.labResultsPicker("newer", "2019-05-17", ""), + ); + expect(base).not.toEqual( + queryKeys.labResultsPicker("older", "2020-01-01", ""), + ); + expect(queryKeys.labResultsPicker("search", null, "a")).not.toEqual( + queryKeys.labResultsPicker("search", null, "b"), + ); + }); + + it("documents and visits: under their roots", () => { + expect( + queryKeys.inboundDocumentVaultPicker("older", null, "").slice(0, 1), + ).toEqual(["documents"]); + expect(queryKeys.encounterPicker("older", null, "").slice(0, 1)).toEqual( + queryKeys.encounters(), ); }); }); diff --git a/src/lib/query-keys/documents.ts b/src/lib/query-keys/documents.ts index a562672b7..beee7a450 100644 --- a/src/lib/query-keys/documents.ts +++ b/src/lib/query-keys/documents.ts @@ -61,12 +61,24 @@ export const documentKeys = { inboundDocumentPicker: (q: string) => ["documents", "inbound", "picker", q] as const, /** - * The whole vault as a link-picker option list (every page, walked at the - * list route's ceiling), shared by the visit and vaccination forms. Own - * segment because the shape is a flat document array, not a page. + * The vault as a link-picker option list, shared by the visit and + * vaccination forms: one paged direction from the record's date or a + * search. Own segment because the page shape is the picker's, not the + * library's. */ - inboundDocumentVaultPicker: () => - ["documents", "inbound", "vault-picker"] as const, + inboundDocumentVaultPicker: ( + direction: string, + pivot: string | null, + term: string, + ) => + [ + "documents", + "inbound", + "vault-picker", + direction, + pivot ?? "", + term, + ] as const, /** * Storage usage + effective limits (`GET /api/documents/inbound/usage`). * Invalidated through the `["documents"]` prefix after every upload / diff --git a/src/lib/query-keys/encounters.ts b/src/lib/query-keys/encounters.ts index f9c5586ad..84fab90e2 100644 --- a/src/lib/query-keys/encounters.ts +++ b/src/lib/query-keys/encounters.ts @@ -45,6 +45,13 @@ export const encounterKeys = { status ?? null, episodeId ?? null, ] as const, + /** + * The document sheet's visit picker: one paged direction from the + * document's date. Its own key because its page shape (offset steps inside + * a window) is not the list's. + */ + encounterPicker: (direction: string, pivot: string | null, term: string) => + ["encounters", "picker", direction, pivot ?? "", term] as const, encounter: (id: string) => ["encounters", "detail", id] as const, /** * The procedure history for one filter. Under the `["encounters"]` root, so diff --git a/src/lib/query-keys/illness.ts b/src/lib/query-keys/illness.ts index 61e5c2f35..c1f658aef 100644 --- a/src/lib/query-keys/illness.ts +++ b/src/lib/query-keys/illness.ts @@ -15,10 +15,14 @@ export const illnessKeys = { illness: () => ["illness"] as const, illnessEpisodes: (includeResolved: boolean) => ["illness", "episodes", includeResolved] as const, - // The visit form's episode picker, with its own page size (the illness - // pages read the route's default); a shared key would serve one to the other. - illnessEpisodesPicker: (limit: number) => - ["illness", "episodes-picker", limit] as const, + // The visit form's episode picker: one paged direction from the visit's + // date or a search, with its own page shape; a shared key would serve one + // read to the other. + illnessEpisodesPicker: ( + direction: string, + pivot: string | null, + term: string, + ) => ["illness", "episodes-picker", direction, pivot ?? "", term] as const, illnessEpisode: (id: string) => ["illness", "episode", id] as const, illnessDayLog: (episodeId: string, date: string) => ["illness", "day-log", episodeId, date] as const, diff --git a/src/lib/query-keys/labs.ts b/src/lib/query-keys/labs.ts index 45cdf949a..c15eb86e1 100644 --- a/src/lib/query-keys/labs.ts +++ b/src/lib/query-keys/labs.ts @@ -36,11 +36,12 @@ export const labKeys = { labAnalyteLink: (analyte: string) => ["lab-results", "analyte-link", analyte] as const, - // The visit form's lab picker: newest results, its own page size. Its own - // key because the Labs list reads a different page size, and a shared key - // would serve one read's page to the other. - labResultsPicker: (limit: number) => - ["lab-results", "picker", limit] as const, + // The visit form's lab picker: one paged direction from the visit's date + // (`older` / `newer`) or a search across the whole history. Its own key + // because the Labs list reads other pages, and a shared key would serve + // one read's page to the other. + labResultsPicker: (direction: string, pivot: string | null, term: string) => + ["lab-results", "picker", direction, pivot ?? "", term] as const, // v1.25 — paginated (offset) reading feed for the biomarker detail page. // Distinct from `labResultsList` (single-page key) so the infinite-query diff --git a/src/lib/validations/encounters.ts b/src/lib/validations/encounters.ts index 9955204dd..4d8126699 100644 --- a/src/lib/validations/encounters.ts +++ b/src/lib/validations/encounters.ts @@ -164,6 +164,11 @@ export const encounterListQuerySchema = z */ episodeId: id.optional(), limit: z.coerce.number().int().min(1).max(200).optional(), + /** + * Rows to skip in each arm, for a picker that pages a long history in + * `limit`-sized steps inside a date window. + */ + offset: z.coerce.number().int().min(0).max(100_000).optional(), }) .strict() .refine((q) => !(q.from && q.to) || q.from <= q.to, { diff --git a/src/lib/validations/illness.ts b/src/lib/validations/illness.ts index b96933709..afc5e5a83 100644 --- a/src/lib/validations/illness.ts +++ b/src/lib/validations/illness.ts @@ -136,6 +136,17 @@ export type IllnessEpisodeResolve = z.infer; export const illnessEpisodeListQuerySchema = z.object({ limit: z.coerce.number().int().min(1).max(100).optional(), includeResolved: z.union([z.literal("true"), z.literal("false")]).optional(), + /** + * Paging and a date window over the onset, for a picker that opens on the + * episodes around a visit and pages older or newer from there. + */ + offset: z.coerce.number().int().min(0).max(100_000).optional(), + from: z.iso.datetime({ offset: true }).optional(), + to: z.iso.datetime({ offset: true }).optional(), + /** Onset order; newest first by default. */ + sortDir: z.enum(["asc", "desc"]).optional(), + /** Case-insensitive substring over the episode's label. */ + q: z.string().trim().min(1).max(120).optional(), }); /* ── day-log capture (one row per day of an episode) ─────────────── */ diff --git a/src/lib/validations/labs.ts b/src/lib/validations/labs.ts index d7c2ea3c3..6bbc3ef0d 100644 --- a/src/lib/validations/labs.ts +++ b/src/lib/validations/labs.ts @@ -284,6 +284,11 @@ export const listLabResultsSchema = z.object({ biomarkerId: z.string().trim().min(1).max(64).optional(), analyte: z.string().trim().min(1).max(120).optional(), panel: z.string().trim().min(1).max(120).optional(), + /** + * Case-insensitive substring over the analyte and the panel name, so a + * picker can find one result in a long history without paging to it. + */ + q: z.string().trim().min(1).max(120).optional(), from: z .string() .datetime() diff --git a/tests/integration/visit-link-picker-long-history.test.ts b/tests/integration/visit-link-picker-long-history.test.ts new file mode 100644 index 000000000..b9d286a4d --- /dev/null +++ b/tests/integration/visit-link-picker-long-history.test.ts @@ -0,0 +1,311 @@ +/** + * A visit can link a record from anywhere in a long history. + * + * The visit form's lab picker loaded the newest 200 results and offered only + * those, the condition picker the newest 100 conditions. On a record with a + * long history everything older could not be linked: a 2019 visit could not + * reach its own 2019 lab panel. The pickers now open on a window around the + * visit's date and page and search through the list routes. + * + * Everything here goes through the shipped route exports with the requests + * the pickers build (`picker-requests.ts`), against a record of 1,200 lab + * results spread over seven years, so the window, the search and the paging + * are proven against real SQL, and the link is saved and read back. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +process.env.ENCRYPTION_KEY ??= + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +import { NextRequest } from "next/server"; + +import { + GET as listEncounters, + POST as createEncounter, +} from "@/app/api/encounters/route"; +import { GET as listEpisodes } from "@/app/api/illness/episodes/route"; +import { GET as listLabs } from "@/app/api/labs/route"; +import { + encounterPickerUrl, + episodePickerUrl, + labPickerUrl, +} from "@/components/links/picker-requests"; +import { + nextOffset, + pickerPivot, + type PickerPageRequest, +} from "@/components/links/use-link-picker-pages"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => cookieJar.set(name, value), + delete: (name: string) => cookieJar.delete(name), + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +const OWNER_ID = "long-history-owner"; +const DAY_MS = 24 * 60 * 60 * 1000; +/** Historical on purpose: the visit and its panel sit years back. */ +const VISIT_AT = "2019-04-03T09:30:00.000Z"; +const FERRITIN_AT = new Date("2019-04-03T07:00:00.000Z"); +const RESULT_COUNT = 1_200; + +async function seedOwner() { + const prisma = getPrismaClient(); + await prisma.user.create({ + data: { + id: OWNER_ID, + username: "long-history", + email: "long-history@example.test", + timezone: "Europe/Berlin", + modulePreferencesJson: { labs: true, illness: true }, + }, + }); + const session = await prisma.session.create({ + data: { + userId: OWNER_ID, + expiresAt: new Date(Date.now() + 7 * DAY_MS), + mfaVerifiedAt: new Date(), + }, + }); + cookieJar.set("healthlog_session", session.id); +} + +/** + * 1,200 glucose results, one every two days back from today, plus the one + * ferritin result drawn on the morning of the 2019 visit. + */ +async function seedLabs(): Promise { + const prisma = getPrismaClient(); + const now = Date.now(); + await prisma.labResult.createMany({ + data: Array.from({ length: RESULT_COUNT }, (_, i) => ({ + userId: OWNER_ID, + panel: "Metabolic", + analyte: "Glucose", + value: 90 + (i % 10), + unit: "mg/dL", + takenAt: new Date(now - (i + 1) * 2 * DAY_MS), + })), + }); + const ferritin = await prisma.labResult.create({ + data: { + userId: OWNER_ID, + panel: "Iron studies", + analyte: "Ferritin", + value: 54, + unit: "ng/mL", + takenAt: FERRITIN_AT, + }, + }); + return ferritin.id; +} + +async function get( + handler: (request: NextRequest) => Promise, + url: string, +): Promise { + const response = await handler(new NextRequest(`http://localhost${url}`)); + expect(response.status, url).toBe(200); + return ((await response.json()) as { data: T }).data; +} + +interface LabPage { + results: Array<{ id: string; analyte: string; takenAt: string }>; + meta: { total: number }; +} + +const labPage = (request: PickerPageRequest) => + get(listLabs as never, labPickerUrl(request)); + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); + await seedOwner(); +}); + +describe("the visit form's lab picker on a 1,200-result record", () => { + it("the old newest-200 page never reached the 2019 result", async () => { + const ferritinId = await seedLabs(); + const page = await get( + listLabs as never, + "/api/labs?limit=200&offset=0&sortDir=desc", + ); + expect(page.meta.total).toBe(RESULT_COUNT + 1); + expect(page.results.some((row) => row.id === ferritinId)).toBe(false); + }); + + it("opens on the visit's own date, with the 2019 result on the first page", async () => { + const ferritinId = await seedLabs(); + const pivot = pickerPivot(VISIT_AT); + const first = await labPage({ + direction: "older", + pivot, + term: "", + cursor: null, + }); + expect(first.results.some((row) => row.id === ferritinId)).toBe(true); + // Nothing after the window's split is on it. + for (const row of first.results) { + expect(new Date(row.takenAt).getTime()).toBeLessThanOrEqual( + new Date(pivot!).getTime(), + ); + } + + // "Load newer" pages forward from the split, oldest first, without + // repeating a row. + const seen = new Set(first.results.map((row) => row.id)); + let cursor: string | null = null; + let newerRows = 0; + let previous = new Date(pivot!).getTime(); + for (let guard = 0; guard < 20; guard++) { + const page = await labPage({ + direction: "newer", + pivot, + term: "", + cursor, + }); + for (const row of page.results) { + expect(seen.has(row.id)).toBe(false); + seen.add(row.id); + const at = new Date(row.takenAt).getTime(); + expect(at).toBeGreaterThanOrEqual(previous); + previous = at; + } + newerRows += page.results.length; + cursor = nextOffset(cursor, page.results.length); + if (cursor === null) break; + } + // Older window plus every newer page cover the whole record once. + expect(seen.size).toBe(RESULT_COUNT + 1); + expect(newerRows).toBeGreaterThan(0); + }); + + it("a search finds the 2019 result among 1,200 others, by analyte and by panel", async () => { + const ferritinId = await seedLabs(); + for (const term of ["ferritin", "iron stud"]) { + const page = await labPage({ + direction: "search", + pivot: null, + term, + cursor: null, + }); + expect(page.results.map((row) => row.id)).toEqual([ferritinId]); + } + }); + + it("the found result links to the visit and reads back on it", async () => { + const ferritinId = await seedLabs(); + const created = await createEncounter( + new Request("http://localhost/api/encounters", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + occurredAt: VISIT_AT, + status: "DONE", + kind: "ROUTINE", + labResultIds: [ferritinId], + }), + }) as never, + ); + expect(created.status).toBe(201); + + const list = await get<{ + past: Array<{ + id: string; + links?: { labResults: Array<{ id: string; label: string | null }> }; + }>; + }>( + listEncounters as never, + encounterPickerUrl({ + direction: "older", + pivot: pickerPivot(VISIT_AT), + term: "", + cursor: null, + }), + ); + expect(list.past).toHaveLength(1); + expect(list.past[0]!.links?.labResults).toEqual([ + expect.objectContaining({ + id: ferritinId, + label: "Iron studies · Ferritin", + }), + ]); + }); +}); + +describe("the condition picker's window and search", () => { + it("pages the episodes around a date and finds one by name", async () => { + const prisma = getPrismaClient(); + const now = Date.now(); + await prisma.illnessEpisode.createMany({ + data: Array.from({ length: 150 }, (_, i) => ({ + userId: OWNER_ID, + label: `Cold ${i}`, + type: "INFECTION" as const, + onsetAt: new Date(now - (i + 1) * 10 * DAY_MS), + })), + }); + const old = await prisma.illnessEpisode.create({ + data: { + userId: OWNER_ID, + label: "Tendinitis", + type: "INJURY", + onsetAt: new Date("2019-03-20T00:00:00.000Z"), + }, + }); + + const pivot = pickerPivot(VISIT_AT); + const around = await get>( + listEpisodes as never, + episodePickerUrl({ direction: "older", pivot, term: "", cursor: null }), + ); + expect(around[0]!.id).toBe(old.id); + + const found = await get>( + listEpisodes as never, + episodePickerUrl({ + direction: "search", + pivot: null, + term: "tendin", + cursor: null, + }), + ); + expect(found.map((row) => row.id)).toEqual([old.id]); + + // Offset paging walks the whole newer side without a repeat. + const ids = new Set(); + let cursor: string | null = null; + for (let guard = 0; guard < 5; guard++) { + const page: Array<{ id: string }> = await get( + listEpisodes as never, + episodePickerUrl({ direction: "newer", pivot, term: "", cursor }), + ); + for (const row of page) { + expect(ids.has(row.id)).toBe(false); + ids.add(row.id); + } + cursor = nextOffset(cursor, page.length); + if (cursor === null) break; + } + expect(ids.size).toBe(150); + }); +}); From 7be05608f8e678fc7e06dc8db7cbdc538ab281dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 15 Oct 2026 12:23:09 +0200 Subject: [PATCH 3/3] test: date fixtures that feed a clock-relative window relative to now A fixture dated near the day it was written passes until the date slides out of a window the route counts back from the clock. Five did: a snooze until a fixed date that becomes the past in January, a prognosis read against the wall clock over days seeded at a fixed date, sleep rows and a weight streak read through windows that end now, and a sync token that would expire on a fixed date. The e2e mood heatmap mock and a timeline URL are dated from today as well. A guard scans the integration and e2e trees for near-now absolute dates within reach of a window parameter, with an allowlist for files whose windows are fixed on both ends. --- e2e/timeline.spec.ts | 2 +- e2e/utils/mock-mood-insights.ts | 12 +- .../aging-fixture-dates-guard.test.ts | 203 ++++++++++++++++++ .../google-health-sync-progress.test.ts | 4 +- .../integration/mood-prognosis-ladder.test.ts | 18 +- .../sharing-manage-handler-matrix.test.ts | 7 +- .../source-priority-two-axis.test.ts | 23 +- tests/integration/timezone-per-user.test.ts | 9 +- 8 files changed, 263 insertions(+), 15 deletions(-) create mode 100644 src/__tests__/aging-fixture-dates-guard.test.ts diff --git a/e2e/timeline.spec.ts b/e2e/timeline.spec.ts index 39b4d40fb..37a03977c 100644 --- a/e2e/timeline.spec.ts +++ b/e2e/timeline.spec.ts @@ -560,7 +560,7 @@ test("a chosen range lives in the URL: reload keeps it, Back returns to it", asy // A parameter it cannot read opens the same default as no zoom: this // record has entries in the last three months, so three months. - await page.goto("/timeline?zoom=range&from=nonsense&to=2026-01-01"); + await page.goto(`/timeline?zoom=range&from=nonsense&to=${TODAY}`); await expect(zoom("quarter")).toHaveAttribute("aria-checked", "true", { timeout: 20_000, }); diff --git a/e2e/utils/mock-mood-insights.ts b/e2e/utils/mock-mood-insights.ts index 36fe5c144..a85ceb457 100644 --- a/e2e/utils/mock-mood-insights.ts +++ b/e2e/utils/mock-mood-insights.ts @@ -23,13 +23,21 @@ function correlation(r: number, strength: string, n: number) { }; } +/** + * A calendar day this many days back, so the heatmap cells stay inside its + * 90-day window whenever the suite runs. + */ +function dayKeyDaysAgo(days: number): string { + return new Date(Date.now() - days * 86_400_000).toISOString().slice(0, 10); +} + const MOOD_INSIGHTS_PAYLOAD = { summary: { totalEntries: 269, inTargetPct: 62 }, heatmap: { windowDays: 90, cells: [ - { date: "2026-07-01", score: 4, samples: 2 }, - { date: "2026-07-02", score: 3, samples: 1 }, + { date: dayKeyDaysAgo(14), score: 4, samples: 2 }, + { date: dayKeyDaysAgo(13), score: 3, samples: 1 }, ], }, distribution: [], diff --git a/src/__tests__/aging-fixture-dates-guard.test.ts b/src/__tests__/aging-fixture-dates-guard.test.ts new file mode 100644 index 000000000..b4db923dd --- /dev/null +++ b/src/__tests__/aging-fixture-dates-guard.test.ts @@ -0,0 +1,203 @@ +/** + * Guard: an integration or e2e fixture dated near "now" must not feed a time + * window. + * + * A fixture written today with an absolute date (`new Date("2026-07-15…")`, + * `?date=2026-07-02`) next to a window parameter passes on the day it is + * written and fails months later, when the date slides out of a window the + * route counts back from the clock. The suite then goes red on a release + * branch that changed nothing near it, and the fix is archaeology. That has + * happened more than once (chart tabs, the 90-day share-link window). + * + * The rule: an absolute ISO date within the last 400 days (or in the future) + * that sits within two lines of a window parameter (`days=`, `from=`, `to=`, + * `since`, `until`, `date=`, `range=`, `rangeDays`, `windowDays`, + * `lookbackDays`) is a + * finding. Fix it by dating the fixture relative to now + * (`new Date(Date.now() - 3 * DAY_MS)`, a `daysAgo(n)` helper), or, when the + * window is fixed on both ends and the route never measures it against the + * clock, allowlist the file below with that reason and the number of findings + * it may carry, so a new one in the same file still fails. + * + * What it cannot see: a fixture date and a relative window that are further + * apart than two lines (a seed helper at the top of the file, `days=90` in a + * request at the bottom). It is a textual net for the common shape, not a + * proof; the clock-shifted run described in the allowlist reasons is what + * proved the allowlisted files fixed. + */ +import fs from "node:fs"; +import path from "node:path"; + +import { describe, expect, it } from "vitest"; + +const ROOT = path.resolve(__dirname, "..", ".."); +const SCAN_ROOTS = ["tests/integration", "e2e"]; +const DAY_MS = 86_400_000; +/** How far back an absolute date still counts as "near now". */ +const HORIZON_DAYS = 400; +/** Lines either side of a date searched for a window parameter. */ +const NEIGHBOURHOOD = 2; + +const ISO_DATE = /\b(20\d\d)-(\d\d)-(\d\d)/g; +const WINDOW_PARAM = + /[?&](?:days|from|to|since|until|date|range|rangeDays|windowDays)=|\b(?:since|until|rangeDays|windowDays|lookbackDays)\b\s*[:=]/; + +/** + * Files whose flagged dates feed windows fixed on both ends, which the routes + * never compare with the clock. `max` is the number of findings the file may + * carry; one more fails. + */ +const ALLOWLIST: Record = { + // Each entry passed with the test clock moved a year forward, which is what + // an aging fixture fails under; the reason says why it cannot age. + "tests/integration/day-view.test.ts": { + max: 21, + reason: + "day index, timeline and notable reads are asked for explicit from/to windows; the reminder since/until params are fixture data, not a window counted from now", + }, + "tests/integration/sharing-delegable-routes.test.ts": { + max: 5, + reason: + "day-log reads name one explicit calendar day (?date=), never a window counted from now", + }, + "tests/integration/measurements-aggregate-daily.test.ts": { + max: 5, + reason: "every series read passes both from and to", + }, + "tests/integration/cycle-routes.test.ts": { + max: 5, + reason: + "day-log reads name one explicit day and the calendar read passes both from and to", + }, + "tests/integration/medication-auto-mark-taken.test.ts": { + max: 2, + reason: "the cases pin the clock with fake timers around the fixed dates", + }, + "tests/integration/symptoms.test.ts": { + max: 1, + reason: + "the discovery matrix reads from a fixed since forward; the fixture days stay inside it as now moves on", + }, +}; + +export interface AgingFinding { + file: string; + line: number; + text: string; +} + +/** Every near-now absolute date within reach of a window parameter. */ +export function findAgingFixtureDates( + files: ReadonlyArray<{ file: string; source: string }>, + now: number, +): AgingFinding[] { + const floor = now - HORIZON_DAYS * DAY_MS; + const findings: AgingFinding[] = []; + for (const { file, source } of files) { + const lines = source.split("\n"); + lines.forEach((text, index) => { + for (const match of text.matchAll(ISO_DATE)) { + const at = Date.UTC( + Number(match[1]), + Number(match[2]) - 1, + Number(match[3]), + ); + if (Number.isNaN(at) || at < floor) continue; + const around = lines + .slice(Math.max(0, index - NEIGHBOURHOOD), index + NEIGHBOURHOOD + 1) + .join("\n"); + if (WINDOW_PARAM.test(around)) { + findings.push({ file, line: index + 1, text: text.trim() }); + break; + } + } + }); + } + return findings; +} + +function walk(dir: string): string[] { + return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) return walk(full); + return /\.(ts|tsx|mts)$/.test(entry.name) ? [full] : []; + }); +} + +function scannedFiles() { + return SCAN_ROOTS.flatMap((root) => walk(path.join(ROOT, root))).map( + (full) => ({ + file: path.relative(ROOT, full), + source: fs.readFileSync(full, "utf8"), + }), + ); +} + +describe("aging fixture dates", () => { + const files = scannedFiles(); + + it("scans the integration and e2e trees (never vacuously)", () => { + expect(files.length).toBeGreaterThan(300); + expect(files.some((f) => f.file.startsWith("e2e/"))).toBe(true); + }); + + it("no near-now absolute date feeds a window outside the allowlist", () => { + const findings = findAgingFixtureDates(files, Date.now()); + const byFile = new Map(); + for (const finding of findings) { + const list = byFile.get(finding.file) ?? []; + list.push(finding); + byFile.set(finding.file, list); + } + const offenders: string[] = []; + for (const [file, list] of byFile) { + const allowed = ALLOWLIST[file]?.max ?? 0; + if (list.length > allowed) { + offenders.push( + ...list.map( + (f) => + `${f.file}:${f.line} ${f.text}\n -> date it relative to now (Date.now() - n * DAY_MS), or allowlist a fixed window with its reason`, + ), + ); + } + } + expect(offenders, offenders.join("\n")).toEqual([]); + }); + + it("every allowlist entry still names a file with findings", () => { + // A stale entry would quietly allow the next finding in that file. + const findings = findAgingFixtureDates(files, Date.now()); + for (const file of Object.keys(ALLOWLIST)) { + expect( + findings.some((f) => f.file === file), + `${file} has no findings left; drop its allowlist entry`, + ).toBe(true); + } + }); + + it("flags a near-now fixture next to a window, and nothing older", () => { + const now = Date.UTC(2026, 9, 15); + const findings = findAgingFixtureDates( + [ + { + file: "a.test.ts", + source: [ + 'const takenAt = new Date("2026-07-15T08:00:00Z");', + 'await get("/api/series?kind=pulse&days=90");', + ].join("\n"), + }, + { file: "b.test.ts", source: 'get("/api/day-logs?date=2026-07-02");' }, + { + file: "c.test.ts", + source: 'get("/api/labs?from=2019-04-01T00:00:00Z&to=2019-05-01");', + }, + { file: "d.test.ts", source: 'const at = new Date("2026-07-15");' }, + ], + now, + ); + expect(findings.map((f) => `${f.file}:${f.line}`)).toEqual([ + "a.test.ts:1", + "b.test.ts:1", + ]); + }); +}); diff --git a/tests/integration/google-health-sync-progress.test.ts b/tests/integration/google-health-sync-progress.test.ts index 0a80f416c..45f857cd6 100644 --- a/tests/integration/google-health-sync-progress.test.ts +++ b/tests/integration/google-health-sync-progress.test.ts @@ -42,7 +42,9 @@ async function seedConnection(userId: string): Promise { googleUserId: `google-${userId}`, accessToken: "encrypted-access", refreshToken: "encrypted-refresh", - tokenExpiresAt: new Date("2027-01-01T00:00:00.000Z"), + // Valid for a year from whenever the suite runs: a fixed date would + // expire and send the sync down the refresh path. + tokenExpiresAt: new Date(Date.now() + 365 * 86_400_000), }, }); } diff --git a/tests/integration/mood-prognosis-ladder.test.ts b/tests/integration/mood-prognosis-ladder.test.ts index 888e69581..7176734b4 100644 --- a/tests/integration/mood-prognosis-ladder.test.ts +++ b/tests/integration/mood-prognosis-ladder.test.ts @@ -15,7 +15,7 @@ * tooltip. */ import { NextRequest } from "next/server"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { cookieJar } from "./mock-next-headers"; import { getPrismaClient, switchSessionTo, truncateAllTables } from "./setup"; @@ -46,7 +46,14 @@ vi.mock("@/lib/db-compat", () => ({ })); /** Fixed clock: a ladder test that slides with the calendar is a date bomb. */ -const NOW = new Date("2026-08-08T09:00:00.000Z"); +const FIXED_NOW = new Date("2026-08-08T09:00:00.000Z"); +/** + * The instant the seed and the job are dated against. The ladder cases keep + * the fixed clock; the route cases move it to the real one, because the route + * reads the forecast against the wall clock and a forecast seeded at a fixed + * date goes stale under it as the calendar moves on. + */ +let NOW = FIXED_NOW; function dayKey(daysAgo: number): string { return new Date(NOW.getTime() - daysAgo * DAY_MS).toISOString().slice(0, 10); @@ -271,6 +278,13 @@ describe("the minimum-n ladder, end to end", () => { }); describe("GET /api/mood/prognosis", () => { + beforeEach(() => { + NOW = new Date(); + }); + afterEach(() => { + NOW = FIXED_NOW; + }); + /** * A signed-in browser session. * diff --git a/tests/integration/sharing-manage-handler-matrix.test.ts b/tests/integration/sharing-manage-handler-matrix.test.ts index 34583f305..6b7d83fce 100644 --- a/tests/integration/sharing-manage-handler-matrix.test.ts +++ b/tests/integration/sharing-manage-handler-matrix.test.ts @@ -795,7 +795,12 @@ manageContract("POST /api/measurement-reminders/[id]/snooze", { POST as Handler, "POST", `/api/measurement-reminders/${row.id}/snooze`, - { until: "2027-01-15" }, + // A snooze must end in the future; a fixed date stops being one. + { + until: new Date(Date.now() + 90 * 86_400_000) + .toISOString() + .slice(0, 10), + }, { id: row.id }, ); }, diff --git a/tests/integration/source-priority-two-axis.test.ts b/tests/integration/source-priority-two-axis.test.ts index b90ef2bd7..4710e0740 100644 --- a/tests/integration/source-priority-two-axis.test.ts +++ b/tests/integration/source-priority-two-axis.test.ts @@ -34,6 +34,15 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { cookieJar, headerJar } from "./mock-next-headers"; import { getPrismaClient, truncateAllTables } from "./setup"; +/** + * The night the fixtures sleep, ten days back from whenever the suite runs. + * The analytics route summarises a window that ends now; a fixed date slides + * out of it as the calendar moves on. + */ +const SLEEP_DAY = new Date(Date.now() - 10 * 86_400_000) + .toISOString() + .slice(0, 10); + vi.mock("next/headers", async () => { const { cookieJar, headerJar } = await import("./mock-next-headers"); return { @@ -130,7 +139,7 @@ describe("GET /api/analytics — two-axis canonical source resolution (W8c)", () value: 420, unit: "minutes", source: "WITHINGS", - measuredAt: new Date("2026-05-12T07:00:00.000Z"), + measuredAt: new Date(`${SLEEP_DAY}T07:00:00.000Z`), }, }); @@ -162,7 +171,7 @@ describe("GET /api/analytics — two-axis canonical source resolution (W8c)", () value: 480, unit: "minutes", source: "APPLE_HEALTH", - measuredAt: new Date("2026-05-12T06:00:00.000Z"), + measuredAt: new Date(`${SLEEP_DAY}T06:00:00.000Z`), externalId: "ah-sleep-1", }, }); @@ -174,7 +183,7 @@ describe("GET /api/analytics — two-axis canonical source resolution (W8c)", () value: 410, unit: "minutes", source: "WITHINGS", - measuredAt: new Date("2026-05-12T07:00:00.000Z"), + measuredAt: new Date(`${SLEEP_DAY}T07:00:00.000Z`), }, }); @@ -200,7 +209,7 @@ describe("GET /api/analytics — two-axis canonical source resolution (W8c)", () value: 380, unit: "minutes", source: "APPLE_HEALTH", - measuredAt: new Date("2026-05-12T06:00:00.000Z"), + measuredAt: new Date(`${SLEEP_DAY}T06:00:00.000Z`), externalId: "ah-sleep-watch", deviceType: "watch", }, @@ -212,7 +221,7 @@ describe("GET /api/analytics — two-axis canonical source resolution (W8c)", () value: 480, unit: "minutes", source: "APPLE_HEALTH", - measuredAt: new Date("2026-05-12T06:30:00.000Z"), + measuredAt: new Date(`${SLEEP_DAY}T06:30:00.000Z`), externalId: "ah-sleep-phone", deviceType: "phone", }, @@ -240,7 +249,7 @@ describe("GET /api/analytics — two-axis canonical source resolution (W8c)", () value: 480, unit: "minutes", source: "APPLE_HEALTH", - measuredAt: new Date("2026-05-12T06:00:00.000Z"), + measuredAt: new Date(`${SLEEP_DAY}T06:00:00.000Z`), externalId: "ah-sleep-watch", deviceType: "watch", }, @@ -252,7 +261,7 @@ describe("GET /api/analytics — two-axis canonical source resolution (W8c)", () value: 380, unit: "minutes", source: "APPLE_HEALTH", - measuredAt: new Date("2026-05-12T06:30:00.000Z"), + measuredAt: new Date(`${SLEEP_DAY}T06:30:00.000Z`), externalId: "ah-sleep-phone", deviceType: "phone", }, diff --git a/tests/integration/timezone-per-user.test.ts b/tests/integration/timezone-per-user.test.ts index 8a191e52a..218764d21 100644 --- a/tests/integration/timezone-per-user.test.ts +++ b/tests/integration/timezone-per-user.test.ts @@ -420,8 +420,15 @@ describe("per-user timezone — Pacific/Auckland end-to-end", () => { // doesn't trigger a tz divergence on its own. The point is to // confirm the route returns a non-zero streak and goes through // the user-tz path without crashing. + // Three consecutive days ending a week back: the summary reads its + // streaks from a window that ends now, so fixed dates would slide out. + const firstDay = Date.UTC( + new Date().getUTCFullYear(), + new Date().getUTCMonth(), + new Date().getUTCDate() - 9, + ); for (let i = 0; i < 3; i++) { - const dt = new Date(`2026-05-${12 + i}T11:30:00.000Z`); + const dt = new Date(firstDay + i * 86_400_000 + 11.5 * 3_600_000); await prisma.measurement.create({ data: { userId: me.id,