From cc9cbc4640fa144ad6407b76a186aea658a98c5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 11 Nov 2026 00:18:17 +0100 Subject: [PATCH] fix(labs): read odd filter links kindly and match names without accents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A link with the date bounds reversed showed an empty list; it now reads as the range it means. The search ignores accents as well as case, so "hamoglobin" finds "Hämoglobin". The category pill only appears when there are categories to choose between, or when a link already filters by the only one, so the filter can still be cleared where it shows. --- .../lab-filter-rail.browser.test.tsx | 7 ++++ .../labs/__tests__/lab-filter-rail.test.tsx | 42 +++++++++++++++++++ .../labs/__tests__/lab-list-filters.test.ts | 24 +++++++++++ .../lab-list-url-sync.browser.test.tsx | 7 ++-- .../labs/__tests__/lab-list.test.tsx | 5 ++- src/components/labs/lab-filter-rail.tsx | 5 ++- src/components/labs/lab-list-filters.ts | 23 ++++++++-- 7 files changed, 104 insertions(+), 9 deletions(-) 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; }