refactor(labs): drop the sort pill from the filter rail

Settings > Labs already holds the list's sort order. The pill added a
second, URL-only one that was lost on the next visit, and Reset cleared
it while the rail counted it as no filter at all. The list now sorts
the way Settings says, and a ?sort param is ignored.
This commit is contained in:
Marc-André Bombeck committed 2026-11-11 00:07:58 +01:00
1 parent a486c3c8a3
commit 2f4da62c46
11 files changed
+52 -585

No files matched your search

@@ -14,7 +14,6 @@ function props(): LabFilterRailProps {
onCategoryChange: vi.fn(),
onFromDayChange: vi.fn(),
onToDayChange: vi.fn(),
onSortChange: vi.fn(),
onResetFilters: vi.fn(),
isFiltered: false,
categories: [
@@ -27,7 +26,6 @@ function props(): LabFilterRailProps {
},
],
total: 5,
currentSortDir: "recentDesc",
};
}
@@ -80,11 +78,6 @@ describe("<LabFilterRail> layout", () => {
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(),
];
@@ -95,7 +88,7 @@ describe("<LabFilterRail> layout", () => {
}
});
it("keeps sort and reset visible without clipping in German at 1536px", async () => {
it("keeps every control and reset visible without clipping in German at 1536px", async () => {
await page.viewport(1536, 900);
const { screen } = await renderWithApp(
<LabFilterRail
@@ -122,11 +115,6 @@ describe("<LabFilterRail> layout", () => {
})
.element(),
screen.getByRole("combobox", { name: "Kategorie" }).element(),
screen
.getByRole("combobox", {
name: "Sortierung: Neueste Messung zuerst",
})
.element(),
screen.getByRole("button", { name: "Zurücksetzen" }).element(),
];
@@ -145,11 +133,6 @@ describe("<LabFilterRail> layout", () => {
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);
@@ -20,7 +20,6 @@ describe("<LabFilterRail>", () => {
onCategoryChange={vi.fn()}
onFromDayChange={vi.fn()}
onToDayChange={vi.fn()}
onSortChange={vi.fn()}
onResetFilters={vi.fn()}
isFiltered={false}
categories={[
@@ -33,7 +32,6 @@ describe("<LabFilterRail>", () => {
},
]}
total={5}
currentSortDir="recentDesc"
/>
</I18nProvider>,
);
@@ -48,16 +46,13 @@ describe("<LabFilterRail>", () => {
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");
// The saved sort in Settings > Labs is the only sort; the rail has none.
expect(html).not.toContain("Sort order");
expect(html).toContain("5 biomarkers");
});
@@ -73,11 +68,9 @@ describe("<LabFilterRail>", () => {
onCategoryChange={vi.fn()}
onFromDayChange={vi.fn()}
onToDayChange={vi.fn()}
onSortChange={vi.fn()}
onResetFilters={vi.fn()}
isFiltered={true}
total={1}
currentSortDir="recentDesc"
/>
</I18nProvider>,
);
@@ -106,11 +99,9 @@ describe("<LabFilterRail>", () => {
onCategoryChange={vi.fn()}
onFromDayChange={vi.fn()}
onToDayChange={vi.fn()}
onSortChange={vi.fn()}
onResetFilters={vi.fn()}
onResetFilters={vi.fn()}
isFiltered={false}
total={total}
currentSortDir="recentDesc"
/>
</I18nProvider>,
);
@@ -137,59 +128,13 @@ describe("<LabFilterRail>", () => {
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");
});
});
@@ -71,7 +71,7 @@ describe("parseLabListSearchParams & labListFiltersToSearch", () => {
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",
"q=iron&status=abnormal&category=cat_iron&from=2026-01-01&to=2026-12-31",
);
expect(parseLabListSearchParams(params)).toEqual<LabListFilters>({
q: "iron",
@@ -79,13 +79,12 @@ describe("parseLabListSearchParams & labListFiltersToSearch", () => {
category: "cat_iron",
fromDay: "2026-01-01",
toDay: "2026-12-31",
sort: "alphaAsc",
});
});
it("drops invalid status, invalid dates, and invalid sort", () => {
it("drops invalid status and invalid dates", () => {
const params = new URLSearchParams(
"status=invalid&category=ALL&from=not-a-date&to=invalid&sort=random",
"status=invalid&category=ALL&from=not-a-date&to=invalid",
);
expect(parseLabListSearchParams(params)).toEqual({});
});
@@ -106,14 +105,10 @@ describe("parseLabListSearchParams & labListFiltersToSearch", () => {
).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("ignores a sort param: the list follows the saved sort only", () => {
expect(
parseLabListSearchParams(new URLSearchParams("sort=alphaAsc")),
).toEqual({});
});
it("roundtrips valid filters to search string", () => {
@@ -123,7 +118,6 @@ describe("parseLabListSearchParams & labListFiltersToSearch", () => {
category: "lipids",
fromDay: "2026-05-01",
toDay: "2026-05-31",
sort: "recentAsc",
};
const search = labListFiltersToSearch(filters);
const parsed = parseLabListSearchParams(new URLSearchParams(search));
@@ -3,11 +3,6 @@ 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,
@@ -165,316 +160,6 @@ describe("LabList stale category and group header handling", () => {
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",
categoryOrder: [],
},
},
});
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",
categoryOrder: [],
},
},
},
});
// 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",
categoryOrder: [],
},
},
},
});
// 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",
categoryOrder: [],
saved: false,
},
illness: {
view: "cards",
order: [],
sortDir: "recentDesc",
groupBy: "none",
groupsOpen: "expanded",
categoryOrder: [],
saved: false,
},
vorsorge: {
view: "cards",
order: [],
sortDir: "recentDesc",
groupBy: "none",
groupsOpen: "expanded",
categoryOrder: [],
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",
categoryOrder: [],
saved: true,
},
illness: {
view: "cards",
order: [],
sortDir: "recentDesc",
groupBy: "none",
groupsOpen: "expanded",
categoryOrder: [],
saved: false,
},
vorsorge: {
view: "cards",
order: [],
sortDir: "recentDesc",
groupBy: "none",
groupsOpen: "expanded",
categoryOrder: [],
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();
@@ -167,40 +167,15 @@ describe("LabList URL filter sync — interactions write to URL", () => {
expect(browser.push).not.toHaveBeenCalled();
});
it("pushes sort override and does not set isFiltered", () => {
render("");
it("leaves a sort param out of the filter state", () => {
render("sort=alphaAsc");
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");
capturedRailProps!.onStatusChange("abnormal");
expect(browser.push).toHaveBeenCalledWith("/labs?status=abnormal");
});
it("reset clears every facet in one push", () => {
render("status=abnormal&category=cat_iron&from=2026-09-01&sort=alphaAsc");
render("status=abnormal&category=cat_iron&from=2026-09-01");
expect(capturedRailProps!.isFiltered).toBe(true);
capturedRailProps!.onResetFilters();
expect(browser.push).toHaveBeenCalledWith("/labs");
@@ -210,23 +185,21 @@ describe("LabList URL filter sync — interactions write to URL", () => {
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",
"q=ferritin&status=in-range&category=cat_iron&from=2026-09-01&to=2026-09-30",
);
expect(capturedRailProps!.filters.q).toBe("ferritin");
expect(capturedRailProps!.filters.status).toBe("in-range");
expect(capturedRailProps!.filters.category).toBe("cat_iron");
expect(capturedRailProps!.filters.fromDay).toBe("2026-09-01");
expect(capturedRailProps!.filters.toDay).toBe("2026-09-30");
expect(capturedRailProps!.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");
render("status=INVALID&from=2026-02-30&to=not-a-date");
expect(capturedRailProps!.filters.status).toBeUndefined();
expect(capturedRailProps!.filters.fromDay).toBeUndefined();
expect(capturedRailProps!.filters.toDay).toBeUndefined();
expect(capturedRailProps!.currentSortDir).toBe("recentDesc");
expect(capturedRailProps!.isFiltered).toBe(false);
});
@@ -252,12 +252,12 @@ describe("<LabList>", () => {
expect(html).toContain("1 of 1 outside range");
});
it("handles sort=manual gracefully when manual order is empty", () => {
mockSearch = "sort=manual";
it("sorts by the saved preference and ignores a sort param in the URL", () => {
mockSearch = "sort=recentDesc";
mockModulePrefs = {
view: "list",
order: [], // no manual order
sortDir: "recentDesc",
order: [],
sortDir: "alphaAsc",
groupBy: "none",
groupsOpen: "expanded",
categoryOrder: [],
@@ -268,6 +268,12 @@ describe("<LabList>", () => {
});
const overview: LabOverviewResponse = {
markers: [
{
key: "bm:b2",
count: 1,
trend: [5],
latest: mockReading({ id: "r2", biomarkerId: "b2", analyte: "Zinc" }),
},
{
key: "bm:b1",
count: 1,
@@ -288,9 +294,9 @@ describe("<LabList>", () => {
</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");
expect(html.indexOf("Ferritin")).toBeGreaterThan(-1);
expect(html.indexOf("Zinc")).toBeGreaterThan(html.indexOf("Ferritin"));
expect(html).not.toContain("Sort order");
});
it("filters by local day bounds using display timezone (Europe/Warsaw)", () => {
-56
View File
@@ -7,21 +7,10 @@ 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,
@@ -38,13 +27,10 @@ export interface LabFilterRailProps {
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({
@@ -56,13 +42,10 @@ export function LabFilterRail({
onCategoryChange,
onFromDayChange,
onToDayChange,
onSortChange,
onResetFilters,
isFiltered,
categories,
total,
currentSortDir,
hasManualOrder,
}: LabFilterRailProps) {
const { t, tCount } = useTranslations();
const fmt = useFormatters();
@@ -81,19 +64,6 @@ export function LabFilterRail({
}),
);
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
@@ -173,32 +143,6 @@ export function LabFilterRail({
/>
)}
<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>
);
+10 -19
View File
@@ -2,17 +2,14 @@
/**
* 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
* documents-vault and measurements patterns: the list's filter state
* lives in the page URL (?q&status&category&from&to) so a filtered view is
* deep-linkable and survives navigation, reload and back/forward. Parsing is
* lenient — an unknown facet drops silently — and serialisation omits defaults
* so the unfiltered list keeps a clean URL.
*/
import { applyOrder } from "@/lib/module-list-prefs";
import {
isModuleSortDir,
type ModuleSortDir,
} from "@/lib/settings/module-list-layout";
import type { ModuleSortDir } from "@/lib/settings/module-list-layout";
import {
dateOnlyKey,
isCalendarDateKey,
@@ -45,8 +42,6 @@ export interface LabListFilters {
/** 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);
@@ -99,11 +94,6 @@ export function parseLabListSearchParams(
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;
}
@@ -111,10 +101,7 @@ export function parseLabListSearchParams(
* 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 {
export function labListFiltersToSearch(filters: LabListFilters): string {
const sp = new URLSearchParams();
if (filters.q?.trim()) sp.set("q", filters.q.trim());
if (filters.status && filters.status !== "ALL")
@@ -124,7 +111,6 @@ export function labListFiltersToSearch(
}
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();
}
@@ -205,7 +191,12 @@ export function filterMarkerRows(
}
/**
* Sort marker rows according to the chosen sort direction and optional manual order.
* The person's Labs sort (Settings > Labs) over the server's
* most-recent-first order: `recentAsc` reverses, the alpha options sort by
* analyte name (#43), and `manual` applies the persisted biomarker order
* (legacy un-linked rows, which carry no biomarkerId, sort after the ordered
* block). Under categories the same order holds inside each category. The
* list has no sort control of its own; the saved one is the only one.
*/
export function sortMarkerRows(
rows: readonly MarkerRow[],
+4 -29
View File
@@ -17,7 +17,6 @@ import { queryKeys } from "@/lib/query-keys";
import {
useModuleListPrefs,
type ModuleListView,
type ModuleSortDir,
} from "@/lib/module-list-prefs";
import { LabFilterRail } from "./lab-filter-rail";
@@ -328,7 +327,7 @@ export function LabList({
const { t, locale } = useTranslations();
const { canWriteDomain } = useRecordCapabilities();
const canAddLab = canWriteDomain("labs");
const { prefs, isLoaded } = useModuleListPrefs("labs");
const { prefs } = useModuleListPrefs("labs");
const displayTz = useDisplayTimezone();
// v1.22 — a short, factual line under each marker heading describing what the
@@ -343,12 +342,6 @@ export function LabList({
queryFn: () => apiGet<LabOverviewResponse>("/api/labs/overview"),
});
const hasManualOrder = prefs.order.length > 0;
const effectiveDefaultSort: ModuleSortDir =
prefs.sortDir === "manual" && !hasManualOrder
? "recentDesc"
: prefs.sortDir;
const {
filters,
searchDraft,
@@ -358,15 +351,8 @@ export function LabList({
setCategoryFilter,
setFromDay,
setToDay,
setSortFilter,
resetFilters,
} = useLabListFilters({
defaultSort: effectiveDefaultSort,
});
const rawSort = filters.sort ?? effectiveDefaultSort;
const effectiveSortDir =
rawSort === "manual" && !hasManualOrder ? "recentDesc" : rawSort;
} = useLabListFilters();
const rawRows = useMemo(
() => toMarkerRows(data?.markers ?? [], t),
@@ -395,14 +381,6 @@ export function LabList({
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(() => {
@@ -436,8 +414,8 @@ export function LabList({
);
const rows = useMemo(
() => sortMarkerRows(filteredRows, effectiveSortDir, prefs.order),
[filteredRows, effectiveSortDir, prefs.order],
() => sortMarkerRows(filteredRows, prefs.sortDir, prefs.order),
[filteredRows, prefs.sortDir, prefs.order],
);
// Categories only when the person asked for them and the server says they
@@ -483,13 +461,10 @@ export function LabList({
onCategoryChange={setCategoryFilter}
onFromDayChange={setFromDay}
onToDayChange={setToDay}
onSortChange={setSortFilter}
onResetFilters={resetFilters}
isFiltered={effectiveIsFiltered}
categories={categories}
total={rows.length}
currentSortDir={effectiveSortDir}
hasManualOrder={hasManualOrder}
/>
);
+2 -28
View File
@@ -5,7 +5,6 @@ import {
useUrlFilterSync,
type UrlFilterSyncOptions,
} from "@/hooks/use-url-filter-sync";
import type { ModuleSortDir } from "@/lib/settings/module-list-layout";
import {
labListFiltersToSearch,
parseLabListSearchParams,
@@ -13,20 +12,11 @@ import {
type LabStatusFilter,
} from "./lab-list-filters";
export function useLabListFilters({
defaultSort = "recentDesc",
}: {
defaultSort?: ModuleSortDir;
} = {}) {
const serialise = useCallback(
(f: LabListFilters) => labListFiltersToSearch(f, defaultSort),
[defaultSort],
);
export function useLabListFilters() {
const { filters, applyFilters, patchFilters } =
useUrlFilterSync<LabListFilters>({
parse: parseLabListSearchParams,
serialise,
serialise: labListFiltersToSearch,
});
// Debounced search draft
@@ -115,21 +105,6 @@ export function useLabListFilters({
[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({});
@@ -144,7 +119,6 @@ export function useLabListFilters({
setCategoryFilter,
setFromDay,
setToDay,
setSortFilter,
resetFilters,
};
}
+5 -8
View File
@@ -57,11 +57,8 @@ import { cn } from "@/lib/utils";
* ui-guidelines §5).
*/
/**
* 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 =
/** Shared pill chrome — trigger and chip in one element. */
const 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";
@@ -186,7 +183,7 @@ export function FilterBarSelect({
data-slot="filter-bar-pill"
data-active={active ? "true" : "false"}
className={cn(
FILTER_BAR_PILL_CLASSES,
PILL_CLASSES,
active && cn(PILL_ACTIVE_CLASSES, "pr-8"),
)}
>
@@ -265,7 +262,7 @@ export function FilterBarDateRange({
data-slot="filter-bar-pill"
data-active={active ? "true" : "false"}
className={cn(
FILTER_BAR_PILL_CLASSES,
PILL_CLASSES,
"outline-none",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
active && cn(PILL_ACTIVE_CLASSES, "pr-8"),
@@ -360,7 +357,7 @@ export function FilterBarNumberRange({
data-slot="filter-bar-pill"
data-active={active ? "true" : "false"}
className={cn(
FILTER_BAR_PILL_CLASSES,
PILL_CLASSES,
"outline-none",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
active && cn(PILL_ACTIVE_CLASSES, "pr-8"),