feat(labs): add biomarker filtering and sorting

This commit is contained in:
Konrad Garbal committed 2026-11-10 20:46:57 +01:00
1 parent 22b85ff97d
commit b4ac67ee7c
36 files changed
+3290 -172

No files matched your search

@@ -0,0 +1,158 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { page } from "vitest/browser";
import { renderWithApp } from "../../../../tests/browser/render";
import { LabFilterRail, type LabFilterRailProps } from "../lab-filter-rail";
function props(): LabFilterRailProps {
return {
filters: {},
searchDraft: "",
onSearchChange: vi.fn(),
searchInputRef: { current: null },
onStatusChange: vi.fn(),
onCategoryChange: vi.fn(),
onFromDayChange: vi.fn(),
onToDayChange: vi.fn(),
onSortChange: 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",
},
],
total: 5,
currentSortDir: "recentDesc",
};
}
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);
const { screen } = await renderWithApp(<LabFilterRail {...props()} />);
const search = screen.getByRole("searchbox", { name: "Search biomarkers" });
const dateRange = screen.getByRole("button", { name: "Date range" });
const searchRect = search.element().getBoundingClientRect();
const dateRect = dateRange.element().getBoundingClientRect();
expect(searchRect.width).toBeGreaterThan(300);
expect(dateRect.top).toBeGreaterThan(searchRect.bottom);
});
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: "Date range" }).element(),
screen.getByRole("combobox", { name: "Status: Out of range" }).element(),
screen.getByRole("combobox", { name: "Category" }).element(),
screen
.getByRole("combobox", {
name: "Sort order: Most recent reading first",
})
.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 sort 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: "Zeitraum" }).element(),
screen
.getByRole("combobox", {
name: "Status: Außerhalb des Bereichs",
})
.element(),
screen.getByRole("combobox", { name: "Kategorie" }).element(),
screen
.getByRole("combobox", {
name: "Sortierung: Neueste Messung zuerst",
})
.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: "Date range" }).element(),
screen.getByRole("combobox", { name: "Status" }).element(),
screen.getByRole("combobox", { name: "Category" }).element(),
screen
.getByRole("combobox", {
name: "Sort order: Most recent reading first",
})
.element(),
];
const centers = controls.map(centerY);
expect(Math.max(...centers) - Math.min(...centers)).toBeLessThan(2);
});
});
@@ -0,0 +1,195 @@
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()}
onSortChange={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",
},
]}
total={5}
currentSortDir="recentDesc"
/>
</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("Date range");
const statusIdx = html.indexOf("Status");
const categoryIdx = html.indexOf("Category");
const sortIdx = html.indexOf("Sort order");
expect(searchIdx).toBeGreaterThan(-1);
expect(dateRangeIdx).toBeGreaterThan(searchIdx);
expect(statusIdx).toBeGreaterThan(dateRangeIdx);
expect(categoryIdx).toBeGreaterThan(statusIdx);
expect(sortIdx).toBeGreaterThan(categoryIdx);
// Active sort label is shown directly in the trigger
expect(html).toContain("Sort order: Most recent reading first");
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()}
onSortChange={vi.fn()}
onResetFilters={vi.fn()}
isFiltered={true}
total={1}
currentSortDir="recentDesc"
/>
</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[^>]*data-slot="filter-bar-count"[^>]*>([^<]+)<\/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()}
onSortChange={vi.fn()}
onResetFilters={vi.fn()}
isFiltered={false}
total={total}
currentSortDir="recentDesc"
/>
</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()}
onSortChange={vi.fn()}
onResetFilters={vi.fn()}
isFiltered={true}
total={2}
currentSortDir="recentDesc"
/>
</I18nProvider>,
);
expect(html).toContain("Reset");
});
it("renders active sort label in the trigger", () => {
const htmlAlpha = 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()}
onSortChange={vi.fn()}
onResetFilters={vi.fn()}
isFiltered={false}
total={3}
currentSortDir="alphaAsc"
/>
</I18nProvider>,
);
expect(htmlAlpha).toContain("Sort order: Name (A–Z)");
const htmlRecent = 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()}
onSortChange={vi.fn()}
onResetFilters={vi.fn()}
isFiltered={false}
total={3}
currentSortDir="recentDesc"
/>
</I18nProvider>,
);
expect(htmlRecent).toContain("Sort order: Most recent reading first");
});
});
@@ -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,356 @@
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&sort=alphaAsc",
);
expect(parseLabListSearchParams(params)).toEqual<LabListFilters>({
q: "iron",
status: "abnormal",
category: "cat_iron",
fromDay: "2026-01-01",
toDay: "2026-12-31",
sort: "alphaAsc",
});
});
it("drops invalid status, invalid dates, and invalid sort", () => {
const params = new URLSearchParams(
"status=invalid&category=ALL&from=not-a-date&to=invalid&sort=random",
);
expect(parseLabListSearchParams(params)).toEqual({});
});
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("omits default sort from search string", () => {
expect(labListFiltersToSearch({ sort: "recentDesc" }, "recentDesc")).toBe(
"",
);
expect(labListFiltersToSearch({ sort: "alphaAsc" }, "recentDesc")).toBe(
"sort=alphaAsc",
);
expect(labListFiltersToSearch({ sort: "alphaAsc" }, "alphaAsc")).toBe("");
});
it("roundtrips valid filters to search string", () => {
const filters: LabListFilters = {
q: "cholesterol",
status: "above",
category: "lipids",
fromDay: "2026-05-01",
toDay: "2026-05-31",
sort: "recentAsc",
};
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("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,562 @@
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 {
legacyModuleListStorageKey,
resetModuleListCarryOverForTests,
} from "@/lib/module-list-prefs";
import { stubFetch } from "../../../../tests/browser/fetch-stub";
import { renderWithApp, testQueryClient } from "../../../../tests/browser/render";
import {
currentSearch,
recordHistory,
setSearch,
} from "../../../../tests/browser/url";
import { LabList } from "../lab-list";
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 stale category: marker is visible, category pill is inactive
await expect.element(screen.getByText("Ferritin")).toBeVisible();
await expect
.element(screen.getByRole("combobox", { name: "Category" }))
.toHaveAttribute("data-active", "false");
});
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("normalizes sort=manual without order in URL via replace after paint", async () => {
setSearch("?sort=manual");
const history = recordHistory();
const queryClient = labQueryClient();
queryClient.setQueryData(queryKeys.moduleListLayout(), {
version: 1,
lists: {
labs: {
view: "list",
order: [],
sortDir: "recentDesc",
groupBy: "none",
groupsOpen: "expanded",
},
},
});
queryClient.setQueryData(
queryKeys.labResultsOverview("en"),
overviewWithCategories,
);
await renderWithApp(<LabList />, { queryClient });
await expect.poll(currentSearch).toBe("");
expect(history.pushes()).toEqual([]);
expect(history.replaces()).toEqual([""]);
});
it("preserves sort=manual while preferences load and keeps it when manual order arrives", async () => {
setSearch("?sort=manual");
const history = recordHistory();
const api = stubFetch();
const pendingLayout = api.deferred("GET", "/api/settings/module-list-layout");
const queryClient = labQueryClient();
queryClient.setQueryData(
queryKeys.labResultsOverview("en"),
overviewWithCategories,
);
const { screen } = await renderWithApp(<LabList />, { queryClient });
// Wait for double-rAF afterPaint to pass; without isLoaded gate, this would scrub prematurely
await new Promise((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(resolve)),
);
expect(currentSearch()).toBe("?sort=manual");
expect(history.replaces()).toEqual([]);
// Server responds with persisted manual order
pendingLayout.release({
data: {
version: 1,
lists: {
labs: {
view: "list",
order: ["bm:b1"],
sortDir: "recentDesc",
groupBy: "none",
groupsOpen: "expanded",
},
},
},
});
// Wait until preference data has rendered and the manual sort option is visible/selected
await expect
.element(
screen.getByRole("combobox", { name: "Sort order: Sort manually" }),
)
.toBeVisible();
// Ensure any afterPaint effects from the render have settled
await new Promise((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(resolve)),
);
// Valid manual sort remains preserved in URL without triggering replace
expect(currentSearch()).toBe("?sort=manual");
expect(history.replaces()).toEqual([]);
});
it("preserves sort=manual when preferences request fails", async () => {
setSearch("?sort=manual");
const history = recordHistory();
const api = stubFetch();
const pendingLayout = api.deferred("GET", "/api/settings/module-list-layout");
const queryClient = labQueryClient();
queryClient.setQueryData(
queryKeys.labResultsOverview("en"),
overviewWithCategories,
);
await renderWithApp(<LabList />, { queryClient });
// While preference query is still pending, sort=manual is preserved
await new Promise((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(resolve)),
);
expect(currentSearch()).toBe("?sort=manual");
expect(history.replaces()).toEqual([]);
// Server request fails
pendingLayout.release({
status: 500,
error: "Internal Server Error",
});
// Wait for the query to transition to error state
await expect
.poll(() => queryClient.getQueryState(queryKeys.moduleListLayout())?.status)
.toBe("error");
// Wait for double-rAF afterPaint to pass; if readiness was tied to !isLoading, this would scrub
await new Promise((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(resolve)),
);
// sort=manual remains intact when preference state is unknown due to failure
expect(currentSearch()).toBe("?sort=manual");
expect(history.replaces()).toEqual([]);
});
it("preserves sort=manual while preferences load and normalizes via replace when server confirms empty order", async () => {
setSearch("?sort=manual");
const history = recordHistory();
const api = stubFetch();
const pendingLayout = api.deferred("GET", "/api/settings/module-list-layout");
const queryClient = labQueryClient();
queryClient.setQueryData(
queryKeys.labResultsOverview("en"),
overviewWithCategories,
);
await renderWithApp(<LabList />, { queryClient });
// While preference query is still pending, sort=manual is preserved
await new Promise((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(resolve)),
);
expect(currentSearch()).toBe("?sort=manual");
expect(history.replaces()).toEqual([]);
// Server responds with confirmed empty manual order
pendingLayout.release({
data: {
version: 1,
lists: {
labs: {
view: "list",
order: [],
sortDir: "recentDesc",
groupBy: "none",
groupsOpen: "expanded",
},
},
},
});
// Unsupported manual sort is normalized via replace after preferences load
await expect.poll(currentSearch).toBe("");
expect(history.pushes()).toEqual([]);
expect(history.replaces()).toEqual([""]);
});
it("preserves sort=manual while carry-over PUT is pending and keeps it when carry-over succeeds", async () => {
const storageKey = legacyModuleListStorageKey("labs");
window.localStorage.setItem(
storageKey,
JSON.stringify({
view: "list",
order: ["bm:b1"],
sortDir: "manual",
}),
);
resetModuleListCarryOverForTests();
try {
setSearch("?sort=manual");
const history = recordHistory();
const api = stubFetch();
const pendingLayout = api.deferred("GET", "/api/settings/module-list-layout");
const pendingPut = api.deferred("PUT", "/api/settings/module-list-layout");
const queryClient = labQueryClient();
queryClient.setQueryData(
queryKeys.labResultsOverview("en"),
overviewWithCategories,
);
const { screen } = await renderWithApp(<LabList />, { queryClient });
// Before layout GET arrives, sort=manual is preserved
await new Promise((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(resolve)),
);
expect(currentSearch()).toBe("?sort=manual");
expect(history.replaces()).toEqual([]);
// Release layout GET with saved: false and empty order (unarranged server state)
pendingLayout.release({
data: {
version: 1,
lists: {
labs: {
view: "list",
order: [],
sortDir: "recentDesc",
groupBy: "none",
groupsOpen: "expanded",
saved: false,
},
illness: {
view: "cards",
order: [],
sortDir: "recentDesc",
groupBy: "none",
groupsOpen: "expanded",
saved: false,
},
vorsorge: {
view: "cards",
order: [],
sortDir: "recentDesc",
groupBy: "none",
groupsOpen: "expanded",
saved: false,
},
},
},
});
// Wait until carry-over PUT is dispatched
await expect
.poll(() => api.calls("PUT", "/api/settings/module-list-layout").length)
.toBe(1);
// Wait for double-rAF afterPaint to pass; while carry-over PUT is pending,
// legacy order is preferred so sort=manual is not replaced
await new Promise((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(resolve)),
);
expect(currentSearch()).toBe("?sort=manual");
expect(history.replaces()).toEqual([]);
// Release the carry-over PUT with saved manual order
pendingPut.release({
data: {
version: 1,
lists: {
labs: {
view: "list",
order: ["bm:b1"],
sortDir: "manual",
groupBy: "none",
groupsOpen: "expanded",
saved: true,
},
illness: {
view: "cards",
order: [],
sortDir: "recentDesc",
groupBy: "none",
groupsOpen: "expanded",
saved: false,
},
vorsorge: {
view: "cards",
order: [],
sortDir: "recentDesc",
groupBy: "none",
groupsOpen: "expanded",
saved: false,
},
},
},
});
// Wait until preference data has rendered and the manual sort option is visible/selected
await expect
.element(
screen.getByRole("combobox", { name: "Sort order: Sort manually" }),
)
.toBeVisible();
await new Promise((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(resolve)),
);
// URL remains intact and no replace occurred throughout
expect(currentSearch()).toBe("?sort=manual");
expect(history.replaces()).toEqual([]);
} finally {
window.localStorage.removeItem(storageKey);
resetModuleListCarryOverForTests();
}
});
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",
},
},
});
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 flagged-mode default-open behavior consistent with visible filtered rows", async () => {
// overviewWithAbnormalMarker has 2 markers in Iron Panel:
// Iron (below) and Ferritin (in-range). Category server status is "below".
// When filtered to ?status=in-range, Iron is excluded and only Ferritin (in-range) remains.
// In flagged mode, the group should NOT be open by default because visible rows are all in-range.
setSearch("?status=in-range");
const queryClient = labQueryClient();
queryClient.setQueryData(queryKeys.moduleListLayout(), {
version: 1,
lists: {
labs: {
view: "list",
order: [],
sortDir: "recentDesc",
groupBy: "category",
groupsOpen: "flagged",
},
},
});
queryClient.setQueryData(
queryKeys.labResultsOverview("en"),
overviewWithAbnormalMarker,
);
const { screen } = await renderWithApp(<LabList />, { queryClient });
// Group button reflects in-range status ("All in range") and is collapsed (aria-expanded="false")
const groupButton = screen.getByRole("button", { name: /Iron Panel/i });
await expect.element(groupButton).toBeVisible();
await expect.element(groupButton.getByText("All in range")).toBeVisible();
await expect.element(groupButton).toHaveAttribute("aria-expanded", "false");
});
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,249 @@
/**
* 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",
};
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("pushes sort override and does not set isFiltered", () => {
render("");
expect(capturedRailProps!.isFiltered).toBe(false);
capturedRailProps!.onSortChange("alphaAsc");
expect(browser.push).toHaveBeenCalledWith("/labs?sort=alphaAsc");
});
it("omits sort from URL when selecting the preference default", () => {
render("sort=alphaAsc");
capturedRailProps!.onSortChange("recentDesc");
expect(browser.push).toHaveBeenCalledWith("/labs");
});
it("treats recentDesc as effective default sort when prefs.sortDir is manual without manual order", () => {
mockModulePrefs.sortDir = "manual";
mockModulePrefs.order = [];
render("");
expect(capturedRailProps!.currentSortDir).toBe("recentDesc");
capturedRailProps!.onSortChange("recentDesc");
// Does not write redundant ?sort=recentDesc to URL
expect(browser.push).not.toHaveBeenCalled();
});
it("clears sort override back to bare pathname when selecting recentDesc under manual prefs without manual order", () => {
mockModulePrefs.sortDir = "manual";
mockModulePrefs.order = [];
render("sort=alphaAsc");
expect(capturedRailProps!.currentSortDir).toBe("alphaAsc");
capturedRailProps!.onSortChange("recentDesc");
expect(browser.push).toHaveBeenCalledWith("/labs");
});
it("reset clears every facet in one push", () => {
render("status=abnormal&category=cat_iron&from=2026-09-01&sort=alphaAsc");
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&sort=alphaDesc",
);
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!.currentSortDir).toBe("alphaDesc");
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&sort=UNKNOWN");
expect(capturedRailProps!.filters.status).toBeUndefined();
expect(capturedRailProps!.filters.fromDay).toBeUndefined();
expect(capturedRailProps!.filters.toDay).toBeUndefined();
expect(capturedRailProps!.currentSortDir).toBe("recentDesc");
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,384 @@
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",
};
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",
};
});
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",
};
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("handles sort=manual gracefully when manual order is empty", () => {
mockSearch = "sort=manual";
mockModulePrefs = {
view: "list",
order: [], // no manual order
sortDir: "recentDesc",
groupBy: "none",
groupsOpen: "expanded",
};
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>,
);
// Should fall back to recentDesc instead of displaying raw "manual"
expect(html).toContain("Sort order: Most recent reading first");
expect(html).not.toContain("Sort order: manual");
});
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: marker is not filtered out, category dropdown is inactive
expect(html).toContain("Ferritin");
expect(html).toContain('aria-label="Category" data-active="false"');
expect(html).not.toContain("Reset");
expect(html).not.toContain("stale_unknown_category");
});
});
+205
View File
@@ -0,0 +1,205 @@
"use client";
import * as React from "react";
import { Search, X } from "lucide-react";
import {
FilterBar,
FilterBarDateRange,
FilterBarSelect,
FILTER_BAR_PILL_CLASSES,
type FilterBarSelectOption,
} from "@/components/ui/filter-bar";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
} from "@/components/ui/select";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import {
isModuleSortDir,
type ModuleSortDir,
} from "@/lib/settings/module-list-layout";
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;
onSortChange: (sort: ModuleSortDir) => void;
onResetFilters: () => void;
isFiltered: boolean;
categories?: LabOverviewCategoryDto[] | null;
total?: number;
currentSortDir: ModuleSortDir;
hasManualOrder?: boolean;
}
export function LabFilterRail({
filters,
searchDraft,
onSearchChange,
searchInputRef,
onStatusChange,
onCategoryChange,
onFromDayChange,
onToDayChange,
onSortChange,
onResetFilters,
isFiltered,
categories,
total,
currentSortDir,
hasManualOrder,
}: 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") },
];
const categoryOptions: FilterBarSelectOption[] = (categories ?? []).map(
(c) => ({
value: c.key,
label: c.label,
}),
);
const sortOptions: FilterBarSelectOption[] = [
{ value: "recentDesc", label: t("labs.sort.recentDesc") },
{ value: "recentAsc", label: t("labs.sort.recentAsc") },
{ value: "alphaAsc", label: t("labs.sort.alphaAsc") },
{ value: "alphaDesc", label: t("labs.sort.alphaDesc") },
...(hasManualOrder
? [{ value: "manual", label: t("labs.sort.manual") }]
: []),
];
const currentSortLabel =
sortOptions.find((o) => o.value === currentSortDir)?.label ?? currentSortDir;
return (
<div data-slot="lab-filter-rail" className="space-y-3">
<FilterBar
isFiltered={isFiltered}
onReset={onResetFilters}
railClassName="max-sm:mx-0 max-sm:flex-wrap max-sm:overflow-visible max-sm:px-0 sm:min-w-0 sm:flex-1"
count={
total !== undefined
? tCount("labs.filter.markerCount", total, {
count: fmt.integer(total),
})
: undefined
}
>
<div className="relative w-full shrink-0 sm:w-56">
<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("")}
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]"
>
<X className="size-4" aria-hidden="true" />
</button>
)}
</div>
<FilterBarDateRange
label={t("dataList.dateRange")}
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 > 0 && (
<FilterBarSelect
label={t("labs.filter.categoryLabel")}
value={filters.category ?? "ALL"}
onValueChange={onCategoryChange}
options={categoryOptions}
allValue="ALL"
allLabel={t("labs.filter.categoryAll")}
/>
)}
<span className="relative inline-flex shrink-0">
<Select
value={currentSortDir}
onValueChange={(val) => {
if (isModuleSortDir(val)) onSortChange(val);
}}
>
<SelectTrigger
aria-label={`${t("labs.sort.heading")}: ${currentSortLabel}`}
data-slot="filter-bar-pill"
className={FILTER_BAR_PILL_CLASSES}
>
<span className="truncate">
<span className="text-muted-foreground">{t("labs.sort.heading")}: </span>
{currentSortLabel}
</span>
</SelectTrigger>
<SelectContent position="popper" align="start">
{sortOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</span>
</FilterBar>
</div>
);
}
+228
View File
@@ -0,0 +1,228 @@
"use client";
/**
* Pure URL-facet helpers for the labs overview list, following the
* documents-vault and measurements patterns: the list's filter and sort state
* lives in the page URL (?q&status&category&from&to&sort) 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 {
isModuleSortDir,
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;
/** Inclusive day bounds, YYYY-MM-DD for newest reading (latest.takenAt). */
fromDay?: string;
toDay?: string;
/** On-page sort override: recentDesc | recentAsc | alphaAsc | alphaDesc | manual. */
sort?: ModuleSortDir;
}
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;
const sort = params.get("sort")?.trim();
if (sort && isModuleSortDir(sort)) {
filters.sort = sort;
}
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,
defaultSort: ModuleSortDir = "recentDesc",
): 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);
if (filters.sort && filters.sort !== defaultSort) sp.set("sort", filters.sort);
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);
}
/**
* 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 query = filters.q?.trim().toLowerCase();
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 = row.analyte.toLowerCase().includes(query);
const panelMatch = row.panel?.toLowerCase().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. Date bounds on latest reading
if (fromDay || toDay) {
const takenDate = dayKeyOf(row.latest.takenAt);
if (fromDay && takenDate < fromDay) return false;
if (toDay && takenDate > toDay) return false;
}
return true;
});
}
/**
* Sort marker rows according to the chosen sort direction and optional manual order.
*/
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];
}
+98 -21
View File
@@ -5,9 +5,11 @@
* header itself, and which categories are open.
*
* Everything about a category comes from the server (`categories` on
* `GET /api/labs/overview`): its members, label, order, counts and status.
* This module only places the already-sorted rows under them and builds the
* status sentence from the counts; it never decides a category or a status.
* `GET /api/labs/overview`): its members, label, order, counts and status,
* which describe the full category. When no effective list filters are
* active, this module renders those server category counts and status;
* when filters narrow the visible rows, it summarizes the visible subset
* client-side.
*/
import { useMemo, useState } from "react";
import { ChevronDown } from "lucide-react";
@@ -16,6 +18,7 @@ import { ScorePill } from "@/components/ui/score-pill";
import { TagChip } from "@/components/ui/tag-chip";
import { useMounted } from "@/hooks/use-mounted";
import { useTranslations } from "@/lib/i18n/context";
import { priorityReferenceRangeStatus } from "@/lib/labs/reference-range";
import type { ModuleGroupsOpen } from "@/lib/module-list-prefs";
import { cn } from "@/lib/utils";
@@ -97,12 +100,14 @@ export function writeStoredOpen(
}
function openByDefault(
category: LabOverviewCategoryDto,
mode: ModuleGroupsOpen,
status: LabOverviewCategoryDto["status"],
): boolean {
if (mode === "expanded") return true;
if (mode === "collapsed") return false;
return category.status === "above" || category.status === "below";
// Consumers supply category.status for unfiltered categories, or the
// summarised status for filtered groups.
return status === "above" || status === "below";
}
/**
@@ -112,8 +117,14 @@ function openByDefault(
* itself changes. Storage is read only after hydration and may be missing.
*/
export function useGroupsOpen(mode: ModuleGroupsOpen): {
isOpen: (category: LabOverviewCategoryDto) => boolean;
toggle: (category: LabOverviewCategoryDto) => void;
isOpen: (
category: LabOverviewCategoryDto,
status: LabOverviewCategoryDto["status"],
) => boolean;
toggle: (
category: LabOverviewCategoryDto,
status: LabOverviewCategoryDto["status"],
) => void;
} {
const mounted = useMounted();
const stored = useMemo(
@@ -123,28 +134,71 @@ export function useGroupsOpen(mode: ModuleGroupsOpen): {
const [toggled, setToggled] = useState<StoredOpen>({ mode, open: {} });
const own = toggled.mode === mode ? toggled.open : {};
const isOpen = (category: LabOverviewCategoryDto) =>
own[category.key] ?? stored[category.key] ?? openByDefault(category, mode);
const toggle = (category: LabOverviewCategoryDto) => {
const next = { ...stored, ...own, [category.key]: !isOpen(category) };
const isOpen = (
category: LabOverviewCategoryDto,
status: LabOverviewCategoryDto["status"],
) =>
own[category.key] ??
stored[category.key] ??
openByDefault(mode, status);
const toggle = (
category: LabOverviewCategoryDto,
status: LabOverviewCategoryDto["status"],
) => {
const next = {
...stored,
...own,
[category.key]: !isOpen(category, status),
};
setToggled({ mode, open: next });
writeStoredOpen(mode, next);
};
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 +212,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
@@ -191,10 +268,10 @@ export function LabGroupHeader({
{category.label}
</span>
<TagChip className="shrink-0 tabular-nums">
{category.markerKeys.length}
{rows.length}
</TagChip>
<span className="ml-auto shrink-0">
<GroupStatus category={category} />
<GroupStatus counts={counts} status={status} />
</span>
</button>
</h2>
+307 -93
View File
@@ -1,7 +1,7 @@
"use client";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { useId, useMemo, useEffect } from "react";
import { useCallback, useId, useMemo, useEffect } from "react";
import { useQuery } from "@tanstack/react-query";
import { FlaskConical } from "lucide-react";
@@ -11,18 +11,27 @@ 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,
type ModuleSortDir,
} 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 +43,10 @@ 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 +55,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 +75,7 @@ function LabTable({
key={group.category.key}
group={group}
groupsOpen={groupsOpen}
isFiltered={isFiltered}
/>
))
: rows.map((row) => <LabListRow key={row.key} row={row} />)}
@@ -93,11 +85,15 @@ function LabTable({
);
}
function useGroupToggle(group: MarkerGroup, groupsOpen: GroupsOpen) {
function useGroupToggle(
category: LabOverviewCategoryDto,
groupsOpen: GroupsOpen,
status: LabOverviewCategoryDto["status"],
) {
const panelId = `${useId()}-panel`;
return {
open: groupsOpen.isOpen(group.category),
onToggle: () => groupsOpen.toggle(group.category),
open: groupsOpen.isOpen(category, status),
onToggle: () => groupsOpen.toggle(category, status),
panelId,
};
}
@@ -109,18 +105,33 @@ 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,
status,
);
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 +173,33 @@ 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,
status,
);
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 +208,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,
@@ -193,8 +328,8 @@ export function LabList({
const { t, locale } = useTranslations();
const { canWriteDomain } = useRecordCapabilities();
const canAddLab = canWriteDomain("labs");
const { prefs } = useModuleListPrefs("labs");
const groupsOpen = useGroupsOpen(prefs.groupsOpen);
const { prefs, isLoaded } = useModuleListPrefs("labs");
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 +343,118 @@ export function LabList({
queryFn: () => apiGet<LabOverviewResponse>("/api/labs/overview"),
});
const rows = useMemo(
() => sortRows(toMarkerRows(data?.markers ?? [], t), prefs),
[data?.markers, prefs, t],
const hasManualOrder = prefs.order.length > 0;
const effectiveDefaultSort: ModuleSortDir =
prefs.sortDir === "manual" && !hasManualOrder
? "recentDesc"
: prefs.sortDir;
const {
filters,
searchDraft,
setSearchDraft,
searchInputRef,
setStatusFilter,
setCategoryFilter,
setFromDay,
setToDay,
setSortFilter,
resetFilters,
} = useLabListFilters({
defaultSort: effectiveDefaultSort,
});
const rawSort = filters.sort ?? effectiveDefaultSort;
const effectiveSortDir =
rawSort === "manual" && !hasManualOrder ? "recentDesc" : rawSort;
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);
// Scrub unsupported manual sort if preferences confirm no manual order exists.
// afterPaint protects mount writes from router history initialization.
useEffect(() => {
if (isLoaded && filters.sort === "manual" && !hasManualOrder) {
setSortFilter(effectiveDefaultSort, { mode: "replace", afterPaint: true });
}
}, [isLoaded, filters.sort, hasManualOrder, effectiveDefaultSort, setSortFilter]);
// 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, effectiveSortDir, prefs.order),
[filteredRows, effectiveSortDir, 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 +469,49 @@ 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}
onSortChange={setSortFilter}
onResetFilters={resetFilters}
isFiltered={effectiveIsFiltered}
categories={categories}
total={rows.length}
currentSortDir={effectiveSortDir}
hasManualOrder={hasManualOrder}
/>
);
if (rows.length === 0) {
return (
<EmptyState
icon={<FlaskConical className="size-6" />}
title={t("labs.emptyTitle")}
description={t("labs.emptyDescription")}
action={
onAddFirst && canAddLab ? (
<Button onClick={onAddFirst}>{t("labs.addFirst")}</Button>
) : undefined
}
/>
);
}
// v1.18.10 (#3) — delete lives ONLY on the value detail view (next to Edit),
// not on these overview rows. The list/tile rows navigate; the trash icon was
// removed here so a stray tap can't delete a biomarker from the overview.
// v1.18.6 — compact list view is the default (#40); the card/tile view is
// the alternative the settings toggle selects.
if (prefs.view === "list") {
return <LabTable rows={rows} groups={groups} groupsOpen={groupsOpen} />;
}
// v1.18.9 (#40) — card/tile view.
if (groups) {
return (
<div data-slot="lab-list" className="space-y-10">
{groups.map((group) => (
<CardGroup
key={group.category.key}
group={group}
groupsOpen={groupsOpen}
/>
))}
<div className="space-y-6">
{filterRail}
<LabListNoMatches onReset={resetFilters} />
</div>
);
}
return (
<div data-slot="lab-list">
<CardGrid rows={rows} underCategory={false} />
<div className="space-y-6">
{filterRail}
<LabListContent
view={prefs.view}
rows={rows}
groups={groups}
groupsOpen={groupsOpen}
isFiltered={effectiveIsFiltered}
/>
</div>
);
}
+2 -1
View File
@@ -1,3 +1,4 @@
import type { ReferenceRangeStatusCounts } from "@/lib/labs/reference-range";
import type { RelatedLink } from "@/lib/links/related";
import type {
@@ -87,7 +88,7 @@ export interface LabOverviewCategoryDto {
label: string;
/** The `markers[].key` values in this category. */
markerKeys: string[];
counts: { inRange: number; below: number; above: number; unknown: number };
counts: ReferenceRangeStatusCounts;
status: LabResultDto["rangeStatus"];
}
+150
View File
@@ -0,0 +1,150 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import {
useUrlFilterSync,
type UrlFilterSyncOptions,
} from "@/hooks/use-url-filter-sync";
import type { ModuleSortDir } from "@/lib/settings/module-list-layout";
import {
labListFiltersToSearch,
parseLabListSearchParams,
type LabListFilters,
type LabStatusFilter,
} from "./lab-list-filters";
export function useLabListFilters({
defaultSort = "recentDesc",
}: {
defaultSort?: ModuleSortDir;
} = {}) {
const serialise = useCallback(
(f: LabListFilters) => labListFiltersToSearch(f, defaultSort),
[defaultSort],
);
const { filters, applyFilters, patchFilters } =
useUrlFilterSync<LabListFilters>({
parse: parseLabListSearchParams,
serialise,
});
// Debounced search draft
const [searchDraft, setSearchDraft] = useState(filters.q ?? "");
const [lastUrlQ, setLastUrlQ] = useState(filters.q ?? "");
const searchInputRef = useRef<HTMLInputElement | null>(null);
if ((filters.q ?? "") !== lastUrlQ) {
setLastUrlQ(filters.q ?? "");
setSearchDraft(filters.q ?? "");
}
useEffect(() => {
const handle = setTimeout(() => {
const trimmed = searchDraft.trim();
if (trimmed === (filters.q ?? "")) return;
applyFilters(
(current) =>
trimmed === (current.q ?? "")
? current
: { ...current, q: trimmed === "" ? undefined : trimmed },
"replace",
);
}, 200);
return () => clearTimeout(handle);
}, [searchDraft, filters.q, applyFilters]);
// "/" focuses search input anywhere on the page
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== "/" || event.metaKey || event.ctrlKey || event.altKey) {
return;
}
const target = event.target as HTMLElement | null;
if (
target &&
(target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.tagName === "SELECT" ||
target.isContentEditable)
) {
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 setSortFilter = useCallback(
(
value: ModuleSortDir,
modeOrOptions: "push" | "replace" | UrlFilterSyncOptions = "push",
) => {
patchFilters(
{
sort: value === defaultSort ? undefined : value,
},
modeOrOptions,
);
},
[defaultSort, patchFilters],
);
const resetFilters = useCallback(() => {
setSearchDraft("");
applyFilters({});
}, [applyFilters]);
return {
filters,
searchDraft,
setSearchDraft,
searchInputRef,
setStatusFilter,
setCategoryFilter,
setFromDay,
setToDay,
setSortFilter,
resetFilters,
};
}
+29 -12
View File
@@ -28,19 +28,22 @@ import { cn } from "@/lib/utils";
* instead of the per-page rows of large labelled Selects + date inputs
* that had drifted apart. The grammar:
*
* [filter icon] [Zeitraum] [Typ] [Quelle] … [Zurücksetzen] [count]
* [search] [filter icon] [Zeitraum] [Typ] [Quelle] … [Zurücksetzen] [count]
*
* - Each dimension renders as ONE compact pill that is trigger and
* active-filter chip at once: inactive it shows just the dimension
* name ("Typ"), active it shows `Name: Wert`, picks up the primary
* accent, and grows a removable ✕. No second chip row, no duplicated
* state.
* - Canonical order at the call sites: date range, type, source —
* surface-specific dimensions append after.
* - Canonical order at the call sites: search (if present), date range,
* type, source — surface-specific dimensions append after.
* - Reset appears only while at least one dimension is active.
* - Mobile (< sm): the rail is a horizontal scroll row (hidden
* - Mobile (< sm): standard pill rails use a horizontal scroll row (hidden
* scrollbar, edge-bleed) so pills never wrap into a tall block; the
* result count drops onto its own line.
* result count drops onto its own line. Surfaces that integrate search
* into the rail (such as labs) wrap on mobile instead via
* `railClassName` (`max-sm:flex-wrap`) so the search input stays
* full-width and pills wrap naturally underneath.
* - Purely presentational: filter state, query keys and fetching stay
* in the owning page/component — this file only renders the controls.
*
@@ -54,8 +57,11 @@ import { cn } from "@/lib/utils";
* ui-guidelines §5).
*/
/** Shared pill chrome — trigger and chip in one element. */
const PILL_CLASSES =
/**
* Shared pill chrome for filter-bar triggers and active chips.
* Exported for custom controls (such as select triggers) that participate in the rail.
*/
export const FILTER_BAR_PILL_CLASSES =
"border-border bg-card text-foreground inline-flex min-h-11 shrink-0 items-center gap-1.5 rounded-full border px-3 text-sm whitespace-nowrap shadow-xs transition-all duration-150 ease-out sm:min-h-9 h-auto py-1";
const PILL_ACTIVE_CLASSES = "border-primary/40 bg-primary/10";
@@ -84,6 +90,7 @@ export function FilterBar({
onReset,
count,
children,
railClassName,
}: {
/** True when at least one dimension is active — shows the reset button. */
isFiltered: boolean;
@@ -91,6 +98,8 @@ export function FilterBar({
onReset: () => void;
/** Result count node (already translated/formatted by the caller). */
count?: React.ReactNode;
/** Optional classes for the row of filter controls. */
railClassName?: string;
children: React.ReactNode;
}) {
const { t } = useTranslations();
@@ -102,7 +111,12 @@ export function FilterBar({
{/* Pill rail — horizontal scroll row below `sm` (hidden scrollbar,
edge-bleed so the row scrolls to the screen edge), wrapping flex
row on desktop. */}
<div className="-mx-4 flex [scrollbar-width:none] items-center gap-2 overflow-x-auto px-4 sm:mx-0 sm:flex-wrap sm:overflow-visible sm:px-0 [&::-webkit-scrollbar]:hidden">
<div
className={cn(
"-mx-4 flex [scrollbar-width:none] items-center gap-2 overflow-x-auto px-4 sm:mx-0 sm:flex-wrap sm:overflow-visible sm:px-0 [&::-webkit-scrollbar]:hidden",
railClassName,
)}
>
<ListFilter
className="text-muted-foreground size-4 shrink-0"
aria-hidden="true"
@@ -121,7 +135,10 @@ export function FilterBar({
)}
</div>
{count != null && (
<span className="text-muted-foreground text-sm tabular-nums">
<span
data-slot="filter-bar-count"
className="text-muted-foreground text-sm tabular-nums"
>
{count}
</span>
)}
@@ -169,7 +186,7 @@ export function FilterBarSelect({
data-slot="filter-bar-pill"
data-active={active ? "true" : "false"}
className={cn(
PILL_CLASSES,
FILTER_BAR_PILL_CLASSES,
active && cn(PILL_ACTIVE_CLASSES, "pr-8"),
)}
>
@@ -248,7 +265,7 @@ export function FilterBarDateRange({
data-slot="filter-bar-pill"
data-active={active ? "true" : "false"}
className={cn(
PILL_CLASSES,
FILTER_BAR_PILL_CLASSES,
"outline-none",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
active && cn(PILL_ACTIVE_CLASSES, "pr-8"),
@@ -343,7 +360,7 @@ export function FilterBarNumberRange({
data-slot="filter-bar-pill"
data-active={active ? "true" : "false"}
className={cn(
PILL_CLASSES,
FILTER_BAR_PILL_CLASSES,
"outline-none",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
active && cn(PILL_ACTIVE_CLASSES, "pr-8"),