From b4ac67ee7caedd794472622f368f26b5ee7f9a33 Mon Sep 17 00:00:00 2001 From: Konrad Garbal Date: Tue, 10 Nov 2026 19:25:56 +0100 Subject: [PATCH] feat(labs): add biomarker filtering and sorting --- .gitignore | 4 + CHANGELOG.md | 10 + docs/api/openapi.yaml | 10 +- docs/ui-guidelines.md | 15 +- messages/de.json | 15 + messages/en.json | 15 + messages/es.json | 15 + messages/fr.json | 15 + messages/it.json | 15 + messages/ko.json | 15 + messages/nl.json | 15 + messages/pl.json | 15 + messages/pt-BR.json | 15 + messages/ru.json | 15 + ...s-overview-response-consumer-guard.test.ts | 41 +- .../lab-filter-rail.browser.test.tsx | 158 +++++ .../labs/__tests__/lab-filter-rail.test.tsx | 195 ++++++ .../labs/__tests__/lab-list-columns.test.tsx | 5 +- .../labs/__tests__/lab-list-filters.test.ts | 356 +++++++++++ .../lab-list-url-sync.browser.test.tsx | 562 ++++++++++++++++++ .../labs/__tests__/lab-list-url-sync.test.tsx | 249 ++++++++ .../labs/__tests__/lab-list.test.tsx | 384 ++++++++++++ src/components/labs/lab-filter-rail.tsx | 205 +++++++ src/components/labs/lab-list-filters.ts | 228 +++++++ src/components/labs/lab-list-groups.tsx | 119 +++- src/components/labs/lab-list.tsx | 394 +++++++++--- src/components/labs/types.ts | 3 +- src/components/labs/use-lab-list-filters.ts | 150 +++++ src/components/ui/filter-bar.tsx | 41 +- .../use-url-filter-sync.browser.test.tsx | 30 + src/hooks/use-url-filter-sync.ts | 31 +- .../labs/__tests__/reference-range.test.ts | 21 + src/lib/labs/marker-category.ts | 28 +- src/lib/labs/reference-range.ts | 27 + src/lib/module-list-prefs.ts | 36 +- src/lib/openapi/routes/labs.ts | 4 +- 36 files changed, 3287 insertions(+), 169 deletions(-) create mode 100644 src/components/labs/__tests__/lab-filter-rail.browser.test.tsx create mode 100644 src/components/labs/__tests__/lab-filter-rail.test.tsx create mode 100644 src/components/labs/__tests__/lab-list-filters.test.ts create mode 100644 src/components/labs/__tests__/lab-list-url-sync.browser.test.tsx create mode 100644 src/components/labs/__tests__/lab-list-url-sync.test.tsx create mode 100644 src/components/labs/__tests__/lab-list.test.tsx create mode 100644 src/components/labs/lab-filter-rail.tsx create mode 100644 src/components/labs/lab-list-filters.ts create mode 100644 src/components/labs/use-lab-list-filters.ts diff --git a/.gitignore b/.gitignore index 655bb269e4..620d5f46a0 100644 --- a/.gitignore +++ b/.gitignore @@ -101,3 +101,7 @@ next-env.d.ts # Mutation testing (stryker.config.mjs): reports and the sandbox copy. /reports/mutation /.stryker-tmp + +# Local planning and code-review notes (agent output, never part of the +# public docs surface or the shipped tree). +/plans/ diff --git a/CHANGELOG.md b/CHANGELOG.md index b6f74563ea..6182def9e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ ## [Unreleased] +### Added + +- **Biomarker list filtering and sorting.** The labs overview list gains + search, reference-range status (out of range, in range, above, below), + category, and date-range filtering alongside on-page sort options. Filter + state is synced to the URL (`?q`, `?status`, `?category`, `?from`, `?to`, + `?sort`) for shareable and navigable views, with local day-boundary alignment, + singular/few/other pluralized counts, and reactive group header counts and + statuses. + ## [1.47.1] — 2026-11-07 ### Added diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 45964de7d4..8736679267 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -77172,10 +77172,9 @@ components: description: "One category of the labs list, resolved by the server. `key` is opaque: a catalog panel key, `custom:` for the person's own panel, or `other`; it carries no user text and is stable, so a client may key collapse state on it. `label` is rendered as given (a catalog panel in the caller's locale, the person's own - spelling, or the localised \"Other\"). `markerKeys` names the `markers[].key` values in the category. `status` - combines the members' newest readings: `above` when any is above its range, else `below` when any is below, else - `in-range` when any was assessed, else `unknown`. A client renders these values and never rebuilds the category, - the order or the status." + spelling, or the localised \"Other\"). `markerKeys` names the `markers[].key` values in the category. `counts` + and `status` describe the full category without filters; a client renders these values directly when unfiltered, + and may summarize the visible row subset client-side while filters are active." LabOverviewCategoryCounts: type: object properties: @@ -77201,7 +77200,8 @@ components: - above - unknown additionalProperties: false - description: How many members' newest readings are in range, below, above, or unassessed (qualitative, or no range). + description: How many members' newest readings are in range, below, above, or unassessed (qualitative, or no range) for + the full category without filters. GetLabResultResponse: type: object properties: diff --git a/docs/ui-guidelines.md b/docs/ui-guidelines.md index 5b00604e22..1eeb0af918 100644 --- a/docs/ui-guidelines.md +++ b/docs/ui-guidelines.md @@ -236,15 +236,20 @@ lists) uses the unified `` rail from `src/components/ui/filter-bar.tsx` — never a per-page row of large labelled ` onSearchChange(e.target.value)} + placeholder={t("labs.filter.searchPlaceholder")} + aria-label={t("labs.filter.searchLabel")} + aria-keyshortcuts="/" + className="w-full min-w-0 pr-11 pl-9 [&::-webkit-search-cancel-button]:appearance-none" + /> + {searchDraft === "" ? ( + + ) : ( + + )} + + + + + { + if (isLabStatusFilter(val)) onStatusChange(val); + }} + options={statusOptions} + allValue="ALL" + allLabel={t("labs.filter.statusAll")} + /> + + {categoryOptions.length > 0 && ( + + )} + + + + + + + ); +} diff --git a/src/components/labs/lab-list-filters.ts b/src/components/labs/lab-list-filters.ts new file mode 100644 index 0000000000..74795159fd --- /dev/null +++ b/src/components/labs/lab-list-filters.ts @@ -0,0 +1,228 @@ +"use client"; + +/** + * Pure URL-facet helpers for the labs overview list, following the + * documents-vault and measurements patterns: the list's filter and sort state + * lives in the page URL (?q&status&category&from&to&sort) so a filtered view is + * deep-linkable and survives navigation, reload and back/forward. Parsing is + * lenient — an unknown facet drops silently — and serialisation omits defaults + * so the unfiltered list keeps a clean URL. + */ +import { applyOrder } from "@/lib/module-list-prefs"; +import { + isModuleSortDir, + type ModuleSortDir, +} from "@/lib/settings/module-list-layout"; +import { + dateOnlyKey, + isCalendarDateKey, + isNoonUtcAnchor, +} from "@/lib/tz/date-only"; +import { userDayKey } from "@/lib/tz/format"; +import type { MarkerRow } from "./lab-list-rows"; +import type { LabOverviewCategoryDto } from "./types"; + +export const LAB_STATUS_FILTERS = [ + "ALL", + "abnormal", + "in-range", + "below", + "above", +] as const; +export type LabStatusFilter = (typeof LAB_STATUS_FILTERS)[number]; + +export function isLabStatusFilter(value: unknown): value is LabStatusFilter { + return typeof value === "string" && STATUS_SET.has(value); +} + +export interface LabListFilters { + /** Case-insensitive substring search over analyte name and panel. */ + q?: string; + /** Status filter: ALL | abnormal | in-range | below | above. */ + status?: LabStatusFilter; + /** Category key (matches category.key) or ALL. */ + category?: string; + /** Inclusive day bounds, YYYY-MM-DD for newest reading (latest.takenAt). */ + fromDay?: string; + toDay?: string; + /** On-page sort override: recentDesc | recentAsc | alphaAsc | alphaDesc | manual. */ + sort?: ModuleSortDir; +} + +const STATUS_SET = new Set(LAB_STATUS_FILTERS); + +function dayOrUndefined(value: string | null): string | undefined { + const trimmed = value?.trim(); + return trimmed && isCalendarDateKey(trimmed) ? trimmed : undefined; +} + +/** + * Build a lookup map from markerKey (`bm:` or `analyte:`) to category.key. + */ +export function buildMarkerCategoryMap( + categories: readonly LabOverviewCategoryDto[] | undefined | null, +): Map { + const map = new Map(); + if (!categories) return map; + for (const cat of categories) { + for (const key of cat.markerKeys) { + map.set(key, cat.key); + } + } + return map; +} + +/** + * Parse the labs overview URL search params into the filter object. + */ +export function parseLabListSearchParams( + params: URLSearchParams, +): LabListFilters { + const filters: LabListFilters = {}; + + const q = params.get("q")?.trim(); + if (q) filters.q = q; + + const status = params.get("status")?.trim(); + if (status && isLabStatusFilter(status) && status !== "ALL") { + filters.status = status; + } + + const category = params.get("category")?.trim(); + if (category && category !== "ALL") { + filters.category = category; + } + + const fromDay = dayOrUndefined(params.get("from")); + if (fromDay) filters.fromDay = fromDay; + + const toDay = dayOrUndefined(params.get("to")); + if (toDay) filters.toDay = toDay; + + const sort = params.get("sort")?.trim(); + if (sort && isModuleSortDir(sort)) { + filters.sort = sort; + } + + return filters; +} + +/** + * Serialise the filter object back into the page URL query string (no leading `?`). + * Defaults/ALL values are omitted. + */ +export function labListFiltersToSearch( + filters: LabListFilters, + defaultSort: ModuleSortDir = "recentDesc", +): string { + const sp = new URLSearchParams(); + if (filters.q?.trim()) sp.set("q", filters.q.trim()); + if (filters.status && filters.status !== "ALL") + sp.set("status", filters.status); + if (filters.category && filters.category !== "ALL") { + sp.set("category", filters.category); + } + if (filters.fromDay) sp.set("from", filters.fromDay); + if (filters.toDay) sp.set("to", filters.toDay); + if (filters.sort && filters.sort !== defaultSort) sp.set("sort", filters.sort); + return sp.toString(); +} + +/** + * Resolve the local calendar day key (`YYYY-MM-DD`) for a lab reading. + * + * Data model semantics & date-only heuristic: + * The data model (`LabResult` in Prisma and `LabResultDto`) stores `takenAt` as + * an ISO timestamp and does not have an explicit date-only marker column (e.g. + * `hasTime` or `dateOnly`). Instead, the codebase convention (`@/lib/tz/date-only`) + * stores date-only imports at 12:00:00.000 UTC (`dateOnlyAtNoonUtc`). + * + * Inherent ambiguity: + * An instant reading entered at exactly 12:00:00.000Z is indistinguishable from a + * date-only anchor without schema changes. We preserve the `isNoonUtcAnchor` + * heuristic here so that the day key used for filtering matches exactly what the + * row displays to the user via `useLabDate` (`calendarDate(instant)` for noon-UTC + * anchors, avoiding timezone-shift discrepancies in timezones like UTC+14). + * Timestamps with a time of day resolve in the user's timezone via `userDayKey`. + */ +export function labReadingDayKey(takenAt: string, tz: string): string { + const instant = new Date(takenAt); + if (isNoonUtcAnchor(instant)) { + return dateOnlyKey(instant); + } + return userDayKey(instant, tz); +} + +/** + * Filter rows by text search, reference range status, category, and date bounds. + */ +export function filterMarkerRows( + rows: readonly MarkerRow[], + filters: LabListFilters, + categoryOf?: (markerKey: string) => string | undefined, + dayKeyOf: (takenAt: string) => string = (iso) => iso.slice(0, 10), +): MarkerRow[] { + const query = filters.q?.trim().toLowerCase(); + const status = filters.status; + const category = filters.category; + const fromDay = filters.fromDay; + const toDay = filters.toDay; + + return rows.filter((row) => { + // 1. Text search + if (query) { + const analyteMatch = row.analyte.toLowerCase().includes(query); + const panelMatch = row.panel?.toLowerCase().includes(query) ?? false; + if (!analyteMatch && !panelMatch) return false; + } + + // 2. Reference range status + if (status && status !== "ALL") { + const rowStatus = row.latest.rangeStatus; + if (status === "abnormal") { + if (rowStatus !== "below" && rowStatus !== "above") return false; + } else if (rowStatus !== status) { + return false; + } + } + + // 3. Category — fail closed when categoryOf is unavailable + if (category && category !== "ALL") { + if (!categoryOf) return false; + const markerCategory = categoryOf(row.key); + if (markerCategory !== category) return false; + } + + // 4. Date bounds on latest reading + if (fromDay || toDay) { + const takenDate = dayKeyOf(row.latest.takenAt); + if (fromDay && takenDate < fromDay) return false; + if (toDay && takenDate > toDay) return false; + } + + return true; + }); +} + +/** + * Sort marker rows according to the chosen sort direction and optional manual order. + */ +export function sortMarkerRows( + rows: readonly MarkerRow[], + sortDir: ModuleSortDir, + manualOrder?: readonly string[], +): MarkerRow[] { + if (sortDir === "recentAsc") return [...rows].reverse(); + if (sortDir === "alphaAsc" || sortDir === "alphaDesc") { + const dir = sortDir === "alphaAsc" ? 1 : -1; + return [...rows].sort( + (a, b) => + dir * + a.analyte.localeCompare(b.analyte, undefined, { sensitivity: "base" }), + ); + } + if (sortDir === "manual" && manualOrder && manualOrder.length > 0) { + return applyOrder(rows, manualOrder, (row) => row.biomarkerId ?? row.key); + } + return [...rows]; +} diff --git a/src/components/labs/lab-list-groups.tsx b/src/components/labs/lab-list-groups.tsx index 9d3f896959..ca6e931216 100644 --- a/src/components/labs/lab-list-groups.tsx +++ b/src/components/labs/lab-list-groups.tsx @@ -5,9 +5,11 @@ * header itself, and which categories are open. * * Everything about a category comes from the server (`categories` on - * `GET /api/labs/overview`): its members, label, order, counts and status. - * This module only places the already-sorted rows under them and builds the - * status sentence from the counts; it never decides a category or a status. + * `GET /api/labs/overview`): its members, label, order, counts and status, + * which describe the full category. When no effective list filters are + * active, this module renders those server category counts and status; + * when filters narrow the visible rows, it summarizes the visible subset + * client-side. */ import { useMemo, useState } from "react"; import { ChevronDown } from "lucide-react"; @@ -16,6 +18,7 @@ import { ScorePill } from "@/components/ui/score-pill"; import { TagChip } from "@/components/ui/tag-chip"; import { useMounted } from "@/hooks/use-mounted"; import { useTranslations } from "@/lib/i18n/context"; +import { priorityReferenceRangeStatus } from "@/lib/labs/reference-range"; import type { ModuleGroupsOpen } from "@/lib/module-list-prefs"; import { cn } from "@/lib/utils"; @@ -97,12 +100,14 @@ export function writeStoredOpen( } function openByDefault( - category: LabOverviewCategoryDto, mode: ModuleGroupsOpen, + status: LabOverviewCategoryDto["status"], ): boolean { if (mode === "expanded") return true; if (mode === "collapsed") return false; - return category.status === "above" || category.status === "below"; + // Consumers supply category.status for unfiltered categories, or the + // summarised status for filtered groups. + return status === "above" || status === "below"; } /** @@ -112,8 +117,14 @@ function openByDefault( * itself changes. Storage is read only after hydration and may be missing. */ export function useGroupsOpen(mode: ModuleGroupsOpen): { - isOpen: (category: LabOverviewCategoryDto) => boolean; - toggle: (category: LabOverviewCategoryDto) => void; + isOpen: ( + category: LabOverviewCategoryDto, + status: LabOverviewCategoryDto["status"], + ) => boolean; + toggle: ( + category: LabOverviewCategoryDto, + status: LabOverviewCategoryDto["status"], + ) => void; } { const mounted = useMounted(); const stored = useMemo( @@ -123,28 +134,71 @@ export function useGroupsOpen(mode: ModuleGroupsOpen): { const [toggled, setToggled] = useState({ mode, open: {} }); const own = toggled.mode === mode ? toggled.open : {}; - const isOpen = (category: LabOverviewCategoryDto) => - own[category.key] ?? stored[category.key] ?? openByDefault(category, mode); - const toggle = (category: LabOverviewCategoryDto) => { - const next = { ...stored, ...own, [category.key]: !isOpen(category) }; + const isOpen = ( + category: LabOverviewCategoryDto, + status: LabOverviewCategoryDto["status"], + ) => + own[category.key] ?? + stored[category.key] ?? + openByDefault(mode, status); + + const toggle = ( + category: LabOverviewCategoryDto, + status: LabOverviewCategoryDto["status"], + ) => { + const next = { + ...stored, + ...own, + [category.key]: !isOpen(category, status), + }; setToggled({ mode, open: next }); writeStoredOpen(mode, next); }; return { isOpen, toggle }; } -/** The status of a category, from the counts the server sent. */ -function GroupStatus({ category }: { category: LabOverviewCategoryDto }) { +export interface LabGroupSummary { + counts: LabOverviewCategoryDto["counts"]; + status: LabOverviewCategoryDto["status"]; +} + +/** Summarise counts and overall status for a list of rows. */ +export function summariseGroupRows(rows: readonly MarkerRow[]): LabGroupSummary { + const counts = { inRange: 0, below: 0, above: 0, unknown: 0 }; + for (const row of rows) { + const s = row.latest.rangeStatus; + if (s === "in-range") counts.inRange += 1; + else if (s === "below") counts.below += 1; + else if (s === "above") counts.above += 1; + else counts.unknown += 1; + } + const status = priorityReferenceRangeStatus(counts); + return { counts, status }; +} + +/** + * Group-status semantics: + * When filters or search are active, the group summary (both the count badge + * and the range status pill, e.g. "All in range" or "X of Y outside range") + * describes only the visible rows currently matching the filters in this + * group, rather than unfiltered server category totals. + */ +function GroupStatus({ + counts, + status, +}: { + counts: LabOverviewCategoryDto["counts"]; + status: LabOverviewCategoryDto["status"]; +}) { const { t, tCount } = useTranslations(); - if (category.status === "unknown") return null; - if (category.status === "in-range") { + if (status === "unknown") return null; + if (status === "in-range") { return {t("labs.groups.allInRange")}; } - const { inRange, below, above, unknown } = category.counts; return ( - - {tCount("labs.groups.outsideCount", above + below, { - total: inRange + below + above + unknown, + + {tCount("labs.groups.outsideCount", counts.above + counts.below, { + total: counts.inRange + counts.below + counts.above + counts.unknown, })} ); @@ -158,17 +212,40 @@ function GroupStatus({ category }: { category: LabOverviewCategoryDto }) { */ export function LabGroupHeader({ category, + rows, open, onToggle, panelId, className, + isFiltered = false, + status: statusProp, + summary: summaryProp, }: { category: LabOverviewCategoryDto; + rows: readonly MarkerRow[]; open: boolean; onToggle: () => void; panelId: string; className?: string; + isFiltered?: boolean; + status?: LabOverviewCategoryDto["status"]; + summary?: LabGroupSummary; }) { + const { counts, status } = useMemo(() => { + if (isFiltered) { + const summary = summaryProp ?? summariseGroupRows(rows); + return { + counts: summary.counts, + status: statusProp ?? summary.status, + }; + } + const { inRange, below, above, unknown } = category.counts; + return { + counts: { inRange, below, above, unknown }, + status: statusProp ?? category.status, + }; + }, [category, rows, isFiltered, statusProp, summaryProp]); + return (

diff --git a/src/components/labs/lab-list.tsx b/src/components/labs/lab-list.tsx index d2feca2360..efb6cb379b 100644 --- a/src/components/labs/lab-list.tsx +++ b/src/components/labs/lab-list.tsx @@ -1,7 +1,7 @@ "use client"; import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; -import { useId, useMemo, useEffect } from "react"; +import { useCallback, useId, useMemo, useEffect } from "react"; import { useQuery } from "@tanstack/react-query"; import { FlaskConical } from "lucide-react"; @@ -11,18 +11,27 @@ import { EmptyState } from "@/components/ui/empty-state"; import { QueryErrorCard } from "@/components/ui/query-error-card"; import { Skeleton } from "@/components/ui/skeleton"; import { apiGet } from "@/lib/api/api-fetch"; -import { useTranslations } from "@/lib/i18n/context"; +import { useDisplayTimezone, useTranslations } from "@/lib/i18n/context"; import { cn } from "@/lib/utils"; import { queryKeys } from "@/lib/query-keys"; import { - applyOrder, useModuleListPrefs, - type ModuleListPrefs, + type ModuleListView, + type ModuleSortDir, } from "@/lib/module-list-prefs"; +import { LabFilterRail } from "./lab-filter-rail"; +import { + buildMarkerCategoryMap, + filterMarkerRows, + labReadingDayKey, + sortMarkerRows, +} from "./lab-list-filters"; +import { useLabListFilters } from "./use-lab-list-filters"; import { groupMarkerRows, LabGroupHeader, + summariseGroupRows, useGroupsOpen, type MarkerGroup, } from "./lab-list-groups"; @@ -34,30 +43,10 @@ import { toMarkerRows, type MarkerRow, } from "./lab-list-rows"; -import type { LabOverviewResponse } from "./types"; - -/** - * The person's Labs sort over the server's most-recent-first order: - * `recentAsc` reverses, the alpha options sort by analyte name (#43), and - * `manual` applies the persisted biomarker order (legacy un-linked rows, which - * carry no biomarkerId, sort after the ordered block). Under categories the - * same order holds inside each category. - */ -function sortRows(rows: MarkerRow[], prefs: ModuleListPrefs): MarkerRow[] { - if (prefs.sortDir === "recentAsc") return [...rows].reverse(); - if (prefs.sortDir === "alphaAsc" || prefs.sortDir === "alphaDesc") { - const dir = prefs.sortDir === "alphaAsc" ? 1 : -1; - return [...rows].sort( - (a, b) => - dir * - a.analyte.localeCompare(b.analyte, undefined, { sensitivity: "base" }), - ); - } - if (prefs.sortDir === "manual") { - return applyOrder(rows, prefs.order, (row) => row.biomarkerId ?? row.key); - } - return rows; -} +import type { + LabOverviewCategoryDto, + LabOverviewResponse, +} from "./types"; type GroupsOpen = ReturnType; @@ -66,10 +55,12 @@ function LabTable({ rows, groups, groupsOpen, + isFiltered, }: { rows: MarkerRow[]; groups: MarkerGroup[] | null; groupsOpen: GroupsOpen; + isFiltered: boolean; }) { return ( // `lab-list` marks the read results in both view shapes. The loading @@ -84,6 +75,7 @@ function LabTable({ key={group.category.key} group={group} groupsOpen={groupsOpen} + isFiltered={isFiltered} /> )) : rows.map((row) => )} @@ -93,11 +85,15 @@ function LabTable({ ); } -function useGroupToggle(group: MarkerGroup, groupsOpen: GroupsOpen) { +function useGroupToggle( + category: LabOverviewCategoryDto, + groupsOpen: GroupsOpen, + status: LabOverviewCategoryDto["status"], +) { const panelId = `${useId()}-panel`; return { - open: groupsOpen.isOpen(group.category), - onToggle: () => groupsOpen.toggle(group.category), + open: groupsOpen.isOpen(category, status), + onToggle: () => groupsOpen.toggle(category, status), panelId, }; } @@ -109,18 +105,33 @@ function useGroupToggle(group: MarkerGroup, groupsOpen: GroupsOpen) { function TableGroup({ group, groupsOpen, + isFiltered, }: { group: MarkerGroup; groupsOpen: GroupsOpen; + isFiltered: boolean; }) { - const { open, onToggle, panelId } = useGroupToggle(group, groupsOpen); + const summary = useMemo( + () => (isFiltered ? summariseGroupRows(group.rows) : undefined), + [isFiltered, group.rows], + ); + const status = summary?.status ?? group.category.status; + const { open, onToggle, panelId } = useGroupToggle( + group.category, + groupsOpen, + status, + ); return (
(isFiltered ? summariseGroupRows(group.rows) : undefined), + [isFiltered, group.rows], + ); + const status = summary?.status ?? group.category.status; + const { open, onToggle, panelId } = useGroupToggle( + group.category, + groupsOpen, + status, + ); return (