Merge PR #1370: biomarker filtering and search in the labs list

This commit is contained in:
Marc-André Bombeck committed 2026-11-11 00:25:12 +01:00
commit f20efca500
32 files changed
+3027 -138

No files matched your search

+10
View File
@@ -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
+2 -1
View File
@@ -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:
+16
View File
@@ -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": {
+16
View File
@@ -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": {
+16
View File
@@ -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": {
+16
View File
@@ -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": {
+16
View File
@@ -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": {
+16
View File
@@ -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": {
+16
View File
@@ -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": {
+16
View File
@@ -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": {
+16
View File
@@ -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": {
+16
View File
@@ -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": {
@@ -93,9 +93,10 @@ const READERS: Record<string, Reader> = {
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);
});
});
@@ -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("<LabFilterRail> 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(
<div className="px-4">
<LabFilterRail {...props()} />
</div>,
);
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(
<LabFilterRail
{...props()}
isFiltered={true}
filters={{ status: "abnormal" }}
/>,
);
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(
<LabFilterRail
{...props()}
isFiltered={true}
filters={{ status: "abnormal" }}
/>,
{ 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(<LabFilterRail {...props()} />);
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 (
<>
<LabFilterRail
{...props()}
filters={lab.filters}
searchDraft={lab.searchDraft}
onSearchChange={lab.setSearchDraft}
searchInputRef={lab.searchInputRef}
/>
<button type="button" onClick={() => setDialog(true)}>
Open dialog
</button>
{dialog ? (
<div role="dialog" aria-label="Sheet">
<button type="button" autoFocus>
Inside
</button>
</div>
) : null}
</>
);
}
describe("<LabFilterRail> keyboard", () => {
it("focuses the search field on / and keeps focus there after clearing", async () => {
const { screen } = await renderWithApp(<WiredRail />);
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(<WiredRail />);
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();
});
});
@@ -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("<LabFilterRail>", () => {
it("renders search input, shortcut hint, and filter pills in canonical order", () => {
const html = renderToStaticMarkup(
<I18nProvider initialLocale="en">
<LabFilterRail
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}
/>
</I18nProvider>,
);
expect(html).toContain('data-slot="lab-filter-rail"');
expect(html).toContain('placeholder="Search biomarkers…"');
expect(html).toContain('aria-keyshortcuts="/"');
expect(html).toMatch(/<kbd[^>]*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(
<I18nProvider initialLocale="en">
<LabFilterRail
filters={{ q: "ferritin" }}
searchDraft="ferritin"
onSearchChange={vi.fn()}
searchInputRef={{ current: null }}
onStatusChange={vi.fn()}
onCategoryChange={vi.fn()}
onFromDayChange={vi.fn()}
onToDayChange={vi.fn()}
onResetFilters={vi.fn()}
isFiltered={true}
total={1}
/>
</I18nProvider>,
);
expect(html).toContain('aria-label="Clear search"');
expect(html).not.toContain("<kbd");
});
it("pluralizes marker count with tCount across locales", () => {
const extractCountText = (html: string) => {
const match = html.match(/<span[^>]*tabular-nums[^>]*>([^<]+)<\/span>/);
return match ? match[1].trim() : null;
};
const renderCount = (locale: "en" | "pl" | "ru", total: number) =>
renderToStaticMarkup(
<I18nProvider initialLocale={locale}>
<LabFilterRail
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}
total={total}
/>
</I18nProvider>,
);
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(
<I18nProvider initialLocale="en">
<LabFilterRail
filters={{ status: "abnormal" }}
searchDraft=""
onSearchChange={vi.fn()}
searchInputRef={{ current: null }}
onStatusChange={vi.fn()}
onCategoryChange={vi.fn()}
onFromDayChange={vi.fn()}
onToDayChange={vi.fn()}
onResetFilters={vi.fn()}
isFiltered={true}
total={2}
/>
</I18nProvider>,
);
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");
});
});
@@ -47,16 +47,17 @@ function reading(over: Partial<LabResultDto>): 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(
@@ -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>): 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>): 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<LabListFilters>({
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();
});
});
@@ -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>): 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 (
<button type="button" onClick={() => setTick((n) => n + 1)}>
Rerender
</button>
);
}
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(<LabList />, { 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(<LabList />, { 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(<LabList />, { 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(<LabList />, { 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(<LabList />, { 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(<LabList />, { 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(<LabList />, { 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(<LabList />, { 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(
<FilterCallbackStabilityProbe onRender={(cb) => callbacks.push(cb)} />,
);
await screen.getByRole("button", { name: "Rerender" }).click();
expect(callbacks.length).toBeGreaterThanOrEqual(2);
expect(callbacks[0]).toBe(callbacks[1]);
});
});
@@ -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 <div data-slot="lab-filter-rail" />;
},
}));
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<typeof import("@/lib/module-list-prefs")>();
return {
...actual,
useModuleListPrefs: () => ({
prefs: mockModulePrefs,
}),
};
});
function mockReading(over: Partial<LabResultDto>): 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(
<QueryClientProvider client={queryClient}>
<I18nProvider initialLocale="en">
<LabList />
</I18nProvider>
</QueryClientProvider>,
);
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();
});
});
@@ -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<typeof import("@/lib/i18n/context")>();
return {
...actual,
useDisplayTimezone: () => displayZone.current,
};
});
vi.mock("@/lib/module-list-prefs", async (importOriginal) => {
const actual =
await importOriginal<typeof import("@/lib/module-list-prefs")>();
return {
...actual,
useModuleListPrefs: () => ({
prefs: mockModulePrefs,
}),
};
});
function mockReading(over: Partial<LabResultDto>): 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("<LabList>", () => {
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(
<QueryClientProvider client={queryClient}>
<I18nProvider initialLocale="en">
<LabList />
</I18nProvider>
</QueryClientProvider>,
);
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(
<QueryClientProvider client={queryClient}>
<I18nProvider initialLocale="en">
<LabList />
</I18nProvider>
</QueryClientProvider>,
);
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(
<QueryClientProvider client={queryClient}>
<I18nProvider initialLocale="en">
<LabList />
</I18nProvider>
</QueryClientProvider>,
);
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(
<QueryClientProvider client={queryClient}>
<I18nProvider initialLocale="en">
<LabList />
</I18nProvider>
</QueryClientProvider>,
);
// 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(
<QueryClientProvider client={queryClient}>
<I18nProvider initialLocale="en">
<LabList />
</I18nProvider>
</QueryClientProvider>,
);
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(
<QueryClientProvider client={queryClient}>
<I18nProvider initialLocale="en">
<LabList />
</I18nProvider>
</QueryClientProvider>,
);
// 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(
<QueryClientProvider client={queryClient}>
<I18nProvider initialLocale="en">
<LabList />
</I18nProvider>
</QueryClientProvider>,
);
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(
<QueryClientProvider client={queryClient}>
<I18nProvider initialLocale="en">
<LabList />
</I18nProvider>
</QueryClientProvider>,
);
// 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");
});
});
+162
View File
@@ -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<HTMLInputElement | null>;
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 (
<div
data-slot="lab-filter-rail"
className="flex flex-col gap-3 sm:flex-row sm:items-start"
>
<div className="relative w-full sm:w-56 sm:shrink-0">
<Search
className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2"
aria-hidden="true"
/>
<Input
ref={searchInputRef}
type="search"
value={searchDraft}
onChange={(e) => onSearchChange(e.target.value)}
placeholder={t("labs.filter.searchPlaceholder")}
aria-label={t("labs.filter.searchLabel")}
aria-keyshortcuts="/"
className="w-full min-w-0 pr-11 pl-9 [&::-webkit-search-cancel-button]:appearance-none"
/>
{searchDraft === "" ? (
<kbd
aria-hidden="true"
className="border-border text-muted-foreground pointer-events-none absolute top-1/2 right-2.5 hidden -translate-y-1/2 rounded border px-1.5 font-mono text-xs sm:inline-block"
>
/
</kbd>
) : (
<button
type="button"
onClick={() => {
onSearchChange("");
// The button goes away with the text; focus stays in the field.
searchInputRef.current?.focus();
}}
aria-label={t("labs.filter.clearSearch")}
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 absolute top-1/2 right-0 inline-flex size-11 -translate-y-1/2 items-center justify-center rounded-full outline-none focus-visible:ring-[3px] sm:size-10"
>
<X className="size-4" aria-hidden="true" />
</button>
)}
</div>
<div className="min-w-0 sm:flex-1">
<FilterBar
isFiltered={isFiltered}
onReset={onResetFilters}
count={
total !== undefined
? tCount("labs.filter.markerCount", total, {
count: fmt.integer(total),
})
: undefined
}
>
<FilterBarDateRange
label={t("labs.filter.lastMeasured")}
from={filters.fromDay ?? ""}
to={filters.toDay ?? ""}
onFromChange={onFromDayChange}
onToChange={onToDayChange}
idPrefix="labs-filter-date"
/>
<FilterBarSelect
label={t("labs.filter.statusLabel")}
value={filters.status ?? "ALL"}
onValueChange={(val) => {
if (isLabStatusFilter(val)) onStatusChange(val);
}}
options={statusOptions}
allValue="ALL"
allLabel={t("labs.filter.statusAll")}
/>
{(categoryOptions.length > 1 || filters.category) && (
<FilterBarSelect
label={t("labs.filter.categoryLabel")}
value={filters.category ?? "ALL"}
onValueChange={onCategoryChange}
options={categoryOptions}
allValue="ALL"
allLabel={t("labs.filter.categoryAll")}
/>
)}
</FilterBar>
</div>
</div>
);
}
+241
View File
@@ -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<string>(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:<id>` or `analyte:<name>`) to category.key.
*/
export function buildMarkerCategoryMap(
categories: readonly LabOverviewCategoryDto[] | undefined | null,
): Map<string, string> {
const map = new Map<string, string>();
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];
}
+105 -17
View File
@@ -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<StoredOpen>({ mode, open: {} });
const own = toggled.mode === mode ? toggled.open : {};
const [filtered, setFiltered] = useState<{
filtering: boolean;
open: Record<string, boolean>;
}>({ 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 <ScorePill tone="neutral">{t("labs.groups.allInRange")}</ScorePill>;
}
const { inRange, below, above, unknown } = category.counts;
return (
<ScorePill tone={category.status === "above" ? "warning" : "info"}>
{tCount("labs.groups.outsideCount", above + below, {
total: inRange + below + above + unknown,
<ScorePill tone={status === "above" ? "warning" : "info"}>
{tCount("labs.groups.outsideCount", counts.above + counts.below, {
total: counts.inRange + counts.below + counts.above + counts.unknown,
})}
</ScorePill>
);
@@ -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 (
<h2 className={cn("min-w-0", className)}>
<button
@@ -190,11 +280,9 @@ export function LabGroupHeader({
<span className="text-muted-foreground min-w-0 truncate text-xs font-medium tracking-wide uppercase">
{category.label}
</span>
<TagChip className="shrink-0 tabular-nums">
{category.markerKeys.length}
</TagChip>
<TagChip className="shrink-0 tabular-nums">{rows.length}</TagChip>
<span className="ml-auto shrink-0">
<GroupStatus category={category} />
<GroupStatus counts={counts} status={status} />
</span>
</button>
</h2>
+275 -92
View File
@@ -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<typeof useGroupsOpen>;
@@ -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) => <LabListRow key={row.key} row={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 (
<div className={cn(LIST_SUBGRID_CLASS, "divide-y-0")}>
<LabGroupHeader
category={group.category}
rows={group.rows}
open={open}
onToggle={onToggle}
panelId={panelId}
isFiltered={isFiltered}
status={status}
summary={summary}
className="px-4 py-1 lg:col-span-full"
/>
<div
@@ -162,18 +167,32 @@ function CardGrid({
function CardGroup({
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 (
<section className="space-y-4">
<LabGroupHeader
category={group.category}
rows={group.rows}
open={open}
onToggle={onToggle}
panelId={panelId}
isFiltered={isFiltered}
status={status}
summary={summary}
/>
<div id={panelId} hidden={!open}>
{open ? <CardGrid rows={group.rows} underCategory /> : null}
@@ -182,6 +201,115 @@ function CardGroup({
);
}
function LabListLoading() {
return (
<div className="space-y-3" data-slot="lab-list-loading">
{Array.from({ length: 3 }, (_, i) => (
<Card key={i} aria-hidden="true">
<CardContent className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0 space-y-2">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-3 w-56" />
</div>
<Skeleton className="h-8 w-24 self-end sm:self-center" />
</CardContent>
</Card>
))}
</div>
);
}
function LabListEmpty({
canAddLab,
onAddFirst,
}: {
canAddLab: boolean;
onAddFirst?: () => void;
}) {
const { t } = useTranslations();
return (
<EmptyState
icon={<FlaskConical className="size-6" />}
title={t("labs.emptyTitle")}
description={t("labs.emptyDescription")}
action={
onAddFirst && canAddLab ? (
<Button onClick={onAddFirst}>{t("labs.addFirst")}</Button>
) : undefined
}
/>
);
}
function LabListNoMatches({ onReset }: { onReset: () => void }) {
const { t } = useTranslations();
return (
<EmptyState
icon={<FlaskConical className="size-6" />}
title={t("labs.filter.noMatchesTitle")}
description={t("labs.filter.noMatchesDescription")}
action={
<Button variant="outline" onClick={onReset}>
{t("dataList.resetFilters")}
</Button>
}
/>
);
}
function LabListContent({
view,
rows,
groups,
groupsOpen,
isFiltered,
}: {
view: ModuleListView;
rows: MarkerRow[];
groups: MarkerGroup[] | null;
groupsOpen: GroupsOpen;
isFiltered: boolean;
}) {
// v1.18.10 (#3) — delete lives ONLY on the value detail view (next to Edit),
// not on these overview rows. The list/tile rows navigate; the trash icon was
// removed here so a stray tap can't delete a biomarker from the overview.
// v1.18.6 — compact list view is the default (#40); the card/tile view is
// the alternative the settings toggle selects.
if (view === "list") {
return (
<LabTable
rows={rows}
groups={groups}
groupsOpen={groupsOpen}
isFiltered={isFiltered}
/>
);
}
// v1.18.9 (#40) — card/tile view.
if (groups) {
return (
<div data-slot="lab-list" className="space-y-10">
{groups.map((group) => (
<CardGroup
key={group.category.key}
group={group}
groupsOpen={groupsOpen}
isFiltered={isFiltered}
/>
))}
</div>
);
}
return (
<div data-slot="lab-list">
<CardGrid rows={rows} underCategory={false} />
</div>
);
}
export function LabList({
onAddFirst,
onEmptyChange,
@@ -194,7 +322,7 @@ export function LabList({
const { canWriteDomain } = useRecordCapabilities();
const canAddLab = canWriteDomain("labs");
const { prefs } = useModuleListPrefs("labs");
const groupsOpen = useGroupsOpen(prefs.groupsOpen);
const displayTz = useDisplayTimezone();
// v1.22 — a short, factual line under each marker heading describing what the
// biomarker measures, sourced from the catalog via i18n. Free-text markers
@@ -208,40 +336,97 @@ export function LabList({
queryFn: () => apiGet<LabOverviewResponse>("/api/labs/overview"),
});
const rows = useMemo(
() => sortRows(toMarkerRows(data?.markers ?? [], t), prefs),
[data?.markers, prefs, t],
const {
filters,
searchDraft,
setSearchDraft,
searchInputRef,
setStatusFilter,
setCategoryFilter,
setFromDay,
setToDay,
resetFilters,
} = useLabListFilters();
const rawRows = useMemo(
() => toMarkerRows(data?.markers ?? [], t),
[data?.markers, t],
);
const categories = data?.categories;
const categoryValid =
!filters.category ||
filters.category === "ALL" ||
!categories ||
categories.some((c) => c.key === filters.category);
const effectiveFilters = useMemo(
() => (categoryValid ? filters : { ...filters, category: undefined }),
[categoryValid, filters],
);
const effectiveIsFiltered = Boolean(
(effectiveFilters.q && effectiveFilters.q.trim() !== "") ||
(effectiveFilters.status && effectiveFilters.status !== "ALL") ||
(effectiveFilters.category && effectiveFilters.category !== "ALL") ||
effectiveFilters.fromDay ||
effectiveFilters.toDay,
);
const groupsOpen = useGroupsOpen(prefs.groupsOpen, effectiveIsFiltered);
// Scrub stale category filter when categories load.
// afterPaint protects mount writes from router history initialization.
useEffect(() => {
if (
categories &&
filters.category &&
!categories.some((c) => c.key === filters.category)
) {
setCategoryFilter("ALL", { mode: "replace", afterPaint: true });
}
}, [categories, filters.category, setCategoryFilter]);
const categoryMap = useMemo(
() => buildMarkerCategoryMap(categories),
[categories],
);
const categoryOf = useCallback(
(key: string) => categoryMap.get(key),
[categoryMap],
);
const dayKeyOf = useCallback(
(takenAt: string) => labReadingDayKey(takenAt, displayTz),
[displayTz],
);
const filteredRows = useMemo(
() => filterMarkerRows(rawRows, effectiveFilters, categoryOf, dayKeyOf),
[rawRows, effectiveFilters, categoryOf, dayKeyOf],
);
const rows = useMemo(
() => sortMarkerRows(filteredRows, prefs.sortDir, prefs.order),
[filteredRows, prefs.sortDir, prefs.order],
);
// Categories only when the person asked for them and the server says they
// add something; otherwise the flat list it always was.
const grouped = prefs.groupBy === "category" && data?.grouped === true;
const categories = data?.categories;
const groups = useMemo(
() => (grouped && categories ? groupMarkerRows(rows, categories) : null),
[grouped, categories, rows],
);
const listEmpty = !isLoading && !isError && rows.length === 0;
const rawEmpty = !isLoading && !isError && rawRows.length === 0;
useEffect(() => {
onEmptyChange?.(listEmpty);
}, [listEmpty, onEmptyChange]);
onEmptyChange?.(rawEmpty);
}, [rawEmpty, onEmptyChange]);
if (isLoading) {
return (
<div className="space-y-3" data-slot="lab-list-loading">
{Array.from({ length: 3 }, (_, i) => (
<Card key={i} aria-hidden="true">
<CardContent className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0 space-y-2">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-3 w-56" />
</div>
<Skeleton className="h-8 w-24 self-end sm:self-center" />
</CardContent>
</Card>
))}
</div>
);
return <LabListLoading />;
}
if (isError) {
@@ -256,48 +441,46 @@ export function LabList({
);
}
if (rawRows.length === 0) {
return <LabListEmpty canAddLab={canAddLab} onAddFirst={onAddFirst} />;
}
const filterRail = (
<LabFilterRail
filters={effectiveFilters}
searchDraft={searchDraft}
onSearchChange={setSearchDraft}
searchInputRef={searchInputRef}
onStatusChange={setStatusFilter}
onCategoryChange={setCategoryFilter}
onFromDayChange={setFromDay}
onToDayChange={setToDay}
onResetFilters={resetFilters}
isFiltered={effectiveIsFiltered}
categories={categories}
total={rows.length}
/>
);
if (rows.length === 0) {
return (
<EmptyState
icon={<FlaskConical className="size-6" />}
title={t("labs.emptyTitle")}
description={t("labs.emptyDescription")}
action={
onAddFirst && canAddLab ? (
<Button onClick={onAddFirst}>{t("labs.addFirst")}</Button>
) : undefined
}
/>
);
}
// v1.18.10 (#3) — delete lives ONLY on the value detail view (next to Edit),
// not on these overview rows. The list/tile rows navigate; the trash icon was
// removed here so a stray tap can't delete a biomarker from the overview.
// v1.18.6 — compact list view is the default (#40); the card/tile view is
// the alternative the settings toggle selects.
if (prefs.view === "list") {
return <LabTable rows={rows} groups={groups} groupsOpen={groupsOpen} />;
}
// v1.18.9 (#40) — card/tile view.
if (groups) {
return (
<div data-slot="lab-list" className="space-y-10">
{groups.map((group) => (
<CardGroup
key={group.category.key}
group={group}
groupsOpen={groupsOpen}
/>
))}
<div className="space-y-6">
{filterRail}
<LabListNoMatches onReset={resetFilters} />
</div>
);
}
return (
<div data-slot="lab-list">
<CardGrid rows={rows} underCategory={false} />
<div className="space-y-6">
{filterRail}
<LabListContent
view={prefs.view}
rows={rows}
groups={groups}
groupsOpen={groupsOpen}
isFiltered={effectiveIsFiltered}
/>
</div>
);
}
+2 -1
View File
@@ -1,3 +1,4 @@
import type { ReferenceRangeStatusCounts } from "@/lib/labs/reference-range";
import type { RelatedLink } from "@/lib/links/related";
import type {
@@ -87,7 +88,7 @@ export interface LabOverviewCategoryDto {
label: string;
/** The `markers[].key` values in this category. */
markerKeys: string[];
counts: { inRange: number; below: number; above: number; unknown: number };
counts: ReferenceRangeStatusCounts;
status: LabResultDto["rangeStatus"];
}
+145
View File
@@ -0,0 +1,145 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import {
useUrlFilterSync,
type UrlFilterSyncOptions,
} from "@/hooks/use-url-filter-sync";
import {
labListFiltersToSearch,
parseLabListSearchParams,
type LabListFilters,
type LabStatusFilter,
} from "./lab-list-filters";
export function useLabListFilters() {
const { filters, applyFilters, patchFilters } =
useUrlFilterSync<LabListFilters>({
parse: parseLabListSearchParams,
serialise: labListFiltersToSearch,
});
// The field edits a draft; the URL takes it, trimmed, 200 ms after the
// last keystroke. A `q` the URL gains from elsewhere (back/forward, a
// shared link, Reset) replaces the draft. One the draft wrote itself
// does not: adopting it would drop a trailing space ("vitamin " became
// "vitamin", and the next letter ran on), or a letter typed while the
// write was landing.
const urlQ = filters.q ?? "";
const [searchDraft, setSearchDraft] = useState(urlQ);
const [lastUrlQ, setLastUrlQ] = useState(urlQ);
const [ownWrite, setOwnWrite] = useState<string | null>(null);
const searchInputRef = useRef<HTMLInputElement | null>(null);
if (urlQ !== lastUrlQ) {
setLastUrlQ(urlQ);
if (urlQ === ownWrite) setOwnWrite(null);
else if (urlQ !== searchDraft.trim()) setSearchDraft(urlQ);
}
useEffect(() => {
const handle = setTimeout(() => {
const trimmed = searchDraft.trim();
if (trimmed === (filters.q ?? "")) return;
setOwnWrite(trimmed);
applyFilters(
(current) =>
trimmed === (current.q ?? "")
? current
: { ...current, q: trimmed === "" ? undefined : trimmed },
"replace",
);
}, 200);
return () => clearTimeout(handle);
}, [searchDraft, filters.q, applyFilters]);
// "/" focuses the search field, unless the key is meant for something
// else: a field being typed in, or an open dialog, menu or list whose
// focus must not jump to the page behind it.
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (
event.key !== "/" ||
event.metaKey ||
event.ctrlKey ||
event.altKey ||
event.defaultPrevented ||
event.isComposing
) {
return;
}
const target = event.target as HTMLElement | null;
if (
target &&
(target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.tagName === "SELECT" ||
target.isContentEditable ||
target.closest(
'[role="dialog"], [role="alertdialog"], [role="menu"], [role="listbox"]',
))
) {
return;
}
event.preventDefault();
searchInputRef.current?.focus();
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, []);
const setStatusFilter = useCallback(
(value: LabStatusFilter | "ALL") => {
patchFilters({
status: value === "ALL" ? undefined : value,
});
},
[patchFilters],
);
const setCategoryFilter = useCallback(
(
value: string,
modeOrOptions: "push" | "replace" | UrlFilterSyncOptions = "push",
) => {
patchFilters(
{
category: value === "ALL" ? undefined : value,
},
modeOrOptions,
);
},
[patchFilters],
);
const setFromDay = useCallback(
(value: string) => {
patchFilters({ fromDay: value || undefined }, "replace");
},
[patchFilters],
);
const setToDay = useCallback(
(value: string) => {
patchFilters({ toDay: value || undefined }, "replace");
},
[patchFilters],
);
const resetFilters = useCallback(() => {
setSearchDraft("");
applyFilters({});
}, [applyFilters]);
return {
filters,
searchDraft,
setSearchDraft,
searchInputRef,
setStatusFilter,
setCategoryFilter,
setFromDay,
setToDay,
resetFilters,
};
}
@@ -109,6 +109,14 @@ function FilterRail({ syncToUrl = true }: { syncToUrl?: boolean }) {
options={SOURCES}
/>
</FilterBar>
<button
type="button"
onClick={() =>
patchFilters({ type: "PULSE" }, { mode: "replace", afterPaint: true })
}
>
Defer replace type
</button>
</>
);
}
@@ -193,6 +201,27 @@ describe("useUrlFilterSync with FilterBar", () => {
expect(window.history.length).toBe(entries);
});
it("defers writing until after paint and replaces current entry without adding history entries", async () => {
const { screen, history } = await mount("?type=WEIGHT");
const entries = window.history.length;
(
screen
.getByRole("button", { name: "Defer replace type" })
.element() as HTMLButtonElement
).click();
// Immediately after trigger, the write is deferred until after paint:
// search remains unchanged and no history write has executed yet.
expect(history.writes).toEqual([]);
expect(currentSearch()).toBe("?type=WEIGHT");
// Once painted frames flush, the replace lands without extra history entry:
await expectSearch("?type=PULSE");
expect(history.pushes()).toEqual([]);
expect(history.replaces()).toEqual(["?type=PULSE"]);
expect(window.history.length).toBe(entries);
});
it("drops one facet with its clear button and keeps the others", async () => {
const { screen, pill } = await mount("?type=WEIGHT&source=MANUAL");
await screen.getByRole("button", { name: "Clear filter: Type" }).click();
+25 -6
View File
@@ -4,6 +4,13 @@ import { useCallback, useMemo, useState } from "react";
import { setAllParams, useUrlSearch, writeUrlState } from "@/lib/url-state";
export interface UrlFilterSyncOptions {
/** A new history entry (`push`) or the current one rewritten (`replace`). */
mode?: "push" | "replace";
/** Wait two painted frames before writing (a write made while mounting). */
afterPaint?: boolean;
}
/**
* URL-owned filter state — the documents-vault pattern
* (`src/components/documents/documents-view.tsx`), extracted so the
@@ -44,10 +51,13 @@ export function useUrlFilterSync<T>({
filters: T;
applyFilters: (
next: T | ((current: T) => T),
mode?: "push" | "replace",
modeOrOptions?: "push" | "replace" | UrlFilterSyncOptions,
) => void;
/** Merge `patch` into the live filters (the common facet change). */
patchFilters: (patch: Partial<T>, mode?: "push" | "replace") => void;
patchFilters: (
patch: Partial<T>,
modeOrOptions?: "push" | "replace" | UrlFilterSyncOptions,
) => void;
} {
const params = useUrlSearch();
@@ -60,7 +70,14 @@ export function useUrlFilterSync<T>({
);
const applyFilters = useCallback(
(next: T | ((current: T) => T), mode: "push" | "replace" = "push") => {
(
next: T | ((current: T) => T),
modeOrOptions: "push" | "replace" | UrlFilterSyncOptions = "push",
) => {
const options: UrlFilterSyncOptions =
typeof modeOrOptions === "string"
? { mode: modeOrOptions }
: modeOrOptions;
const resolve = (current: T): T =>
typeof next === "function" ? (next as (c: T) => T)(current) : next;
if (!syncToUrl) {
@@ -72,15 +89,17 @@ export function useUrlFilterSync<T>({
const search = serialise(resolve(parse(live)));
setAllParams(search)(live);
},
{ mode },
{ mode: options.mode ?? "push", afterPaint: options.afterPaint },
);
},
[syncToUrl, serialise, parse],
);
const patchFilters = useCallback(
(patch: Partial<T>, mode: "push" | "replace" = "push") =>
applyFilters((current) => ({ ...current, ...patch }), mode),
(
patch: Partial<T>,
modeOrOptions: "push" | "replace" | UrlFilterSyncOptions = "push",
) => applyFilters((current) => ({ ...current, ...patch }), modeOrOptions),
[applyFilters],
);
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
import {
classifyReferenceRange,
formatReferenceRange,
priorityReferenceRangeStatus,
} from "@/lib/labs/reference-range";
describe("classifyReferenceRange", () => {
@@ -63,3 +64,48 @@ describe("formatReferenceRange", () => {
expect(formatReferenceRange(4, 6, oneDecimal)).toBe("4.0–6.0");
});
});
describe("priorityReferenceRangeStatus", () => {
it("implements canonical priority: above > below > in-range > unknown with counts", () => {
expect(
priorityReferenceRangeStatus({
above: 1,
below: 1,
inRange: 1,
unknown: 1,
}),
).toBe("above");
expect(
priorityReferenceRangeStatus({
above: 0,
below: 2,
inRange: 5,
unknown: 1,
}),
).toBe("below");
expect(
priorityReferenceRangeStatus({
above: 0,
below: 0,
inRange: 3,
unknown: 2,
}),
).toBe("in-range");
expect(
priorityReferenceRangeStatus({
above: 0,
below: 0,
inRange: 0,
unknown: 4,
}),
).toBe("unknown");
expect(
priorityReferenceRangeStatus({
above: 0,
below: 0,
inRange: 0,
unknown: 0,
}),
).toBe("unknown");
});
});
+9 -15
View File
@@ -6,6 +6,8 @@
* unlinked one (the same value the card shows as its chip). The server
* resolves the category, its label, its order and a combined status once;
* the web list and the iOS app render that as given and never rebuild it.
* While a filter hides some members, the web list summarises the matching
* ones by the same rule (`priorityReferenceRangeStatus`).
*
* Resolution, first match wins:
* 1. the panel folds to a catalog panel label in any shipped locale
@@ -25,7 +27,11 @@ import { getServerTranslator } from "@/lib/i18n/server-translator";
import { buildLocalisedLabelIndex } from "@/lib/i18n/localised-label-index";
import type { Locale } from "@/lib/i18n/config";
import { resolveIntlLocale } from "@/lib/format-locale";
import type { ReferenceRangeStatus } from "@/lib/labs/reference-range";
import {
priorityReferenceRangeStatus,
type ReferenceRangeStatus,
type ReferenceRangeStatusCounts,
} from "@/lib/labs/reference-range";
import { catalogKeyForName } from "./analyte-match";
import {
@@ -45,12 +51,7 @@ export interface MarkerCategoryRef {
kind: MarkerCategoryKind;
}
export interface CategoryCounts {
inRange: number;
below: number;
above: number;
unknown: number;
}
export type CategoryCounts = ReferenceRangeStatusCounts;
export interface LabOverviewCategory {
/** A catalog panel key, `custom:<hash>`, or `other`. Opaque to clients. */
@@ -129,14 +130,7 @@ export function summariseCategory(statuses: readonly ReferenceRangeStatus[]): {
else if (status === "above") counts.above += 1;
else counts.unknown += 1;
}
const status: ReferenceRangeStatus =
counts.above > 0
? "above"
: counts.below > 0
? "below"
: counts.inRange > 0
? "in-range"
: "unknown";
const status = priorityReferenceRangeStatus(counts);
return { counts, status };
}
+27
View File
@@ -204,3 +204,30 @@ export function classifyAgainstEffectiveRange(
if (value === null) return "unknown";
return classifyReferenceRange(value, effective.low, effective.high);
}
export interface ReferenceRangeStatusCounts {
inRange: number;
below: number;
above: number;
unknown: number;
}
/**
* Resolve combined reference-range status according to the canonical priority rule:
* `above` > `below` > `in-range` > `unknown`.
*
* Any value above its range makes the group "above", else any below makes it "below",
* else any in-range makes it "in-range"; only when all members are unassessed does it
* resolve to "unknown".
*/
export function priorityReferenceRangeStatus(
input: ReferenceRangeStatusCounts,
): ReferenceRangeStatus {
return input.above > 0
? "above"
: input.below > 0
? "below"
: input.inRange > 0
? "in-range"
: "unknown";
}
+1 -1
View File
@@ -163,7 +163,7 @@ export const labOverviewCategory = z
.meta({
id: "LabOverviewCategory",
description:
"One category of the labs list, resolved by the server. `key` is opaque: a catalog panel key, `custom:<hash>` for the person's own panel, or `other`; it carries no user text and is stable, so a client may key collapse state on it. `label` is rendered as given (a catalog panel in the caller's locale, the person's own spelling, or the localised \"Other\"). `markerKeys` names the `markers[].key` values in the category. `status` combines the members' newest readings: `above` when any is above its range, else `below` when any is below, else `in-range` when any was assessed, else `unknown`. A client renders these values and never rebuilds the category, the order or the status.",
"One category of the labs list, resolved by the server. `key` is opaque: a catalog panel key, `custom:<hash>` for the person's own panel, or `other`; it carries no user text and is stable, so a client may key collapse state on it. `label` is rendered as given (a catalog panel in the caller's locale, the person's own spelling, or the localised \"Other\"). `markerKeys` names the `markers[].key` values in the category. `status` combines the members' newest readings: `above` when any is above its range, else `below` when any is below, else `in-range` when any was assessed, else `unknown`. A client renders these values and never rebuilds the category, the order or the status. 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.",
});
export const labOverviewResponse = z