feat(labs): let a person order the lab categories (#1349)

The labs list layout gains categoryOrder, a bounded allow-list of
category keys (200 entries, 64 characters each, deduplicated, malformed
values dropped on read). GET /api/labs/overview reads the caller's own
layout and answers categories already in that order, so every client
renders them as given. Listed categories come first; keys the data does
not have are ignored; a category the order does not name follows in
the default order (catalog panels, then the person's own by label, then
other). Other left at the end of the order stays last, behind any
category added later; moved anywhere else, it stays there.

Settings > Arrangement > Labs shows a category order card while
categories are on. It lists the overview's categories under their
localised labels, sends the whole order after every move, moves the
cached overview at once and refetches it. The card says how the default
order works. The OpenAPI document carries the new field.
This commit is contained in:
Marc-André Bombeck committed 2026-11-10 18:57:48 +01:00
1 parent 157809c24f
commit 928ade5109
28 files changed
+660 -34

No files matched your search

@@ -28,6 +28,21 @@ function render(prefs: Partial<ModuleListPrefs>) {
biomarkers: [],
customPanels: [],
});
// The server's answer, already in the person's order.
queryClient.setQueryData(queryKeys.labResultsOverview("en"), {
markers: [],
grouped: true,
categories: [
{ key: "thyroid", label: "Thyroid" },
{ key: "other", label: "Other" },
{ key: "lipids", label: "Lipids" },
].map((category) => ({
...category,
markerKeys: [],
counts: { inRange: 0, below: 0, above: 0, unknown: 0 },
status: "unknown",
})),
});
return renderToStaticMarkup(
<QueryClientProvider client={queryClient}>
<I18nProvider initialLocale="en">
@@ -60,4 +75,26 @@ describe("<LabsSettings> categories", () => {
"The order applies within each category.",
);
});
it("offers the category order only while categories are on", () => {
expect(render({})).not.toContain('id="labs-category-order"');
expect(render({ groupBy: "category" })).toContain(
'id="labs-category-order"',
);
});
it("lists the categories as the server ordered them, under their labels", () => {
const html = render({ groupBy: "category" });
const card = html.slice(html.indexOf('id="labs-category-order"'));
const at = (label: string) => card.indexOf(`title="${label}"`);
expect(at("Thyroid")).toBeGreaterThan(-1);
expect(at("Thyroid")).toBeLessThan(at("Other"));
expect(at("Other")).toBeLessThan(at("Lipids"));
});
it("says how the categories are ordered until the person moves one", () => {
expect(render({ groupBy: "category" })).toContain(
"Until you move one, built-in categories keep a fixed order, your own follow alphabetically and Other comes last.",
);
});
});
+72 -5
View File
@@ -5,8 +5,8 @@
*
* `LabsSettings` — Settings > Arrangement > Labs: the card-vs-list view
* (MOD-03), the sort (recency, alphabetical or manual, MOD-04) and the manual
* order of the biomarkers, and whether the list shows its categories and
* which of them start open. Persisted per account through
* order of the biomarkers, and whether the list shows its categories, which
* of them start open and their order. Persisted per account through
* `useModuleListPrefs("labs")`.
*
* `LabsModuleSettings` — Settings > Modules > Labs: the on-device text
@@ -19,6 +19,7 @@ import {
ArrowDownUp,
FlaskConical,
FolderTree,
Layers,
LayoutGrid,
ListOrdered,
ScanText,
@@ -27,6 +28,7 @@ import {
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { NativeSelect } from "@/components/ui/native-select";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { Label } from "@/components/ui/label";
import { Skeleton } from "@/components/ui/skeleton";
import { Segmented } from "@/components/ui/segmented";
@@ -49,14 +51,25 @@ import {
} from "@/lib/module-list-prefs";
import { BiomarkerManager } from "./biomarker-manager";
import type { BiomarkerDto, BiomarkerListResponse } from "./types";
import type {
BiomarkerDto,
BiomarkerListResponse,
LabOverviewResponse,
} from "./types";
import { useLabsLocalOcr, useUpdateLabsLocalOcr } from "./use-ocr-extract";
import { labUnitLabel } from "@/lib/labs/dimensionless";
export function LabsSettings() {
const { t } = useTranslations();
const { prefs, setView, setOrder, setSortDir, setGroupBy, setGroupsOpen } =
useModuleListPrefs("labs");
const {
prefs,
setView,
setOrder,
setSortDir,
setGroupBy,
setGroupsOpen,
setCategoryOrder,
} = useModuleListPrefs("labs");
const { data, isLoading } = useQuery({
queryKey: queryKeys.biomarkers(),
@@ -120,6 +133,10 @@ export function LabsSettings() {
onGroupsOpen={setGroupsOpen}
/>
{prefs.groupBy === "category" ? (
<LabsCategoryOrderCard onChange={setCategoryOrder} />
) : null}
{manual ? (
<SettingsCard id="labs-order" className="scroll-mt-28">
<SettingsCardHeader
@@ -204,6 +221,56 @@ function LabsGroupingCard({
);
}
/**
* The order of the categories. The server answers them already in the
* person's order (`GET /api/labs/overview` applies `categoryOrder`), so the
* editor lists them as given, under the labels the overview carries, and
* sends the whole order after every move.
*/
function LabsCategoryOrderCard({
onChange,
}: {
onChange: (categoryOrder: string[]) => void;
}) {
const { t, locale } = useTranslations();
const { data, isLoading, isError, error, refetch } = useQuery({
queryKey: queryKeys.labResultsOverview(locale),
queryFn: () => apiGet<LabOverviewResponse>("/api/labs/overview"),
});
const items: ReorderItem[] = (data?.categories ?? []).map((category) => ({
id: category.key,
name: category.label,
}));
return (
<SettingsCard id="labs-category-order" className="scroll-mt-28">
<SettingsCardHeader
icon={Layers}
title={t("labs.groups.orderHeading")}
description={t("labs.groups.orderDefault", {
other: t("labs.groups.other"),
})}
/>
{isLoading ? (
<div className="space-y-2">
{Array.from({ length: 3 }, (_, i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</div>
) : isError ? (
<QueryErrorRow
error={error}
inset
slot="labs-category-order-error"
onRetry={() => void refetch()}
/>
) : (
<ModuleOrderEditor items={items} onChange={onChange} />
)}
</SettingsCard>
);
}
export function LabsModuleSettings() {
const { t } = useTranslations();
// v1.18.10 — local (in-browser) OCR opt-in. Lets a user whose AI provider