diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index c1dbd36c3..e87d57ba5 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -14833,6 +14833,12 @@ paths: type: string minLength: 1 maxLength: 120 + - in: query + name: q + schema: + type: string + minLength: 1 + maxLength: 120 - in: query name: from schema: @@ -15372,6 +15378,12 @@ paths: type: integer minimum: 1 maximum: 200 + - in: query + name: offset + schema: + type: integer + minimum: 0 + maximum: 100000 responses: "200": description: The caller's visits. @@ -16315,6 +16327,37 @@ paths: const: "true" - type: string const: "false" + - in: query + name: offset + schema: + type: integer + minimum: 0 + maximum: 100000 + - in: query + name: from + schema: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + - in: query + name: to + schema: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + - in: query + name: sortDir + schema: + type: string + enum: + - asc + - desc + - in: query + name: q + schema: + type: string + minLength: 1 + maxLength: 120 responses: "200": description: The caller's illness episodes. diff --git a/messages/de.json b/messages/de.json index 6a814002e..e7a8a1dd0 100644 --- a/messages/de.json +++ b/messages/de.json @@ -12174,7 +12174,10 @@ "suggested": "Um dieses Datum", "loadError": "Die Liste konnte nicht geladen werden.", "removeNamed": "{name} entfernen", - "removed": "Verknüpfung mit {name} gelöst" + "removed": "Verknüpfung mit {name} gelöst", + "linked": "Verknüpft", + "loadNewer": "Neuere laden", + "loadOlder": "Ältere laden" } }, "bodySites": { diff --git a/messages/en.json b/messages/en.json index 356521583..11a517a80 100644 --- a/messages/en.json +++ b/messages/en.json @@ -12174,7 +12174,10 @@ "suggested": "Around this date", "loadError": "The list could not be loaded.", "removeNamed": "Remove {name}", - "removed": "Unlinked {name}" + "removed": "Unlinked {name}", + "linked": "Linked", + "loadNewer": "Load newer", + "loadOlder": "Load older" } }, "bodySites": { diff --git a/messages/es.json b/messages/es.json index dfa682e77..43c808b32 100644 --- a/messages/es.json +++ b/messages/es.json @@ -12174,7 +12174,10 @@ "suggested": "En torno a esta fecha", "loadError": "No se pudo cargar la lista.", "removeNamed": "Quitar {name}", - "removed": "Se desvinculó {name}" + "removed": "Se desvinculó {name}", + "linked": "Vinculados", + "loadNewer": "Cargar más recientes", + "loadOlder": "Cargar anteriores" } }, "bodySites": { diff --git a/messages/fr.json b/messages/fr.json index 587297fd3..3f3de9ecd 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -12174,7 +12174,10 @@ "suggested": "Autour de cette date", "loadError": "Impossible de charger la liste.", "removeNamed": "Retirer {name}", - "removed": "{name} dissocié" + "removed": "{name} dissocié", + "linked": "Liés", + "loadNewer": "Charger les plus récents", + "loadOlder": "Charger les plus anciens" } }, "bodySites": { diff --git a/messages/it.json b/messages/it.json index 5302c2727..4853184ed 100644 --- a/messages/it.json +++ b/messages/it.json @@ -12174,7 +12174,10 @@ "suggested": "Intorno a questa data", "loadError": "Impossibile caricare l’elenco.", "removeNamed": "Rimuovi {name}", - "removed": "Collegamento a {name} rimosso" + "removed": "Collegamento a {name} rimosso", + "linked": "Collegati", + "loadNewer": "Carica più recenti", + "loadOlder": "Carica meno recenti" } }, "bodySites": { diff --git a/messages/ko.json b/messages/ko.json index fe1370a61..122c25d92 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -12174,7 +12174,10 @@ "suggested": "이 날짜 전후", "loadError": "목록을 불러오지 못했습니다.", "removeNamed": "{name} 제거", - "removed": "{name} 연결 해제됨" + "removed": "{name} 연결 해제됨", + "linked": "연결됨", + "loadNewer": "최근 항목 더 보기", + "loadOlder": "이전 항목 더 보기" } }, "bodySites": { diff --git a/messages/pl.json b/messages/pl.json index 7a3e8b972..5907e3c21 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -12174,7 +12174,10 @@ "suggested": "Wokół tej daty", "loadError": "Nie udało się wczytać listy.", "removeNamed": "Usuń {name}", - "removed": "Odłączono: {name}" + "removed": "Odłączono: {name}", + "linked": "Połączone", + "loadNewer": "Wczytaj nowsze", + "loadOlder": "Wczytaj starsze" } }, "bodySites": { diff --git a/src/app/api/encounters/route.ts b/src/app/api/encounters/route.ts index 43151980d..ef90cc80b 100644 --- a/src/app/api/encounters/route.ts +++ b/src/app/api/encounters/route.ts @@ -63,6 +63,7 @@ export const GET = apiHandler(async (request: NextRequest) => { practitionerId: params.get("practitionerId") ?? undefined, episodeId: params.get("episodeId") ?? undefined, limit: params.get("limit") ?? undefined, + offset: params.get("offset") ?? undefined, }); if (!parsed.success) { return returnAllZodIssues(parsed.error, 422, { @@ -72,6 +73,7 @@ export const GET = apiHandler(async (request: NextRequest) => { const query = parsed.data; const take = query.limit ?? 100; + const skip = query.offset ?? 0; const now = new Date(); const where = { @@ -112,8 +114,9 @@ export const GET = apiHandler(async (request: NextRequest) => { // `gt` and `gte` are different keys, so the caller's lower bound and this // arm's floor both survive the spread; only the `lte` pair collided. where: { ...where, occurredAt: { ...where.occurredAt, gt: now } }, - orderBy: { occurredAt: "asc" }, + orderBy: [{ occurredAt: "asc" }, { id: "asc" }], take, + skip, include: ENCOUNTER_INCLUDE, }), prisma.encounter.findMany({ @@ -121,8 +124,9 @@ export const GET = apiHandler(async (request: NextRequest) => { ...where, occurredAt: { ...where.occurredAt, lte: pastCeiling }, }, - orderBy: { occurredAt: "desc" }, + orderBy: [{ occurredAt: "desc" }, { id: "desc" }], take, + skip, include: ENCOUNTER_INCLUDE, }), ]); diff --git a/src/app/api/illness/episodes/route.ts b/src/app/api/illness/episodes/route.ts index 15425425f..255e0f8c0 100644 --- a/src/app/api/illness/episodes/route.ts +++ b/src/app/api/illness/episodes/route.ts @@ -43,6 +43,11 @@ export const GET = apiHandler(async (request: NextRequest) => { const parsed = illnessEpisodeListQuerySchema.safeParse({ limit: params.get("limit") ?? undefined, includeResolved: params.get("includeResolved") ?? undefined, + offset: params.get("offset") ?? undefined, + from: params.get("from") ?? undefined, + to: params.get("to") ?? undefined, + sortDir: params.get("sortDir") ?? undefined, + q: params.get("q") ?? undefined, }); if (!parsed.success) { return returnAllZodIssues(parsed.error, 422, { @@ -50,15 +55,28 @@ export const GET = apiHandler(async (request: NextRequest) => { }); } + const { from, to, q, offset, sortDir } = parsed.data; const limit = parsed.data.limit ?? 50; + const order = sortDir ?? "desc"; const rows = await prisma.illnessEpisode.findMany({ where: { userId: user.id, deletedAt: null, ...(parsed.data.includeResolved === "false" ? { resolvedAt: null } : {}), + ...(from || to + ? { + onsetAt: { + ...(from ? { gte: new Date(from) } : {}), + ...(to ? { lte: new Date(to) } : {}), + }, + } + : {}), + ...(q ? { label: { contains: q, mode: "insensitive" as const } } : {}), }, - orderBy: { onsetAt: "desc" }, + // `id` breaks ties so offset paging never repeats or drops a row. + orderBy: [{ onsetAt: order }, { id: order }], take: limit, + skip: offset ?? 0, }); annotate({ diff --git a/src/app/api/labs/route.ts b/src/app/api/labs/route.ts index e18282a76..7d1b8857e 100644 --- a/src/app/api/labs/route.ts +++ b/src/app/api/labs/route.ts @@ -85,7 +85,7 @@ export const GET = apiHandler(async (request: NextRequest) => { return returnAllZodIssues(parsed.error, 422); } - const { biomarkerId, analyte, panel, from, to, limit, offset, sortDir } = + const { biomarkerId, analyte, panel, q, from, to, limit, offset, sortDir } = parsed.data; const where = { @@ -94,6 +94,12 @@ export const GET = apiHandler(async (request: NextRequest) => { ...(biomarkerId && { biomarkerId }), ...(analyte && { analyte }), ...(panel && { panel }), + ...(q && { + OR: [ + { analyte: { contains: q, mode: "insensitive" as const } }, + { panel: { contains: q, mode: "insensitive" as const } }, + ], + }), ...(from || to ? { takenAt: { diff --git a/src/components/documents/__tests__/document-record-links.test.tsx b/src/components/documents/__tests__/document-record-links.test.tsx index e83374fcf..209e10413 100644 --- a/src/components/documents/__tests__/document-record-links.test.tsx +++ b/src/components/documents/__tests__/document-record-links.test.tsx @@ -4,12 +4,26 @@ import { renderToStaticMarkup } from "react-dom/server"; vi.mock("@/hooks/use-auth", () => ({ useAuth: () => ({ user: { modules: { vaccinations: true } } }), })); -vi.mock("@/hooks/use-encounters", () => ({ - useEncounters: () => ({ - data: { upcoming: [], past: [] }, - isPending: false, - isError: false, - refetch: vi.fn(), +vi.mock("@/components/links/use-link-picker-pages", async (importOriginal) => ({ + ...(await importOriginal< + typeof import("@/components/links/use-link-picker-pages") + >()), + // The visit picker's pages: none loaded, nothing more to page. + useLinkPickerPages: () => ({ + items: [], + pending: false, + error: false, + retry: vi.fn(), + term: "", + setTerm: vi.fn(), + searchPending: false, + filterLocally: true, + hasOlder: false, + hasNewer: false, + loadingOlder: false, + loadingNewer: false, + loadOlder: vi.fn(), + loadNewer: vi.fn(), }), })); vi.mock("@/components/vaccinations/use-vaccinations", () => ({ diff --git a/src/components/documents/document-record-links.tsx b/src/components/documents/document-record-links.tsx index b91b7fb0d..a80281ef9 100644 --- a/src/components/documents/document-record-links.tsx +++ b/src/components/documents/document-record-links.tsx @@ -23,18 +23,31 @@ * reach the doses (`vaccinationLinks: null`) shows no dose block rather than * an empty one that would claim the page records none. */ -import { useState } from "react"; +import { useMemo, useState } from "react"; import Link from "next/link"; import { CalendarDays, Syringe } from "lucide-react"; -import { EntityLinkPicker } from "@/components/links/entity-link-picker"; +import { + EntityLinkPicker, + serverPagingFrom, +} from "@/components/links/entity-link-picker"; import type { EntityLinkOption } from "@/components/links/entity-link-picker"; import { isNearAnchor } from "@/components/links/vault-document-options"; +import { encounterPickerUrl } from "@/components/links/picker-requests"; +import { + PICKER_PAGE_SIZE, + pickerPivot, + useLinkPickerPages, + type PickerPage, + type PickerPageRequest, +} from "@/components/links/use-link-picker-pages"; import { encounterKindText } from "@/components/encounters/encounter-labels"; import { useVaccinations } from "@/components/vaccinations/use-vaccinations"; -import { useEncounters } from "@/hooks/use-encounters"; import { useAuth } from "@/hooks/use-auth"; +import { apiGet } from "@/lib/api/api-fetch"; import { useFormatters, useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; +import type { EncounterDTO, EncounterListDTO } from "@/lib/encounters/dto"; import type { EncounterKind } from "@/generated/prisma/client"; import type { InboundDocumentDetailDto } from "@/lib/validations/inbound-documents"; @@ -75,6 +88,25 @@ export function recordOptions( return [...near, ...rest]; } +/** + * One picker page of visits. The route answers in two arms, each paged by the + * same offset; another page exists while either arm came back full. + */ +export async function fetchEncounterPage( + request: PickerPageRequest, +): Promise> { + const list = await apiGet(encounterPickerUrl(request)); + const full = + list.upcoming.length >= PICKER_PAGE_SIZE || + list.past.length >= PICKER_PAGE_SIZE; + return { + items: [...list.upcoming, ...list.past], + next: full + ? String(Number(request.cursor ?? "0") + PICKER_PAGE_SIZE) + : null, + }; +} + /** Where a visit opens: its sheet on the checkups page. */ function visitHref(encounterId: string): string { return `/checkups?visit=${encodeURIComponent(encounterId)}`; @@ -132,7 +164,16 @@ export function DocumentRecordLinks({ const vaccinationsOn = user?.modules?.vaccinations !== false; const anchor = doc.documentDate ?? doc.reportDate; - const encounters = useEncounters(canManage); + // The visits around the document's date, paged older and newer from + // there. The visit route has no text search (its free text is ciphertext + // at rest), so the term filters what is loaded. + const encounters = useLinkPickerPages({ + enabled: canManage, + pivot: pickerPivot(anchor), + queryKey: queryKeys.encounterPicker, + fetchPage: fetchEncounterPage, + serverSearch: false, + }); const vaccinations = useVaccinations( null, canManage && vaccinationsOn && doc.vaccinationLinks !== null, @@ -150,16 +191,16 @@ export function DocumentRecordLinks({ }; const visitOptions = recordOptions( - [ - ...(encounters.data?.upcoming ?? []), - ...(encounters.data?.past ?? []), - ].map((visit) => ({ - id: visit.id, - label: encounterKindText(t, visit.kind as EncounterKind), - meta: visit.practitioner?.name ?? null, - occurredAt: visit.occurredAt, - href: visitHref(visit.id), - })), + [...encounters.items] + // Two arms per page arrive in two orders; the list reads newest first. + .sort((a, b) => b.occurredAt.localeCompare(a.occurredAt)) + .map((visit) => ({ + id: visit.id, + label: encounterKindText(t, visit.kind as EncounterKind), + meta: visit.practitioner?.name ?? null, + occurredAt: visit.occurredAt, + href: visitHref(visit.id), + })), anchor, labels, ); @@ -176,6 +217,19 @@ export function DocumentRecordLinks({ const showDoses = vaccinationsOn && doc.vaccinationLinks !== null; + // The visits the document is filed against, as its own read names them, + // so their chips show whatever page of visits is loaded. + const knownVisits = useMemo( + () => + doc.encounterLinks.map((link) => ({ + id: link.encounterId, + label: encounterKindText(t, link.kind as EncounterKind), + dateLabel: link.occurredAt ? format.date(link.occurredAt) : null, + href: visitHref(link.encounterId), + })), + [doc.encounterLinks, t, format], + ); + return ( <> {canManage ? ( @@ -183,11 +237,11 @@ export function DocumentRecordLinks({ icon={CalendarDays} title={t("documents.detail.visitsLabel")} slot="document-visit-links" - pending={encounters.isPending || !seedFresh} - error={encounters.isError || seedError} + pending={encounters.pending || !seedFresh} + error={encounters.error || seedError} errorLabel={t("links.picker.loadError")} onRetry={() => { - if (encounters.isError) void encounters.refetch(); + if (encounters.error) encounters.retry(); if (seedError) onRetrySeed?.(); }} selected={visitIds ?? doc.encounterLinks.map((l) => l.encounterId)} @@ -196,6 +250,8 @@ export function DocumentRecordLinks({ onChange({ encounterIds: ids }); }} options={visitOptions} + server={serverPagingFrom(encounters)} + knownOptions={knownVisits} searchPlaceholder={t("links.picker.searchPlaceholder")} emptyLabel={t("documents.detail.noVisitsToLink")} /> diff --git a/src/components/encounters/__tests__/encounter-link-picker-limits.test.ts b/src/components/encounters/__tests__/encounter-link-picker-limits.test.ts deleted file mode 100644 index 9cae124b8..000000000 --- a/src/components/encounters/__tests__/encounter-link-picker-limits.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { illnessEpisodeListQuerySchema } from "@/lib/validations/illness"; -import { listLabResultsSchema } from "@/lib/validations/labs"; -import { - EPISODE_FETCH_LIMIT, - PICKER_FETCH_LIMIT, -} from "../encounter-link-pickers"; - -/** - * Each picker's page size must be one its list route accepts. A refused size - * comes back as a 422, and a picker that treats a failed read as an empty - * list tells the person there is nothing to link. That is how the visit - * form's condition picker and both document pickers showed nothing at all. - */ -describe("visit link picker page sizes", () => { - it("the condition picker asks for a page the episode route accepts", () => { - expect( - illnessEpisodeListQuerySchema.safeParse({ - limit: String(EPISODE_FETCH_LIMIT), - includeResolved: "true", - }).success, - ).toBe(true); - }); - - it("the lab picker asks for a page the lab route accepts", () => { - expect( - listLabResultsSchema.safeParse({ limit: String(PICKER_FETCH_LIMIT) }) - .success, - ).toBe(true); - }); -}); diff --git a/src/components/encounters/encounter-form.tsx b/src/components/encounters/encounter-form.tsx index 3b65668d5..9183aa7c9 100644 --- a/src/components/encounters/encounter-form.tsx +++ b/src/components/encounters/encounter-form.tsx @@ -33,6 +33,7 @@ import { useAuth } from "@/hooks/use-auth"; import { useTranslations } from "@/lib/i18n/context"; import type { Encounter, EncounterWriteBody } from "@/hooks/use-encounters"; import type { Practitioner } from "@/hooks/use-practitioners"; +import type { EncounterLinksDTO } from "@/lib/encounters/dto"; import { ENCOUNTER_KINDS, ENCOUNTER_STATUSES, @@ -57,6 +58,12 @@ export interface EncounterDraft { documentIds: string[]; labResultIds: string[]; episodeIds: string[]; + /** + * The links the visit carried when the sheet opened, as its read names + * them, so their chips show whatever page the pickers have loaded. Display + * only; the id arrays above are what a save writes. + */ + links: EncounterLinksDTO | null; } function localValue(instant: Date): string { @@ -91,6 +98,7 @@ export function emptyDraft( documentIds: [], labResultIds: [], episodeIds: [], + links: null, ...overrides, }; } @@ -108,6 +116,7 @@ export function draftFromEncounter(row: Encounter): EncounterDraft { documentIds: row.links?.documents.map((link) => link.id) ?? [], labResultIds: row.links?.labResults.map((link) => link.id) ?? [], episodeIds: row.links?.conditions.map((link) => link.id) ?? [], + links: row.links ?? null, }; } @@ -303,6 +312,7 @@ export function EncounterForm({ ; + results: LabPickerResult[]; +} + +/** One picker page of lab results. */ +export async function fetchLabPage( + request: PickerPageRequest, +): Promise> { + const page = await apiGet(labPickerUrl(request)); + return { + items: page.results, + next: nextOffset(request.cursor, page.results.length), + }; +} + +/** One picker page of condition episodes. */ +export async function fetchEpisodePage( + request: PickerPageRequest, +): Promise> { + const rows = await apiGet(episodePickerUrl(request)); + return { items: rows, next: nextOffset(request.cursor, rows.length) }; +} + +/** A visit's existing link as a chip option, or null when it is withheld. */ +function linkedOption( + link: { id: string; label: string | null; date: string | null }, + date: (iso: string) => string, + href?: string, +): EntityLinkOption | null { + if (link.label === null) return null; + return { + id: link.id, + label: link.label, + dateLabel: link.date ? date(link.date) : null, + ...(href ? { href } : {}), + }; } export function EncounterLinkPickers({ modules, anchor, + links, documentIds, labResultIds, episodeIds, onChange, }: { modules: Partial> | undefined; - /** The visit's own date, for the document suggestions on top. */ + /** The visit's own date: where every picker opens. */ anchor: string | null; + /** + * What the visit links already, as its own read names it (an edit). Their + * chips show whether or not a loaded page holds them. + */ + links?: EncounterLinksDTO | null; documentIds: string[]; labResultIds: string[]; episodeIds: string[]; @@ -79,64 +135,76 @@ export function EncounterLinkPickers({ const documentsOn = modules?.inboundDocuments === true; const labsOn = modules?.labs === true; const illnessOn = modules?.illness === true; + const pivot = pickerPivot(anchor); - // The whole vault, suggestions near the visit's date on top — see - // `useVaultDocumentOptions` for why this no longer asks for one oversized - // page. + // The vault around the visit's date, suggestions on top — see + // `useVaultDocumentOptions`. const documents = useVaultDocumentOptions({ enabled: documentsOn, anchor, }); - const labs = useQuery({ - queryKey: queryKeys.labResultsPicker(PICKER_FETCH_LIMIT), + const labs = useLinkPickerPages({ enabled: labsOn, - queryFn: () => - apiGet( - `/api/labs?limit=${PICKER_FETCH_LIMIT}&offset=0&sortDir=desc`, - ), + pivot, + queryKey: queryKeys.labResultsPicker, + fetchPage: fetchLabPage, }); - const episodes = useQuery({ - queryKey: queryKeys.illnessEpisodesPicker(EPISODE_FETCH_LIMIT), + const episodes = useLinkPickerPages({ enabled: illnessOn, - queryFn: () => - apiGet( - `/api/illness/episodes?includeResolved=true&limit=${EPISODE_FETCH_LIMIT}`, - ), + pivot, + queryKey: queryKeys.illnessEpisodesPicker, + fetchPage: fetchEpisodePage, }); + const known = useMemo(() => { + const date = (iso: string) => format.date(iso); + const keep = ( + option: EntityLinkOption | null, + ): option is EntityLinkOption => option !== null; + return { + documents: (links?.documents ?? []) + .map((link) => linkedDocumentOption(link, date)) + .filter(keep), + labResults: (links?.labResults ?? []) + .map((link) => linkedOption(link, date)) + .filter(keep), + conditions: (links?.conditions ?? []) + .map((link) => + linkedOption(link, date, `/illness/${encodeURIComponent(link.id)}`), + ) + .filter(keep), + }; + }, [links, format]); + if (!documentsOn && !labsOn && !illnessOn) return null; // Labs group by sample date + panel: "Blutbild · 12.05.2026", the analyte // underneath. A visit links the whole panel from a day in one "select all". - const labOptions: EntityLinkOption[] = (labs.data?.results ?? []).map( - (result) => { - const dayLabel = format.date(result.takenAt); - const groupLabel = result.panel - ? `${result.panel} · ${dayLabel}` - : dayLabel; - return { - id: result.id, - label: result.analyte, - dateLabel: dayLabel, - group: { - key: `${result.takenAt.slice(0, 10)}|${result.panel ?? ""}`, - label: groupLabel, - }, - }; - }, - ); + const labOptions: EntityLinkOption[] = labs.items.map((result) => { + const dayLabel = format.date(result.takenAt); + const groupLabel = result.panel + ? `${result.panel} · ${dayLabel}` + : dayLabel; + return { + id: result.id, + label: result.analyte, + dateLabel: dayLabel, + group: { + key: `${result.takenAt.slice(0, 10)}|${result.panel ?? ""}`, + label: groupLabel, + }, + }; + }); - // Episodes stay flat (a bounded list), newest onset first from the route. - const episodeOptions: EntityLinkOption[] = (episodes.data ?? []).map( - (episode) => ({ - id: episode.id, - label: episode.label, - dateLabel: episode.onsetAt ? format.date(episode.onsetAt) : null, - href: `/illness/${encodeURIComponent(episode.id)}`, - }), - ); + // Episodes stay flat, newest onset first. + const episodeOptions: EntityLinkOption[] = episodes.items.map((episode) => ({ + id: episode.id, + label: episode.label, + dateLabel: episode.onsetAt ? format.date(episode.onsetAt) : null, + href: `/illness/${encodeURIComponent(episode.id)}`, + })); return (
@@ -152,6 +220,8 @@ export function EncounterLinkPickers({ selected={documentIds} onChange={(ids) => onChange({ documentIds: ids })} options={documents.options} + server={documents.server} + knownOptions={known.documents} searchPlaceholder={t("links.picker.searchPlaceholder")} emptyLabel={t("encounters.form.linkNothingToOffer")} extraAction={ @@ -173,13 +243,15 @@ export function EncounterLinkPickers({ icon={FlaskConical} title={t("encounters.form.linkLabResults")} slot="encounter-link-labs" - pending={labs.isPending} - error={labs.isError} + pending={labs.pending} + error={labs.error} errorLabel={t("links.picker.loadError")} - onRetry={() => void labs.refetch()} + onRetry={labs.retry} selected={labResultIds} onChange={(ids) => onChange({ labResultIds: ids })} options={labOptions} + server={serverPagingFrom(labs)} + knownOptions={known.labResults} searchPlaceholder={t("links.picker.searchPlaceholder")} emptyLabel={t("encounters.form.linkNothingToOffer")} /> @@ -190,13 +262,15 @@ export function EncounterLinkPickers({ icon={Stethoscope} title={t("encounters.form.linkConditions")} slot="encounter-link-conditions" - pending={episodes.isPending} - error={episodes.isError} + pending={episodes.pending} + error={episodes.error} errorLabel={t("links.picker.loadError")} - onRetry={() => void episodes.refetch()} + onRetry={episodes.retry} selected={episodeIds} onChange={(ids) => onChange({ episodeIds: ids })} options={episodeOptions} + server={serverPagingFrom(episodes)} + knownOptions={known.conditions} searchPlaceholder={t("links.picker.searchPlaceholder")} emptyLabel={t("encounters.form.linkNothingToOffer")} /> diff --git a/src/components/links/__tests__/entity-link-picker-paging.test.tsx b/src/components/links/__tests__/entity-link-picker-paging.test.tsx new file mode 100644 index 000000000..8c7e67de3 --- /dev/null +++ b/src/components/links/__tests__/entity-link-picker-paging.test.tsx @@ -0,0 +1,199 @@ +/** + * The link picker over a list that pages on the server. + * + * A visit's link pickers used to offer one capped page and nothing past it. + * Paged, three things have to hold: what the record already links shows + * whether or not a loaded page holds it, the sheet offers the paging the + * server reports, and an empty first window is not read as "nothing to link". + * + * ResponsiveSheet portals its body, which static markup does not render, so it + * is mocked down to a wrapper that always shows its children. + */ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { FlaskConical } from "lucide-react"; + +vi.mock("@/components/ui/responsive-sheet", () => ({ + ResponsiveSheet: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import { + EntityLinkPicker, + resolvableOptions, + type EntityLinkOption, + type EntityLinkServerPaging, +} from "../entity-link-picker"; +import { mergeWindowPages, uniqueById } from "../use-link-picker-pages"; + +function render(node: React.ReactNode) { + return renderToStaticMarkup( + {node}, + ); +} + +const server = (over: Partial = {}) => ({ + term: "", + onTermChange: () => undefined, + searchPending: false, + hasOlder: false, + hasNewer: false, + loadingOlder: false, + loadingNewer: false, + onOlder: () => undefined, + onNewer: () => undefined, + ...over, +}); + +const base = { + icon: FlaskConical, + title: "Lab results", + slot: "labs", + searchPlaceholder: "Search", + emptyLabel: "Nothing to link", + pending: false, + onChange: () => undefined, +}; + +const loaded: EntityLinkOption[] = [ + { id: "recent", label: "Glucose", dateLabel: "12.05.2026" }, +]; +const linked2019: EntityLinkOption = { + id: "ferritin-2019", + label: "Ferritin", + dateLabel: "03.04.2019", +}; + +describe("paged link picker", () => { + it("a linked record outside every loaded page still shows as a chip and in the sheet", () => { + const html = render( + , + ); + expect(html).toContain('data-slot="labs-chip"'); + expect(html).toContain("Ferritin"); + expect(html).toContain("03.04.2019"); + // In the sheet, on top under its own heading. + expect(html).toContain("Linked"); + }); + + it("offers older and newer pages while the server reports them", () => { + const html = render( + , + ); + expect(html).toContain('data-slot="labs-load-older"'); + expect(html).toContain('data-slot="labs-load-newer"'); + expect(html).toContain("Load older"); + expect(html).toContain("Load newer"); + }); + + it("offers no paging once the server has no more", () => { + const html = render( + , + ); + expect(html).not.toContain('data-slot="labs-load-older"'); + expect(html).not.toContain('data-slot="labs-load-newer"'); + }); + + it("an empty first window with more to page is not 'nothing to link'", () => { + const html = render( + , + ); + expect(html).not.toContain("Nothing to link"); + expect(html).toContain('data-slot="labs-add"'); + }); + + it("does not filter a server answer again by the term", () => { + // The route matched on the panel name; the option does not render it. + const html = render( + , + ); + expect(html).toContain('data-slot="labs-option"'); + }); + + it("a route without search filters what is loaded", () => { + const html = render( + , + ); + expect(html).not.toContain('data-slot="labs-option"'); + }); +}); + +describe("resolvableOptions", () => { + it("prefers the loaded option, then the known one, then a remembered pick", () => { + const picked = new Map([ + ["a", { id: "a", label: "picked" }], + ["b", { id: "b", label: "picked" }], + ["c", { id: "c", label: "picked" }], + ]); + const map = resolvableOptions( + [{ id: "a", label: "loaded" }], + [ + { id: "a", label: "known" }, + { id: "b", label: "known" }, + ], + picked, + ); + expect(map.get("a")?.label).toBe("loaded"); + expect(map.get("b")?.label).toBe("known"); + expect(map.get("c")?.label).toBe("picked"); + }); +}); + +describe("mergeWindowPages", () => { + it("puts the newer window, newest first, above the older one", () => { + const merged = mergeWindowPages( + [ + { items: [{ id: "n1" }, { id: "n2" }], next: "2" }, + { items: [{ id: "n3" }], next: null }, + ], + [{ items: [{ id: "o1" }, { id: "o2" }], next: null }], + ); + expect(merged.map((item) => item.id)).toEqual([ + "n3", + "n2", + "n1", + "o1", + "o2", + ]); + }); + + it("drops a record the two windows both returned", () => { + expect(uniqueById([{ id: "x" }, { id: "y" }, { id: "x" }])).toEqual([ + { id: "x" }, + { id: "y" }, + ]); + }); +}); diff --git a/src/components/links/__tests__/picker-requests.test.ts b/src/components/links/__tests__/picker-requests.test.ts new file mode 100644 index 000000000..125939e4b --- /dev/null +++ b/src/components/links/__tests__/picker-requests.test.ts @@ -0,0 +1,122 @@ +/** + * Every request a link picker can send must be one its list route accepts. + * + * A refused parameter comes back as a 422, and a picker that reads a failed + * read as an empty list tells the person there is nothing to link. That is how + * the visit form's condition picker and both document pickers once showed + * nothing at all. Each builder is checked in every direction, with and without + * a date, against the very schema its route parses with. + */ +import { describe, expect, it } from "vitest"; + +import { documentListQuerySchema } from "@/lib/validations/inbound-documents"; +import { encounterListQuerySchema } from "@/lib/validations/encounters"; +import { illnessEpisodeListQuerySchema } from "@/lib/validations/illness"; +import { listLabResultsSchema } from "@/lib/validations/labs"; + +import { + documentPickerUrl, + encounterPickerUrl, + episodePickerUrl, + labPickerUrl, +} from "../picker-requests"; +import { + PICKER_PAGE_SIZE, + nextOffset, + pickerPivot, + type PickerPageRequest, +} from "../use-link-picker-pages"; + +const pivot = pickerPivot("2019-05-10T10:00:00.000Z"); + +const REQUESTS: PickerPageRequest[] = [ + { direction: "older", pivot, term: "", cursor: null }, + { direction: "older", pivot, term: "", cursor: "200" }, + { direction: "older", pivot: null, term: "", cursor: null }, + { direction: "newer", pivot, term: "", cursor: null }, + { direction: "newer", pivot, term: "", cursor: "100" }, + { direction: "search", pivot: null, term: "ferritin", cursor: null }, + { direction: "search", pivot: null, term: "ferritin", cursor: "100" }, +]; + +function params(url: string): Record { + return Object.fromEntries(new URL(url, "http://local").searchParams); +} + +describe.each([ + ["labs", labPickerUrl, listLabResultsSchema], + ["episodes", episodePickerUrl, illnessEpisodeListQuerySchema], + ["encounters", encounterPickerUrl, encounterListQuerySchema], +])("%s picker", (_name, build, schema) => { + it.each(REQUESTS)("$direction (cursor $cursor) parses", (request) => { + const result = schema.safeParse(params(build(request))); + expect(result.success, JSON.stringify(result.error?.issues)).toBe(true); + }); +}); + +describe("documents picker", () => { + it.each(REQUESTS)("$direction parses", (request) => { + // The document cursor is a row id, not an offset. + const url = documentPickerUrl({ + ...request, + cursor: request.cursor ? "doc-id" : null, + }); + const result = documentListQuerySchema.safeParse(params(url)); + expect(result.success, JSON.stringify(result.error?.issues)).toBe(true); + }); + + it("splits the day-granular window at the pivot's day", () => { + const older = params( + documentPickerUrl({ direction: "older", pivot, term: "", cursor: null }), + ); + const newer = params( + documentPickerUrl({ direction: "newer", pivot, term: "", cursor: null }), + ); + expect(older.to).toBe("2019-05-17"); + expect(newer.from).toBe("2019-05-18"); + expect(older.order).toBe("desc"); + expect(newer.order).toBe("asc"); + }); +}); + +describe("the windows around a record's date", () => { + it("splits a week after the date, at the end of that day", () => { + expect(pivot).toBe("2019-05-17T23:59:59.999Z"); + expect(pickerPivot(null)).toBeNull(); + expect(pickerPivot("not a date")).toBeNull(); + }); + + it("older reads up to the split newest first, newer after it oldest first", () => { + const older = params( + labPickerUrl({ direction: "older", pivot, term: "", cursor: null }), + ); + const newer = params( + labPickerUrl({ direction: "newer", pivot, term: "", cursor: null }), + ); + expect(older).toMatchObject({ to: pivot, sortDir: "desc", offset: "0" }); + expect(newer).toMatchObject({ + from: "2019-05-18T00:00:00.000Z", + sortDir: "asc", + }); + }); + + it("a search ignores the window and sends the term", () => { + const search = params( + labPickerUrl({ + direction: "search", + pivot: null, + term: "ferritin", + cursor: null, + }), + ); + expect(search.q).toBe("ferritin"); + expect(search.from).toBeUndefined(); + expect(search.to).toBeUndefined(); + }); + + it("an offset page that came back full has a next page, a short one has none", () => { + expect(nextOffset(null, PICKER_PAGE_SIZE)).toBe(String(PICKER_PAGE_SIZE)); + expect(nextOffset("100", PICKER_PAGE_SIZE)).toBe("200"); + expect(nextOffset("100", 3)).toBeNull(); + }); +}); diff --git a/src/components/links/__tests__/vault-document-options.test.ts b/src/components/links/__tests__/vault-document-options.test.ts index e0dbbace6..049c915d8 100644 --- a/src/components/links/__tests__/vault-document-options.test.ts +++ b/src/components/links/__tests__/vault-document-options.test.ts @@ -91,37 +91,36 @@ describe("documentOptionsWithSuggestions", () => { }); }); -describe("fetchWholeVault", () => { - it("asks only for pages the list route accepts, and walks every cursor", async () => { +describe("fetchVaultPage", () => { + it("asks for one page the list route accepts and hands back its cursor", async () => { const { documentListQuerySchema } = await import("@/lib/validations/inbound-documents"); const apiFetch = await import("@/lib/api/api-fetch"); const urls: string[] = []; - const pages = [ - { documents: [doc("a", "2026-05-01")], nextCursor: "a" }, - { documents: [doc("b", "1991-03-01")], nextCursor: null }, - ]; const spy = vi .spyOn(apiFetch, "apiGet") .mockImplementation(async (url: string) => { urls.push(url); - return pages.shift() as never; + return { + documents: [doc("a", "2026-05-01")], + nextCursor: "a", + } as never; }); try { - const { fetchWholeVault } = await import("../vault-document-options"); - const documents = await fetchWholeVault(); - expect(documents.map((d) => d.id)).toEqual(["a", "b"]); - expect(urls).toHaveLength(2); - for (const url of urls) { - // The route parses its query with this schema and answers a 422 to - // anything it refuses; the picker used to read that 422 as an empty - // vault. - const params = Object.fromEntries( - new URL(url, "http://local").searchParams, - ); - expect(documentListQuerySchema.safeParse(params).success).toBe(true); - } - expect(urls[1]).toContain("cursor=a"); + const { fetchVaultPage } = await import("../vault-document-options"); + const page = await fetchVaultPage({ + direction: "older", + pivot: "2026-05-17T23:59:59.999Z", + term: "", + cursor: "z", + }); + expect(page.items.map((d) => d.id)).toEqual(["a"]); + expect(page.next).toBe("a"); + const params = Object.fromEntries( + new URL(urls[0]!, "http://local").searchParams, + ); + expect(documentListQuerySchema.safeParse(params).success).toBe(true); + expect(params).toMatchObject({ cursor: "z", to: "2026-05-17" }); } finally { spy.mockRestore(); } diff --git a/src/components/links/entity-link-picker.tsx b/src/components/links/entity-link-picker.tsx index 8d70d10b0..919dfd9e7 100644 --- a/src/components/links/entity-link-picker.tsx +++ b/src/components/links/entity-link-picker.tsx @@ -30,10 +30,18 @@ * sheet where its button cannot be pressed while the sheet is open. The chip * carries the option's date, so two doses of one vaccine read as two * different records. + * + * **A long list pages on the server.** A caller over a list that can outgrow + * one page passes `server`: the search box then asks the list route instead + * of filtering what is loaded, and "Load newer" / "Load older" page through + * the windows around the record's date (see `useLinkPickerPages`). A record + * that is linked always shows as a chip, whether or not the page that holds + * it is loaded: the caller passes what it knows about the existing links as + * `knownOptions`, and every option picked in this session is remembered. */ import { useMemo, useState, type ReactNode } from "react"; import Link from "next/link"; -import { Check, Plus, Search, X } from "lucide-react"; +import { Check, Loader2, Plus, Search, X } from "lucide-react"; import type { LucideIcon } from "lucide-react"; import { Badge } from "@/components/ui/badge"; @@ -82,6 +90,74 @@ export function filterOptions( return options.filter((option) => haystack(option).includes(needle)); } +/** + * Server-side search and paging for a list that can outgrow one page. The + * caller owns the term (it is part of the list route's query) and the pages. + */ +export interface EntityLinkServerPaging { + term: string; + onTermChange: (term: string) => void; + /** The typed term has not been answered yet. */ + searchPending: boolean; + hasOlder: boolean; + hasNewer: boolean; + loadingOlder: boolean; + loadingNewer: boolean; + onOlder: () => void; + onNewer: () => void; + /** + * The list route has no text search: the term filters the loaded options + * here instead of being sent. + */ + filterLocally?: boolean; +} + +/** + * The paging props from a `useLinkPickerPages` result, so every caller + * wires the same fields the same way. + */ +export function serverPagingFrom(pages: { + term: string; + setTerm: (term: string) => void; + searchPending: boolean; + hasOlder: boolean; + hasNewer: boolean; + loadingOlder: boolean; + loadingNewer: boolean; + loadOlder: () => void; + loadNewer: () => void; + filterLocally: boolean; +}): EntityLinkServerPaging { + return { + term: pages.term, + onTermChange: pages.setTerm, + searchPending: pages.searchPending, + hasOlder: pages.hasOlder, + hasNewer: pages.hasNewer, + loadingOlder: pages.loadingOlder, + loadingNewer: pages.loadingNewer, + onOlder: pages.loadOlder, + onNewer: pages.loadNewer, + filterLocally: pages.filterLocally, + }; +} + +/** + * The options a chip can be resolved from: what is loaded, what the caller + * knows about the existing links, and what was picked in this session. The + * loaded option wins, then the known one, then the picked one. + */ +export function resolvableOptions( + options: readonly EntityLinkOption[], + knownOptions: readonly EntityLinkOption[], + picked: ReadonlyMap, +): Map { + const map = new Map(picked); + for (const option of knownOptions) map.set(option.id, option); + for (const option of options) map.set(option.id, option); + return map; +} + export interface EntityLinkGroup { /** Group key, or null for the flat (ungrouped) bucket. */ key: string | null; @@ -165,6 +241,8 @@ export function EntityLinkPicker({ errorLabel, onRetry, extraAction, + server, + knownOptions, }: { icon: LucideIcon; title: string; @@ -191,27 +269,68 @@ export function EntityLinkPicker({ * the way forward. The element renders nothing when it has nothing to offer. */ extraAction?: ReactNode; + /** Server-side search and paging; omit for a list that fits one read. */ + server?: EntityLinkServerPaging; + /** + * What the caller knows about records already linked (label and date from + * the record's own read), so their chips show even when no loaded page + * holds them. + */ + knownOptions?: EntityLinkOption[]; }) { const { t } = useTranslations(); const [open, setOpen] = useState(false); - const [term, setTerm] = useState(""); + const [localTerm, setLocalTerm] = useState(""); + const term = server ? server.term : localTerm; + const setTerm = server ? server.onTermChange : setLocalTerm; - const optionById = useMemo(() => { - const map = new Map(); - for (const option of options) map.set(option.id, option); - return map; - }, [options]); - - const groups = useMemo( - () => groupOptions(filterOptions(options, term)), - [options, term], + // Every option picked in this session, so its chip survives a search or a + // page change that takes it out of the loaded list. Written from the event + // handlers, never during render. + const [picked, setPicked] = useState>( + () => new Map(), ); + const remember = (more: readonly EntityLinkOption[]) => { + if (more.length === 0) return; + setPicked((prev) => { + const next = new Map(prev); + for (const option of more) next.set(option.id, option); + return next; + }); + }; + + const optionById = useMemo( + () => resolvableOptions(options, knownOptions ?? [], picked), + [options, knownOptions, picked], + ); + + // A server-paged list is already the answer to the term; filtering it + // again here would drop a match on a field the option does not render. + const groups = useMemo(() => { + const visible = + server && !server.filterLocally ? options : filterOptions(options, term); + // Linked records the loaded pages do not hold stay reachable in the + // sheet, on top, so they can be unticked where they are listed. + const loaded = new Set(options.map((option) => option.id)); + const linkedElsewhere = term.trim() + ? [] + : selected + .filter((id) => !loaded.has(id)) + .map((id) => optionById.get(id)) + .filter((option): option is EntityLinkOption => option != null) + .map((option) => ({ + ...option, + group: { key: "__linked__", label: t("links.picker.linked") }, + })); + return groupOptions([...linkedElsewhere, ...visible]); + }, [options, term, server, selected, optionById, t]); // The link the last X removed, offered back until the next change. const [lastRemoved, setLastRemoved] = useState(null); - const change = (ids: string[]) => { + const change = (ids: string[], touched: readonly EntityLinkOption[] = []) => { setLastRemoved(null); + remember(touched); onChange(ids); }; @@ -245,7 +364,9 @@ export function EntityLinkPicker({ onRetry={onRetry} slot={`${slot}-error`} /> - ) : options.length === 0 ? ( + ) : options.length === 0 && + selected.length === 0 && + !(server && (server.hasOlder || server.hasNewer)) ? (

{emptyLabel}

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

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

) : (
+ {server?.hasNewer ? ( + + ) : null} {groups.map((group) => { const groupIds = group.options.map((option) => option.id); return ( @@ -384,7 +526,9 @@ export function EntityLinkPicker({
); })} + {server?.hasOlder ? ( + + ) : null}
)} @@ -451,3 +603,37 @@ export function EntityLinkPicker({ ); } + +/** "Load newer" / "Load older": a full-width row inside the scrolling list. */ +function PageButton({ + slot, + loading, + onClick, + label, +}: { + slot: string; + loading: boolean; + onClick: () => void; + label: string; +}) { + return ( + + ); +} diff --git a/src/components/links/picker-requests.ts b/src/components/links/picker-requests.ts new file mode 100644 index 000000000..2d4be8381 --- /dev/null +++ b/src/components/links/picker-requests.ts @@ -0,0 +1,96 @@ +/** + * The list-route requests behind each link picker, one builder per family. + * + * Pure, so a test can parse every URL a picker can send against the route's + * own query schema: a page size or a parameter the route refuses answers 422, + * and a picker that reads a 422 as an empty list hides the record it exists to + * offer. That is exactly how a vault once read as empty. + * + * Every builder speaks one {@link PickerPageRequest}: `older` is the window up + * to the split newest first, `newer` the window after it oldest first, + * `search` the term across the whole history. The cursor is the family's own: + * an offset for the routes that page by offset, the last row's id for the + * document route's keyset. + */ +import { + PICKER_PAGE_SIZE, + afterPivot, + type PickerPageRequest, +} from "./use-link-picker-pages"; + +function withParams(path: string, params: URLSearchParams): string { + return `${path}?${params.toString()}`; +} + +/** `GET /api/labs` — offset paging, `from` / `to` on the draw instant. */ +export function labPickerUrl(request: PickerPageRequest): string { + const params = new URLSearchParams({ + limit: String(PICKER_PAGE_SIZE), + offset: request.cursor ?? "0", + sortDir: request.direction === "newer" ? "asc" : "desc", + }); + if (request.direction === "search") params.set("q", request.term); + else if (request.pivot !== null) { + if (request.direction === "older") params.set("to", request.pivot); + else params.set("from", afterPivot(request.pivot)); + } + return withParams("/api/labs", params); +} + +/** `GET /api/illness/episodes` — offset paging, the window on the onset. */ +export function episodePickerUrl(request: PickerPageRequest): string { + const params = new URLSearchParams({ + includeResolved: "true", + limit: String(PICKER_PAGE_SIZE), + offset: request.cursor ?? "0", + sortDir: request.direction === "newer" ? "asc" : "desc", + }); + if (request.direction === "search") params.set("q", request.term); + else if (request.pivot !== null) { + if (request.direction === "older") params.set("to", request.pivot); + else params.set("from", afterPivot(request.pivot)); + } + return withParams("/api/illness/episodes", params); +} + +/** + * `GET /api/documents/inbound` — keyset paging on the filing date. The route's + * window is day-granular and inclusive, so the newer window starts the day + * after the split's day. + */ +export function documentPickerUrl(request: PickerPageRequest): string { + const params = new URLSearchParams({ + sort: "documentDate", + order: request.direction === "newer" ? "asc" : "desc", + limit: String(PICKER_PAGE_SIZE), + }); + if (request.cursor) params.set("cursor", request.cursor); + if (request.direction === "search") params.set("q", request.term); + else if (request.pivot !== null) { + if (request.direction === "older") { + params.set("to", request.pivot.slice(0, 10)); + } else { + params.set("from", afterPivot(request.pivot).slice(0, 10)); + } + } + return withParams("/api/documents/inbound", params); +} + +/** + * `GET /api/encounters` — offset paging inside a window on the visit instant. + * The route answers in two arms (upcoming soonest first, past newest first), + * each paged by the same offset; the picker merges and orders them itself. + * The visit route has no text search, so `search` reads the whole history + * and the picker filters what it has loaded. + */ +export function encounterPickerUrl(request: PickerPageRequest): string { + const params = new URLSearchParams({ + limit: String(PICKER_PAGE_SIZE), + offset: request.cursor ?? "0", + }); + if (request.direction !== "search" && request.pivot !== null) { + if (request.direction === "older") params.set("to", request.pivot); + else params.set("from", afterPivot(request.pivot)); + } + return withParams("/api/encounters", params); +} diff --git a/src/components/links/use-link-picker-pages.ts b/src/components/links/use-link-picker-pages.ts new file mode 100644 index 000000000..28f9e1f41 --- /dev/null +++ b/src/components/links/use-link-picker-pages.ts @@ -0,0 +1,235 @@ +"use client"; + +/** + * The data behind a link picker over a list that can be long: the records + * around one date, paged older and newer from there, or a search across the + * whole history. + * + * The pickers used to fetch one capped page, newest first (200 lab results, + * 100 conditions, ten pages of documents, the last 100 visits), and offered + * only that. On a long record everything older than the cap could not be + * linked at all: a visit from 2019 could not reach its own 2019 lab panel. + * + * Now a picker opens on the records around the date of the thing being linked + * (the visit's, the dose's, the document's). The list is split a few days + * after that date (the same window the "belongs to a visit?" suggestion uses, + * so a result drawn two days after the visit is on the first page) into two + * server-side windows: + * + * - `older`: up to the split, newest first, so the record's own days lead + * the list, paged further back on request; + * - `newer`: after the split, oldest first, fetched only when asked for and + * paged further forward from there; + * + * and a typed term switches to `search`, which asks the list route itself + * (`q`) so a match is found wherever it sits in the history. With no date + * yet the picker opens on the newest records and pages back. + * + * Every page comes from the family's own list route through `fetchPage`, so + * paging, the filter and the ownership check stay server-side. + */ +import { useMemo, useState } from "react"; +import { useInfiniteQuery } from "@tanstack/react-query"; + +import { useDebouncedValue } from "@/hooks/use-debounced-value"; +import { ENCOUNTER_SUGGEST_WINDOW_DAYS } from "@/lib/encounters/suggest-window"; + +export type PickerDirection = "older" | "newer" | "search"; + +/** One page of a picker list, and the cursor for the next one (or null). */ +export interface PickerPage { + items: T[]; + next: string | null; +} + +export interface PickerPageRequest { + direction: PickerDirection; + /** The split instant (ISO), or null when the record has no date yet. */ + pivot: string | null; + /** The trimmed search term; empty outside `search`. */ + term: string; + /** The family's own cursor (an offset or a row id), null for page one. */ + cursor: string | null; +} + +/** Records per page, the same for every family. */ +export const PICKER_PAGE_SIZE = 100; + +/** How long typing settles before the search asks the server. */ +const SEARCH_DEBOUNCE_MS = 250; + +/** Drop repeats by id, keeping the first occurrence's position. */ +export function uniqueById(items: readonly T[]): T[] { + const seen = new Set(); + const out: T[] = []; + for (const item of items) { + if (seen.has(item.id)) continue; + seen.add(item.id); + out.push(item); + } + return out; +} + +/** + * The visible list, newest first: the newer pages (fetched oldest first, so + * reversed) above the older ones. + */ +export function mergeWindowPages( + newerPages: ReadonlyArray>, + olderPages: ReadonlyArray>, +): T[] { + const newer = newerPages.flatMap((page) => page.items).reverse(); + const older = olderPages.flatMap((page) => page.items); + return uniqueById([...newer, ...older]); +} + +export function useLinkPickerPages({ + enabled, + pivot, + queryKey, + fetchPage, + serverSearch = true, +}: { + enabled: boolean; + pivot: string | null; + /** The family's factory key for one direction, pivot and term. */ + queryKey: ( + direction: PickerDirection, + pivot: string | null, + term: string, + ) => readonly unknown[]; + fetchPage: (request: PickerPageRequest) => Promise>; + /** + * Whether the list route can search. A route without a text filter (the + * visit list: its free text is ciphertext at rest) leaves the term to the + * picker, which filters what is loaded. + */ + serverSearch?: boolean; +}) { + const [rawTerm, setTerm] = useState(""); + const debounced = useDebouncedValue(rawTerm.trim(), SEARCH_DEBOUNCE_MS); + const term = serverSearch ? debounced : ""; + const searching = term.length > 0; + + const older = useInfiniteQuery({ + queryKey: queryKey("older", pivot, ""), + enabled: enabled && !searching, + initialPageParam: null as string | null, + queryFn: ({ pageParam }) => + fetchPage({ direction: "older", pivot, term: "", cursor: pageParam }), + getNextPageParam: (last) => last.next ?? undefined, + }); + + // The newer window is read only once asked for, and only for the split it + // was asked for: a changed date starts over on the older window. + const [newerFor, setNewerFor] = useState(null); + const newerRequested = pivot !== null && newerFor === pivot; + + const newer = useInfiniteQuery({ + queryKey: queryKey("newer", pivot, ""), + // No date, no "after": the older window already starts at the newest. + enabled: enabled && !searching && newerRequested, + initialPageParam: null as string | null, + queryFn: ({ pageParam }) => + fetchPage({ direction: "newer", pivot, term: "", cursor: pageParam }), + getNextPageParam: (last) => last.next ?? undefined, + }); + + const search = useInfiniteQuery({ + queryKey: queryKey("search", null, term), + enabled: enabled && searching, + initialPageParam: null as string | null, + queryFn: ({ pageParam }) => + fetchPage({ direction: "search", pivot: null, term, cursor: pageParam }), + getNextPageParam: (last) => last.next ?? undefined, + }); + + const items = useMemo(() => { + if (searching) { + return uniqueById((search.data?.pages ?? []).flatMap((p) => p.items)); + } + return mergeWindowPages(newer.data?.pages ?? [], older.data?.pages ?? []); + }, [searching, search.data, newer.data, older.data]); + + const activeError = searching + ? search.isError + : older.isError || (newerRequested && newer.isError); + + return { + items, + /** The first window is still loading; the inline block shows a skeleton. */ + pending: older.isPending && !searching, + error: activeError, + retry: () => { + if (searching) void search.refetch(); + else { + void older.refetch(); + if (newerRequested) void newer.refetch(); + } + }, + term: rawTerm, + setTerm, + /** A search is in flight (typed, or its first page not back yet). */ + searchPending: + serverSearch && + (rawTerm.trim() !== term || (searching && search.isPending)), + /** The picker filters the loaded options by the term itself. */ + filterLocally: !serverSearch, + hasOlder: searching ? search.hasNextPage : older.hasNextPage, + // Unknown until asked: offered once for every dated record, then only + // while the server reports another page. + hasNewer: + !searching && + pivot !== null && + (!newerRequested || newer.isFetching || newer.hasNextPage), + loadingOlder: searching + ? search.isFetchingNextPage + : older.isFetchingNextPage, + loadingNewer: !searching && newerRequested && newer.isFetching, + loadOlder: () => { + if (searching) void search.fetchNextPage(); + else void older.fetchNextPage(); + }, + loadNewer: () => { + if (searching || pivot === null) return; + if (!newerRequested) setNewerFor(pivot); + else void newer.fetchNextPage(); + }, + }; +} + +export type LinkPickerPages = ReturnType< + typeof useLinkPickerPages +>; + +/** + * Where a record's date splits the list: the end of the UTC day + * {@link ENCOUNTER_SUGGEST_WINDOW_DAYS} days after it, so everything the + * suggestion window would offer is on the first page. Day-granular so typing + * the time of a visit does not refetch. Null when there is no date (or it does + * not parse). + */ +export function pickerPivot(anchor: string | null): string | null { + if (!anchor) return null; + const parsed = new Date(anchor); + if (Number.isNaN(parsed.getTime())) return null; + const split = new Date( + parsed.getTime() + ENCOUNTER_SUGGEST_WINDOW_DAYS * 86_400_000, + ); + split.setUTCHours(23, 59, 59, 999); + return split.toISOString(); +} + +/** The instant just after the split, where the newer window starts. */ +export function afterPivot(pivot: string): string { + return new Date(new Date(pivot).getTime() + 1).toISOString(); +} + +/** Offset cursors, for the list routes that page by `offset`. */ +export function nextOffset( + cursor: string | null, + received: number, +): string | null { + if (received < PICKER_PAGE_SIZE) return null; + return String(Number(cursor ?? "0") + received); +} diff --git a/src/components/links/vault-document-options.ts b/src/components/links/vault-document-options.ts index 91aee8604..3ac090efd 100644 --- a/src/components/links/vault-document-options.ts +++ b/src/components/links/vault-document-options.ts @@ -7,38 +7,41 @@ * The visit and vaccination forms used to fetch their document options with a * page size the list route refuses (`limit=200` against a ceiling of 100). The * 422 read as an empty list, so the picker said "No documents yet." to a - * person with a full vault, and the only way a scan ever got attached was the - * ±7-day suggestion offered at upload. A childhood record covering eight doses - * from one sheet of paper could therefore be filed against exactly one of - * them. + * person with a full vault. The next version walked the whole vault, ten + * pages deep, and anything past the thousandth document was out of reach. * - * Now the whole vault is offered, paged through at the route's own ceiling, - * and a read failure says so instead of pretending the vault is empty. The - * documents dated within the same ±{@link ENCOUNTER_SUGGEST_WINDOW_DAYS}-day - * window the upload suggestion uses stay on top under their own heading, so - * the common case is still one tap; everything else follows, grouped by month - * and searchable. + * Now the picker opens on the documents filed around the record's date and + * pages older and newer from there, and the search box asks the list route + * itself, so a document is reachable wherever it sits in a vault of any size + * (`useLinkPickerPages`). A read failure says so instead of pretending the + * vault is empty. The documents dated within the same + * ±{@link ENCOUNTER_SUGGEST_WINDOW_DAYS}-day window the upload suggestion uses + * stay on top under their own heading, so the common case is still one tap; + * everything else follows, grouped by month. + * + * A document whose filing date was cleared falls outside every date window; + * the search still finds it by title, file name or content. */ import { useMemo } from "react"; -import { useQuery } from "@tanstack/react-query"; -import type { EntityLinkOption } from "@/components/links/entity-link-picker"; +import { + serverPagingFrom, + type EntityLinkOption, +} from "@/components/links/entity-link-picker"; +import { documentPickerUrl } from "@/components/links/picker-requests"; +import { + pickerPivot, + useLinkPickerPages, + type PickerPage, + type PickerPageRequest, +} from "@/components/links/use-link-picker-pages"; import { apiGet } from "@/lib/api/api-fetch"; import { ENCOUNTER_SUGGEST_WINDOW_DAYS } from "@/lib/encounters/suggest-window"; import { useFormatters, useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; -import { - DOCUMENT_LIST_MAX_LIMIT, - type InboundDocumentDto, -} from "@/lib/validations/inbound-documents"; +import type { InboundDocumentDto } from "@/lib/validations/inbound-documents"; import { daysBetweenDateKeys } from "@/lib/tz/format"; -/** - * How many pages the picker walks. At the route's ceiling of 100 that is a - * thousand documents, past which the vault's own search is the better tool. - */ -const VAULT_PICKER_MAX_PAGES = 10; - /** The fields the option list reads off a document. */ export type VaultDocument = Pick< InboundDocumentDto, @@ -50,25 +53,12 @@ interface ListPage { nextCursor: string | null; } -/** Walk the vault newest filing date first, at the route's own page ceiling. */ -export async function fetchWholeVault(): Promise { - const documents: VaultDocument[] = []; - let cursor: string | null = null; - for (let page = 0; page < VAULT_PICKER_MAX_PAGES; page++) { - const params = new URLSearchParams({ - sort: "documentDate", - order: "desc", - limit: String(DOCUMENT_LIST_MAX_LIMIT), - }); - if (cursor) params.set("cursor", cursor); - const result: ListPage = await apiGet( - `/api/documents/inbound?${params.toString()}`, - ); - documents.push(...result.documents); - cursor = result.nextCursor; - if (!cursor) break; - } - return documents; +/** One picker page of the vault. */ +export async function fetchVaultPage( + request: PickerPageRequest, +): Promise> { + const result = await apiGet(documentPickerUrl(request)); + return { items: result.documents, next: result.nextCursor }; } /** @@ -135,8 +125,9 @@ export function documentOptionsWithSuggestions( } /** - * The picker's data: the whole vault as options, the suggestion group on top - * when the record has a date, and the query state an error row needs. + * The picker's data: the vault around the record's date as options, the + * suggestion group on top when the record has a date, the paging the sheet + * offers, and the query state an error row needs. */ export function useVaultDocumentOptions({ enabled, @@ -148,26 +139,42 @@ export function useVaultDocumentOptions({ }) { const { t } = useTranslations(); const format = useFormatters(); - const query = useQuery({ - queryKey: queryKeys.inboundDocumentVaultPicker(), + const pages = useLinkPickerPages({ enabled, - queryFn: fetchWholeVault, + pivot: pickerPivot(anchor), + queryKey: queryKeys.inboundDocumentVaultPicker, + fetchPage: fetchVaultPage, }); const options = useMemo( () => - documentOptionsWithSuggestions(query.data ?? [], anchor, { + documentOptionsWithSuggestions(pages.items, anchor, { suggested: t("links.picker.suggested"), month: (iso) => `${format.monthShort(iso)} ${iso.slice(0, 4)}`, date: (iso) => format.date(iso), }), - [query.data, anchor, t, format], + [pages.items, anchor, t, format], ); return { options, - pending: query.isPending, - error: query.isError, - retry: () => void query.refetch(), + pending: pages.pending, + error: pages.error, + retry: pages.retry, + server: serverPagingFrom(pages), + }; +} + +/** A linked document as the record's own read names it, for its chip. */ +export function linkedDocumentOption( + link: { id: string; label: string | null; date: string | null }, + date: (iso: string) => string, +): EntityLinkOption | null { + if (link.label === null) return null; + return { + id: link.id, + label: link.label, + dateLabel: link.date ? date(link.date) : null, + href: `/documents?doc=${encodeURIComponent(link.id)}`, }; } diff --git a/src/components/vaccinations/vaccination-document-picker.tsx b/src/components/vaccinations/vaccination-document-picker.tsx index 6fb195261..da8f17a0e 100644 --- a/src/components/vaccinations/vaccination-document-picker.tsx +++ b/src/components/vaccinations/vaccination-document-picker.tsx @@ -5,10 +5,11 @@ * transcribed from. The shared {@link EntityLinkPicker} — an inline summary * (removable chips + an add button) over a searchable, month-grouped sheet. * - * The whole vault is offered, not only the pages dated near the dose: one + * The whole vault is reachable, not only the pages dated near the dose: one * childhood record commonly covers a dozen doses given years apart, and it - * belongs on every one of them. The pages dated within the upload - * suggestion's window of this dose sit on top as suggestions. + * belongs on every one of them. The picker opens on the pages around the + * dose's date, the ones within the upload suggestion's window on top as + * suggestions, and pages older and newer or searches the vault from there. * * **The gate blanks the block, it does not post-filter it.** When the * `inboundDocuments` module is off, this renders nothing — no heading, no empty @@ -23,11 +24,15 @@ * than showing an empty set, which would both hide the links and offer to * save their removal. */ +import { useMemo } from "react"; import { FolderOpen } from "lucide-react"; -import { useTranslations } from "@/lib/i18n/context"; +import { useFormatters, useTranslations } from "@/lib/i18n/context"; import { EntityLinkPicker } from "@/components/links/entity-link-picker"; -import { useVaultDocumentOptions } from "@/components/links/vault-document-options"; +import { + linkedDocumentOption, + useVaultDocumentOptions, +} from "@/components/links/vault-document-options"; import { ImportFromSourceButton } from "@/components/documents/sources/import-from-source-button"; import { mergeIds } from "@/components/documents/sources/merge-ids"; @@ -35,6 +40,7 @@ export function VaccinationDocumentPicker({ enabled, anchor, documentIds, + linkedDocuments, seedPending = false, seedError = false, onRetrySeed, @@ -45,6 +51,15 @@ export function VaccinationDocumentPicker({ /** The dose's own date (ISO), for the suggestions on top. */ anchor: string | null; documentIds: string[]; + /** + * The pages the dose is filed against, as its own read names them, so their + * chips show whatever part of the vault is loaded. + */ + linkedDocuments?: ReadonlyArray<{ + id: string; + label: string | null; + date: string | null; + }>; /** The dose's own links are still loading. */ seedPending?: boolean; /** Reading the dose's own links failed. */ @@ -53,7 +68,15 @@ export function VaccinationDocumentPicker({ onChange: (documentIds: string[]) => void; }) { const { t } = useTranslations(); + const format = useFormatters(); const vault = useVaultDocumentOptions({ enabled, anchor }); + const known = useMemo( + () => + (linkedDocuments ?? []) + .map((link) => linkedDocumentOption(link, (iso) => format.date(iso))) + .filter((option) => option !== null), + [linkedDocuments, format], + ); if (!enabled) return null; @@ -73,6 +96,8 @@ export function VaccinationDocumentPicker({ selected={documentIds} onChange={onChange} options={vault.options} + server={vault.server} + knownOptions={known} searchPlaceholder={t("links.picker.searchPlaceholder")} emptyLabel={t("vaccinations.form.linkNothingToOffer")} extraAction={ diff --git a/src/components/vaccinations/vaccination-form.tsx b/src/components/vaccinations/vaccination-form.tsx index aff06ca44..bdb30c955 100644 --- a/src/components/vaccinations/vaccination-form.tsx +++ b/src/components/vaccinations/vaccination-form.tsx @@ -191,6 +191,12 @@ export function VaccinationForm({ */ savedDocuments?: { ids: string[] | null; + /** The same links with the label and date the detail read carries. */ + links?: ReadonlyArray<{ + id: string; + label: string | null; + date: string | null; + }>; pending: boolean; error: boolean; retry: () => void; @@ -376,6 +382,7 @@ export function VaccinationForm({ enabled={user?.modules?.inboundDocuments === true} anchor={anchor} documentIds={draft.documentIds ?? savedDocuments?.ids ?? []} + linkedDocuments={savedDocuments?.links} seedPending={ draft.documentIds === null && (savedDocuments?.pending ?? false) } diff --git a/src/components/vaccinations/vaccination-sheet.tsx b/src/components/vaccinations/vaccination-sheet.tsx index 3f76cc21b..3cb0e8130 100644 --- a/src/components/vaccinations/vaccination-sheet.tsx +++ b/src/components/vaccinations/vaccination-sheet.tsx @@ -141,6 +141,7 @@ export function VaccinationSheet({ ids: detail.data?.documents?.map((document) => document.id) ?? null, + links: detail.data?.documents, pending: detail.isPending, error: detail.isError, retry: () => void detail.refetch(), diff --git a/src/lib/query-keys/__tests__/picker-list-keys.test.ts b/src/lib/query-keys/__tests__/picker-list-keys.test.ts index ec41804a9..f22bfd8be 100644 --- a/src/lib/query-keys/__tests__/picker-list-keys.test.ts +++ b/src/lib/query-keys/__tests__/picker-list-keys.test.ts @@ -1,11 +1,11 @@ /** * One key, one fetch. The visit form's link pickers read labs and illness - * episodes with their own page size (200 results, 100 episodes), the Labs - * list and the illness hook with theirs (500, the route's default 50). Under - * a shared key whichever read landed first served the other, so the picker - * could offer 50 episodes or the Labs list could render a 200-row page as - * the whole list. The picker reads carry their own keys, still under the - * domain roots so a lab or episode write evicts them. + * episodes in their own windows and pages, the Labs list and the illness hook + * with theirs. Under a shared key whichever read landed first served the + * other, so the picker could offer 50 episodes or the Labs list could render + * a picker page as the whole list. The picker reads carry their own keys, + * still under the domain roots so a lab or episode write evicts them, and + * each direction, split and term is its own key. */ import { describe, expect, it } from "vitest"; @@ -22,20 +22,36 @@ const LIST_PARAMS = { describe("the link pickers' reads have keys of their own", () => { it("labs: differs from the Labs list and sits under the lab root", () => { - const key = queryKeys.labResultsPicker(200); + const key = queryKeys.labResultsPicker("older", null, ""); expect(key).not.toEqual(queryKeys.labResultsList(LIST_PARAMS)); expect(key.slice(0, 1)).toEqual(queryKeys.labResults()); }); it("episodes: differs from the illness list and sits under the illness root", () => { - const key = queryKeys.illnessEpisodesPicker(100); + const key = queryKeys.illnessEpisodesPicker("older", null, ""); expect(key).not.toEqual(queryKeys.illnessEpisodes(true)); expect(key.slice(0, 1)).toEqual(queryKeys.illness()); }); - it("a different page size is a different key", () => { - expect(queryKeys.labResultsPicker(200)).not.toEqual( - queryKeys.labResultsPicker(500), + it("a different direction, split or term is a different key", () => { + const base = queryKeys.labResultsPicker("older", "2019-05-17", ""); + expect(base).not.toEqual( + queryKeys.labResultsPicker("newer", "2019-05-17", ""), + ); + expect(base).not.toEqual( + queryKeys.labResultsPicker("older", "2020-01-01", ""), + ); + expect(queryKeys.labResultsPicker("search", null, "a")).not.toEqual( + queryKeys.labResultsPicker("search", null, "b"), + ); + }); + + it("documents and visits: under their roots", () => { + expect( + queryKeys.inboundDocumentVaultPicker("older", null, "").slice(0, 1), + ).toEqual(["documents"]); + expect(queryKeys.encounterPicker("older", null, "").slice(0, 1)).toEqual( + queryKeys.encounters(), ); }); }); diff --git a/src/lib/query-keys/documents.ts b/src/lib/query-keys/documents.ts index a562672b7..beee7a450 100644 --- a/src/lib/query-keys/documents.ts +++ b/src/lib/query-keys/documents.ts @@ -61,12 +61,24 @@ export const documentKeys = { inboundDocumentPicker: (q: string) => ["documents", "inbound", "picker", q] as const, /** - * The whole vault as a link-picker option list (every page, walked at the - * list route's ceiling), shared by the visit and vaccination forms. Own - * segment because the shape is a flat document array, not a page. + * The vault as a link-picker option list, shared by the visit and + * vaccination forms: one paged direction from the record's date or a + * search. Own segment because the page shape is the picker's, not the + * library's. */ - inboundDocumentVaultPicker: () => - ["documents", "inbound", "vault-picker"] as const, + inboundDocumentVaultPicker: ( + direction: string, + pivot: string | null, + term: string, + ) => + [ + "documents", + "inbound", + "vault-picker", + direction, + pivot ?? "", + term, + ] as const, /** * Storage usage + effective limits (`GET /api/documents/inbound/usage`). * Invalidated through the `["documents"]` prefix after every upload / diff --git a/src/lib/query-keys/encounters.ts b/src/lib/query-keys/encounters.ts index f9c5586ad..84fab90e2 100644 --- a/src/lib/query-keys/encounters.ts +++ b/src/lib/query-keys/encounters.ts @@ -45,6 +45,13 @@ export const encounterKeys = { status ?? null, episodeId ?? null, ] as const, + /** + * The document sheet's visit picker: one paged direction from the + * document's date. Its own key because its page shape (offset steps inside + * a window) is not the list's. + */ + encounterPicker: (direction: string, pivot: string | null, term: string) => + ["encounters", "picker", direction, pivot ?? "", term] as const, encounter: (id: string) => ["encounters", "detail", id] as const, /** * The procedure history for one filter. Under the `["encounters"]` root, so diff --git a/src/lib/query-keys/illness.ts b/src/lib/query-keys/illness.ts index 61e5c2f35..c1f658aef 100644 --- a/src/lib/query-keys/illness.ts +++ b/src/lib/query-keys/illness.ts @@ -15,10 +15,14 @@ export const illnessKeys = { illness: () => ["illness"] as const, illnessEpisodes: (includeResolved: boolean) => ["illness", "episodes", includeResolved] as const, - // The visit form's episode picker, with its own page size (the illness - // pages read the route's default); a shared key would serve one to the other. - illnessEpisodesPicker: (limit: number) => - ["illness", "episodes-picker", limit] as const, + // The visit form's episode picker: one paged direction from the visit's + // date or a search, with its own page shape; a shared key would serve one + // read to the other. + illnessEpisodesPicker: ( + direction: string, + pivot: string | null, + term: string, + ) => ["illness", "episodes-picker", direction, pivot ?? "", term] as const, illnessEpisode: (id: string) => ["illness", "episode", id] as const, illnessDayLog: (episodeId: string, date: string) => ["illness", "day-log", episodeId, date] as const, diff --git a/src/lib/query-keys/labs.ts b/src/lib/query-keys/labs.ts index 45cdf949a..c15eb86e1 100644 --- a/src/lib/query-keys/labs.ts +++ b/src/lib/query-keys/labs.ts @@ -36,11 +36,12 @@ export const labKeys = { labAnalyteLink: (analyte: string) => ["lab-results", "analyte-link", analyte] as const, - // The visit form's lab picker: newest results, its own page size. Its own - // key because the Labs list reads a different page size, and a shared key - // would serve one read's page to the other. - labResultsPicker: (limit: number) => - ["lab-results", "picker", limit] as const, + // The visit form's lab picker: one paged direction from the visit's date + // (`older` / `newer`) or a search across the whole history. Its own key + // because the Labs list reads other pages, and a shared key would serve + // one read's page to the other. + labResultsPicker: (direction: string, pivot: string | null, term: string) => + ["lab-results", "picker", direction, pivot ?? "", term] as const, // v1.25 — paginated (offset) reading feed for the biomarker detail page. // Distinct from `labResultsList` (single-page key) so the infinite-query diff --git a/src/lib/validations/encounters.ts b/src/lib/validations/encounters.ts index 9955204dd..4d8126699 100644 --- a/src/lib/validations/encounters.ts +++ b/src/lib/validations/encounters.ts @@ -164,6 +164,11 @@ export const encounterListQuerySchema = z */ episodeId: id.optional(), limit: z.coerce.number().int().min(1).max(200).optional(), + /** + * Rows to skip in each arm, for a picker that pages a long history in + * `limit`-sized steps inside a date window. + */ + offset: z.coerce.number().int().min(0).max(100_000).optional(), }) .strict() .refine((q) => !(q.from && q.to) || q.from <= q.to, { diff --git a/src/lib/validations/illness.ts b/src/lib/validations/illness.ts index b96933709..afc5e5a83 100644 --- a/src/lib/validations/illness.ts +++ b/src/lib/validations/illness.ts @@ -136,6 +136,17 @@ export type IllnessEpisodeResolve = z.infer; export const illnessEpisodeListQuerySchema = z.object({ limit: z.coerce.number().int().min(1).max(100).optional(), includeResolved: z.union([z.literal("true"), z.literal("false")]).optional(), + /** + * Paging and a date window over the onset, for a picker that opens on the + * episodes around a visit and pages older or newer from there. + */ + offset: z.coerce.number().int().min(0).max(100_000).optional(), + from: z.iso.datetime({ offset: true }).optional(), + to: z.iso.datetime({ offset: true }).optional(), + /** Onset order; newest first by default. */ + sortDir: z.enum(["asc", "desc"]).optional(), + /** Case-insensitive substring over the episode's label. */ + q: z.string().trim().min(1).max(120).optional(), }); /* ── day-log capture (one row per day of an episode) ─────────────── */ diff --git a/src/lib/validations/labs.ts b/src/lib/validations/labs.ts index d7c2ea3c3..6bbc3ef0d 100644 --- a/src/lib/validations/labs.ts +++ b/src/lib/validations/labs.ts @@ -284,6 +284,11 @@ export const listLabResultsSchema = z.object({ biomarkerId: z.string().trim().min(1).max(64).optional(), analyte: z.string().trim().min(1).max(120).optional(), panel: z.string().trim().min(1).max(120).optional(), + /** + * Case-insensitive substring over the analyte and the panel name, so a + * picker can find one result in a long history without paging to it. + */ + q: z.string().trim().min(1).max(120).optional(), from: z .string() .datetime() diff --git a/tests/integration/visit-link-picker-long-history.test.ts b/tests/integration/visit-link-picker-long-history.test.ts new file mode 100644 index 000000000..b9d286a4d --- /dev/null +++ b/tests/integration/visit-link-picker-long-history.test.ts @@ -0,0 +1,311 @@ +/** + * A visit can link a record from anywhere in a long history. + * + * The visit form's lab picker loaded the newest 200 results and offered only + * those, the condition picker the newest 100 conditions. On a record with a + * long history everything older could not be linked: a 2019 visit could not + * reach its own 2019 lab panel. The pickers now open on a window around the + * visit's date and page and search through the list routes. + * + * Everything here goes through the shipped route exports with the requests + * the pickers build (`picker-requests.ts`), against a record of 1,200 lab + * results spread over seven years, so the window, the search and the paging + * are proven against real SQL, and the link is saved and read back. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +process.env.ENCRYPTION_KEY ??= + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +import { NextRequest } from "next/server"; + +import { + GET as listEncounters, + POST as createEncounter, +} from "@/app/api/encounters/route"; +import { GET as listEpisodes } from "@/app/api/illness/episodes/route"; +import { GET as listLabs } from "@/app/api/labs/route"; +import { + encounterPickerUrl, + episodePickerUrl, + labPickerUrl, +} from "@/components/links/picker-requests"; +import { + nextOffset, + pickerPivot, + type PickerPageRequest, +} from "@/components/links/use-link-picker-pages"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => cookieJar.set(name, value), + delete: (name: string) => cookieJar.delete(name), + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +const OWNER_ID = "long-history-owner"; +const DAY_MS = 24 * 60 * 60 * 1000; +/** Historical on purpose: the visit and its panel sit years back. */ +const VISIT_AT = "2019-04-03T09:30:00.000Z"; +const FERRITIN_AT = new Date("2019-04-03T07:00:00.000Z"); +const RESULT_COUNT = 1_200; + +async function seedOwner() { + const prisma = getPrismaClient(); + await prisma.user.create({ + data: { + id: OWNER_ID, + username: "long-history", + email: "long-history@example.test", + timezone: "Europe/Berlin", + modulePreferencesJson: { labs: true, illness: true }, + }, + }); + const session = await prisma.session.create({ + data: { + userId: OWNER_ID, + expiresAt: new Date(Date.now() + 7 * DAY_MS), + mfaVerifiedAt: new Date(), + }, + }); + cookieJar.set("healthlog_session", session.id); +} + +/** + * 1,200 glucose results, one every two days back from today, plus the one + * ferritin result drawn on the morning of the 2019 visit. + */ +async function seedLabs(): Promise { + const prisma = getPrismaClient(); + const now = Date.now(); + await prisma.labResult.createMany({ + data: Array.from({ length: RESULT_COUNT }, (_, i) => ({ + userId: OWNER_ID, + panel: "Metabolic", + analyte: "Glucose", + value: 90 + (i % 10), + unit: "mg/dL", + takenAt: new Date(now - (i + 1) * 2 * DAY_MS), + })), + }); + const ferritin = await prisma.labResult.create({ + data: { + userId: OWNER_ID, + panel: "Iron studies", + analyte: "Ferritin", + value: 54, + unit: "ng/mL", + takenAt: FERRITIN_AT, + }, + }); + return ferritin.id; +} + +async function get( + handler: (request: NextRequest) => Promise, + url: string, +): Promise { + const response = await handler(new NextRequest(`http://localhost${url}`)); + expect(response.status, url).toBe(200); + return ((await response.json()) as { data: T }).data; +} + +interface LabPage { + results: Array<{ id: string; analyte: string; takenAt: string }>; + meta: { total: number }; +} + +const labPage = (request: PickerPageRequest) => + get(listLabs as never, labPickerUrl(request)); + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); + await seedOwner(); +}); + +describe("the visit form's lab picker on a 1,200-result record", () => { + it("the old newest-200 page never reached the 2019 result", async () => { + const ferritinId = await seedLabs(); + const page = await get( + listLabs as never, + "/api/labs?limit=200&offset=0&sortDir=desc", + ); + expect(page.meta.total).toBe(RESULT_COUNT + 1); + expect(page.results.some((row) => row.id === ferritinId)).toBe(false); + }); + + it("opens on the visit's own date, with the 2019 result on the first page", async () => { + const ferritinId = await seedLabs(); + const pivot = pickerPivot(VISIT_AT); + const first = await labPage({ + direction: "older", + pivot, + term: "", + cursor: null, + }); + expect(first.results.some((row) => row.id === ferritinId)).toBe(true); + // Nothing after the window's split is on it. + for (const row of first.results) { + expect(new Date(row.takenAt).getTime()).toBeLessThanOrEqual( + new Date(pivot!).getTime(), + ); + } + + // "Load newer" pages forward from the split, oldest first, without + // repeating a row. + const seen = new Set(first.results.map((row) => row.id)); + let cursor: string | null = null; + let newerRows = 0; + let previous = new Date(pivot!).getTime(); + for (let guard = 0; guard < 20; guard++) { + const page = await labPage({ + direction: "newer", + pivot, + term: "", + cursor, + }); + for (const row of page.results) { + expect(seen.has(row.id)).toBe(false); + seen.add(row.id); + const at = new Date(row.takenAt).getTime(); + expect(at).toBeGreaterThanOrEqual(previous); + previous = at; + } + newerRows += page.results.length; + cursor = nextOffset(cursor, page.results.length); + if (cursor === null) break; + } + // Older window plus every newer page cover the whole record once. + expect(seen.size).toBe(RESULT_COUNT + 1); + expect(newerRows).toBeGreaterThan(0); + }); + + it("a search finds the 2019 result among 1,200 others, by analyte and by panel", async () => { + const ferritinId = await seedLabs(); + for (const term of ["ferritin", "iron stud"]) { + const page = await labPage({ + direction: "search", + pivot: null, + term, + cursor: null, + }); + expect(page.results.map((row) => row.id)).toEqual([ferritinId]); + } + }); + + it("the found result links to the visit and reads back on it", async () => { + const ferritinId = await seedLabs(); + const created = await createEncounter( + new Request("http://localhost/api/encounters", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + occurredAt: VISIT_AT, + status: "DONE", + kind: "ROUTINE", + labResultIds: [ferritinId], + }), + }) as never, + ); + expect(created.status).toBe(201); + + const list = await get<{ + past: Array<{ + id: string; + links?: { labResults: Array<{ id: string; label: string | null }> }; + }>; + }>( + listEncounters as never, + encounterPickerUrl({ + direction: "older", + pivot: pickerPivot(VISIT_AT), + term: "", + cursor: null, + }), + ); + expect(list.past).toHaveLength(1); + expect(list.past[0]!.links?.labResults).toEqual([ + expect.objectContaining({ + id: ferritinId, + label: "Iron studies · Ferritin", + }), + ]); + }); +}); + +describe("the condition picker's window and search", () => { + it("pages the episodes around a date and finds one by name", async () => { + const prisma = getPrismaClient(); + const now = Date.now(); + await prisma.illnessEpisode.createMany({ + data: Array.from({ length: 150 }, (_, i) => ({ + userId: OWNER_ID, + label: `Cold ${i}`, + type: "INFECTION" as const, + onsetAt: new Date(now - (i + 1) * 10 * DAY_MS), + })), + }); + const old = await prisma.illnessEpisode.create({ + data: { + userId: OWNER_ID, + label: "Tendinitis", + type: "INJURY", + onsetAt: new Date("2019-03-20T00:00:00.000Z"), + }, + }); + + const pivot = pickerPivot(VISIT_AT); + const around = await get>( + listEpisodes as never, + episodePickerUrl({ direction: "older", pivot, term: "", cursor: null }), + ); + expect(around[0]!.id).toBe(old.id); + + const found = await get>( + listEpisodes as never, + episodePickerUrl({ + direction: "search", + pivot: null, + term: "tendin", + cursor: null, + }), + ); + expect(found.map((row) => row.id)).toEqual([old.id]); + + // Offset paging walks the whole newer side without a repeat. + const ids = new Set(); + let cursor: string | null = null; + for (let guard = 0; guard < 5; guard++) { + const page: Array<{ id: string }> = await get( + listEpisodes as never, + episodePickerUrl({ direction: "newer", pivot, term: "", cursor }), + ); + for (const row of page) { + expect(ids.has(row.id)).toBe(false); + ids.add(row.id); + } + cursor = nextOffset(cursor, page.length); + if (cursor === null) break; + } + expect(ids.size).toBe(150); + }); +});