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

+1 -1
View File
@@ -930,7 +930,7 @@ const DELEGABLE_ROUTES: Record<string, DelegableEntry> = {
},
"app/api/labs/overview/route.ts": {
domain: "labs",
why: "The Labs list's per-marker overview of the record: the newest reading of each biomarker, its count and a short trend. The same rows and the same biomarker include as `GET /api/labs`, grouped server-side; read only.",
why: "The Labs list's per-marker overview of the record: the newest reading of each biomarker, its count and a short trend. The same rows and the same biomarker include as `GET /api/labs`, grouped server-side; read only. The category order it applies comes from the caller's own list layout, never the owner's, so it reveals nothing about the record.",
},
"app/api/biomarkers/route.ts": {
domain: "labs",
+28 -4
View File
@@ -2,10 +2,12 @@ import { NextRequest } from "next/server";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { apiSuccess, returnAllZodIssues } from "@/lib/api-response";
import { prisma } from "@/lib/db";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
import { categoriseOverview } from "@/lib/labs/marker-category";
import { loadLabOverview } from "@/lib/labs/overview";
import { annotate } from "@/lib/logging/context";
import { resolveModuleListLayout } from "@/lib/settings/module-list-layout";
import { labOverviewQuerySchema } from "@/lib/validations/labs";
/**
@@ -18,7 +20,11 @@ import { labOverviewQuerySchema } from "@/lib/validations/labs";
* `categories` and `grouped` are the list's categories, resolved here once
* (`src/lib/labs/marker-category.ts`): order, label, members, counts and a
* combined status. Labels follow the viewer's locale, so on a managed record
* the caller's own language wins, not the record owner's.
* the caller's own language wins, not the record owner's. The order is the
* default one with the caller's `categoryOrder` (the labs list layout,
* `PUT /api/settings/module-list-layout`) applied, for the same reason: it is
* the viewer's presentation, so a person acting for another record sees the
* categories in their own order. A client renders the order as given.
*/
export const GET = apiHandler(async (request: NextRequest) => {
const { user, actor } = await requireRecordAuth("read", "labs");
@@ -34,15 +40,33 @@ export const GET = apiHandler(async (request: NextRequest) => {
return returnAllZodIssues(parsed.error, 422);
}
const markers = await loadLabOverview(user.id);
const [markers, layoutRow] = await Promise.all([
loadLabOverview(user.id),
prisma.user.findUnique({
where: { id: actor.id },
select: { moduleListLayoutJson: true },
}),
]);
const { categoryOrder } = resolveModuleListLayout(
layoutRow?.moduleListLayoutJson,
).lists.labs;
const locale = await resolveServerLocale({
request,
userLocale: actor.locale ?? null,
});
const { categories, grouped } = categoriseOverview(markers, locale);
const { categories, grouped } = categoriseOverview(
markers,
locale,
categoryOrder,
);
annotate({
action: { name: "labs.overview" },
meta: { markers: markers.length, categories: categories.length, grouped },
meta: {
markers: markers.length,
categories: categories.length,
grouped,
categoryOrderLength: categoryOrder.length,
},
});
return apiSuccess({ markers, categories, grouped });
});
@@ -58,14 +58,15 @@ export const GET = apiHandler(async () => {
export const PUT = apiHandler(async (request: Request) => {
const { user } = await requireActorAuth();
// 500 ids of up to 64 characters plus the envelope.
// 500 ids plus 200 category keys of up to 64 characters, plus the envelope.
const parsed = await parseJsonBody(request, moduleListLayoutPutSchema, {
maxBytes: 48 * 1024,
maxBytes: 64 * 1024,
action: "settings.module-list-layout.validation-failed",
});
if (!parsed.ok) return parsed.error;
const { list, view, order, sortDir, groupBy, groupsOpen } = parsed.data;
const { list, view, order, sortDir, groupBy, groupsOpen, categoryOrder } =
parsed.data;
// Preserve-when-absent: one stored read fills whichever field the body
// left out, and every other list stays as stored. Only fields a person has
// chosen are written, so a field never picked keeps following the default.
@@ -79,6 +80,7 @@ export const PUT = apiHandler(async (request: Request) => {
...(sortDir ? { sortDir } : {}),
...(groupBy ? { groupBy } : {}),
...(groupsOpen ? { groupsOpen } : {}),
...(categoryOrder ? { categoryOrder: [...new Set(categoryOrder)] } : {}),
};
const next = { ...stored, [list]: nextChoices };
await tx.user.update({
@@ -98,6 +100,7 @@ export const PUT = apiHandler(async (request: Request) => {
groupBy: layout.lists[list].groupBy,
groupsOpen: layout.lists[list].groupsOpen,
orderLength: layout.lists[list].order.length,
categoryOrderLength: layout.lists[list].categoryOrder.length,
},
});
return apiSuccess(layout);
@@ -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
@@ -68,6 +68,7 @@ describe("parseModuleListPrefs", () => {
order: ["a", "b"],
groupBy: "none",
groupsOpen: "expanded",
categoryOrder: [],
});
});
@@ -204,6 +205,85 @@ describe("runSetModuleListPrefs", () => {
});
});
describe("runSetModuleListPrefs category order", () => {
const t = (key: string) => key;
const overview = (keys: string[]) => ({
markers: [],
grouped: true,
categories: keys.map((key) => ({ key, label: key })),
});
it("moves the cached overview's categories at once and refetches it", async () => {
const queryClient = new QueryClient();
const cell = queryKeys.labResultsOverview("en");
queryClient.setQueryData(cell, overview(["lipids", "thyroid", "other"]));
const invalidate = vi.spyOn(queryClient, "invalidateQueries");
let cachedDuringWrite: unknown;
vi.mocked(apiPut).mockImplementation(async () => {
cachedDuringWrite = queryClient.getQueryData(cell);
return resolveModuleListLayout({
labs: { categoryOrder: ["thyroid", "other", "lipids"] },
});
});
await runSetModuleListPrefs({
list: "labs",
patch: { categoryOrder: ["thyroid", "other", "lipids"] },
queryClient,
t,
});
expect(apiPut).toHaveBeenCalledWith("/api/settings/module-list-layout", {
version: 1,
list: "labs",
categoryOrder: ["thyroid", "other", "lipids"],
});
expect(
(cachedDuringWrite as ReturnType<typeof overview>).categories.map(
(c) => c.key,
),
).toEqual(["thyroid", "other", "lipids"]);
expect(invalidate).toHaveBeenCalledWith({
queryKey: queryKeys.labResultsOverview(),
});
// An order move is its own feedback, like the marker order.
expect(toastWrittenOutcome).not.toHaveBeenCalled();
});
it("refetches the overview after a failed write too, so the server order returns", async () => {
const queryClient = new QueryClient();
queryClient.setQueryData(
queryKeys.labResultsOverview("en"),
overview(["lipids", "thyroid"]),
);
const invalidate = vi.spyOn(queryClient, "invalidateQueries");
vi.mocked(apiPut).mockRejectedValue(new Error("offline"));
await runSetModuleListPrefs({
list: "labs",
patch: { categoryOrder: ["thyroid", "lipids"] },
queryClient,
t,
});
expect(showErrorToast).toHaveBeenCalled();
expect(invalidate).toHaveBeenCalledWith({
queryKey: queryKeys.labResultsOverview(),
});
});
it("leaves the overview alone for any other field", async () => {
const queryClient = new QueryClient();
const invalidate = vi.spyOn(queryClient, "invalidateQueries");
vi.mocked(apiPut).mockResolvedValue(resolveModuleListLayout(null));
await runSetModuleListPrefs({
list: "labs",
patch: { order: ["m1"] },
queryClient,
t,
});
expect(invalidate).not.toHaveBeenCalled();
});
});
describe("applyOrder", () => {
const items = [{ id: "a" }, { id: "b" }, { id: "c" }];
const idOf = (i: { id: string }) => i.id;
@@ -6,6 +6,7 @@ import type { ReferenceRangeStatus } from "@/lib/labs/reference-range";
import { ALIAS_PANEL, BIOMARKER_PANELS } from "../biomarker-catalog";
import {
applyCategoryOrder,
categoriseOverview,
customCategoryLabel,
customPanelSuggestions,
@@ -222,3 +223,110 @@ describe("categoriseOverview", () => {
).toBe(true);
});
});
describe("applyCategoryOrder", () => {
const cats = (...keys: string[]) => keys.map((key) => ({ key }));
const keysOf = (list: { key: string }[]) => list.map((c) => c.key);
it("keeps the default order without a saved order", () => {
expect(
keysOf(applyCategoryOrder(cats("lipids", "thyroid", "other"), [])),
).toEqual(["lipids", "thyroid", "other"]);
});
it("puts the listed categories first, in the listed order", () => {
expect(
keysOf(
applyCategoryOrder(cats("lipids", "thyroid", "liver", "other"), [
"liver",
"lipids",
"thyroid",
"other",
]),
),
).toEqual(["liver", "lipids", "thyroid", "other"]);
});
it("ignores keys the data does not have", () => {
expect(
keysOf(
applyCategoryOrder(cats("lipids", "thyroid"), [
"custom:gone",
"thyroid",
"iron",
"lipids",
]),
),
).toEqual(["thyroid", "lipids"]);
});
it("adds a category the order does not name after the listed ones, in the default order", () => {
expect(
keysOf(
applyCategoryOrder(cats("lipids", "glucose", "thyroid", "liver"), [
"thyroid",
"lipids",
]),
),
).toEqual(["thyroid", "lipids", "glucose", "liver"]);
});
it("keeps other last when the person left it at the end", () => {
expect(
keysOf(
applyCategoryOrder(cats("lipids", "iron", "custom:abc", "other"), [
"lipids",
"other",
]),
),
).toEqual(["lipids", "iron", "custom:abc", "other"]);
});
it("keeps other where the person moved it", () => {
expect(
keysOf(
applyCategoryOrder(cats("lipids", "iron", "thyroid", "other"), [
"other",
"lipids",
"thyroid",
]),
),
).toEqual(["other", "lipids", "thyroid", "iron"]);
});
it("judges other's place among the present categories only", () => {
// "thyroid" is gone from the data, so "other" ends the listed block.
expect(
keysOf(
applyCategoryOrder(cats("lipids", "iron", "other"), [
"lipids",
"other",
"thyroid",
]),
),
).toEqual(["lipids", "iron", "other"]);
});
});
describe("categoriseOverview with a saved category order", () => {
it("answers the categories in the person's order", () => {
const { categories, grouped } = categoriseOverview(
[
marker("bm:1", "Mystery", null),
marker("bm:2", "TSH", null),
marker("bm:3", "LDL", null),
marker("bm:4", "Cortisol", "Hormones"),
],
"en",
["thyroid", "other", "lipids"],
);
expect(grouped).toBe(true);
// Hormones is not in the order: it follows the listed categories.
expect(categories.map((c) => c.label)).toEqual([
"Thyroid",
"Other",
"Lipids",
"Hormones",
]);
});
});
+36 -3
View File
@@ -197,14 +197,47 @@ export interface CategorisableMarker {
}
/**
* Every category of an overview, in display order (catalog panels in catalog
* order, then the person's own by label, then "other"), and whether the list
* The person's category order (`categoryOrder` in the labs list layout)
* over categories already in the default order.
*
* Listed keys come first, in the listed order; a key the data does not have
* is skipped. A category the list does not name (a panel added since the
* person arranged the list) follows the listed ones in the default order.
* "other" is the one exception: left at the end of the listed categories it
* stays last, after any category added later, because a catch-all belongs
* at the bottom unless the person moved it up. An empty order is the
* default order.
*/
export function applyCategoryOrder<T extends { key: string }>(
categories: readonly T[],
order: readonly string[],
): T[] {
if (order.length === 0) return [...categories];
const present = new Set(categories.map((category) => category.key));
const listed = order.filter((key) => present.has(key));
const rank = new Map(listed.map((key, index) => [key, index]));
const ordered = categories
.filter((category) => rank.has(category.key))
.sort((a, b) => rank.get(a.key)! - rank.get(b.key)!);
const rest = categories.filter((category) => !rank.has(category.key));
const otherLast = ordered.at(-1)?.key === OTHER_CATEGORY;
if (!otherLast || rest.length === 0) return [...ordered, ...rest];
return [...ordered.slice(0, -1), ...rest, ordered.at(-1)!];
}
/**
* Every category of an overview, in display order, and whether the list
* groups at all: it does when there are two categories or more and at least
* one is not "other". One category, or only "other", stays a flat list.
*
* The default order is catalog panels in catalog order (`BIOMARKER_PANELS`),
* then the person's own by label, then "other"; `categoryOrder`, the
* person's own arrangement, is applied over it (`applyCategoryOrder`).
*/
export function categoriseOverview(
markers: readonly CategorisableMarker[],
locale: Locale,
categoryOrder: readonly string[] = [],
): { categories: LabOverviewCategory[]; grouped: boolean } {
const { t } = getServerTranslator(locale);
const buckets = new Map<
@@ -270,5 +303,5 @@ export function categoriseOverview(
const grouped =
categories.length >= 2 &&
categories.some((category) => category.key !== OTHER_CATEGORY);
return { categories, grouped };
return { categories: applyCategoryOrder(categories, categoryOrder), grouped };
}
+46 -2
View File
@@ -2,7 +2,8 @@
/**
* Per-list presentation for the labs, illness and checkups lists: card or
* list view, a manual order, and (labs only) the sort and the categories.
* list view, a manual order, and (labs only) the sort, the categories and
* their order.
*
* The choices live on the server (`GET/PUT /api/settings/module-list-layout`,
* `User.moduleListLayoutJson`) so they follow a person to every device. Until
@@ -198,6 +199,11 @@ type Translator = (
* Apply one field optimistically (the control has to feel instant), then
* persist it. On failure the cache rolls back and a toast says so; a view or
* sort change confirms with a toast, an order change is its own feedback.
*
* The category order is applied by the server (`GET /api/labs/overview`
* answers `categories` already arranged), so a change to it moves the
* cached overview's categories to the full order the editor sent, at once,
* and then refetches the overview, whose answer is the one that stays.
*/
export async function runSetModuleListPrefs(deps: {
list: ModuleListKey;
@@ -210,6 +216,25 @@ export async function runSetModuleListPrefs(deps: {
if (queryClient.isFetching({ queryKey: key }) > 0) {
await queryClient.cancelQueries({ queryKey: key });
}
const overviewKey = queryKeys.labResultsOverview();
const categoryOrder = patch.categoryOrder;
if (categoryOrder !== undefined) {
await queryClient.cancelQueries({ queryKey: overviewKey });
queryClient.setQueriesData<{ categories?: { key: string }[] }>(
{ queryKey: overviewKey },
(cached) =>
cached?.categories
? {
...cached,
categories: applyOrder(
cached.categories,
categoryOrder,
(category) => category.key,
),
}
: cached,
);
}
const previous = queryClient.getQueryData<ModuleListLayout>(key);
const base = previous ?? resolveModuleListLayout(null);
queryClient.setQueryData<ModuleListLayout>(key, {
@@ -237,6 +262,10 @@ export async function runSetModuleListPrefs(deps: {
} catch (failure) {
queryClient.setQueryData(key, previous);
showErrorToast(t("settings.savingError"), { cause: failure });
} finally {
if (categoryOrder !== undefined) {
void queryClient.invalidateQueries({ queryKey: overviewKey });
}
}
}
@@ -251,6 +280,7 @@ export function useModuleListPrefs(list: ModuleListKey): {
setSortDir: (sortDir: ModuleSortDir) => void;
setGroupBy: (groupBy: ModuleGroupBy) => void;
setGroupsOpen: (groupsOpen: ModuleGroupsOpen) => void;
setCategoryOrder: (categoryOrder: string[]) => void;
} {
const queryClient = useQueryClient();
const { t } = useTranslations();
@@ -278,6 +308,7 @@ export function useModuleListPrefs(list: ModuleListKey): {
sortDir: entry.sortDir,
groupBy: entry.groupBy,
groupsOpen: entry.groupsOpen,
categoryOrder: entry.categoryOrder,
};
}
const defaults = moduleListDefaults(list);
@@ -310,5 +341,18 @@ export function useModuleListPrefs(list: ModuleListKey): {
[write],
);
return { prefs, setView, setOrder, setSortDir, setGroupBy, setGroupsOpen };
const setCategoryOrder = useCallback(
(categoryOrder: string[]) => write({ categoryOrder }),
[write],
);
return {
prefs,
setView,
setOrder,
setSortDir,
setGroupBy,
setGroupsOpen,
setCategoryOrder,
};
}
+1 -1
View File
@@ -171,7 +171,7 @@ export const labOverviewResponse = z
markers: z.array(labOverviewMarker),
categories: z.array(labOverviewCategory).meta({
description:
"The list's categories in display order: catalog panels in catalog order, then the person's own panels by label, then `other`.",
"The list's categories in display order, already arranged for the caller: the default order is catalog panels in catalog order, then the person's own panels by label, then `other`, and the caller's `categoryOrder` (`PUT /api/settings/module-list-layout`, labs list) is applied over it. Render them in this order.",
}),
grouped: z.boolean().meta({
description:
+5
View File
@@ -59,6 +59,11 @@ const moduleListPrefsEntry = z
.describe(
'Which labs categories start open: every one (`expanded`), only those whose `status` is `above` or `below` (`flagged`), or none (`collapsed`). Default "expanded". The other lists ignore it.',
),
categoryOrder: z
.array(z.string())
.describe(
'The person\'s order of the labs categories, as `categories[].key` values of `GET /api/labs/overview`. The server applies it there, so a client renders `categories` as given and never sorts them itself. Keys missing from the live data are ignored; a category missing from the list follows the listed ones in the default order, and "other" left at the end of the list stays last. Default empty (the default order). The other lists ignore it.',
),
saved: z
.boolean()
.describe(
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import {
MODULE_LIST_CATEGORY_ORDER_MAX,
MODULE_LIST_ORDER_MAX,
normaliseModuleListOrder,
resolveModuleListLayout,
@@ -16,6 +17,7 @@ describe("resolveModuleListLayout", () => {
sortDir: "recentDesc",
groupBy: "none",
groupsOpen: "expanded",
categoryOrder: [],
saved: false,
});
expect(layout.lists.illness.view).toBe("cards");
@@ -34,6 +36,7 @@ describe("resolveModuleListLayout", () => {
sortDir: "recentDesc",
groupBy: "none",
groupsOpen: "expanded",
categoryOrder: [],
saved: true,
});
expect(layout.lists.labs.saved).toBe(false);
@@ -50,6 +53,7 @@ describe("resolveModuleListLayout", () => {
sortDir: "recentDesc",
groupBy: "none",
groupsOpen: "expanded",
categoryOrder: [],
saved: false,
});
expect(layout.lists.vorsorge.saved).toBe(false);
@@ -70,11 +74,49 @@ describe("category choices", () => {
expect(layout.lists.illness).toMatchObject({
groupBy: "none",
groupsOpen: "expanded",
categoryOrder: [],
saved: false,
});
});
});
describe("category order", () => {
it("keeps a stored category order, deduplicated, and marks the list saved", () => {
const layout = resolveModuleListLayout({
labs: { categoryOrder: ["thyroid", "lipids", "thyroid", 7, "other"] },
});
expect(layout.lists.labs).toMatchObject({
categoryOrder: ["thyroid", "lipids", "other"],
saved: true,
});
});
it("drops a malformed category order and leaves the default", () => {
const layout = resolveModuleListLayout({
labs: { categoryOrder: "lipids" },
});
expect(layout.lists.labs.categoryOrder).toEqual([]);
expect(layout.lists.labs.saved).toBe(false);
});
it("caps the category order on its own limit", () => {
const keys = Array.from(
{ length: MODULE_LIST_CATEGORY_ORDER_MAX + 5 },
(_, i) => `custom:${i}`,
);
const layout = resolveModuleListLayout({ labs: { categoryOrder: keys } });
expect(layout.lists.labs.categoryOrder).toHaveLength(
MODULE_LIST_CATEGORY_ORDER_MAX,
);
});
it("hands every list its own copy of the default", () => {
const layout = resolveModuleListLayout(null);
layout.lists.labs.categoryOrder.push("lipids");
expect(resolveModuleListLayout(null).lists.labs.categoryOrder).toEqual([]);
});
});
describe("normaliseModuleListOrder", () => {
it("keeps strings, drops duplicates, empties and over-long ids", () => {
expect(
+28 -3
View File
@@ -14,6 +14,11 @@
* the list under its server-resolved categories (`"category"`) or flat
* (`"none"`, the default); `groupsOpen` says which categories start open:
* all of them, only those with a value outside its range, or none.
* - `categoryOrder`: only the labs list reads it, and only on the server: a
* category-key allow-list `GET /api/labs/overview` applies before it
* answers `categories` (`applyCategoryOrder` in
* `src/lib/labs/marker-category.ts`), so every client receives the
* categories already in the person's order. Same tolerance as `order`.
*
* Null / missing column = the defaults below. The GET never lazy-writes, and
* the PUT stores only the fields a person has actually chosen, so a default
@@ -51,6 +56,11 @@ export type ModuleGroupsOpen = (typeof MODULE_GROUPS_OPEN)[number];
export const MODULE_LIST_ORDER_MAX = 500;
/** Longest id an order entry may carry (cuid and uuid fit comfortably). */
export const MODULE_LIST_ID_MAX = 64;
/**
* Longest category order a list keeps. Ten catalog panels plus "other" plus
* the person's own panels; a longer one is cut, never refused.
*/
export const MODULE_LIST_CATEGORY_ORDER_MAX = 200;
export interface ModuleListPrefs {
view: ModuleListView;
@@ -58,6 +68,7 @@ export interface ModuleListPrefs {
sortDir: ModuleSortDir;
groupBy: ModuleGroupBy;
groupsOpen: ModuleGroupsOpen;
categoryOrder: string[];
}
export interface ModuleListPrefsEntry extends ModuleListPrefs {
@@ -76,6 +87,7 @@ const BASE_DEFAULTS: ModuleListPrefs = {
sortDir: "recentDesc",
groupBy: "none",
groupsOpen: "expanded",
categoryOrder: [],
};
/** Labs opens on the compact list, the dense reading table (#40). */
@@ -87,7 +99,11 @@ const LIST_DEFAULTS: Record<ModuleListKey, ModuleListPrefs> = {
export function moduleListDefaults(list: ModuleListKey): ModuleListPrefs {
const defaults = LIST_DEFAULTS[list];
return { ...defaults, order: [...defaults.order] };
return {
...defaults,
order: [...defaults.order],
categoryOrder: [...defaults.categoryOrder],
};
}
export function isModuleListView(value: unknown): value is ModuleListView {
@@ -119,7 +135,10 @@ export function isModuleGroupsOpen(value: unknown): value is ModuleGroupsOpen {
}
/** Keep string ids of a sane length, first occurrence wins, capped. */
export function normaliseModuleListOrder(raw: unknown): string[] | null {
export function normaliseModuleListOrder(
raw: unknown,
max: number = MODULE_LIST_ORDER_MAX,
): string[] | null {
if (!Array.isArray(raw)) return null;
const seen = new Set<string>();
const out: string[] = [];
@@ -129,7 +148,7 @@ export function normaliseModuleListOrder(raw: unknown): string[] | null {
if (seen.has(value)) continue;
seen.add(value);
out.push(value);
if (out.length >= MODULE_LIST_ORDER_MAX) break;
if (out.length >= max) break;
}
return out;
}
@@ -148,8 +167,13 @@ export function storedModuleListChoices(
sortDir?: unknown;
groupBy?: unknown;
groupsOpen?: unknown;
categoryOrder?: unknown;
};
const order = normaliseModuleListOrder(blob.order);
const categoryOrder = normaliseModuleListOrder(
blob.categoryOrder,
MODULE_LIST_CATEGORY_ORDER_MAX,
);
return {
...(isModuleListView(blob.view) ? { view: blob.view } : {}),
...(order !== null ? { order } : {}),
@@ -158,6 +182,7 @@ export function storedModuleListChoices(
...(isModuleGroupsOpen(blob.groupsOpen)
? { groupsOpen: blob.groupsOpen }
: {}),
...(categoryOrder !== null ? { categoryOrder } : {}),
};
}
+5
View File
@@ -9,6 +9,7 @@ import { z } from "zod/v4";
import {
MODULE_GROUPS_OPEN,
MODULE_GROUP_BYS,
MODULE_LIST_CATEGORY_ORDER_MAX,
MODULE_LIST_ID_MAX,
MODULE_LIST_KEYS,
MODULE_LIST_ORDER_MAX,
@@ -28,5 +29,9 @@ export const moduleListLayoutPutSchema = z
sortDir: z.enum(MODULE_SORT_DIRS).optional(),
groupBy: z.enum(MODULE_GROUP_BYS).optional(),
groupsOpen: z.enum(MODULE_GROUPS_OPEN).optional(),
categoryOrder: z
.array(z.string().min(1).max(MODULE_LIST_ID_MAX))
.max(MODULE_LIST_CATEGORY_ORDER_MAX)
.optional(),
})
.strict();