diff --git a/src/components/labs/__tests__/lab-filter-rail.browser.test.tsx b/src/components/labs/__tests__/lab-filter-rail.browser.test.tsx index a9d934326..a5199067f 100644 --- a/src/components/labs/__tests__/lab-filter-rail.browser.test.tsx +++ b/src/components/labs/__tests__/lab-filter-rail.browser.test.tsx @@ -26,6 +26,13 @@ function props(): LabFilterRailProps { counts: { inRange: 1, below: 0, above: 0, unknown: 0 }, status: "in-range", }, + { + key: "cat_lipids", + label: "Lipids", + markerKeys: ["bm:b2"], + counts: { inRange: 1, below: 0, above: 0, unknown: 0 }, + status: "in-range", + }, ], total: 5, }; diff --git a/src/components/labs/__tests__/lab-filter-rail.test.tsx b/src/components/labs/__tests__/lab-filter-rail.test.tsx index e04ccb3a7..89ffe0233 100644 --- a/src/components/labs/__tests__/lab-filter-rail.test.tsx +++ b/src/components/labs/__tests__/lab-filter-rail.test.tsx @@ -30,6 +30,13 @@ describe("", () => { counts: { inRange: 1, below: 0, above: 0, unknown: 0 }, status: "in-range", }, + { + key: "cat_lipids", + label: "Lipids", + markerKeys: ["bm:b2"], + counts: { inRange: 1, below: 0, above: 0, unknown: 0 }, + status: "in-range", + }, ]} total={5} /> @@ -137,4 +144,39 @@ describe("", () => { expect(html).toContain("Reset"); }); + + it("offers the category pill only when there is more than one category", () => { + const one = [ + { + key: "cat_iron", + label: "Iron Panel", + markerKeys: ["bm:b1"], + counts: { inRange: 1, below: 0, above: 0, unknown: 0 }, + status: "in-range" as const, + }, + ]; + const render = (category?: string) => + renderToStaticMarkup( + + + , + ); + expect(render()).not.toContain("Category"); + // A link that already filters by the one category keeps its pill, so + // the filter can be cleared where it shows. + expect(render("cat_iron")).toContain("Category: Iron Panel"); + }); }); diff --git a/src/components/labs/__tests__/lab-list-filters.test.ts b/src/components/labs/__tests__/lab-list-filters.test.ts index 2dca96802..4affdcb55 100644 --- a/src/components/labs/__tests__/lab-list-filters.test.ts +++ b/src/components/labs/__tests__/lab-list-filters.test.ts @@ -89,6 +89,14 @@ describe("parseLabListSearchParams & labListFiltersToSearch", () => { expect(parseLabListSearchParams(params)).toEqual({}); }); + it("reads reversed date bounds as the range they mean", () => { + expect( + parseLabListSearchParams( + new URLSearchParams("from=2026-09-30&to=2026-09-01"), + ), + ).toEqual({ fromDay: "2026-09-01", toDay: "2026-09-30" }); + }); + it("rejects non-existent calendar dates like 2026-02-30", () => { const params = new URLSearchParams("from=2026-02-30&to=2026-04-31"); expect(parseLabListSearchParams(params)).toEqual({}); @@ -160,6 +168,22 @@ describe("filterMarkerRows", () => { expect(filterMarkerRows(rows, { q: "Iron" })).toEqual([row1, row2]); }); + it("matches the search regardless of case and accents", () => { + const accented = mockMarkerRow({ + key: "bm:hb", + analyte: "Hämoglobin", + panel: "Blutbild", + }); + expect(filterMarkerRows([accented], { q: "HAMOGLOBIN" })).toEqual([ + accented, + ]); + expect(filterMarkerRows([accented], { q: "hämo" })).toEqual([accented]); + expect(filterMarkerRows([accented], { q: "blutbild" })).toEqual([ + accented, + ]); + expect(filterMarkerRows([accented], { q: "ferritin" })).toEqual([]); + }); + it("filters by status: abnormal (below or above)", () => { expect(filterMarkerRows(rows, { status: "abnormal" })).toEqual([ row2, diff --git a/src/components/labs/__tests__/lab-list-url-sync.browser.test.tsx b/src/components/labs/__tests__/lab-list-url-sync.browser.test.tsx index 160b8e4e5..08879aa22 100644 --- a/src/components/labs/__tests__/lab-list-url-sync.browser.test.tsx +++ b/src/components/labs/__tests__/lab-list-url-sync.browser.test.tsx @@ -137,11 +137,12 @@ describe("LabList stale category and group header handling", () => { expect(history.pushes()).toEqual([]); expect(history.replaces()).toEqual([""]); - // Visible filtering ignores stale category: marker is visible, category pill is inactive + // Visible filtering ignores the stale category: the marker is visible, + // and the one real category leaves no category pill behind. await expect.element(screen.getByText("Ferritin")).toBeVisible(); await expect - .element(screen.getByRole("combobox", { name: "Category" })) - .toHaveAttribute("data-active", "false"); + .element(screen.getByRole("combobox", { name: /^Category/ })) + .not.toBeInTheDocument(); }); it("preserves active non-stale filters and scrubs stale category from URL after paint", async () => { diff --git a/src/components/labs/__tests__/lab-list.test.tsx b/src/components/labs/__tests__/lab-list.test.tsx index e5d9ef1a1..9698a9290 100644 --- a/src/components/labs/__tests__/lab-list.test.tsx +++ b/src/components/labs/__tests__/lab-list.test.tsx @@ -385,9 +385,10 @@ describe("", () => { , ); - // Stale category is ignored: marker is not filtered out, category dropdown is inactive + // Stale category is ignored: the marker is not filtered out, and with a + // single category there is no category pill to show it on. expect(html).toContain("Ferritin"); - expect(html).toContain('aria-label="Category" data-active="false"'); + expect(html).not.toContain('aria-label="Category'); expect(html).not.toContain("Reset"); expect(html).not.toContain("stale_unknown_category"); }); diff --git a/src/components/labs/lab-filter-rail.tsx b/src/components/labs/lab-filter-rail.tsx index 813455319..660df9fdf 100644 --- a/src/components/labs/lab-filter-rail.tsx +++ b/src/components/labs/lab-filter-rail.tsx @@ -57,6 +57,9 @@ export function LabFilterRail({ { value: "above", label: t("labs.range.above") }, ]; + // The server's categories in the server's order, so the pill follows the + // saved category order. One category is nothing to choose between, unless + // a link already filters by it and the pill is the way to clear it. const categoryOptions: FilterBarSelectOption[] = (categories ?? []).map( (c) => ({ value: c.key, @@ -142,7 +145,7 @@ export function LabFilterRail({ allLabel={t("labs.filter.statusAll")} /> - {categoryOptions.length > 0 && ( + {(categoryOptions.length > 1 || filters.category) && ( filters.toDay) { + [filters.fromDay, filters.toDay] = [filters.toDay, filters.fromDay]; + } + return filters; } @@ -144,6 +150,14 @@ export function labReadingDayKey(takenAt: string, tz: string): string { return userDayKey(instant, tz); } +/** + * Case- and accent-insensitive form for the search, so "hamoglobin" finds + * "Hämoglobin" and "proteine" finds "Protéine". + */ +function searchFold(value: string): string { + return value.normalize("NFD").replace(/\p{M}/gu, "").toLowerCase(); +} + /** * Filter rows by text search, reference range status, category, and date bounds. */ @@ -153,7 +167,8 @@ export function filterMarkerRows( categoryOf?: (markerKey: string) => string | undefined, dayKeyOf: (takenAt: string) => string = (iso) => iso.slice(0, 10), ): MarkerRow[] { - const query = filters.q?.trim().toLowerCase(); + const trimmed = filters.q?.trim(); + const query = trimmed ? searchFold(trimmed) : undefined; const status = filters.status; const category = filters.category; const fromDay = filters.fromDay; @@ -162,8 +177,10 @@ export function filterMarkerRows( return rows.filter((row) => { // 1. Text search if (query) { - const analyteMatch = row.analyte.toLowerCase().includes(query); - const panelMatch = row.panel?.toLowerCase().includes(query) ?? false; + const analyteMatch = searchFold(row.analyte).includes(query); + const panelMatch = row.panel + ? searchFold(row.panel).includes(query) + : false; if (!analyteMatch && !panelMatch) return false; }