mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
feat(labs): add biomarker filtering and sorting
This commit is contained in:
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");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ReferenceRangeStatusCounts } from "@/lib/labs/reference-range";
|
||||
import type { RelatedLink } from "@/lib/links/related";
|
||||
|
||||
import type {
|
||||
@@ -87,7 +88,7 @@ export interface LabOverviewCategoryDto {
|
||||
label: string;
|
||||
/** The `markers[].key` values in this category. */
|
||||
markerKeys: string[];
|
||||
counts: { inRange: number; below: number; above: number; unknown: number };
|
||||
counts: ReferenceRangeStatusCounts;
|
||||
status: LabResultDto["rangeStatus"];
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,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,
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user