diff --git a/CHANGELOG.md b/CHANGELOG.md index 6065a7d78..cb8d67402 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,16 @@ ### Added +- **Search and filter the labs list.** Once the list ran past a + screenful, finding one marker meant scrolling. The labs page now has a + search field (press / to jump to it) that matches a marker's name or + panel, and filters for the newest reading's status (out of range, in + range, below or above), the category, and when a marker was last + measured. While a filter is active every category with a match is + open, and its header counts only the matches. The filters are part of + the page address, so a filtered view survives a reload and + back/forward and can be shared as a link. The list keeps the sort + order set under Settings > Arrangement > Labs. (#1370) - **Choose how the calendar subscription draws a forecast.** A logged period of a few days next to a forecast spanning more than a week was hard to read, because the forecast event covered every day the period diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 3297361ea..f3300ccaa 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -77275,7 +77275,8 @@ components: 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." + the order or the status. A client that shows only the members matching a filter may summarise that subset by the + same rule; without a filter it shows `counts` and `status` as sent." LabOverviewCategoryCounts: type: object properties: diff --git a/messages/de.json b/messages/de.json index e7bc53513..cb5657f98 100644 --- a/messages/de.json +++ b/messages/de.json @@ -10699,6 +10699,22 @@ "unit": { "none": "Keine Einheit", "noneHint": "Für einen Quotienten oder Index wie INR oder Cholesterin/HDL." + }, + "filter": { + "searchPlaceholder": "Biomarker suchen…", + "searchLabel": "Biomarker suchen", + "clearSearch": "Suche leeren", + "lastMeasured": "Zuletzt gemessen", + "statusLabel": "Status", + "statusAll": "Alle Status", + "statusAbnormal": "Außerhalb des Bereichs", + "categoryLabel": "Kategorie", + "categoryAll": "Alle Kategorien", + "markerCountOne": "{count} Biomarker", + "markerCountFew": "{count} Biomarker", + "markerCountOther": "{count} Biomarker", + "noMatchesTitle": "Keine passenden Biomarker", + "noMatchesDescription": "Passe deinen Suchbegriff oder die Filter an." } }, "modules": { diff --git a/messages/en.json b/messages/en.json index f2d3a970d..cdc7a3e1c 100644 --- a/messages/en.json +++ b/messages/en.json @@ -10699,6 +10699,22 @@ "unit": { "none": "No unit", "noneHint": "For a ratio or an index such as INR or cholesterol/HDL." + }, + "filter": { + "searchPlaceholder": "Search biomarkers…", + "searchLabel": "Search biomarkers", + "clearSearch": "Clear search", + "lastMeasured": "Last measured", + "statusLabel": "Status", + "statusAll": "All statuses", + "statusAbnormal": "Out of range", + "categoryLabel": "Category", + "categoryAll": "All categories", + "markerCountOne": "{count} biomarker", + "markerCountFew": "{count} biomarkers", + "markerCountOther": "{count} biomarkers", + "noMatchesTitle": "No matching biomarkers", + "noMatchesDescription": "Try adjusting your search query or filters." } }, "modules": { diff --git a/messages/es.json b/messages/es.json index aea9a878c..f1fe3f6dc 100644 --- a/messages/es.json +++ b/messages/es.json @@ -10699,6 +10699,22 @@ "unit": { "none": "Sin unidad", "noneHint": "Para un cociente o un índice como el INR o colesterol/HDL." + }, + "filter": { + "searchPlaceholder": "Buscar biomarcadores…", + "searchLabel": "Buscar biomarcadores", + "clearSearch": "Borrar búsqueda", + "lastMeasured": "Última medición", + "statusLabel": "Estado", + "statusAll": "Todos los estados", + "statusAbnormal": "Fuera de rango", + "categoryLabel": "Categoría", + "categoryAll": "Todas las categorías", + "markerCountOne": "{count} biomarcador", + "markerCountFew": "{count} biomarcadores", + "markerCountOther": "{count} biomarcadores", + "noMatchesTitle": "No se encontraron biomarcadores", + "noMatchesDescription": "Intenta cambiar los términos de búsqueda o los filtros." } }, "modules": { diff --git a/messages/fr.json b/messages/fr.json index 8ee397390..9d314b68a 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -10699,6 +10699,22 @@ "unit": { "none": "Sans unité", "noneHint": "Pour un rapport ou un indice comme l'INR ou cholestérol/HDL." + }, + "filter": { + "searchPlaceholder": "Rechercher des biomarqueurs…", + "searchLabel": "Rechercher des biomarqueurs", + "clearSearch": "Effacer la recherche", + "lastMeasured": "Dernière mesure", + "statusLabel": "Statut", + "statusAll": "Tous les statuts", + "statusAbnormal": "Hors normes", + "categoryLabel": "Catégorie", + "categoryAll": "Toutes les catégories", + "markerCountOne": "{count} biomarqueur", + "markerCountFew": "{count} biomarqueurs", + "markerCountOther": "{count} biomarqueurs", + "noMatchesTitle": "Aucun biomarqueur trouvé", + "noMatchesDescription": "Essayez d'ajuster votre recherche ou vos filtres." } }, "modules": { diff --git a/messages/it.json b/messages/it.json index 13db50090..76cd79a7d 100644 --- a/messages/it.json +++ b/messages/it.json @@ -10699,6 +10699,22 @@ "unit": { "none": "Nessuna unità", "noneHint": "Per un rapporto o un indice come l'INR o colesterolo/HDL." + }, + "filter": { + "searchPlaceholder": "Cerca biomarcatori…", + "searchLabel": "Cerca biomarcatori", + "clearSearch": "Cancella ricerca", + "lastMeasured": "Ultima misurazione", + "statusLabel": "Stato", + "statusAll": "Tutti gli stati", + "statusAbnormal": "Fuori intervallo", + "categoryLabel": "Categoria", + "categoryAll": "Tutte le categorie", + "markerCountOne": "{count} biomarcatore", + "markerCountFew": "{count} biomarcatori", + "markerCountOther": "{count} biomarcatori", + "noMatchesTitle": "Nessun biomarcatore trovato", + "noMatchesDescription": "Prova a modificare i termini di ricerca o i filtri." } }, "modules": { diff --git a/messages/ko.json b/messages/ko.json index 0e4464586..7d3a9009e 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -10699,6 +10699,22 @@ "unit": { "none": "단위 없음", "noneHint": "INR이나 콜레스테롤/HDL 같은 비율 또는 지수에 사용해요." + }, + "filter": { + "searchPlaceholder": "바이오마커 검색…", + "searchLabel": "바이오마커 검색", + "clearSearch": "검색어 지우기", + "lastMeasured": "마지막 측정", + "statusLabel": "상태", + "statusAll": "모든 상태", + "statusAbnormal": "범위 벗어남", + "categoryLabel": "카테고리", + "categoryAll": "모든 카테고리", + "markerCountOne": "바이오마커 {count}개", + "markerCountFew": "바이오마커 {count}개", + "markerCountOther": "바이오마커 {count}개", + "noMatchesTitle": "일치하는 바이오마커 없음", + "noMatchesDescription": "검색어 또는 필터를 조정해 보세요." } }, "modules": { diff --git a/messages/nl.json b/messages/nl.json index c78d5a4ab..ffbcddc34 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -10699,6 +10699,22 @@ "unit": { "none": "Geen eenheid", "noneHint": "Voor een verhouding of index zoals INR of cholesterol/HDL." + }, + "filter": { + "searchPlaceholder": "Biomarkers zoeken…", + "searchLabel": "Biomarkers zoeken", + "clearSearch": "Zoekopdracht wissen", + "lastMeasured": "Laatst gemeten", + "statusLabel": "Status", + "statusAll": "Alle statussen", + "statusAbnormal": "Buiten bereik", + "categoryLabel": "Categorie", + "categoryAll": "Alle categorieën", + "markerCountOne": "{count} biomarker", + "markerCountFew": "{count} biomarkers", + "markerCountOther": "{count} biomarkers", + "noMatchesTitle": "Geen passende biomarkers", + "noMatchesDescription": "Probeer je zoekopdracht of filters aan te passen." } }, "modules": { diff --git a/messages/pl.json b/messages/pl.json index a411d2f18..56015a3d5 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -10699,6 +10699,22 @@ "unit": { "none": "Bez jednostki", "noneHint": "Dla ilorazu lub wskaźnika, np. INR albo cholesterol/HDL." + }, + "filter": { + "searchPlaceholder": "Szukaj biomarkerów…", + "searchLabel": "Szukaj biomarkerów", + "clearSearch": "Wyczyść wyszukiwanie", + "lastMeasured": "Ostatni pomiar", + "statusLabel": "Status", + "statusAll": "Wszystkie statusy", + "statusAbnormal": "Poza zakresem", + "categoryLabel": "Kategoria", + "categoryAll": "Wszystkie kategorie", + "markerCountOne": "{count} biomarker", + "markerCountFew": "{count} biomarkery", + "markerCountOther": "{count} biomarkerów", + "noMatchesTitle": "Brak pasujących biomarkerów", + "noMatchesDescription": "Spróbuj zmienić zapytanie lub zresetować filtry." } }, "modules": { diff --git a/messages/pt-BR.json b/messages/pt-BR.json index 754f44ed9..5e354a4ef 100644 --- a/messages/pt-BR.json +++ b/messages/pt-BR.json @@ -10699,6 +10699,22 @@ "unit": { "none": "Sem unidade", "noneHint": "Para uma razão ou um índice como INR ou colesterol/HDL." + }, + "filter": { + "searchPlaceholder": "Buscar biomarcadores…", + "searchLabel": "Buscar biomarcadores", + "clearSearch": "Limpar busca", + "lastMeasured": "Última medição", + "statusLabel": "Status", + "statusAll": "Todos os status", + "statusAbnormal": "Fora da faixa", + "categoryLabel": "Categoria", + "categoryAll": "Todas as categorias", + "markerCountOne": "{count} biomarcador", + "markerCountFew": "{count} biomarcadores", + "markerCountOther": "{count} biomarcadores", + "noMatchesTitle": "Nenhum biomarcador encontrado", + "noMatchesDescription": "Tente ajustar sua busca ou os filtros." } }, "modules": { diff --git a/messages/ru.json b/messages/ru.json index f3c9d7077..fd3b9d550 100644 --- a/messages/ru.json +++ b/messages/ru.json @@ -10699,6 +10699,22 @@ "unit": { "none": "Без единицы", "noneHint": "Для коэффициента или индекса, например МНО или холестерин/ЛПВП." + }, + "filter": { + "searchPlaceholder": "Поиск биомаркеров…", + "searchLabel": "Поиск биомаркеров", + "clearSearch": "Очистить поиск", + "lastMeasured": "Последнее измерение", + "statusLabel": "Статус", + "statusAll": "Все статусы", + "statusAbnormal": "Вне нормы", + "categoryLabel": "Категория", + "categoryAll": "Все категории", + "markerCountOne": "{count} биомаркер", + "markerCountFew": "{count} биомаркера", + "markerCountOther": "{count} биомаркеров", + "noMatchesTitle": "Биомаркеры не найдены", + "noMatchesDescription": "Попробуйте изменить поисковый запрос или фильтры." } }, "modules": { diff --git a/src/__tests__/labs-overview-response-consumer-guard.test.ts b/src/__tests__/labs-overview-response-consumer-guard.test.ts index 5f5e5cc2b..bbd9ddc95 100644 --- a/src/__tests__/labs-overview-response-consumer-guard.test.ts +++ b/src/__tests__/labs-overview-response-consumer-guard.test.ts @@ -93,9 +93,10 @@ const READERS: Record = { file: GROUPS, pattern: COUNTS_DESTRUCTURE("unknown"), }, + // Code lines only: a comment that names `category.status` is not a reader. "overview:categories[].status": { file: GROUPS, - pattern: /\bcategory\.status\s*===\s*"above"/, + pattern: /^(?![ \t]*(?:\/\/|\/?\*)).*\bcategory\.status\b/m, }, "biomarkers:customPanels[]": { file: FORM_PANEL, @@ -132,8 +133,9 @@ describe("labs categories response/consumer guard", () => { const owned = ownedLeaves(); it("walks the schemas it guards", () => { - // Nine category leaves, `grouped`, the panels and the adopted panel, plus - // the exempt marker rows; far fewer means the walk broke. + // Category leaves (key, label, markerKeys[], counts.*, status), `grouped`, + // the panels and the adopted panel, plus the exempt marker rows; far fewer + // means the walk broke. expect(owned.filter((leaf) => !isExempt(leaf)).length).toBe( Object.keys(READERS).length, ); @@ -159,8 +161,41 @@ describe("labs categories response/consumer guard", () => { expect( COUNTS_DESTRUCTURE("inRange").test("const { below } = category.counts;"), ).toBe(false); + expect( + READERS["overview:categories[].status"].pattern.test( + "category.otherStatus", + ), + ).toBe(false); + expect( + READERS["overview:categories[].status"].pattern.test( + " // Consumers supply category.status for unfiltered categories.", + ), + ).toBe(false); + expect( + READERS["overview:categories[].status"].pattern.test( + " status: statusProp ?? category.status,", + ), + ).toBe(true); expect(READERS["overview:grouped"].pattern.test("data?.groupedX")).toBe( false, ); }); + + it("guards filtered summary branch for counts and status", () => { + // When filters narrow the visible rows, LabGroupHeader reads `summary.counts` + // and `summary.status` (summarised client-side), rather than unfiltered server category totals. + const groupsSource = readSource(GROUPS, [ + /\bsummary\.counts\b/, + /\bsummary\.status\b/, + ]); + expect(/\bsummary\.counts\b/.test(groupsSource)).toBe(true); + expect(/\bsummary\.status\b/.test(groupsSource)).toBe(true); + + const listSource = readSource(LIST, [ + /\bsummariseGroupRows\b/, + /\bsummary\?\.status\b/, + ]); + expect(/\bsummariseGroupRows\b/.test(listSource)).toBe(true); + expect(/\bsummary\?\.status\b/.test(listSource)).toBe(true); + }); }); diff --git a/src/components/labs/__tests__/lab-filter-rail.browser.test.tsx b/src/components/labs/__tests__/lab-filter-rail.browser.test.tsx new file mode 100644 index 000000000..2a38d01e1 --- /dev/null +++ b/src/components/labs/__tests__/lab-filter-rail.browser.test.tsx @@ -0,0 +1,224 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { page, userEvent } from "vitest/browser"; +import { useState } from "react"; + +import { renderWithApp } from "../../../../tests/browser/render"; +import { LabFilterRail, type LabFilterRailProps } from "../lab-filter-rail"; +import { useLabListFilters } from "../use-lab-list-filters"; + +function props(): LabFilterRailProps { + return { + filters: {}, + searchDraft: "", + onSearchChange: vi.fn(), + searchInputRef: { current: null }, + onStatusChange: vi.fn(), + onCategoryChange: vi.fn(), + onFromDayChange: vi.fn(), + onToDayChange: vi.fn(), + onResetFilters: vi.fn(), + isFiltered: false, + categories: [ + { + key: "cat_iron", + label: "Iron Panel", + markerKeys: ["bm:b1"], + 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, + }; +} + +function centerY(element: Element): number { + const rect = element.getBoundingClientRect(); + return rect.top + rect.height / 2; +} + +describe(" layout", () => { + afterEach(async () => { + await page.viewport(1280, 900); + }); + + it("keeps search full width and wraps filter controls on phones", async () => { + await page.viewport(390, 844); + // The page gutter the labs page gives the rail (its edge bleed relies on it). + const { screen } = await renderWithApp( +
+ +
, + ); + + const search = screen.getByRole("searchbox", { name: "Search biomarkers" }); + const dateRange = screen.getByRole("button", { name: "Last measured" }); + const searchRect = search.element().getBoundingClientRect(); + const dateRect = dateRange.element().getBoundingClientRect(); + + expect(searchRect.width).toBeGreaterThan(340); + expect(dateRect.top).toBeGreaterThan(searchRect.bottom); + + // The filter icon leads the pill row instead of sitting on a line of + // its own between the search field and the pills. + const icon = dateRange + .element() + .closest('[data-slot="filter-bar"]') + ?.querySelector("svg.lucide-list-filter, svg.lucide-funnel"); + expect(icon).toBeTruthy(); + expect( + Math.abs(centerY(icon!) - centerY(dateRange.element())), + ).toBeLessThan(2); + expect(document.documentElement.scrollWidth).toBeLessThanOrEqual(390); + }); + + it("wraps controls cleanly without horizontal overflow at intermediate widths", async () => { + await page.viewport(1024, 768); + const { screen } = await renderWithApp( + , + ); + + const rail = screen + .getByRole("searchbox", { name: "Search biomarkers" }) + .element() + .closest('[data-slot="lab-filter-rail"]'); + expect(rail).not.toBeNull(); + const railRect = rail!.getBoundingClientRect(); + + const search = screen.getByRole("searchbox", { name: "Search biomarkers" }); + const searchRect = search.element().getBoundingClientRect(); + expect(searchRect.width).toBeLessThan(300); + + const controls = [ + search.element(), + screen.getByRole("button", { name: "Last measured" }).element(), + screen.getByRole("combobox", { name: "Status: Out of range" }).element(), + screen.getByRole("combobox", { name: "Category" }).element(), + screen.getByRole("button", { name: "Reset" }).element(), + ]; + + for (const el of controls) { + const rect = el.getBoundingClientRect(); + expect(rect.left).toBeGreaterThanOrEqual(railRect.left - 1); + expect(rect.right).toBeLessThanOrEqual(railRect.right + 1); + } + }); + + it("keeps every control and reset visible without clipping in German at 1536px", async () => { + await page.viewport(1536, 900); + const { screen } = await renderWithApp( + , + { locale: "de" }, + ); + + const rail = screen + .getByRole("searchbox", { name: "Biomarker suchen" }) + .element() + .closest('[data-slot="lab-filter-rail"]'); + expect(rail).not.toBeNull(); + const railRect = rail!.getBoundingClientRect(); + + const controls = [ + screen.getByRole("searchbox", { name: "Biomarker suchen" }).element(), + screen.getByRole("button", { name: "Zuletzt gemessen" }).element(), + screen + .getByRole("combobox", { + name: "Status: Außerhalb des Bereichs", + }) + .element(), + screen.getByRole("combobox", { name: "Kategorie" }).element(), + screen.getByRole("button", { name: "Zurücksetzen" }).element(), + ]; + + for (const el of controls) { + const rect = el.getBoundingClientRect(); + expect(rect.left).toBeGreaterThanOrEqual(railRect.left - 1); + expect(rect.right).toBeLessThanOrEqual(railRect.right + 1); + } + }); + + it("aligns search and filter controls in one row on wide screens", async () => { + await page.viewport(2560, 900); + const { screen } = await renderWithApp(); + const controls = [ + screen.getByRole("searchbox", { name: "Search biomarkers" }).element(), + screen.getByRole("button", { name: "Last measured" }).element(), + screen.getByRole("combobox", { name: "Status" }).element(), + screen.getByRole("combobox", { name: "Category" }).element(), + ]; + const centers = controls.map(centerY); + + expect(Math.max(...centers) - Math.min(...centers)).toBeLessThan(2); + }); +}); + +/** The rail wired to the real filter hook, with a dialog to open over it. */ +function WiredRail() { + const lab = useLabListFilters(); + const [dialog, setDialog] = useState(false); + return ( + <> + + + {dialog ? ( +
+ +
+ ) : null} + + ); +} + +describe(" keyboard", () => { + it("focuses the search field on / and keeps focus there after clearing", async () => { + const { screen } = await renderWithApp(); + const search = screen.getByRole("searchbox", { name: "Search biomarkers" }); + + ( + screen + .getByRole("button", { name: "Open dialog" }) + .element() as HTMLElement + ).focus(); + await userEvent.keyboard("/"); + await expect.element(search).toHaveFocus(); + await expect.element(search).toHaveValue(""); + + await userEvent.keyboard("iron"); + await screen.getByRole("button", { name: "Clear search" }).click(); + await expect.element(search).toHaveValue(""); + await expect.element(search).toHaveFocus(); + }); + + it("leaves / alone while focus is inside an open dialog", async () => { + const { screen } = await renderWithApp(); + await screen.getByRole("button", { name: "Open dialog" }).click(); + const inside = screen.getByRole("button", { name: "Inside" }); + await expect.element(inside).toHaveFocus(); + await userEvent.keyboard("/"); + await expect.element(inside).toHaveFocus(); + }); +}); diff --git a/src/components/labs/__tests__/lab-filter-rail.test.tsx b/src/components/labs/__tests__/lab-filter-rail.test.tsx new file mode 100644 index 000000000..2208fa256 --- /dev/null +++ b/src/components/labs/__tests__/lab-filter-rail.test.tsx @@ -0,0 +1,180 @@ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { primeLocales } from "@/__tests__/helpers/prime-locales"; +import { I18nProvider } from "@/lib/i18n/context"; +import { LabFilterRail } from "../lab-filter-rail"; + +primeLocales("en", "pl", "ru"); + +describe("", () => { + it("renders search input, shortcut hint, and filter pills in canonical order", () => { + const html = renderToStaticMarkup( + + + , + ); + + expect(html).toContain('data-slot="lab-filter-rail"'); + expect(html).toContain('placeholder="Search biomarkers…"'); + expect(html).toContain('aria-keyshortcuts="/"'); + expect(html).toMatch(/]*aria-hidden="true"[^>]*>\s*\/\s*<\/kbd>/); + + // The shared rail keeps search and all filter dimensions in one ordered row. + const searchIdx = html.indexOf('placeholder="Search biomarkers…"'); + const dateRangeIdx = html.indexOf("Last measured"); + const statusIdx = html.indexOf("Status"); + const categoryIdx = html.indexOf("Category"); + + expect(searchIdx).toBeGreaterThan(-1); + expect(dateRangeIdx).toBeGreaterThan(searchIdx); + expect(statusIdx).toBeGreaterThan(dateRangeIdx); + expect(categoryIdx).toBeGreaterThan(statusIdx); + // The saved sort in Settings > Labs is the only sort; the rail has none. + expect(html).not.toContain("Sort order"); + expect(html).toContain("5 biomarkers"); + }); + + it("shows search clear button with accessible label and hides kbd shortcut when search has text", () => { + const html = renderToStaticMarkup( + + + , + ); + + expect(html).toContain('aria-label="Clear search"'); + expect(html).not.toContain(" { + const extractCountText = (html: string) => { + const match = html.match(/]*tabular-nums[^>]*>([^<]+)<\/span>/); + return match ? match[1].trim() : null; + }; + + const renderCount = (locale: "en" | "pl" | "ru", total: number) => + renderToStaticMarkup( + + + , + ); + + expect(extractCountText(renderCount("en", 1))).toBe("1 biomarker"); + expect(extractCountText(renderCount("en", 2))).toBe("2 biomarkers"); + expect(extractCountText(renderCount("pl", 1))).toBe("1 biomarker"); + expect(extractCountText(renderCount("pl", 2))).toBe("2 biomarkery"); + expect(extractCountText(renderCount("pl", 5))).toBe("5 biomarkerów"); + expect(extractCountText(renderCount("ru", 1))).toBe("1 биомаркер"); + expect(extractCountText(renderCount("ru", 2))).toBe("2 биомаркера"); + expect(extractCountText(renderCount("ru", 5))).toBe("5 биомаркеров"); + }); + + it("shows reset button when isFiltered is true", () => { + const html = renderToStaticMarkup( + + + , + ); + + 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-columns.test.tsx b/src/components/labs/__tests__/lab-list-columns.test.tsx index 5546fe33c..0ebf68607 100644 --- a/src/components/labs/__tests__/lab-list-columns.test.tsx +++ b/src/components/labs/__tests__/lab-list-columns.test.tsx @@ -47,16 +47,17 @@ function reading(over: Partial): LabResultDto { sourceReferenceLow: null, sourceReferenceHigh: null, sourceReferenceText: null, + printedReferenceText: null, referenceOrigin: "catalog", referenceDivergesFromCatalog: false, takenAt: "2026-09-01T12:00:00.000Z", source: "MANUAL", hasNote: false, - rangeStatus: "in_range", + rangeStatus: "in-range", createdAt: "2026-09-01T12:00:00.000Z", updatedAt: "2026-09-01T12:00:00.000Z", ...over, - } as LabResultDto; + }; } function renderList( diff --git a/src/components/labs/__tests__/lab-list-filters.test.ts b/src/components/labs/__tests__/lab-list-filters.test.ts new file mode 100644 index 000000000..e1088ba13 --- /dev/null +++ b/src/components/labs/__tests__/lab-list-filters.test.ts @@ -0,0 +1,375 @@ +import { describe, expect, it } from "vitest"; + +import { + buildMarkerCategoryMap, + filterMarkerRows, + labListFiltersToSearch, + labReadingDayKey, + parseLabListSearchParams, + sortMarkerRows, + type LabListFilters, +} from "../lab-list-filters"; +import type { MarkerRow } from "../lab-list-rows"; +import type { LabOverviewCategoryDto, LabResultDto } from "../types"; + +function mockReading(over: Partial): LabResultDto { + return { + id: "r1", + biomarkerId: "b1", + panel: null, + analyte: "Ferritin", + value: 80, + valueText: null, + unit: "ng/mL", + converted: false, + sourceValue: null, + sourceUnit: null, + referenceLow: 30, + referenceHigh: 400, + catalogReferenceLow: 30, + catalogReferenceHigh: 400, + sourceReferenceLow: null, + sourceReferenceHigh: null, + sourceReferenceText: null, + printedReferenceText: null, + referenceOrigin: "catalog", + referenceDivergesFromCatalog: false, + takenAt: "2026-09-01T12:00:00.000Z", + source: "MANUAL", + hasNote: false, + rangeStatus: "in-range", + createdAt: "2026-09-01T12:00:00.000Z", + updatedAt: "2026-09-01T12:00:00.000Z", + ...over, + }; +} + +function mockMarkerRow(over: Partial): MarkerRow { + return { + key: "bm:1", + biomarkerId: "1", + analyte: "Ferritin", + panel: "Iron Studies", + unit: "ng/mL", + count: 2, + trend: [70, 80], + latest: mockReading({ + biomarkerId: "1", + analyte: "Ferritin", + panel: "Iron Studies", + rangeStatus: "in-range", + takenAt: "2026-09-01T12:00:00.000Z", + }), + ...over, + }; +} + +describe("parseLabListSearchParams & labListFiltersToSearch", () => { + it("parses empty search params into empty filters", () => { + expect(parseLabListSearchParams(new URLSearchParams())).toEqual({}); + }); + + it("parses valid facets", () => { + const params = new URLSearchParams( + "q=iron&status=abnormal&category=cat_iron&from=2026-01-01&to=2026-12-31", + ); + expect(parseLabListSearchParams(params)).toEqual({ + q: "iron", + status: "abnormal", + category: "cat_iron", + fromDay: "2026-01-01", + toDay: "2026-12-31", + }); + }); + + it("drops invalid status and invalid dates", () => { + const params = new URLSearchParams( + "status=invalid&category=ALL&from=not-a-date&to=invalid", + ); + 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({}); + }); + + it("parses category param and ignores category=ALL", () => { + expect( + parseLabListSearchParams(new URLSearchParams("category=cat_valid")), + ).toEqual({ + category: "cat_valid", + }); + expect( + parseLabListSearchParams(new URLSearchParams("category=ALL")), + ).toEqual({}); + }); + + it("ignores a sort param: the list follows the saved sort only", () => { + expect( + parseLabListSearchParams(new URLSearchParams("sort=alphaAsc")), + ).toEqual({}); + }); + + it("roundtrips valid filters to search string", () => { + const filters: LabListFilters = { + q: "cholesterol", + status: "above", + category: "lipids", + fromDay: "2026-05-01", + toDay: "2026-05-31", + }; + const search = labListFiltersToSearch(filters); + const parsed = parseLabListSearchParams(new URLSearchParams(search)); + expect(parsed).toEqual(filters); + }); +}); + +describe("filterMarkerRows", () => { + const row1 = mockMarkerRow({ + key: "bm:1", + analyte: "Ferritin", + panel: "Iron Studies", + latest: mockReading({ + rangeStatus: "in-range", + takenAt: "2026-08-01T10:00:00.000Z", + }), + }); + const row2 = mockMarkerRow({ + key: "bm:2", + analyte: "Iron", + panel: "Iron Studies", + latest: mockReading({ + rangeStatus: "below", + takenAt: "2026-07-15T10:00:00.000Z", + }), + }); + const row3 = mockMarkerRow({ + key: "bm:3", + analyte: "Total Cholesterol", + panel: "Lipid Panel", + latest: mockReading({ + rangeStatus: "above", + takenAt: "2026-09-10T10:00:00.000Z", + }), + }); + const rows = [row1, row2, row3]; + + it("filters by query matching analyte or panel", () => { + expect(filterMarkerRows(rows, { q: "cholest" })).toEqual([row3]); + 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, + row3, + ]); + }); + + it("filters by status: in-range", () => { + expect(filterMarkerRows(rows, { status: "in-range" })).toEqual([row1]); + }); + + it("filters by status: below", () => { + expect(filterMarkerRows(rows, { status: "below" })).toEqual([row2]); + }); + + it("filters by category using categoryOf lookup", () => { + const categoryOf = (key: string) => + key === "bm:1" || key === "bm:2" ? "iron_panel" : "lipid_panel"; + expect( + filterMarkerRows(rows, { category: "lipid_panel" }, categoryOf), + ).toEqual([row3]); + }); + + it("fails closed when filtering by category without categoryOf", () => { + expect(filterMarkerRows(rows, { category: "iron_panel" })).toEqual([]); + }); + + it("filters by date bounds on latest takenAt", () => { + expect(filterMarkerRows(rows, { fromDay: "2026-08-01" })).toEqual([ + row1, + row3, + ]); + expect(filterMarkerRows(rows, { toDay: "2026-07-31" })).toEqual([row2]); + expect( + filterMarkerRows(rows, { + fromDay: "2026-07-01", + toDay: "2026-08-15", + }), + ).toEqual([row1, row2]); + }); + + it("matches local day bounds using labReadingDayKey (handling timezone shifts)", () => { + // 2026-08-31T22:30:00.000Z in UTC is 2026-08-31. + // In Europe/Warsaw (UTC+2 in summer / CEST), it is 2026-09-01 00:30. + // In America/New_York (UTC-4 in summer / EDT), it is 2026-08-31 18:30. + const lateRow = mockMarkerRow({ + key: "bm:late", + latest: mockReading({ + takenAt: "2026-08-31T22:30:00.000Z", + }), + }); + + const dayKeyWarsaw = (takenAt: string) => + labReadingDayKey(takenAt, "Europe/Warsaw"); + const dayKeyNy = (takenAt: string) => + labReadingDayKey(takenAt, "America/New_York"); + + // Warsaw (local date 2026-09-01): matches from 2026-09-01, excluded by to 2026-08-31 + expect( + filterMarkerRows( + [lateRow], + { fromDay: "2026-09-01" }, + undefined, + dayKeyWarsaw, + ), + ).toEqual([lateRow]); + expect( + filterMarkerRows( + [lateRow], + { toDay: "2026-08-31" }, + undefined, + dayKeyWarsaw, + ), + ).toEqual([]); + + // New York (local date 2026-08-31): excluded by from 2026-09-01, matches to 2026-08-31 + expect( + filterMarkerRows( + [lateRow], + { fromDay: "2026-09-01" }, + undefined, + dayKeyNy, + ), + ).toEqual([]); + expect( + filterMarkerRows([lateRow], { toDay: "2026-08-31" }, undefined, dayKeyNy), + ).toEqual([lateRow]); + }); +}); + +describe("sortMarkerRows", () => { + const rowA = mockMarkerRow({ key: "bm:1", biomarkerId: "1", analyte: "B12" }); + const rowB = mockMarkerRow({ key: "bm:2", biomarkerId: "2", analyte: "CRP" }); + const rowC = mockMarkerRow({ + key: "bm:3", + biomarkerId: "3", + analyte: "Alanine", + }); + const rows = [rowA, rowB, rowC]; + + it("sorts alphaAsc", () => { + const sorted = sortMarkerRows(rows, "alphaAsc"); + expect(sorted.map((r) => r.analyte)).toEqual(["Alanine", "B12", "CRP"]); + }); + + it("sorts alphaDesc", () => { + const sorted = sortMarkerRows(rows, "alphaDesc"); + expect(sorted.map((r) => r.analyte)).toEqual(["CRP", "B12", "Alanine"]); + }); + + it("sorts recentAsc (reverses input)", () => { + const sorted = sortMarkerRows(rows, "recentAsc"); + expect(sorted).toEqual([rowC, rowB, rowA]); + }); + + it("sorts manual", () => { + const sorted = sortMarkerRows(rows, "manual", ["2", "1", "3"]); + expect(sorted.map((r) => r.key)).toEqual(["bm:2", "bm:1", "bm:3"]); + }); + + it("handles sort=manual without manualOrder gracefully", () => { + expect(sortMarkerRows(rows, "manual", [])).toEqual(rows); + expect(sortMarkerRows(rows, "manual", undefined)).toEqual(rows); + }); +}); + +describe("labReadingDayKey", () => { + it("keeps noon UTC anchors on their stated date across all timezones", () => { + const noonInstant = "2026-09-01T12:00:00.000Z"; + expect(labReadingDayKey(noonInstant, "UTC")).toBe("2026-09-01"); + expect(labReadingDayKey(noonInstant, "America/New_York")).toBe( + "2026-09-01", + ); + expect(labReadingDayKey(noonInstant, "Pacific/Kiritimati")).toBe( + "2026-09-01", + ); + }); + + it("documents exact-noon date-anchor ambiguity vs true instant timestamps", () => { + // Because the data model does not have an explicit `dateOnly` boolean flag, + // exact 12:00:00.000Z is treated as a date anchor by convention to align with + // `useLabDate` / `date-only.ts`. In Pacific/Kiritimati (UTC+14), 12:00 UTC would be + // 02:00 on the NEXT calendar day if treated as an instant; but as an anchor it stays 2026-09-01. + const noonAnchor = "2026-09-01T12:00:00.000Z"; + expect(labReadingDayKey(noonAnchor, "Pacific/Kiritimati")).toBe( + "2026-09-01", + ); + + // Any non-noon instant (even 1 second off noon) is recognized as a true instant with time + // and correctly shifts to the local date in the user's timezone: + const nonNoonInstant = "2026-09-01T12:00:01.000Z"; + expect(labReadingDayKey(nonNoonInstant, "Pacific/Kiritimati")).toBe( + "2026-09-02", + ); + expect(labReadingDayKey(nonNoonInstant, "UTC")).toBe("2026-09-01"); + }); + + it("formats timestamps with times in the user's timezone", () => { + // 2026-09-01 00:30 UTC+2 (Warsaw) = 2026-08-31 22:30 UTC + const eveningUtc = "2026-08-31T22:30:00.000Z"; + expect(labReadingDayKey(eveningUtc, "UTC")).toBe("2026-08-31"); + expect(labReadingDayKey(eveningUtc, "Europe/Warsaw")).toBe("2026-09-01"); + expect(labReadingDayKey(eveningUtc, "America/New_York")).toBe("2026-08-31"); + }); +}); + +describe("buildMarkerCategoryMap", () => { + it("builds map from categories", () => { + const categories: LabOverviewCategoryDto[] = [ + { + key: "cat1", + label: "Category 1", + markerKeys: ["bm:1", "bm:2"], + counts: { inRange: 2, below: 0, above: 0, unknown: 0 }, + status: "in-range", + }, + { + key: "cat2", + label: "Category 2", + markerKeys: ["bm:3"], + counts: { inRange: 0, below: 1, above: 0, unknown: 0 }, + status: "below", + }, + ]; + const map = buildMarkerCategoryMap(categories); + expect(map.get("bm:1")).toBe("cat1"); + expect(map.get("bm:2")).toBe("cat1"); + expect(map.get("bm:3")).toBe("cat2"); + expect(map.get("bm:4")).toBeUndefined(); + }); +}); 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 new file mode 100644 index 000000000..f143574fd --- /dev/null +++ b/src/components/labs/__tests__/lab-list-url-sync.browser.test.tsx @@ -0,0 +1,348 @@ +import { useEffect, useState } from "react"; +import type { QueryClient } from "@tanstack/react-query"; +import { describe, expect, it } from "vitest"; + +import { queryKeys } from "@/lib/query-keys"; +import { + renderWithApp, + testQueryClient, +} from "../../../../tests/browser/render"; +import { + currentSearch, + recordHistory, + setSearch, +} from "../../../../tests/browser/url"; +import { LabList } from "../lab-list"; +import { readStoredOpen } from "../lab-list-groups"; +import type { LabOverviewResponse, LabResultDto } from "../types"; +import { useLabListFilters } from "../use-lab-list-filters"; + +function mockReading(over: Partial): LabResultDto { + return { + id: "r1", + biomarkerId: "b1", + panel: null, + analyte: "Ferritin", + value: 80, + valueText: null, + unit: "ng/mL", + converted: false, + sourceValue: null, + sourceUnit: null, + referenceLow: 30, + referenceHigh: 400, + catalogReferenceLow: 30, + catalogReferenceHigh: 400, + sourceReferenceLow: null, + sourceReferenceHigh: null, + sourceReferenceText: null, + printedReferenceText: null, + referenceOrigin: "catalog", + referenceDivergesFromCatalog: false, + takenAt: "2026-09-01T12:00:00.000Z", + source: "MANUAL", + hasNote: false, + rangeStatus: "in-range", + createdAt: "2026-09-01T12:00:00.000Z", + updatedAt: "2026-09-01T12:00:00.000Z", + ...over, + }; +} + +const overviewWithCategories: LabOverviewResponse = { + markers: [ + { + key: "bm:b1", + count: 1, + trend: [80], + latest: mockReading({ analyte: "Ferritin" }), + }, + ], + categories: [ + { + key: "cat_iron", + label: "Iron Panel", + markerKeys: ["bm:b1"], + counts: { inRange: 1, below: 0, above: 0, unknown: 0 }, + status: "in-range", + }, + ], + grouped: true, +}; + +const overviewWithAbnormalMarker: LabOverviewResponse = { + markers: [ + { + key: "bm:b1", + count: 1, + trend: [10], + latest: mockReading({ analyte: "Iron", rangeStatus: "below" }), + }, + { + key: "bm:b2", + count: 1, + trend: [80], + latest: mockReading({ analyte: "Ferritin", rangeStatus: "in-range" }), + }, + ], + categories: [ + { + key: "cat_iron", + label: "Iron Panel", + markerKeys: ["bm:b1", "bm:b2"], + counts: { inRange: 1, below: 1, above: 0, unknown: 0 }, + status: "below", + }, + ], + grouped: true, +}; + +function labQueryClient(): QueryClient { + const client = testQueryClient(); + client.setDefaultOptions({ + queries: { retry: false, staleTime: Infinity }, + }); + return client; +} + +function FilterCallbackStabilityProbe({ + onRender, +}: { + onRender: (cb: unknown) => void; +}) { + const [, setTick] = useState(0); + const { setCategoryFilter } = useLabListFilters(); + useEffect(() => { + onRender(setCategoryFilter); + }); + return ( + + ); +} + +describe("LabList stale category and group header handling", () => { + it("normalizes URL via replace after paint and does not filter visible rows when category is stale", async () => { + setSearch("?category=stale"); + const history = recordHistory(); + + const queryClient = labQueryClient(); + queryClient.setQueryData( + queryKeys.labResultsOverview("en"), + overviewWithCategories, + ); + + const { screen } = await renderWithApp(, { queryClient }); + + // Stale category is scrubbed from URL after paint using replace (no pushes) + await expect.poll(currentSearch).toBe(""); + expect(history.pushes()).toEqual([]); + expect(history.replaces()).toEqual([""]); + + // 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/ })) + .not.toBeInTheDocument(); + }); + + it("preserves active non-stale filters and scrubs stale category from URL after paint", async () => { + setSearch("?status=abnormal&category=stale"); + const history = recordHistory(); + + const queryClient = labQueryClient(); + queryClient.setQueryData( + queryKeys.labResultsOverview("en"), + overviewWithCategories, + ); + + await renderWithApp(, { queryClient }); + + await expect.poll(currentSearch).toBe("?status=abnormal"); + expect(history.pushes()).toEqual([]); + expect(history.replaces()).toEqual(["?status=abnormal"]); + }); + + it("preserves valid category in URL without triggering replace", async () => { + setSearch("?category=cat_iron"); + const history = recordHistory(); + + const queryClient = labQueryClient(); + queryClient.setQueryData( + queryKeys.labResultsOverview("en"), + overviewWithCategories, + ); + + const { screen } = await renderWithApp(, { queryClient }); + + await expect.poll(currentSearch).toBe("?category=cat_iron"); + expect(history.pushes()).toEqual([]); + expect(history.replaces()).toEqual([]); + await expect.element(screen.getByText("Ferritin")).toBeVisible(); + }); + + it("reflects filtered row count and outside status in category group header DOM", async () => { + setSearch("?status=below"); + const queryClient = labQueryClient(); + queryClient.setQueryData(queryKeys.moduleListLayout(), { + version: 1, + lists: { + labs: { + view: "list", + order: [], + sortDir: "recentDesc", + groupBy: "category", + groupsOpen: "expanded", + categoryOrder: [], + }, + }, + }); + queryClient.setQueryData( + queryKeys.labResultsOverview("en"), + overviewWithAbnormalMarker, + ); + + const { screen } = await renderWithApp(, { queryClient }); + + // Only the 'below' marker (Iron) is visible + await expect.element(screen.getByText("Iron")).toBeVisible(); + await expect.element(screen.getByText("Ferritin")).not.toBeInTheDocument(); + + // Group header button shows the filtered count ("1 of 1 outside range") and chip "1" + const groupButton = screen.getByRole("button", { name: /Iron Panel/i }); + await expect.element(groupButton).toBeVisible(); + await expect + .element(groupButton.getByText("1 of 1 outside range")) + .toBeVisible(); + await expect.element(groupButton.getByText("1")).toBeVisible(); + }); + + it("keeps a flagged group open while a filter leaves only in-range rows", async () => { + // Iron (below) and Ferritin (in-range) share the Iron Panel, whose server + // status is "below". Filtering to in-range leaves Ferritin alone: the + // header now reads "All in range", but the group must stay open, since + // the counter says there is a match. + setSearch("?status=in-range"); + const queryClient = labQueryClient(); + queryClient.setQueryData(queryKeys.moduleListLayout(), { + version: 1, + lists: { + labs: { + view: "list", + order: [], + sortDir: "recentDesc", + groupBy: "category", + groupsOpen: "flagged", + categoryOrder: [], + }, + }, + }); + queryClient.setQueryData( + queryKeys.labResultsOverview("en"), + overviewWithAbnormalMarker, + ); + + const { screen } = await renderWithApp(, { queryClient }); + + const groupButton = screen.getByRole("button", { name: /Iron Panel/i }); + await expect.element(groupButton.getByText("All in range")).toBeVisible(); + await expect.element(groupButton).toHaveAttribute("aria-expanded", "true"); + await expect.element(screen.getByText("Ferritin")).toBeVisible(); + }); + + it("opens every matching group while filtering and restores the closed state after reset", async () => { + setSearch("?q=ferr"); + window.localStorage.clear(); + const queryClient = labQueryClient(); + queryClient.setQueryData(queryKeys.moduleListLayout(), { + version: 1, + lists: { + labs: { + view: "list", + order: [], + sortDir: "recentDesc", + groupBy: "category", + groupsOpen: "collapsed", + categoryOrder: [], + }, + }, + }); + queryClient.setQueryData( + queryKeys.labResultsOverview("en"), + overviewWithAbnormalMarker, + ); + + const { screen } = await renderWithApp(, { queryClient }); + + const groupButton = screen.getByRole("button", { name: /Iron Panel/i }); + await expect.element(groupButton).toHaveAttribute("aria-expanded", "true"); + await expect.element(screen.getByText("Ferritin")).toBeVisible(); + + // A toggle made while filtering holds while the filter changes... + await groupButton.click(); + await expect.element(groupButton).toHaveAttribute("aria-expanded", "false"); + await screen.getByRole("combobox", { name: "Status" }).click(); + await screen.getByRole("option", { name: "In range" }).click(); + await expect.poll(currentSearch).toBe("?q=ferr&status=in-range"); + await expect.element(groupButton).toHaveAttribute("aria-expanded", "false"); + + // ...and is dropped with the filter: "collapsed" applies again. + await screen.getByRole("button", { name: "Reset" }).click(); + await expect.poll(currentSearch).toBe(""); + await expect.element(groupButton).toHaveAttribute("aria-expanded", "false"); + expect(readStoredOpen("collapsed")).toEqual({}); + }); + + it("keeps a trailing space in the search field after the URL takes the query", async () => { + setSearch(""); + const queryClient = labQueryClient(); + queryClient.setQueryData( + queryKeys.labResultsOverview("en"), + overviewWithAbnormalMarker, + ); + + const { screen } = await renderWithApp(, { queryClient }); + + const search = screen.getByRole("searchbox", { name: "Search biomarkers" }); + await search.fill("iron "); + await expect.poll(currentSearch).toBe("?q=iron"); + // One more frame for the URL change to reach the hook. + await new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(resolve)), + ); + await expect.element(search).toHaveValue("iron "); + }); + + it("takes the query from the URL on back navigation", async () => { + setSearch("?q=iron"); + const queryClient = labQueryClient(); + queryClient.setQueryData( + queryKeys.labResultsOverview("en"), + overviewWithAbnormalMarker, + ); + + const { screen } = await renderWithApp(, { queryClient }); + const search = screen.getByRole("searchbox", { name: "Search biomarkers" }); + await expect.element(search).toHaveValue("iron"); + + await screen.getByRole("button", { name: "Reset" }).click(); + await expect.poll(currentSearch).toBe(""); + await expect.element(search).toHaveValue(""); + + window.history.back(); + await expect.poll(currentSearch).toBe("?q=iron"); + await expect.element(search).toHaveValue("iron"); + }); + + it("memoizes setCategoryFilter callback identity across re-renders", async () => { + const callbacks: unknown[] = []; + const { screen } = await renderWithApp( + callbacks.push(cb)} />, + ); + await screen.getByRole("button", { name: "Rerender" }).click(); + expect(callbacks.length).toBeGreaterThanOrEqual(2); + expect(callbacks[0]).toBe(callbacks[1]); + }); +}); diff --git a/src/components/labs/__tests__/lab-list-url-sync.test.tsx b/src/components/labs/__tests__/lab-list-url-sync.test.tsx new file mode 100644 index 000000000..822027c47 --- /dev/null +++ b/src/components/labs/__tests__/lab-list-url-sync.test.tsx @@ -0,0 +1,224 @@ +/** + * URL-owned filter state on the labs overview list (the documents-vault and + * measurements pattern via `useUrlFilterSync`): + * + * - a filter or sort change writes the serialised facet set to the page URL + * (push for discrete selects, replace for date bounds, bare pathname when + * everything clears); + * - mounting with a query string restores every facet into the filter rail; + * - sort selection overrides the list presentation without altering persisted + * user settings, does not mark `isFiltered` true, and reset restores the + * preference default. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; +import { + installUrlStateBrowser, + type UrlStateBrowser, +} from "@/__tests__/helpers/url-state-browser"; +import type { ModuleListPrefs } from "@/lib/module-list-prefs"; +import type { LabFilterRailProps } from "../lab-filter-rail"; +import type { LabOverviewResponse, LabResultDto } from "../types"; +import { LabList } from "../lab-list"; + +let mockSearch = ""; +const pushMock = vi.fn(); +const replaceMock = vi.fn(); +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: pushMock, replace: replaceMock }), + usePathname: () => "/labs", + useSearchParams: () => new URLSearchParams(mockSearch), +})); + +let capturedRailProps: LabFilterRailProps | null = null; +vi.mock("../lab-filter-rail", () => ({ + LabFilterRail: (props: LabFilterRailProps) => { + capturedRailProps = props; + return
; + }, +})); + +const mockModulePrefs: ModuleListPrefs = { + view: "list", + order: [], + sortDir: "recentDesc", + groupBy: "none", + groupsOpen: "expanded", + categoryOrder: [], +}; + +vi.mock("@/lib/module-list-prefs", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + useModuleListPrefs: () => ({ + prefs: mockModulePrefs, + }), + }; +}); + +function mockReading(over: Partial): LabResultDto { + return { + id: "r1", + biomarkerId: "b1", + panel: null, + analyte: "Ferritin", + value: 80, + valueText: null, + unit: "ng/mL", + converted: false, + sourceValue: null, + sourceUnit: null, + referenceLow: 30, + referenceHigh: 400, + catalogReferenceLow: 30, + catalogReferenceHigh: 400, + sourceReferenceLow: null, + sourceReferenceHigh: null, + sourceReferenceText: null, + printedReferenceText: null, + referenceOrigin: "catalog", + referenceDivergesFromCatalog: false, + takenAt: "2026-09-01T12:00:00.000Z", + source: "MANUAL", + hasNote: false, + rangeStatus: "in-range", + createdAt: "2026-09-01T12:00:00.000Z", + updatedAt: "2026-09-01T12:00:00.000Z", + ...over, + }; +} + +const baseOverview: LabOverviewResponse = { + markers: [ + { + key: "bm:b1", + count: 1, + trend: [80], + latest: mockReading({ analyte: "Ferritin" }), + }, + ], + categories: [ + { + key: "cat_iron", + label: "Iron Panel", + markerKeys: ["bm:b1"], + counts: { inRange: 1, below: 0, above: 0, unknown: 0 }, + status: "in-range", + }, + ], + grouped: false, +}; + +let browser: UrlStateBrowser = installUrlStateBrowser("/"); +afterEach(() => browser.uninstall()); + +function render(search: string) { + mockSearch = search; + capturedRailProps = null; + browser.uninstall(); + browser = installUrlStateBrowser(search ? `/labs?${search}` : "/labs"); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + queryClient.setQueryData(queryKeys.labResultsOverview("en"), baseOverview); + + const html = renderToStaticMarkup( + + + + + , + ); + return html; +} + +beforeEach(() => { + pushMock.mockClear(); + replaceMock.mockClear(); + mockModulePrefs.sortDir = "recentDesc"; + mockModulePrefs.order = []; +}); + +describe("LabList URL filter sync — interactions write to URL", () => { + it("pushes the status facet", () => { + render(""); + expect(capturedRailProps).not.toBeNull(); + capturedRailProps!.onStatusChange("abnormal"); + expect(browser.push).toHaveBeenCalledWith("/labs?status=abnormal"); + }); + + it("pushes the category facet on top of active status", () => { + render("status=abnormal"); + capturedRailProps!.onCategoryChange("cat_iron"); + expect(browser.push).toHaveBeenCalledWith( + "/labs?status=abnormal&category=cat_iron", + ); + }); + + it("replaces (no new history entry) for date bounds", () => { + render(""); + capturedRailProps!.onFromDayChange("2026-09-01"); + expect(browser.replace).toHaveBeenCalledWith("/labs?from=2026-09-01"); + expect(browser.push).not.toHaveBeenCalled(); + }); + + it("leaves a sort param out of the filter state", () => { + render("sort=alphaAsc"); + expect(capturedRailProps!.isFiltered).toBe(false); + capturedRailProps!.onStatusChange("abnormal"); + expect(browser.push).toHaveBeenCalledWith("/labs?status=abnormal"); + }); + + it("reset clears every facet in one push", () => { + render("status=abnormal&category=cat_iron&from=2026-09-01"); + expect(capturedRailProps!.isFiltered).toBe(true); + capturedRailProps!.onResetFilters(); + expect(browser.push).toHaveBeenCalledWith("/labs"); + }); +}); + +describe("LabList URL filter sync — mounting restores filters from query", () => { + it("restores every facet into the rail controls", () => { + render( + "q=ferritin&status=in-range&category=cat_iron&from=2026-09-01&to=2026-09-30", + ); + expect(capturedRailProps!.filters.q).toBe("ferritin"); + expect(capturedRailProps!.filters.status).toBe("in-range"); + expect(capturedRailProps!.filters.category).toBe("cat_iron"); + expect(capturedRailProps!.filters.fromDay).toBe("2026-09-01"); + expect(capturedRailProps!.filters.toDay).toBe("2026-09-30"); + expect(capturedRailProps!.isFiltered).toBe(true); + }); + + it("drops invalid status and invalid dates without breaking the page", () => { + render("status=INVALID&from=2026-02-30&to=not-a-date"); + expect(capturedRailProps!.filters.status).toBeUndefined(); + expect(capturedRailProps!.filters.fromDay).toBeUndefined(); + expect(capturedRailProps!.filters.toDay).toBeUndefined(); + expect(capturedRailProps!.isFiltered).toBe(false); + }); + + it("drops stale category from effective filters when mounting with unknown category", () => { + render("category=unknown_stale_cat"); + expect(capturedRailProps!.filters.category).toBeUndefined(); + expect(capturedRailProps!.isFiltered).toBe(false); + }); + + it("replaces URL without pushing history when clearing category with replace mode (no Back-button loop)", () => { + render("category=unknown_stale_cat"); + ( + capturedRailProps!.onCategoryChange as ( + val: string, + mode?: "push" | "replace", + ) => void + )("ALL", "replace"); + expect(browser.replace).toHaveBeenCalledWith("/labs"); + expect(browser.push).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/labs/__tests__/lab-list.test.tsx b/src/components/labs/__tests__/lab-list.test.tsx new file mode 100644 index 000000000..eef03dcc5 --- /dev/null +++ b/src/components/labs/__tests__/lab-list.test.tsx @@ -0,0 +1,396 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; +import type { ModuleListPrefs } from "@/lib/settings/module-list-layout"; +import type { LabOverviewResponse, LabResultDto } from "../types"; +import { LabList } from "../lab-list"; + +let mockSearch = ""; +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), + usePathname: () => "/labs", + useSearchParams: () => new URLSearchParams(mockSearch), +})); + +let mockModulePrefs: ModuleListPrefs = { + view: "list", + order: [], + sortDir: "recentDesc", + groupBy: "none", + groupsOpen: "expanded", + categoryOrder: [], +}; + +const displayZone = vi.hoisted(() => ({ current: "UTC" })); + +vi.mock("@/lib/i18n/context", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useDisplayTimezone: () => displayZone.current, + }; +}); + +vi.mock("@/lib/module-list-prefs", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + useModuleListPrefs: () => ({ + prefs: mockModulePrefs, + }), + }; +}); + +function mockReading(over: Partial): LabResultDto { + return { + id: "r1", + biomarkerId: "b1", + panel: null, + analyte: "Ferritin", + value: 80, + valueText: null, + unit: "ng/mL", + converted: false, + sourceValue: null, + sourceUnit: null, + referenceLow: 30, + referenceHigh: 400, + catalogReferenceLow: 30, + catalogReferenceHigh: 400, + sourceReferenceLow: null, + sourceReferenceHigh: null, + sourceReferenceText: null, + printedReferenceText: null, + referenceOrigin: "catalog", + referenceDivergesFromCatalog: false, + takenAt: "2026-09-01T12:00:00.000Z", + source: "MANUAL", + hasNote: false, + rangeStatus: "in-range", + createdAt: "2026-09-01T12:00:00.000Z", + updatedAt: "2026-09-01T12:00:00.000Z", + ...over, + }; +} + +describe("", () => { + beforeEach(() => { + mockSearch = ""; + displayZone.current = "UTC"; + mockModulePrefs = { + view: "list", + order: [], + sortDir: "recentDesc", + groupBy: "none", + groupsOpen: "expanded", + categoryOrder: [], + }; + }); + + it("renders genuine empty state when no biomarkers exist in data", () => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + const overview: LabOverviewResponse = { + markers: [], + categories: [], + grouped: false, + }; + queryClient.setQueryData(queryKeys.labResultsOverview("en"), overview); + + const html = renderToStaticMarkup( + + + + + , + ); + + expect(html).not.toContain('data-slot="lab-filter-rail"'); + expect(html).toContain("No lab results yet"); + }); + + it("renders filter rail and populated rows when data exists", () => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + const overview: LabOverviewResponse = { + markers: [ + { + key: "bm:b1", + count: 1, + trend: [80], + latest: mockReading({ analyte: "Ferritin" }), + }, + ], + categories: [], + grouped: false, + }; + queryClient.setQueryData(queryKeys.labResultsOverview("en"), overview); + + const html = renderToStaticMarkup( + + + + + , + ); + + expect(html).toContain('data-slot="lab-filter-rail"'); + expect(html).toContain("Ferritin"); + expect(html).toContain("1 biomarker"); + }); + + it("renders filtered empty state with rail and Reset button when filters match 0 rows", () => { + mockSearch = "q=NonExistentBiomarker"; + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + const overview: LabOverviewResponse = { + markers: [ + { + key: "bm:b1", + count: 1, + trend: [80], + latest: mockReading({ analyte: "Ferritin" }), + }, + ], + categories: [], + grouped: false, + }; + queryClient.setQueryData(queryKeys.labResultsOverview("en"), overview); + + const html = renderToStaticMarkup( + + + + + , + ); + + expect(html).toContain('data-slot="lab-filter-rail"'); + expect(html).toContain("No matching biomarkers"); + expect(html).toContain("Reset"); + }); + + it("recalculates category header member count and status pill from filtered rows (B5)", () => { + // 3 markers in cat_iron: 1 below, 2 in-range + // When filtering by ?status=below, category should show 1 member and 1 of 1 outside range + mockSearch = "status=below"; + mockModulePrefs = { + view: "list", + order: [], + sortDir: "recentDesc", + groupBy: "category", + groupsOpen: "expanded", + categoryOrder: [], + }; + + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + const overview: LabOverviewResponse = { + markers: [ + { + key: "bm:b1", + count: 1, + trend: [10], + latest: mockReading({ + analyte: "Iron", + rangeStatus: "below", + }), + }, + { + key: "bm:b2", + count: 1, + trend: [80], + latest: mockReading({ + analyte: "Ferritin", + rangeStatus: "in-range", + }), + }, + { + key: "bm:b3", + count: 1, + trend: [250], + latest: mockReading({ + analyte: "Transferrin", + rangeStatus: "in-range", + }), + }, + ], + categories: [ + { + key: "cat_iron", + label: "Iron Panel", + markerKeys: ["bm:b1", "bm:b2", "bm:b3"], + counts: { inRange: 2, below: 1, above: 0, unknown: 0 }, + status: "below", + }, + ], + grouped: true, + }; + queryClient.setQueryData(queryKeys.labResultsOverview("en"), overview); + + const html = renderToStaticMarkup( + + + + + , + ); + + // Only the 'below' marker (Iron) should be in the list + expect(html).toContain("Iron"); + expect(html).not.toContain("Ferritin"); + expect(html).not.toContain("Transferrin"); + + // The category header reflects the filtered 1 row (tested further in browser DOM suite) + expect(html).toContain("1 of 1 outside range"); + }); + + it("sorts by the saved preference and ignores a sort param in the URL", () => { + mockSearch = "sort=recentDesc"; + mockModulePrefs = { + view: "list", + order: [], + sortDir: "alphaAsc", + groupBy: "none", + groupsOpen: "expanded", + categoryOrder: [], + }; + + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + const overview: LabOverviewResponse = { + markers: [ + { + key: "bm:b2", + count: 1, + trend: [5], + latest: mockReading({ id: "r2", biomarkerId: "b2", analyte: "Zinc" }), + }, + { + key: "bm:b1", + count: 1, + trend: [80], + latest: mockReading({ analyte: "Ferritin" }), + }, + ], + categories: [], + grouped: false, + }; + queryClient.setQueryData(queryKeys.labResultsOverview("en"), overview); + + const html = renderToStaticMarkup( + + + + + , + ); + + expect(html.indexOf("Ferritin")).toBeGreaterThan(-1); + expect(html.indexOf("Zinc")).toBeGreaterThan(html.indexOf("Ferritin")); + expect(html).not.toContain("Sort order"); + }); + + it("filters by local day bounds using display timezone (Europe/Warsaw)", () => { + // 2026-08-31T22:30:00.000Z in UTC is 2026-08-31, but in Europe/Warsaw (UTC+2) it is 2026-09-01 00:30. + displayZone.current = "Europe/Warsaw"; + mockSearch = "from=2026-09-01"; + + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + const overview: LabOverviewResponse = { + markers: [ + { + key: "bm:b1", + count: 1, + trend: [80], + latest: mockReading({ + analyte: "Ferritin", + takenAt: "2026-08-31T22:30:00.000Z", + }), + }, + ], + categories: [], + grouped: false, + }; + queryClient.setQueryData(queryKeys.labResultsOverview("en"), overview); + + const htmlWarsaw = renderToStaticMarkup( + + + + + , + ); + + // In Europe/Warsaw, the reading is on 2026-09-01, matching from=2026-09-01 + expect(htmlWarsaw).toContain("Ferritin"); + expect(htmlWarsaw).toContain("1 biomarker"); + + // In America/New_York (UTC-4), the reading is 2026-08-31 18:30, excluded by from=2026-09-01 + displayZone.current = "America/New_York"; + const htmlNy = renderToStaticMarkup( + + + + + , + ); + + expect(htmlNy).not.toContain("Ferritin"); + expect(htmlNy).toContain("No matching biomarkers"); + }); + + it("ignores stale category from URL when not in loaded categories", () => { + mockSearch = "category=stale_unknown_category"; + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + const overview: LabOverviewResponse = { + markers: [ + { + key: "bm:b1", + count: 1, + trend: [80], + latest: mockReading({ analyte: "Ferritin" }), + }, + ], + categories: [ + { + key: "cat_iron", + label: "Iron Panel", + markerKeys: ["bm:b1"], + counts: { inRange: 1, below: 0, above: 0, unknown: 0 }, + status: "in-range", + }, + ], + grouped: false, + }; + queryClient.setQueryData(queryKeys.labResultsOverview("en"), overview); + + const html = renderToStaticMarkup( + + + + + , + ); + + // 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).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 new file mode 100644 index 000000000..6b4b9117b --- /dev/null +++ b/src/components/labs/lab-filter-rail.tsx @@ -0,0 +1,162 @@ +"use client"; + +import * as React from "react"; +import { Search, X } from "lucide-react"; + +import { + FilterBar, + FilterBarDateRange, + FilterBarSelect, + type FilterBarSelectOption, +} from "@/components/ui/filter-bar"; +import { Input } from "@/components/ui/input"; +import { useFormatters, useTranslations } from "@/lib/i18n/context"; +import { + isLabStatusFilter, + type LabListFilters, + type LabStatusFilter, +} from "./lab-list-filters"; +import type { LabOverviewCategoryDto } from "./types"; + +export interface LabFilterRailProps { + filters: LabListFilters; + searchDraft: string; + onSearchChange: (value: string) => void; + searchInputRef: React.RefObject; + onStatusChange: (status: LabStatusFilter | "ALL") => void; + onCategoryChange: (category: string) => void; + onFromDayChange: (from: string) => void; + onToDayChange: (to: string) => void; + onResetFilters: () => void; + isFiltered: boolean; + categories?: LabOverviewCategoryDto[] | null; + total?: number; +} + +export function LabFilterRail({ + filters, + searchDraft, + onSearchChange, + searchInputRef, + onStatusChange, + onCategoryChange, + onFromDayChange, + onToDayChange, + onResetFilters, + isFiltered, + categories, + total, +}: LabFilterRailProps) { + const { t, tCount } = useTranslations(); + const fmt = useFormatters(); + + const statusOptions: FilterBarSelectOption[] = [ + { value: "abnormal", label: t("labs.filter.statusAbnormal") }, + { value: "in-range", label: t("labs.range.inRange") }, + { value: "below", label: t("labs.range.below") }, + { 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, + label: c.label, + }), + ); + + // Search sits beside the rail rather than in it: below `sm` the rail is + // the shared horizontal scroll row, and a full-width input inside it + // would leave the filter icon stranded on a line of its own. + return ( +
+
+
+ +
+ + + + { + if (isLabStatusFilter(val)) onStatusChange(val); + }} + options={statusOptions} + allValue="ALL" + allLabel={t("labs.filter.statusAll")} + /> + + {(categoryOptions.length > 1 || filters.category) && ( + + )} + +
+
+ ); +} diff --git a/src/components/labs/lab-list-filters.ts b/src/components/labs/lab-list-filters.ts new file mode 100644 index 000000000..81ff7ed15 --- /dev/null +++ b/src/components/labs/lab-list-filters.ts @@ -0,0 +1,241 @@ +"use client"; + +/** + * Pure URL-facet helpers for the labs overview list, following the + * documents-vault and measurements patterns: the list's filter state + * lives in the page URL (?q&status&category&from&to) 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 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; + /** + * "Last measured": inclusive day bounds (`?from` / `?to`, YYYY-MM-DD) on + * the marker's newest reading (`latest.takenAt`), in the display zone. + * Older readings are not looked at, so a marker measured inside the range + * and again after it drops out, which is why the pill reads "Last measured". + */ + fromDay?: string; + toDay?: string; +} + +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; + + // 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; +} + +/** + * Serialise the filter object back into the page URL query string (no leading `?`). + * Defaults/ALL values are omitted. + */ +export function labListFiltersToSearch(filters: LabListFilters): 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); + 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); +} + +/** + * 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. + */ +export function filterMarkerRows( + rows: readonly MarkerRow[], + filters: LabListFilters, + categoryOf?: (markerKey: string) => string | undefined, + dayKeyOf: (takenAt: string) => string = (iso) => iso.slice(0, 10), +): MarkerRow[] { + const trimmed = filters.q?.trim(); + const query = trimmed ? searchFold(trimmed) : undefined; + 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 = searchFold(row.analyte).includes(query); + const panelMatch = row.panel + ? searchFold(row.panel).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. "Last measured": the newest reading's day, never an older one + if (fromDay || toDay) { + const takenDate = dayKeyOf(row.latest.takenAt); + if (fromDay && takenDate < fromDay) return false; + if (toDay && takenDate > toDay) return false; + } + + return true; + }); +} + +/** + * The person's Labs sort (Settings > Labs) 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. The + * list has no sort control of its own; the saved one is the only one. + */ +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 9d3f89695..a060445f8 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"; @@ -102,6 +105,8 @@ function openByDefault( ): boolean { if (mode === "expanded") return true; if (mode === "collapsed") return false; + // The server's status for the whole category, never a filtered summary: + // what a filter hides must not open or close a group. return category.status === "above" || category.status === "below"; } @@ -110,8 +115,17 @@ function openByDefault( * category the person opened or closed by hand stays so in this browser * (keyed by the opaque category key, never by its label) until the choice * itself changes. Storage is read only after hydration and may be missing. + * + * While a filter is active every category that still has a match is open, + * whatever the choice or the stored toggles say, so a match is never hidden + * behind a closed header. A category opened or closed by hand during + * filtering stays so while the filter changes, is not written to storage, + * and the stored state returns once the filter is cleared. */ -export function useGroupsOpen(mode: ModuleGroupsOpen): { +export function useGroupsOpen( + mode: ModuleGroupsOpen, + filtering = false, +): { isOpen: (category: LabOverviewCategoryDto) => boolean; toggle: (category: LabOverviewCategoryDto) => void; } { @@ -123,9 +137,30 @@ export function useGroupsOpen(mode: ModuleGroupsOpen): { const [toggled, setToggled] = useState({ mode, open: {} }); const own = toggled.mode === mode ? toggled.open : {}; + const [filtered, setFiltered] = useState<{ + filtering: boolean; + open: Record; + }>({ filtering, open: {} }); + if (filtered.filtering !== filtering) { + setFiltered({ filtering, open: {} }); + } + const whileFiltering = filtered.filtering === filtering ? filtered.open : {}; + const isOpen = (category: LabOverviewCategoryDto) => - own[category.key] ?? stored[category.key] ?? openByDefault(category, mode); + filtering + ? (whileFiltering[category.key] ?? true) + : (own[category.key] ?? + stored[category.key] ?? + openByDefault(category, mode)); + const toggle = (category: LabOverviewCategoryDto) => { + if (filtering) { + setFiltered({ + filtering, + open: { ...whileFiltering, [category.key]: !isOpen(category) }, + }); + return; + } const next = { ...stored, ...own, [category.key]: !isOpen(category) }; setToggled({ mode, open: next }); writeStoredOpen(mode, next); @@ -133,18 +168,50 @@ export function useGroupsOpen(mode: ModuleGroupsOpen): { 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 +225,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 d2feca236..66e426ee4 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,26 @@ 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, } 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 +42,7 @@ 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 +51,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 +71,7 @@ function LabTable({ key={group.category.key} group={group} groupsOpen={groupsOpen} + isFiltered={isFiltered} /> )) : rows.map((row) => )} @@ -93,11 +81,14 @@ function LabTable({ ); } -function useGroupToggle(group: MarkerGroup, groupsOpen: GroupsOpen) { +function useGroupToggle( + category: LabOverviewCategoryDto, + groupsOpen: GroupsOpen, +) { const panelId = `${useId()}-panel`; return { - open: groupsOpen.isOpen(group.category), - onToggle: () => groupsOpen.toggle(group.category), + open: groupsOpen.isOpen(category), + onToggle: () => groupsOpen.toggle(category), panelId, }; } @@ -109,18 +100,32 @@ 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, + ); return (
(isFiltered ? summariseGroupRows(group.rows) : undefined), + [isFiltered, group.rows], + ); + const status = summary?.status ?? group.category.status; + const { open, onToggle, panelId } = useGroupToggle( + group.category, + groupsOpen, + ); return (