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

@@ -93,9 +93,10 @@ const READERS: Record<string, Reader> = {
file: GROUPS,
pattern: COUNTS_DESTRUCTURE("unknown"),
},
// Code lines only: a comment that names `category.status` is not a reader.
"overview:categories[].status": {
file: GROUPS,
pattern: /\bcategory\.status\s*===\s*"above"/,
pattern: /^(?![ \t]*(?:\/\/|\/?\*)).*\bcategory\.status\b/m,
},
"biomarkers:customPanels[]": {
file: FORM_PANEL,
@@ -132,8 +133,9 @@ describe("labs categories response/consumer guard", () => {
const owned = ownedLeaves();
it("walks the schemas it guards", () => {
// Nine category leaves, `grouped`, the panels and the adopted panel, plus
// the exempt marker rows; far fewer means the walk broke.
// Category leaves (key, label, markerKeys[], counts.*, status), `grouped`,
// the panels and the adopted panel, plus the exempt marker rows; far fewer
// means the walk broke.
expect(owned.filter((leaf) => !isExempt(leaf)).length).toBe(
Object.keys(READERS).length,
);
@@ -159,8 +161,41 @@ describe("labs categories response/consumer guard", () => {
expect(
COUNTS_DESTRUCTURE("inRange").test("const { below } = category.counts;"),
).toBe(false);
expect(
READERS["overview:categories[].status"].pattern.test(
"category.otherStatus",
),
).toBe(false);
expect(
READERS["overview:categories[].status"].pattern.test(
" // Consumers supply category.status for unfiltered categories.",
),
).toBe(false);
expect(
READERS["overview:categories[].status"].pattern.test(
" status: statusProp ?? category.status,",
),
).toBe(true);
expect(READERS["overview:grouped"].pattern.test("data?.groupedX")).toBe(
false,
);
});
it("guards filtered summary branch for counts and status", () => {
// When filters narrow the visible rows, LabGroupHeader reads `summary.counts`
// and `summary.status` (summarised client-side), rather than unfiltered server category totals.
const groupsSource = readSource(GROUPS, [
/\bsummary\.counts\b/,
/\bsummary\.status\b/,
]);
expect(/\bsummary\.counts\b/.test(groupsSource)).toBe(true);
expect(/\bsummary\.status\b/.test(groupsSource)).toBe(true);
const listSource = readSource(LIST, [
/\bsummariseGroupRows\b/,
/\bsummary\?\.status\b/,
]);
expect(/\bsummariseGroupRows\b/.test(listSource)).toBe(true);
expect(/\bsummary\?\.status\b/.test(listSource)).toBe(true);
});
});
@@ -0,0 +1,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"),
@@ -109,6 +109,17 @@ function FilterRail({ syncToUrl = true }: { syncToUrl?: boolean }) {
options={SOURCES}
/>
</FilterBar>
<button
type="button"
onClick={() =>
patchFilters(
{ type: "PULSE" },
{ mode: "replace", afterPaint: true },
)
}
>
Defer replace type
</button>
</>
);
}
@@ -193,6 +204,25 @@ describe("useUrlFilterSync with FilterBar", () => {
expect(window.history.length).toBe(entries);
});
it("defers writing until after paint and replaces current entry without adding history entries", async () => {
const { screen, history } = await mount("?type=WEIGHT");
const entries = window.history.length;
(
screen.getByRole("button", { name: "Defer replace type" }).element() as HTMLButtonElement
).click();
// Immediately after trigger, the write is deferred until after paint:
// search remains unchanged and no history write has executed yet.
expect(history.writes).toEqual([]);
expect(currentSearch()).toBe("?type=WEIGHT");
// Once painted frames flush, the replace lands without extra history entry:
await expectSearch("?type=PULSE");
expect(history.pushes()).toEqual([]);
expect(history.replaces()).toEqual(["?type=PULSE"]);
expect(window.history.length).toBe(entries);
});
it("drops one facet with its clear button and keeps the others", async () => {
const { screen, pill } = await mount("?type=WEIGHT&source=MANUAL");
await screen.getByRole("button", { name: "Clear filter: Type" }).click();
+25 -6
View File
@@ -32,6 +32,13 @@ import { setAllParams, useUrlSearch, writeUrlState } from "@/lib/url-state";
* for a list embedded under a URL that belongs to another surface (the
* type-locked insights readings subpage).
*/
export interface UrlFilterSyncOptions {
/** A new history entry (`push`) or the current one rewritten (`replace`). */
mode?: "push" | "replace";
/** Wait two painted frames before writing (a write made while mounting). */
afterPaint?: boolean;
}
export function useUrlFilterSync<T>({
parse,
serialise,
@@ -44,10 +51,13 @@ export function useUrlFilterSync<T>({
filters: T;
applyFilters: (
next: T | ((current: T) => T),
mode?: "push" | "replace",
modeOrOptions?: "push" | "replace" | UrlFilterSyncOptions,
) => void;
/** Merge `patch` into the live filters (the common facet change). */
patchFilters: (patch: Partial<T>, mode?: "push" | "replace") => void;
patchFilters: (
patch: Partial<T>,
modeOrOptions?: "push" | "replace" | UrlFilterSyncOptions,
) => void;
} {
const params = useUrlSearch();
@@ -60,7 +70,14 @@ export function useUrlFilterSync<T>({
);
const applyFilters = useCallback(
(next: T | ((current: T) => T), mode: "push" | "replace" = "push") => {
(
next: T | ((current: T) => T),
modeOrOptions: "push" | "replace" | UrlFilterSyncOptions = "push",
) => {
const options: UrlFilterSyncOptions =
typeof modeOrOptions === "string"
? { mode: modeOrOptions }
: modeOrOptions;
const resolve = (current: T): T =>
typeof next === "function" ? (next as (c: T) => T)(current) : next;
if (!syncToUrl) {
@@ -72,15 +89,17 @@ export function useUrlFilterSync<T>({
const search = serialise(resolve(parse(live)));
setAllParams(search)(live);
},
{ mode },
{ mode: options.mode ?? "push", afterPaint: options.afterPaint },
);
},
[syncToUrl, serialise, parse],
);
const patchFilters = useCallback(
(patch: Partial<T>, mode: "push" | "replace" = "push") =>
applyFilters((current) => ({ ...current, ...patch }), mode),
(
patch: Partial<T>,
modeOrOptions: "push" | "replace" | UrlFilterSyncOptions = "push",
) => applyFilters((current) => ({ ...current, ...patch }), modeOrOptions),
[applyFilters],
);
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
import {
classifyReferenceRange,
formatReferenceRange,
priorityReferenceRangeStatus,
} from "@/lib/labs/reference-range";
describe("classifyReferenceRange", () => {
@@ -63,3 +64,23 @@ describe("formatReferenceRange", () => {
expect(formatReferenceRange(4, 6, oneDecimal)).toBe("4.0–6.0");
});
});
describe("priorityReferenceRangeStatus", () => {
it("implements canonical priority: above > below > in-range > unknown with counts", () => {
expect(
priorityReferenceRangeStatus({ above: 1, below: 1, inRange: 1, unknown: 1 }),
).toBe("above");
expect(
priorityReferenceRangeStatus({ above: 0, below: 2, inRange: 5, unknown: 1 }),
).toBe("below");
expect(
priorityReferenceRangeStatus({ above: 0, below: 0, inRange: 3, unknown: 2 }),
).toBe("in-range");
expect(
priorityReferenceRangeStatus({ above: 0, below: 0, inRange: 0, unknown: 4 }),
).toBe("unknown");
expect(
priorityReferenceRangeStatus({ above: 0, below: 0, inRange: 0, unknown: 0 }),
).toBe("unknown");
});
});
+11 -17
View File
@@ -4,8 +4,10 @@
* A marker's category is its panel: the serialised `latest.panel`, which is
* the biomarker's panel for a linked marker and the row's own for a legacy
* unlinked one (the same value the card shows as its chip). The server
* resolves the category, its label, its order and a combined status once;
* the web list and the iOS app render that as given and never rebuild it.
* resolves the category, its label, its order, and combined counts and status
* for the full category once; clients (the web list and the iOS app) render
* that as given when unfiltered, while the web list summarizes the visible
* subset client-side when filters are active.
*
* Resolution, first match wins:
* 1. the panel folds to a catalog panel label in any shipped locale
@@ -25,7 +27,11 @@ import { getServerTranslator } from "@/lib/i18n/server-translator";
import { buildLocalisedLabelIndex } from "@/lib/i18n/localised-label-index";
import type { Locale } from "@/lib/i18n/config";
import { resolveIntlLocale } from "@/lib/format-locale";
import type { ReferenceRangeStatus } from "@/lib/labs/reference-range";
import {
priorityReferenceRangeStatus,
type ReferenceRangeStatus,
type ReferenceRangeStatusCounts,
} from "@/lib/labs/reference-range";
import { catalogKeyForName } from "./analyte-match";
import {
@@ -45,12 +51,7 @@ export interface MarkerCategoryRef {
kind: MarkerCategoryKind;
}
export interface CategoryCounts {
inRange: number;
below: number;
above: number;
unknown: number;
}
export type CategoryCounts = ReferenceRangeStatusCounts;
export interface LabOverviewCategory {
/** A catalog panel key, `custom:<hash>`, or `other`. Opaque to clients. */
@@ -129,14 +130,7 @@ export function summariseCategory(statuses: readonly ReferenceRangeStatus[]): {
else if (status === "above") counts.above += 1;
else counts.unknown += 1;
}
const status: ReferenceRangeStatus =
counts.above > 0
? "above"
: counts.below > 0
? "below"
: counts.inRange > 0
? "in-range"
: "unknown";
const status = priorityReferenceRangeStatus(counts);
return { counts, status };
}
+27
View File
@@ -204,3 +204,30 @@ export function classifyAgainstEffectiveRange(
if (value === null) return "unknown";
return classifyReferenceRange(value, effective.low, effective.high);
}
export interface ReferenceRangeStatusCounts {
inRange: number;
below: number;
above: number;
unknown: number;
}
/**
* Resolve combined reference-range status according to the canonical priority rule:
* `above` > `below` > `in-range` > `unknown`.
*
* Any value above its range makes the group "above", else any below makes it "below",
* else any in-range makes it "in-range"; only when all members are unassessed does it
* resolve to "unknown".
*/
export function priorityReferenceRangeStatus(
input: ReferenceRangeStatusCounts,
): ReferenceRangeStatus {
return input.above > 0
? "above"
: input.below > 0
? "below"
: input.inRange > 0
? "in-range"
: "unknown";
}
+31 -5
View File
@@ -246,6 +246,7 @@ export async function runSetModuleListPrefs(deps: {
*/
export function useModuleListPrefs(list: ModuleListKey): {
prefs: ModuleListPrefs;
isLoaded: boolean;
setView: (view: ModuleListView) => void;
setOrder: (order: string[]) => void;
setSortDir: (sortDir: ModuleSortDir) => void;
@@ -261,17 +262,21 @@ export function useModuleListPrefs(list: ModuleListKey): {
staleTime: 5 * 60 * 1000,
});
const isLoaded = data !== undefined;
useEffect(() => {
if (!data) return;
void carryOverLegacyModuleListPrefs({ list, layout: data, queryClient });
}, [data, list, queryClient]);
// The browser copy is read only after hydration, so the server render and
// the first client paint agree (React #418).
// the first client paint agree (React #418). While the server entry is
// unsaved (saved: false), the browser copy is preferred until carry-over
// completes and a saved server entry arrives.
const mounted = useMounted();
const entry = data?.lists[list];
const prefs = useMemo<ModuleListPrefs>(() => {
if (entry) {
if (entry?.saved) {
return {
view: entry.view,
order: entry.order,
@@ -281,8 +286,21 @@ export function useModuleListPrefs(list: ModuleListKey): {
};
}
const defaults = moduleListDefaults(list);
if (!mounted) return defaults;
return parseModuleListPrefs(readLegacy(list), defaults);
const fallback: ModuleListPrefs = entry
? {
view: entry.view,
order: entry.order,
sortDir: entry.sortDir,
groupBy: entry.groupBy,
groupsOpen: entry.groupsOpen,
}
: defaults;
if (!mounted) return fallback;
const legacy = readLegacy(list);
if (legacy !== null) {
return parseModuleListPrefs(legacy, fallback);
}
return fallback;
}, [entry, list, mounted]);
const write = useCallback(
@@ -310,5 +328,13 @@ export function useModuleListPrefs(list: ModuleListKey): {
[write],
);
return { prefs, setView, setOrder, setSortDir, setGroupBy, setGroupsOpen };
return {
prefs,
isLoaded,
setView,
setOrder,
setSortDir,
setGroupBy,
setGroupsOpen,
};
}
+2 -2
View File
@@ -149,7 +149,7 @@ export const labOverviewCategoryCounts = z
.meta({
id: "LabOverviewCategoryCounts",
description:
"How many members' newest readings are in range, below, above, or unassessed (qualitative, or no range).",
"How many members' newest readings are in range, below, above, or unassessed (qualitative, or no range) for the full category without filters.",
});
export const labOverviewCategory = z
@@ -163,7 +163,7 @@ export const labOverviewCategory = z
.meta({
id: "LabOverviewCategory",
description:
"One category of the labs list, resolved by the server. `key` is opaque: a catalog panel key, `custom:<hash>` for the person's own panel, or `other`; it carries no user text and is stable, so a client may key collapse state on it. `label` is rendered as given (a catalog panel in the caller's locale, the person's own spelling, or the localised \"Other\"). `markerKeys` names the `markers[].key` values in the category. `status` combines the members' newest readings: `above` when any is above its range, else `below` when any is below, else `in-range` when any was assessed, else `unknown`. A client renders these values and never rebuilds the category, the order or the status.",
"One category of the labs list, resolved by the server. `key` is opaque: a catalog panel key, `custom:<hash>` for the person's own panel, or `other`; it carries no user text and is stable, so a client may key collapse state on it. `label` is rendered as given (a catalog panel in the caller's locale, the person's own spelling, or the localised \"Other\"). `markerKeys` names the `markers[].key` values in the category. `counts` and `status` describe the full category without filters; a client renders these values directly when unfiltered, and may summarize the visible row subset client-side while filters are active.",
});
export const labOverviewResponse = z