Merge remote-tracking branch 'origin/feat/documents-procedure-filter' into release/v1.40.2

This commit is contained in:
Marc-André Bombeck committed 2026-10-05 07:17:17 +02:00
commit 03c43ef70a
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,
};
});
}