feat(documents): filter the archive by procedure or surgery

The vault's filter row gains a procedure facet beside the condition
facet. It lists the record's procedures and surgeries that hold at least
one live document, headed the way the visits page heads them (the
reason, else the practice, else the kind) with the body site and date
beneath, and narrows the list through the existing `encounterId` filter.
Same control, placement and clear behaviour as the condition facet; the
choice rides the URL as `?encounter=`, and an upload dropped into the
filtered view is filed against the procedure.

The usage read publishes the choices as `linkedProcedures` (additive),
decrypting only the label columns, and returns none when a sharing
grant does not cover the visits section.

On a phone an active facet now gives up label width before the search
field does, and the search keeps room for its icon and padding.
This commit is contained in:
Marc-André Bombeck committed 2026-10-04 21:59:22 +02:00
1 parent dc7061634e
commit 0219a85e92
24 files changed
+1308 -54

No files matched your search

@@ -0,0 +1,122 @@
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
/**
* The vault filter rail's procedure facet, pinned via single-pass static
* renders (the project's component-test convention; the open-and-pick path is
* the vault e2e). The facet mirrors the condition facet beside it: absent when
* nothing is filed against a procedure, an icon-only trigger on a phone while
* inactive, the procedure's own heading once one is picked, placed between the
* condition and the year, and counted by the one clear control.
*
* Mutation checks (each run, each seen red):
* - render the trigger with no choices → "renders no procedure control"
* goes red;
* - drop the active tint → "shows the picked procedure" goes red.
*/
import { I18nProvider } from "@/lib/i18n/context";
import { DocumentFilterBar } from "../document-filter-bar";
import type { ProcedureFilterChoice } from "../vault-utils";
const KNEE: ProcedureFilterChoice = {
encounterId: "enc-knee",
name: "Knee arthroscopy",
detail: "Knee (left) · 2 Oct 2025",
};
function render({
procedures = [KNEE],
activeEncounterId,
activeCount = 0,
}: {
procedures?: ProcedureFilterChoice[];
activeEncounterId?: string;
activeCount?: number;
} = {}) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<DocumentFilterBar
searchValue=""
onSearchChange={() => {}}
searchInputRef={{ current: null }}
activeKinds={new Set()}
onToggleKind={() => {}}
conditionChips={[{ episodeId: "ep-1", name: "Knee" }]}
activeEpisodeId={undefined}
onToggleEpisode={() => {}}
procedureChips={procedures}
activeEncounterId={activeEncounterId}
onToggleEncounter={() => {}}
years={[2025]}
activeYear={undefined}
onToggleYear={() => {}}
activeCount={activeCount}
onClearAll={() => {}}
/>
</I18nProvider>,
);
}
/** The opening tag of the element carrying `data-slot="<slot>"`. */
function triggerTag(html: string, slot: string): string {
const at = html.indexOf(`data-slot="${slot}"`);
expect(at).toBeGreaterThan(-1);
return html.slice(html.lastIndexOf("<", at), html.indexOf(">", at) + 1);
}
describe("<DocumentFilterBar> procedure facet", () => {
it("renders no procedure control when nothing is filed against a procedure", () => {
const html = render({ procedures: [] });
expect(html).not.toContain('data-slot="document-procedure-filter"');
// The positive control: the neighbouring facets still render.
expect(html).toContain('data-slot="document-condition-filter"');
expect(html).toContain('data-slot="document-year-filter"');
});
it("sits between the condition and the year facet on the one row", () => {
const html = render();
const condition = html.indexOf('data-slot="document-condition-filter"');
const procedure = html.indexOf('data-slot="document-procedure-filter"');
const year = html.indexOf('data-slot="document-year-filter"');
expect(condition).toBeGreaterThan(-1);
expect(procedure).toBeGreaterThan(condition);
expect(year).toBeGreaterThan(procedure);
expect(html).toContain("flex flex-nowrap items-center gap-2");
});
it("is an icon-only trigger on a phone while inactive, named for the facet", () => {
const html = render();
const tag = triggerTag(html, "document-procedure-filter");
expect(tag).toContain('aria-label="All procedures"');
expect(tag).not.toContain("border-primary/40");
// Same trigger chrome as the condition facet: the label hides below `sm`.
const conditionTag = triggerTag(html, "document-condition-filter");
expect(tag.match(/class="([^"]*)"/)?.[1]).toBe(
conditionTag.match(/class="([^"]*)"/)?.[1],
);
expect(html).toContain(
'<span class="max-w-28 min-w-0 truncate hidden sm:inline">All procedures</span>',
);
});
it("shows the picked procedure's heading, tinted, and visible on a phone", () => {
const html = render({ activeEncounterId: "enc-knee", activeCount: 1 });
const tag = triggerTag(html, "document-procedure-filter");
expect(tag).toContain('aria-label="Knee arthroscopy"');
expect(tag).toContain("border-primary/40 bg-primary/10 text-foreground");
expect(html).toContain(
'<span class="max-w-28 min-w-0 truncate inline">Knee arthroscopy</span>',
);
// The one clear control appears for it like for any other facet.
expect(html).toContain('aria-label="Clear filters"');
});
it("leaves the trigger at rest for a filtered visit that is not a choice", () => {
// A routine visit reached from its own row filters the list but is not a
// procedure: the facet must not claim it.
const html = render({ activeEncounterId: "enc-routine", activeCount: 1 });
const tag = triggerTag(html, "document-procedure-filter");
expect(tag).toContain('aria-label="All procedures"');
expect(tag).not.toContain("border-primary/40");
});
});
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import type { InboundDocumentDto } from "@/lib/validations/inbound-documents";
import {
buildProcedureChoices,
buildTimelineItems,
buildVaultListApiSearch,
classifyUploadFailure,
@@ -457,3 +458,63 @@ describe("documentCardKeyAction", () => {
}
});
});
describe("buildProcedureChoices", () => {
const labels = {
kindName: "Procedure or surgery",
siteText: (site: string | null, side: string | null) =>
site ? (side ? `${site} (${side.toLowerCase()})` : site) : null,
formatDate: (iso: string) => iso.slice(0, 10),
};
const knee = {
encounterId: "enc-knee",
occurredAt: "2025-10-02T08:00:00.000Z",
reason: "Knee arthroscopy",
bodySite: "Knee",
laterality: "LEFT" as const,
practitionerName: "Day clinic",
};
it("heads each choice like the visits page: reason, else practice, else kind", () => {
const choices = buildProcedureChoices(
[
knee,
{ ...knee, encounterId: "enc-2", reason: null, bodySite: null },
{
...knee,
encounterId: "enc-3",
reason: " ",
practitionerName: null,
laterality: null,
},
],
null,
labels,
);
expect(choices).toEqual([
{
encounterId: "enc-knee",
name: "Knee arthroscopy",
detail: "Knee (left) · 2025-10-02",
},
{ encounterId: "enc-2", name: "Day clinic", detail: "2025-10-02" },
{
encounterId: "enc-3",
name: "Procedure or surgery",
detail: "Knee · 2025-10-02",
},
]);
});
it("keeps an active procedure the usage list no longer holds, once", () => {
const gone = { ...knee, encounterId: "enc-gone", reason: "Appendectomy" };
const choices = buildProcedureChoices([knee], gone, labels);
expect(choices.map((c) => c.encounterId)).toEqual(["enc-knee", "enc-gone"]);
// Already listed: not added a second time.
expect(buildProcedureChoices([knee], knee, labels)).toHaveLength(1);
});
it("offers nothing when no procedure carries a document", () => {
expect(buildProcedureChoices([], null, labels)).toEqual([]);
});
});
@@ -1,11 +1,12 @@
"use client";
/**
* The vault's sticky filter rail: debounced title/filename search plus three
* The vault's sticky filter rail: debounced title/filename search plus four
* compact dropdown facets — type (multi-select, OR inside the facet),
* condition (episodes that actually carry links, single-select), and year
* (years present in the corpus, single-select) — and a one-tap clear with
* the active-facet count.
* condition (episodes that actually carry links, single-select), procedure
* (procedures and surgeries that actually carry links, single-select), and
* year (years present in the corpus, single-select) — and a one-tap clear
* with the active-facet count.
*
* The controls sit on ONE row at every width: the search flexes and can
* shrink to nothing while each dropdown trigger stays a fixed compact
@@ -24,6 +25,7 @@ import {
ChevronDown,
ListFilter,
ScanSearch,
Scissors,
Search,
X,
} from "lucide-react";
@@ -43,14 +45,21 @@ import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import type { InboundDocumentKindValue } from "@/lib/validations/inbound-documents";
import { DOCUMENT_KIND_ICONS, DOCUMENT_KIND_ORDER } from "./document-kind-meta";
import type { ProcedureFilterChoice } from "./vault-utils";
/** Active-facet tint on a dropdown trigger — mirrors the app pill vocabulary. */
const TRIGGER_ACTIVE = "border-primary/40 bg-primary/10 text-foreground";
/**
* Active-facet tint on a dropdown trigger — mirrors the app pill vocabulary.
* An active trigger may also shrink (its label truncates further), so on a
* phone with several facets set the row gives up label width before it gives
* up the search field.
*/
const TRIGGER_ACTIVE =
"border-primary/40 bg-primary/10 text-foreground min-w-0 shrink";
/** Shared trigger chrome: compact, fixed, never shrinks below its label. */
const TRIGGER_CLASSES = "shrink-0 gap-1.5 font-normal";
/**
* The facet label is the whole point of the trigger — but three text labels
* The facet label is the whole point of the trigger — but four text labels
* plus the search will not fit a 360px phone. So the label shows from `sm`
* up, and on a phone only when the facet is ACTIVE (truncated), while the
* leading icon always carries the facet identity. The trailing chevron is
@@ -59,7 +68,10 @@ const TRIGGER_CLASSES = "shrink-0 gap-1.5 font-normal";
* pinned via `aria-label` so an icon-only trigger still announces its state.
*/
function facetLabelClass(active: boolean): string {
return cn("max-w-28 truncate", active ? "inline" : "hidden sm:inline");
return cn(
"max-w-28 min-w-0 truncate",
active ? "inline" : "hidden sm:inline",
);
}
export interface ConditionChip {
@@ -76,6 +88,9 @@ export function DocumentFilterBar({
conditionChips,
activeEpisodeId,
onToggleEpisode,
procedureChips,
activeEncounterId,
onToggleEncounter,
years,
activeYear,
onToggleYear,
@@ -94,6 +109,9 @@ export function DocumentFilterBar({
conditionChips: ConditionChip[];
activeEpisodeId: string | undefined;
onToggleEpisode: (episodeId: string) => void;
procedureChips: ProcedureFilterChoice[];
activeEncounterId: string | undefined;
onToggleEncounter: (encounterId: string) => void;
years: number[];
activeYear: number | undefined;
onToggleYear: (year: number) => void;
@@ -120,6 +138,12 @@ export function DocumentFilterBar({
const conditionLabel =
activeCondition?.name ?? t("documents.filter.conditionAll");
const activeProcedure = procedureChips.find(
(chip) => chip.encounterId === activeEncounterId,
);
const procedureLabel =
activeProcedure?.name ?? t("documents.filter.procedureAll");
const yearLabel =
activeYear !== undefined
? String(activeYear)
@@ -132,10 +156,12 @@ export function DocumentFilterBar({
>
{/* One row at every width: the search flexes (min-w-0 → it can shrink to
nothing), every dropdown trigger stays a fixed compact control, and
the clear pins to the trailing edge. `flex-nowrap` guarantees the bar
the clear pins to the trailing edge. The search keeps at least its
icon plus its padding (the `/` hint and its padding are desktop
only), so it never bleeds under the next control. `flex-nowrap` guarantees the bar
never breaks to a second line. */}
<div className="flex flex-nowrap items-center gap-2">
<div className="relative min-w-0 flex-1">
<div className="relative min-w-12 flex-1">
<Search
className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2"
aria-hidden
@@ -147,7 +173,7 @@ export function DocumentFilterBar({
onChange={(e) => onSearchChange(e.target.value)}
placeholder={t("documents.filter.searchPlaceholder")}
aria-label={t("documents.filter.searchLabel")}
className="pr-8 pl-9"
className="min-w-0 pr-3 pl-9 sm:pr-8"
aria-keyshortcuts="/"
/>
<kbd
@@ -252,6 +278,58 @@ export function DocumentFilterBar({
</DropdownMenu>
) : null}
{/* Procedure — single-select; only rendered when procedures carry
links. Same cadence as the condition facet beside it. */}
{procedureChips.length > 0 ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="outline"
size="sm"
data-slot="document-procedure-filter"
aria-label={procedureLabel}
className={cn(
TRIGGER_CLASSES,
activeProcedure && TRIGGER_ACTIVE,
)}
>
<Scissors className="size-4 shrink-0" aria-hidden />
<span className={facetLabelClass(Boolean(activeProcedure))}>
{procedureLabel}
</span>
<ChevronDown
className="hidden size-3.5 opacity-60 sm:inline"
aria-hidden
/>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
aria-label={t("documents.filter.procedureGroup")}
>
<DropdownMenuLabel>
{t("documents.filter.procedureGroup")}
</DropdownMenuLabel>
<DropdownMenuSeparator />
{procedureChips.map((chip) => (
<DropdownMenuCheckboxItem
key={chip.encounterId}
checked={chip.encounterId === activeEncounterId}
onCheckedChange={() => onToggleEncounter(chip.encounterId)}
>
<span className="flex min-w-0 flex-col">
<span className="max-w-56 truncate">{chip.name}</span>
<span className="text-muted-foreground max-w-56 truncate text-xs">
{chip.detail}
</span>
</span>
</DropdownMenuCheckboxItem>
))}
</DropdownMenuContent>
</DropdownMenu>
) : null}
{/* Year — single-select; only rendered when the corpus spans years. */}
{years.length > 0 ? (
<DropdownMenu>
+63 -4
View File
@@ -3,7 +3,7 @@
/**
* The Dokumente vault — one wide, calm, filing-cabinet-fast surface.
*
* Filter state lives in the URL (`?q&kind&episode&year`) so every view is
* Filter state lives in the URL (`?q&kind&episode&encounter&year`) so every view is
* shareable, back-button-safe, and deep-linkable from the illness page and
* labs. Search debounces 200 ms and `/` focuses it. The timeline below is
* virtualized; uploads appear optimistically above it in < 100 ms.
@@ -44,7 +44,10 @@ import { usePullToRefresh } from "@/hooks/use-pull-to-refresh";
import { useUrlFilterSync } from "@/hooks/use-url-filter-sync";
import { PullToRefreshIndicator } from "@/components/ui/pull-to-refresh-indicator";
import { apiGet, apiPost } from "@/lib/api/api-fetch";
import { encounterKindText } from "@/components/encounters/encounter-labels";
import {
bodySiteText,
encounterKindText,
} from "@/components/encounters/encounter-labels";
import { useEncounters } from "@/hooks/use-encounters";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import type { EncounterKind } from "@/generated/prisma/client";
@@ -73,12 +76,14 @@ import { useReindexAll } from "./use-content-index";
import { useDocumentUpload } from "./use-document-upload";
import { usePageFileDrop } from "./use-page-file-drop";
import {
buildProcedureChoices,
buildVaultListApiSearch,
countActiveFilters,
documentDateKey,
expandRangeSelection,
hasProcessingDocument,
parseVaultSearchParams,
type ProcedureFilterChoice,
resolveBulkShareDocuments,
SHARE_LINK_MAX_DOCUMENTS,
vaultFiltersToSearch,
@@ -345,6 +350,17 @@ export function DocumentsView() {
);
};
const toggleEncounter = (encounterId: string) => {
applyFilters(
{
...filters,
encounterId:
filters.encounterId === encounterId ? undefined : encounterId,
},
"push",
);
};
const toggleYear = (year: number) => {
applyFilters(
{ ...filters, year: filters.year === year ? undefined : year },
@@ -418,15 +434,19 @@ export function DocumentsView() {
enqueueRef.current = upload.enqueue;
}, [upload.enqueue]);
const episodeIdFilter = filters.episodeId;
const encounterIdFilter = filters.encounterId;
const enqueueFiles = useCallback(
(files: File[]) => {
if (files.length === 0) return;
enqueueRef.current(files, { episodeId: episodeIdFilter });
enqueueRef.current(files, {
episodeId: episodeIdFilter,
encounterId: encounterIdFilter,
});
setUploadAnnouncement(
t("documents.upload.queuedAnnouncement", { count: files.length }),
);
},
[episodeIdFilter, t],
[episodeIdFilter, encounterIdFilter, t],
);
const { dropActive } = usePageFileDrop(
canManageDocuments ? enqueueFiles : undefined,
@@ -504,6 +524,42 @@ export function DocumentsView() {
[visits.data, t, format],
);
// Procedure choices: every procedure carrying at least one live document
// link, served by the usage endpoint for the same reason as the condition
// chips, plus the actively filtered visit when it is a procedure (see
// `buildProcedureChoices`).
const procedureChips = useMemo<ProcedureFilterChoice[]>(() => {
const activeVisit = filters.encounterId
? [...(visits.data?.upcoming ?? []), ...(visits.data?.past ?? [])].find(
(v) => v.id === filters.encounterId && v.kind === "PROCEDURE",
)
: undefined;
return buildProcedureChoices(
usage.data?.linkedProcedures ?? [],
activeVisit
? {
encounterId: activeVisit.id,
occurredAt: activeVisit.occurredAt,
reason: activeVisit.reason,
bodySite: activeVisit.bodySite,
laterality: activeVisit.laterality,
practitionerName: activeVisit.practitioner?.name ?? null,
}
: null,
{
kindName: encounterKindText(t, "PROCEDURE"),
siteText: (site, laterality) => bodySiteText(t, site, laterality),
formatDate: (iso) => format.date(iso),
},
);
}, [
usage.data?.linkedProcedures,
filters.encounterId,
visits.data,
t,
format,
]);
// Year segmenter: years present in the loaded corpus (+ the active year).
const years = useMemo(() => {
const set = new Set<number>();
@@ -921,6 +977,9 @@ export function DocumentsView() {
conditionChips={conditionChips}
activeEpisodeId={filters.episodeId}
onToggleEpisode={toggleEpisode}
procedureChips={procedureChips}
activeEncounterId={filters.encounterId}
onToggleEncounter={toggleEncounter}
years={years}
activeYear={filters.year}
onToggleYear={toggleYear}
@@ -59,6 +59,11 @@ export interface UploadQueueItem {
export interface EnqueueOptions {
/** Pre-link every uploaded document to this episode (deep-link uploads). */
episodeId?: string;
/**
* Pre-link every uploaded document to this visit — the active procedure
* filter, so a file dropped into a filtered view stays in it.
*/
encounterId?: string;
}
interface UploadLimits {
@@ -139,6 +144,7 @@ export function uploadViaXhr(
const fd = new FormData();
fd.append("file", file);
if (options.episodeId) fd.append("episodeIds", options.episodeId);
if (options.encounterId) fd.append("encounterIds", options.encounterId);
xhr.send(fd);
});
}
+51
View File
@@ -10,6 +10,7 @@
import {
DOCUMENT_LIST_DEFAULT_LIMIT,
INBOUND_DOCUMENT_KINDS,
type DocumentLinkedProcedureDto,
type InboundDocumentDto,
type InboundDocumentKindValue,
} from "@/lib/validations/inbound-documents";
@@ -455,3 +456,53 @@ export function documentCardKeyAction(key: string): DocumentCardKeyAction {
if (key === "Delete") return "delete";
return null;
}
/** One procedure choice in the filter bar, labelled for the reader. */
export interface ProcedureFilterChoice {
encounterId: string;
/** The heading the visits page shows for it. */
name: string;
/** Body site and date, the line that tells two same-named ones apart. */
detail: string;
}
/**
* The procedure filter's choices: the usage read's linked procedures, plus the
* actively filtered visit when it is a procedure that list does not hold (its
* last link was just removed, or a deep link names it). A filtered visit that
* is not a procedure adds nothing — it is not a procedure choice.
*
* Each is labelled the way the visits page heads the row: the reason, else the
* practice, else the kind's name. The body site (with its side) and the date
* ride the second line. Order is the server's, newest first, with the
* appended active visit last.
*/
export function buildProcedureChoices(
linked: readonly DocumentLinkedProcedureDto[],
active: DocumentLinkedProcedureDto | null,
labels: {
kindName: string;
siteText: (
bodySite: string | null,
laterality: DocumentLinkedProcedureDto["laterality"],
) => string | null;
formatDate: (iso: string) => string;
},
): ProcedureFilterChoice[] {
const sources = [...linked];
if (active && !sources.some((p) => p.encounterId === active.encounterId)) {
sources.push(active);
}
return sources.map((procedure) => {
const site = labels.siteText(procedure.bodySite, procedure.laterality);
const date = labels.formatDate(procedure.occurredAt);
return {
encounterId: procedure.encounterId,
name:
procedure.reason?.trim() ||
procedure.practitionerName?.trim() ||
labels.kindName,
detail: site ? `${site} · ${date}` : date,
};
});
}