mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
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:
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.",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user