diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 317c79c73..17fd03bc0 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/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/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/__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/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/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/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/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/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/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/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()]); + }); +}); 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, 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); + }); +});