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

@@ -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")}
/>