fix(labs): read odd filter links kindly and match names without accents

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.
This commit is contained in:
Marc-André Bombeck committed 2026-11-11 00:18:17 +01:00
1 parent a40c9e4ea4
commit cc9cbc4640
7 files changed
+104 -9

No files matched your search

@@ -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,
};
@@ -30,6 +30,13 @@ describe("<LabFilterRail>", () => {
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("<LabFilterRail>", () => {
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(
<I18nProvider initialLocale="en">
<LabFilterRail
filters={category ? { category } : {}}
searchDraft=""
onSearchChange={vi.fn()}
searchInputRef={{ current: null }}
onStatusChange={vi.fn()}
onCategoryChange={vi.fn()}
onFromDayChange={vi.fn()}
onToDayChange={vi.fn()}
onResetFilters={vi.fn()}
isFiltered={Boolean(category)}
categories={one}
total={1}
/>
</I18nProvider>,
);
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");
});
});
@@ -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,
@@ -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 () => {
@@ -385,9 +385,10 @@ describe("<LabList>", () => {
</QueryClientProvider>,
);
// 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");
});
+4 -1
View File
@@ -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) && (
<FilterBarSelect
label={t("labs.filter.categoryLabel")}
value={filters.category ?? "ALL"}
+20 -3
View File
@@ -99,6 +99,12 @@ export function parseLabListSearchParams(
const toDay = dayOrUndefined(params.get("to"));
if (toDay) filters.toDay = toDay;
// A hand-edited or old link can carry the bounds the wrong way round;
// read it as the range it obviously means rather than as an empty one.
if (filters.fromDay && filters.toDay && filters.fromDay > 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;
}