mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
Merge PR #1370: biomarker filtering and search in the labs list
This commit is contained in:
commit
f20efca500
32 files changed
+3027
-138
No files matched your search
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"];
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user