From da1d31e370f583c6bffd74ae0f03e8533ef26baf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 16 Oct 2026 05:34:08 +0200 Subject: [PATCH] feat(labs): show and file a reading's related report and visit in its sheet --- .../lab-result-related-links.test.tsx | 91 +++++++++++++++++++ src/components/labs/lab-history-list.tsx | 25 ++++- .../labs/lab-result-related-links.tsx | 67 ++++++++++++++ src/components/labs/types.ts | 4 + src/lib/query-keys/labs.ts | 5 + 5 files changed, 187 insertions(+), 5 deletions(-) create mode 100644 src/components/labs/__tests__/lab-result-related-links.test.tsx create mode 100644 src/components/labs/lab-result-related-links.tsx diff --git a/src/components/labs/__tests__/lab-result-related-links.test.tsx b/src/components/labs/__tests__/lab-result-related-links.test.tsx new file mode 100644 index 000000000..b852fa23e --- /dev/null +++ b/src/components/labs/__tests__/lab-result-related-links.test.tsx @@ -0,0 +1,91 @@ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +import { I18nProvider } from "@/lib/i18n/context"; +import type { RelatedLink } from "@/lib/links/related"; +import { queryKeys } from "@/lib/query-keys"; + +const captured = vi.hoisted(() => ({ + pickers: null as null | { + linksPath: string; + kinds: readonly string[]; + onSaved: () => void; + }, +})); + +vi.mock("@/components/links/related-records", async () => { + const actual = await vi.importActual< + typeof import("@/components/links/related-records") + >("@/components/links/related-records"); + return { + ...actual, + RelationLinkPickers: (props: { + linksPath: string; + kinds: readonly string[]; + onSaved: () => void; + }) => { + captured.pickers = props; + return null; + }, + }; +}); + +import { LabResultRelatedLinks } from "../lab-result-related-links"; + +const REPORT: RelatedLink = { + kind: "document", + id: "doc-1", + label: "Lab report March", + date: "2026-03-01T00:00:00.000Z", + href: "/documents?doc=doc-1", + via: "direct", +}; + +function render(qc: QueryClient) { + return renderToStaticMarkup( + + + + + , + ); +} + +/** + * A lab reading shows what it relates to where it is shown in detail (its + * edit sheet), and files the report and the visit through its own links + * route; a save refreshes every end that lists the reading back. + */ +describe("LabResultRelatedLinks", () => { + it("renders nothing before the detail read lands", () => { + expect(render(new QueryClient())).toBe(""); + }); + + it("lists the related records and offers the report and visit pickers", () => { + const qc = new QueryClient(); + qc.setQueryData(queryKeys.labResult("lab-1"), { related: [REPORT] }); + const html = render(qc); + expect(html).toContain('data-slot="lab-result-related"'); + expect(html).toContain("Lab report March"); + expect(captured.pickers?.linksPath).toBe("/api/labs/lab-1/links"); + expect(captured.pickers?.kinds).toEqual(["document", "encounter"]); + }); + + it("refreshes the reading and every end that lists it after a save", () => { + const qc = new QueryClient(); + qc.setQueryData(queryKeys.labResult("lab-1"), { related: [] }); + const spy = vi.spyOn(qc, "invalidateQueries"); + render(qc); + captured.pickers?.onSaved(); + const keys = spy.mock.calls.map((call) => call[0]?.queryKey); + expect(keys).toEqual( + expect.arrayContaining([ + queryKeys.labResult("lab-1"), + queryKeys.documents(), + queryKeys.encounters(), + queryKeys.illness(), + ]), + ); + }); +}); diff --git a/src/components/labs/lab-history-list.tsx b/src/components/labs/lab-history-list.tsx index efaf0d32e..2d8f16c2e 100644 --- a/src/components/labs/lab-history-list.tsx +++ b/src/components/labs/lab-history-list.tsx @@ -28,7 +28,7 @@ import { } from "@/components/ui/table"; import { Textarea } from "@/components/ui/textarea"; import { useTableSort } from "@/hooks/use-table-sort"; -import { apiDelete, apiGet, apiPost, apiPut } from "@/lib/api/api-fetch"; +import { apiDelete, apiPost, apiPut } from "@/lib/api/api-fetch"; import { localizedApiError } from "@/lib/api/localized-error"; import { formatLabReading } from "@/lib/labs/format-value"; import { LabConvertedNote } from "./lab-converted-note"; @@ -40,7 +40,11 @@ import { queryKeys } from "@/lib/query-keys"; import { ReferenceRangeBadge } from "./reference-range-badge"; import { LabReferenceRangeBar } from "./lab-reference-range-bar"; import { SourceRangeNote } from "./source-range-note"; -import type { LabResultDetailDto, LabResultDto } from "./types"; +import { + fetchLabResultDetail, + LabResultRelatedLinks, +} from "./lab-result-related-links"; +import type { LabResultDto } from "./types"; import { useLabDate, useLabNumber } from "./use-lab-format"; const NOTE_MAX_LENGTH = 2000; @@ -242,9 +246,11 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) { // Load the decrypted note (list rows withhold it). setEditLoading(true); try { - const detail = await apiGet( - `/api/labs/${reading.id}`, - ); + // Through the cache: the related section below reads the same detail. + const detail = await queryClient.fetchQuery({ + queryKey: queryKeys.labResult(reading.id), + queryFn: () => fetchLabResultDetail(reading.id), + }); setEditNote(detail.note ?? ""); setEditSeed({ ...seed, note: detail.note ?? "" }); } catch { @@ -314,6 +320,9 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) { }); } + const editingTakenAt = + readings.find((reading) => reading.id === editingId)?.takenAt ?? null; + const ordered = useMemo(() => { const dir = sortDir === "asc" ? 1 : -1; return [...readings].sort((a, b) => { @@ -593,6 +602,12 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) { + {editingId ? ( + + ) : null} (`/api/labs/${encodeURIComponent(id)}`); +} + +export function LabResultRelatedLinks({ + labResultId, + takenAt, +}: { + labResultId: string; + takenAt: string | null; +}) { + const qc = useQueryClient(); + const detail = useQuery({ + queryKey: queryKeys.labResult(labResultId), + queryFn: () => fetchLabResultDetail(labResultId), + }); + // The pickers seed from this read; before it lands they would offer an + // empty selection that the first tap could not compute a change from. + if (!detail.data) return null; + const related = detail.data.related; + return ( +
+ + { + // The other ends list the reading too: the document's and the + // visit's sheets seed their pickers from their cached reads, and a + // condition reaches the reading through the visit. + for (const queryKey of [ + queryKeys.labResult(labResultId), + queryKeys.documents(), + queryKeys.encounters(), + queryKeys.illness(), + ]) { + void qc.invalidateQueries({ queryKey }); + } + }} + /> +
+ ); +} diff --git a/src/components/labs/types.ts b/src/components/labs/types.ts index a523a6585..d7a0e2816 100644 --- a/src/components/labs/types.ts +++ b/src/components/labs/types.ts @@ -1,3 +1,5 @@ +import type { RelatedLink } from "@/lib/links/related"; + import type { ReferenceRangeOrigin, ReferenceRangeStatus, @@ -58,6 +60,8 @@ export interface LabResultDto { /** The single-resource detail DTO — carries the decrypted note. */ export interface LabResultDetailDto extends Omit { note: string | null; + /** v1.43 — the records this reading relates to, resolved on the server. */ + related?: RelatedLink[]; } export interface LabResultListResponse { diff --git a/src/lib/query-keys/labs.ts b/src/lib/query-keys/labs.ts index ee585e878..aa1a6291b 100644 --- a/src/lib/query-keys/labs.ts +++ b/src/lib/query-keys/labs.ts @@ -34,6 +34,11 @@ export const labKeys = { // mutation's `labResults()` invalidation evict it. labResultsOverview: () => ["lab-results", "overview"] as const, + // One reading's detail (`GET /api/labs/{id}`): the decrypted note and the + // records it relates to. Under the `lab-results` prefix so every result + // mutation evicts it. + labResult: (id: string) => ["lab-results", "detail", id] as const, + // `/labs?analyte=` (MCP deep links): the newest reading stored under // that exact analyte name, one row, to find its marker's page. Its own key // because the page size differs from every list read; the `lab-results`