fix(visits): reach every lab result, condition, document and visit from the link pickers

The visit form's lab picker offered the newest 200 results, the condition
picker the newest 100 conditions, the document pickers the newest thousand
documents and the document sheet's visit picker the newest 100 visits.
On a long record everything older could not be linked.

Each picker now opens on the records around the date of the thing being
linked, pages older and newer from there, and searches through the list
route itself. Records already linked always show, from the labels the
record's own read carries and from what was picked in the sheet.

The lab list takes a q filter over analyte and panel, the condition list
from/to/q/offset/sortDir, and the visit list an offset.
This commit is contained in:
Marc-André Bombeck committed 2026-10-15 11:24:40 +02:00
1 parent dad9a7778e
commit 73c4dda224
35 files changed
+1714 -251

No files matched your search

+43
View File
@@ -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.
+4 -1
View File
@@ -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": {
+4 -1
View File
@@ -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": {
+4 -1
View File
@@ -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": {
+4 -1
View File
@@ -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": {
+4 -1
View File
@@ -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": {
+4 -1
View File
@@ -12174,7 +12174,10 @@
"suggested": "이 날짜 전후",
"loadError": "목록을 불러오지 못했습니다.",
"removeNamed": "{name} 제거",
"removed": "{name} 연결 해제됨"
"removed": "{name} 연결 해제됨",
"linked": "연결됨",
"loadNewer": "최근 항목 더 보기",
"loadOlder": "이전 항목 더 보기"
}
},
"bodySites": {
+4 -1
View File
@@ -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": {
+6 -2
View File
@@ -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,
}),
]);
+19 -1
View File
@@ -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({
+7 -1
View File
@@ -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: {
@@ -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", () => ({
@@ -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<PickerPage<EncounterDTO>> {
const list = await apiGet<EncounterListDTO>(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<EncounterDTO>({
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")}
/>
@@ -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);
});
});
@@ -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({
<EncounterLinkPickers
modules={user?.modules}
anchor={draft.occurredAt || null}
links={draft.links}
documentIds={draft.documentIds}
labResultIds={draft.labResultIds}
episodeIds={draft.episodeIds}
@@ -16,10 +16,17 @@
* those things, which is why the gate lives here and not on the route.
*
* Nothing in this block can block a save. Every list starts empty and stays
* valid empty. The picker raises only the FETCH limit so grouping has enough
* to group; the link cap stays authoritative on the server.
* valid empty, and the link cap stays authoritative on the server.
*
* **Every family is reachable however long the history.** The lab and
* condition pickers used to load the newest 200 results and the newest 100
* conditions and offer only those, so on a long record a visit from a few
* years back could not reach its own lab panel. Each picker now opens on the
* records around the visit's date and pages older and newer from there, and
* its search asks the list route (`useLinkPickerPages`). What the visit
* already links always shows, from the labels its own read carries.
*/
import { useQuery } from "@tanstack/react-query";
import { useMemo } from "react";
import { FlaskConical, FolderOpen, Stethoscope } from "lucide-react";
import { apiGet } from "@/lib/api/api-fetch";
@@ -27,43 +34,92 @@ import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import type { ModuleKey } from "@/lib/modules/registry";
import type { IllnessEpisodeDTO } from "@/lib/illness/dto";
import type { EncounterLinksDTO } from "@/lib/encounters/dto";
import {
EntityLinkPicker,
serverPagingFrom,
type EntityLinkOption,
} from "@/components/links/entity-link-picker";
import { useVaultDocumentOptions } from "@/components/links/vault-document-options";
import {
linkedDocumentOption,
useVaultDocumentOptions,
} from "@/components/links/vault-document-options";
import {
episodePickerUrl,
labPickerUrl,
} from "@/components/links/picker-requests";
import {
nextOffset,
pickerPivot,
useLinkPickerPages,
type PickerPage,
type PickerPageRequest,
} from "@/components/links/use-link-picker-pages";
import { ImportFromSourceButton } from "@/components/documents/sources/import-from-source-button";
import { mergeIds } from "@/components/documents/sources/merge-ids";
/** Fetch cap for the picker — the grouped sheet needs enough rows to group. */
export const PICKER_FETCH_LIMIT = 200;
/**
* The episode list route caps `limit` at 100 and answers anything above it
* with a 422, which the picker used to read as "nothing here to link".
*/
export const EPISODE_FETCH_LIMIT = 100;
interface LabPickerResult {
id: string;
analyte: string;
panel: string | null;
takenAt: string;
}
interface LabListPage {
results: Array<{
id: string;
analyte: string;
panel: string | null;
takenAt: string;
}>;
results: LabPickerResult[];
}
/** One picker page of lab results. */
export async function fetchLabPage(
request: PickerPageRequest,
): Promise<PickerPage<LabPickerResult>> {
const page = await apiGet<LabListPage>(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<PickerPage<IllnessEpisodeDTO>> {
const rows = await apiGet<IllnessEpisodeDTO[]>(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<Record<ModuleKey, boolean>> | 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<LabPickerResult>({
enabled: labsOn,
queryFn: () =>
apiGet<LabListPage>(
`/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<IllnessEpisodeDTO>({
enabled: illnessOn,
queryFn: () =>
apiGet<IllnessEpisodeDTO[]>(
`/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 (
<div className="space-y-4 border-t pt-4" data-slot="encounter-link-pickers">
@@ -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")}
/>
@@ -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 }) => (
<div data-slot="mock-sheet">{children}</div>
),
}));
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(
<I18nProvider initialLocale="en">{node}</I18nProvider>,
);
}
const server = (over: Partial<EntityLinkServerPaging> = {}) => ({
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(
<EntityLinkPicker
{...base}
options={loaded}
selected={["ferritin-2019"]}
knownOptions={[linked2019]}
server={server({ hasOlder: true })}
/>,
);
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(
<EntityLinkPicker
{...base}
options={loaded}
selected={[]}
server={server({ hasOlder: true, hasNewer: true })}
/>,
);
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(
<EntityLinkPicker
{...base}
options={loaded}
selected={[]}
server={server()}
/>,
);
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(
<EntityLinkPicker
{...base}
options={[]}
selected={[]}
server={server({ hasNewer: true })}
/>,
);
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(
<EntityLinkPicker
{...base}
options={loaded}
selected={[]}
server={server({ term: "metabolic" })}
/>,
);
expect(html).toContain('data-slot="labs-option"');
});
it("a route without search filters what is loaded", () => {
const html = render(
<EntityLinkPicker
{...base}
options={loaded}
selected={[]}
server={server({ term: "nomatch", filterLocally: true })}
/>,
);
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" },
]);
});
});
@@ -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<string, string> {
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();
});
});
@@ -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();
}
+203 -17
View File
@@ -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<string, EntityLinkOption>,
): Map<string, EntityLinkOption> {
const map = new Map<string, EntityLinkOption>(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<string, EntityLinkOption>();
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<ReadonlyMap<string, EntityLinkOption>>(
() => 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<EntityLinkOption | null>(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)) ? (
<div className="space-y-2">
<p className="text-muted-foreground text-xs">{emptyLabel}</p>
{extraAction}
@@ -315,7 +436,9 @@ export function EntityLinkPicker({
<button
type="button"
data-slot={`${slot}-undo-button`}
onClick={() => change(restoreLink(selected, lastRemoved.id))}
onClick={() =>
change(restoreLink(selected, lastRemoved.id), [lastRemoved])
}
className="text-foreground focus-visible:ring-ring/50 min-h-8 rounded-sm font-medium underline underline-offset-2 focus-visible:ring-[3px] focus-visible:outline-none"
>
{t("common.undo")}
@@ -363,12 +486,31 @@ export function EntityLinkPicker({
/>
</div>
{groups.length === 0 ? (
{server?.searchPending && groups.length === 0 ? (
<div
className="flex justify-center py-6"
data-slot={`${slot}-searching`}
>
<Loader2
className="text-muted-foreground size-4 animate-spin motion-reduce:animate-none"
aria-hidden
/>
</div>
) : groups.length === 0 &&
!(server && (server.hasOlder || server.hasNewer)) ? (
<p className="text-muted-foreground px-1 py-6 text-center text-sm">
{t("links.picker.searchNoMatch")}
</p>
) : (
<div className="max-h-[50vh] space-y-4 overflow-y-auto overscroll-contain">
{server?.hasNewer ? (
<PageButton
slot={`${slot}-load-newer`}
loading={server.loadingNewer}
onClick={server.onNewer}
label={t("links.picker.loadNewer")}
/>
) : null}
{groups.map((group) => {
const groupIds = group.options.map((option) => option.id);
return (
@@ -384,7 +526,9 @@ export function EntityLinkPicker({
<button
type="button"
data-slot={`${slot}-group-select`}
onClick={() => change(toggleAll(selected, groupIds))}
onClick={() =>
change(toggleAll(selected, groupIds), group.options)
}
className="text-muted-foreground hover:text-foreground text-xs underline-offset-2 hover:underline"
>
{t("links.picker.selectAll")}
@@ -401,7 +545,7 @@ export function EntityLinkPicker({
aria-pressed={on}
data-slot={`${slot}-option`}
onClick={() =>
change(toggleOne(selected, option.id))
change(toggleOne(selected, option.id), [option])
}
className={cn(
"border-border hover:bg-muted/50 focus-visible:ring-ring/50 flex min-h-11 w-full items-center gap-2 rounded-md border px-3 text-left focus-visible:ring-[3px] focus-visible:outline-none",
@@ -438,6 +582,14 @@ export function EntityLinkPicker({
</div>
);
})}
{server?.hasOlder ? (
<PageButton
slot={`${slot}-load-older`}
loading={server.loadingOlder}
onClick={server.onOlder}
label={t("links.picker.loadOlder")}
/>
) : null}
</div>
)}
@@ -451,3 +603,37 @@ export function EntityLinkPicker({
</div>
);
}
/** "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 (
<Button
type="button"
variant="ghost"
size="sm"
className="min-h-11 w-full"
data-slot={slot}
disabled={loading}
aria-busy={loading}
onClick={onClick}
>
{loading ? (
<Loader2
className="size-4 animate-spin motion-reduce:animate-none"
aria-hidden
/>
) : null}
{label}
</Button>
);
}
+96
View File
@@ -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);
}
@@ -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<T> {
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<T extends { id: string }>(items: readonly T[]): T[] {
const seen = new Set<string>();
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<T extends { id: string }>(
newerPages: ReadonlyArray<PickerPage<T>>,
olderPages: ReadonlyArray<PickerPage<T>>,
): T[] {
const newer = newerPages.flatMap((page) => page.items).reverse();
const older = olderPages.flatMap((page) => page.items);
return uniqueById([...newer, ...older]);
}
export function useLinkPickerPages<T extends { id: string }>({
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<PickerPage<T>>;
/**
* 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<string | null>(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<T extends { id: string }> = ReturnType<
typeof useLinkPickerPages<T>
>;
/**
* 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);
}
+58 -51
View File
@@ -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<VaultDocument[]> {
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<ListPage>(
`/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<PickerPage<VaultDocument>> {
const result = await apiGet<ListPage>(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<VaultDocument>({
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)}`,
};
}
@@ -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={
@@ -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)
}
@@ -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(),
@@ -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(),
);
});
});
+17 -5
View File
@@ -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 /
+7
View File
@@ -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
+8 -4
View File
@@ -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,
+6 -5
View File
@@ -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
+5
View File
@@ -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, {
+11
View File
@@ -136,6 +136,17 @@ export type IllnessEpisodeResolve = z.infer<typeof illnessEpisodeResolveSchema>;
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) ─────────────── */
+5
View File
@@ -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()
@@ -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<string> {
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<T>(
handler: (request: NextRequest) => Promise<Response>,
url: string,
): Promise<T> {
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<LabPage>(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<LabPage>(
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<Array<{ id: string; onsetAt: string }>>(
listEpisodes as never,
episodePickerUrl({ direction: "older", pivot, term: "", cursor: null }),
);
expect(around[0]!.id).toBe(old.id);
const found = await get<Array<{ id: string }>>(
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<string>();
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);
});
});