feat(settings): carry category choices in the labs list layout

The list layout gains two choices that only the labs list reads:
groupBy (none or category, default none) and groupsOpen (expanded,
flagged or collapsed, default expanded). The resolver keeps a valid
stored value and drops anything else to the default, the PUT merges
both field by field, and the hook exposes a setter for each. A
browser value from before the server column never held them, so the
legacy parse fills them from the defaults.
This commit is contained in:
Marc-André Bombeck committed 2026-11-06 17:44:38 +01:00
1 parent 2e14b2d8b5
commit e2170115c7
10 files changed
+208 -13

No files matched your search

+29
View File
@@ -34117,6 +34117,17 @@ components:
- alphaAsc
- alphaDesc
- manual
groupBy:
type: string
enum:
- none
- category
groupsOpen:
type: string
enum:
- expanded
- flagged
- collapsed
required:
- version
- list
@@ -75011,6 +75022,22 @@ components:
- alphaDesc
- manual
description: Sort of the labs list; `manual` follows `order`. Default "recentDesc". The other lists ignore it.
groupBy:
type: string
enum:
- none
- category
description: 'Whether the labs list shows its server-resolved categories (`category`) or one flat list (`none`). Default
"none". The labs list stays flat while `GET /api/labs/overview` answers `grouped: false`. The other lists
ignore it.'
groupsOpen:
type: string
enum:
- expanded
- flagged
- collapsed
description: '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.'
saved:
type: boolean
description: True when the server holds a choice for this list; false means every field is a default.
@@ -75018,6 +75045,8 @@ components:
- view
- order
- sortDir
- groupBy
- groupsOpen
- saved
additionalProperties: false
ModuleListLayoutSavedEnvelope:
@@ -65,7 +65,7 @@ export const PUT = apiHandler(async (request: Request) => {
});
if (!parsed.ok) return parsed.error;
const { list, view, order, sortDir } = parsed.data;
const { list, view, order, sortDir, groupBy, groupsOpen } = 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.
@@ -77,6 +77,8 @@ export const PUT = apiHandler(async (request: Request) => {
...(view ? { view } : {}),
...(order ? { order: [...new Set(order)] } : {}),
...(sortDir ? { sortDir } : {}),
...(groupBy ? { groupBy } : {}),
...(groupsOpen ? { groupsOpen } : {}),
};
const next = { ...stored, [list]: nextChoices };
await tx.user.update({
@@ -93,6 +95,8 @@ export const PUT = apiHandler(async (request: Request) => {
list,
view: layout.lists[list].view,
sortDir: layout.lists[list].sortDir,
groupBy: layout.lists[list].groupBy,
groupsOpen: layout.lists[list].groupsOpen,
orderLength: layout.lists[list].order.length,
},
});
+7 -1
View File
@@ -62,7 +62,13 @@ describe("parseModuleListPrefs", () => {
JSON.stringify({ view: "grid", sortDir: "weird", order: ["a", "b"] }),
defaults,
),
).toEqual({ view: "cards", sortDir: "recentDesc", order: ["a", "b"] });
).toEqual({
view: "cards",
sortDir: "recentDesc",
order: ["a", "b"],
groupBy: "none",
groupsOpen: "expanded",
});
});
it("applies the labs default (list view, #40) when nothing is stored", () => {
+33 -5
View File
@@ -2,7 +2,7 @@
/**
* Per-list presentation for the labs, illness and checkups lists: card or
* list view, a manual order, and (labs only) the sort.
* list view, a manual order, and (labs only) the sort and the categories.
*
* The choices live on the server (`GET/PUT /api/settings/module-list-layout`,
* `User.moduleListLayoutJson`) so they follow a person to every device. Until
@@ -39,11 +39,15 @@ import {
type ModuleListKey,
type ModuleListLayout,
type ModuleListPrefs,
type ModuleGroupBy,
type ModuleGroupsOpen,
type ModuleListView,
type ModuleSortDir,
} from "@/lib/settings/module-list-layout";
export type {
ModuleGroupBy,
ModuleGroupsOpen,
ModuleListKey,
ModuleListPrefs,
ModuleListView,
@@ -59,7 +63,8 @@ export function legacyModuleListStorageKey(list: ModuleListKey): string {
/**
* Parse a browser-stored blob from before the server column. Anything
* malformed collapses onto the defaults field by field.
* malformed collapses onto the defaults field by field. The browser never
* held the category fields, so those always come from the defaults.
*/
export function parseModuleListPrefs(
raw: string | null,
@@ -69,6 +74,7 @@ export function parseModuleListPrefs(
try {
const blob = JSON.parse(raw) as Record<string, unknown>;
return {
...defaults,
view: isModuleListView(blob.view) ? blob.view : defaults.view,
order: normaliseModuleListOrder(blob.order) ?? [],
sortDir: isModuleSortDir(blob.sortDir) ? blob.sortDir : defaults.sortDir,
@@ -220,7 +226,12 @@ export async function runSetModuleListPrefs(deps: {
...patch,
});
if (saved) queryClient.setQueryData(key, saved);
if (patch.view !== undefined || patch.sortDir !== undefined) {
if (
patch.view !== undefined ||
patch.sortDir !== undefined ||
patch.groupBy !== undefined ||
patch.groupsOpen !== undefined
) {
toastWrittenOutcome("success", t("settings.saved"));
}
} catch (failure) {
@@ -238,6 +249,8 @@ export function useModuleListPrefs(list: ModuleListKey): {
setView: (view: ModuleListView) => void;
setOrder: (order: string[]) => void;
setSortDir: (sortDir: ModuleSortDir) => void;
setGroupBy: (groupBy: ModuleGroupBy) => void;
setGroupsOpen: (groupsOpen: ModuleGroupsOpen) => void;
} {
const queryClient = useQueryClient();
const { t } = useTranslations();
@@ -259,7 +272,13 @@ export function useModuleListPrefs(list: ModuleListKey): {
const entry = data?.lists[list];
const prefs = useMemo<ModuleListPrefs>(() => {
if (entry) {
return { view: entry.view, order: entry.order, sortDir: entry.sortDir };
return {
view: entry.view,
order: entry.order,
sortDir: entry.sortDir,
groupBy: entry.groupBy,
groupsOpen: entry.groupsOpen,
};
}
const defaults = moduleListDefaults(list);
if (!mounted) return defaults;
@@ -282,5 +301,14 @@ export function useModuleListPrefs(list: ModuleListKey): {
[write],
);
return { prefs, setView, setOrder, setSortDir };
const setGroupBy = useCallback(
(groupBy: ModuleGroupBy) => write({ groupBy }),
[write],
);
const setGroupsOpen = useCallback(
(groupsOpen: ModuleGroupsOpen) => write({ groupsOpen }),
[write],
);
return { prefs, setView, setOrder, setSortDir, setGroupBy, setGroupsOpen };
}
+12
View File
@@ -25,6 +25,8 @@ import {
} from "./shared";
import { WEBHOOK_PAYLOAD_FORMATS } from "@/lib/notifications/types";
import {
MODULE_GROUPS_OPEN,
MODULE_GROUP_BYS,
MODULE_LIST_VIEWS,
MODULE_SORT_DIRS,
} from "@/lib/settings/module-list-layout";
@@ -47,6 +49,16 @@ const moduleListPrefsEntry = z
.describe(
'Sort of the labs list; `manual` follows `order`. Default "recentDesc". The other lists ignore it.',
),
groupBy: z
.enum(MODULE_GROUP_BYS)
.describe(
'Whether the labs list shows its server-resolved categories (`category`) or one flat list (`none`). Default "none". The labs list stays flat while `GET /api/labs/overview` answers `grouped: false`. The other lists ignore it.',
),
groupsOpen: z
.enum(MODULE_GROUPS_OPEN)
.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.',
),
saved: z
.boolean()
.describe(
@@ -14,6 +14,8 @@ describe("resolveModuleListLayout", () => {
view: "list",
order: [],
sortDir: "recentDesc",
groupBy: "none",
groupsOpen: "expanded",
saved: false,
});
expect(layout.lists.illness.view).toBe("cards");
@@ -30,6 +32,8 @@ describe("resolveModuleListLayout", () => {
view: "cards",
order: ["b", "a"],
sortDir: "recentDesc",
groupBy: "none",
groupsOpen: "expanded",
saved: true,
});
expect(layout.lists.labs.saved).toBe(false);
@@ -44,12 +48,33 @@ describe("resolveModuleListLayout", () => {
view: "list",
order: [],
sortDir: "recentDesc",
groupBy: "none",
groupsOpen: "expanded",
saved: false,
});
expect(layout.lists.vorsorge.saved).toBe(false);
});
});
describe("category choices", () => {
it("keeps valid category choices and drops unknown ones", () => {
const layout = resolveModuleListLayout({
labs: { groupBy: "category", groupsOpen: "flagged" },
illness: { groupBy: "panel", groupsOpen: "all" },
});
expect(layout.lists.labs).toMatchObject({
groupBy: "category",
groupsOpen: "flagged",
saved: true,
});
expect(layout.lists.illness).toMatchObject({
groupBy: "none",
groupsOpen: "expanded",
saved: false,
});
});
});
describe("normaliseModuleListOrder", () => {
it("keeps strings, drops duplicates, empties and over-long ids", () => {
expect(
+39 -1
View File
@@ -10,6 +10,10 @@
* block in the server's own order.
* - `sortDir`: only the labs list reads it (recency, alphabetical, or the
* manual `order`).
* - `groupBy` / `groupsOpen`: only the labs list reads them. `groupBy` shows
* 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.
*
* 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
@@ -37,6 +41,12 @@ export const MODULE_SORT_DIRS = [
] as const;
export type ModuleSortDir = (typeof MODULE_SORT_DIRS)[number];
export const MODULE_GROUP_BYS = ["none", "category"] as const;
export type ModuleGroupBy = (typeof MODULE_GROUP_BYS)[number];
export const MODULE_GROUPS_OPEN = ["expanded", "flagged", "collapsed"] as const;
export type ModuleGroupsOpen = (typeof MODULE_GROUPS_OPEN)[number];
/** Longest order a list keeps; a longer one is cut, never refused. */
export const MODULE_LIST_ORDER_MAX = 500;
/** Longest id an order entry may carry (cuid and uuid fit comfortably). */
@@ -46,6 +56,8 @@ export interface ModuleListPrefs {
view: ModuleListView;
order: string[];
sortDir: ModuleSortDir;
groupBy: ModuleGroupBy;
groupsOpen: ModuleGroupsOpen;
}
export interface ModuleListPrefsEntry extends ModuleListPrefs {
@@ -62,6 +74,8 @@ const BASE_DEFAULTS: ModuleListPrefs = {
view: "cards",
order: [],
sortDir: "recentDesc",
groupBy: "none",
groupsOpen: "expanded",
};
/** Labs opens on the compact list, the dense reading table (#40). */
@@ -90,6 +104,20 @@ export function isModuleSortDir(value: unknown): value is ModuleSortDir {
);
}
export function isModuleGroupBy(value: unknown): value is ModuleGroupBy {
return (
typeof value === "string" &&
(MODULE_GROUP_BYS as readonly string[]).includes(value)
);
}
export function isModuleGroupsOpen(value: unknown): value is ModuleGroupsOpen {
return (
typeof value === "string" &&
(MODULE_GROUPS_OPEN as readonly string[]).includes(value)
);
}
/** Keep string ids of a sane length, first occurrence wins, capped. */
export function normaliseModuleListOrder(raw: unknown): string[] | null {
if (!Array.isArray(raw)) return null;
@@ -114,12 +142,22 @@ export function storedModuleListChoices(
raw: unknown,
): Partial<ModuleListPrefs> {
if (raw === null || raw === undefined || typeof raw !== "object") return {};
const blob = raw as { view?: unknown; order?: unknown; sortDir?: unknown };
const blob = raw as {
view?: unknown;
order?: unknown;
sortDir?: unknown;
groupBy?: unknown;
groupsOpen?: unknown;
};
const order = normaliseModuleListOrder(blob.order);
return {
...(isModuleListView(blob.view) ? { view: blob.view } : {}),
...(order !== null ? { order } : {}),
...(isModuleSortDir(blob.sortDir) ? { sortDir: blob.sortDir } : {}),
...(isModuleGroupBy(blob.groupBy) ? { groupBy: blob.groupBy } : {}),
...(isModuleGroupsOpen(blob.groupsOpen)
? { groupsOpen: blob.groupsOpen }
: {}),
};
}
+4
View File
@@ -7,6 +7,8 @@
import { z } from "zod/v4";
import {
MODULE_GROUPS_OPEN,
MODULE_GROUP_BYS,
MODULE_LIST_ID_MAX,
MODULE_LIST_KEYS,
MODULE_LIST_ORDER_MAX,
@@ -24,5 +26,7 @@ export const moduleListLayoutPutSchema = z
.max(MODULE_LIST_ORDER_MAX)
.optional(),
sortDir: z.enum(MODULE_SORT_DIRS).optional(),
groupBy: z.enum(MODULE_GROUP_BYS).optional(),
groupsOpen: z.enum(MODULE_GROUPS_OPEN).optional(),
})
.strict();
@@ -124,6 +124,8 @@ describe("/api/settings/module-list-layout — integration", () => {
view: "list",
order: ["m2", "m1"],
sortDir: "manual",
groupBy: "none",
groupsOpen: "expanded",
saved: true,
});
expect(body.data.lists.illness).toMatchObject({
@@ -145,6 +147,49 @@ describe("/api/settings/module-list-layout — integration", () => {
});
});
it("stores the category choices and reads them back", async () => {
const { userId } = await seedUser();
const { GET, PUT } =
await import("@/app/api/settings/module-list-layout/route");
const put = await (PUT as Handler)(
json("PUT", URL, {
version: 1,
list: "labs",
groupBy: "category",
groupsOpen: "flagged",
}),
);
expect(put.status).toBe(200);
// A later write of another field keeps them.
await (PUT as Handler)(
json("PUT", URL, { version: 1, list: "labs", view: "cards" }),
);
const res = await (GET as Handler)(new Request(URL));
const body = (await res.json()) as {
data: { lists: Record<string, Record<string, unknown>> };
};
expect(body.data.lists.labs).toMatchObject({
view: "cards",
groupBy: "category",
groupsOpen: "flagged",
saved: true,
});
const row = await getPrismaClient().user.findUnique({
where: { id: userId },
select: { moduleListLayoutJson: true },
});
expect(row?.moduleListLayoutJson).toEqual({
version: 1,
labs: { groupBy: "category", groupsOpen: "flagged", view: "cards" },
});
const refused = await (PUT as Handler)(
json("PUT", URL, { version: 1, list: "labs", groupsOpen: "all" }),
);
expect(refused.status).toBe(422);
});
it("refuses unknown lists, values and keys with every issue named", async () => {
await seedUser();
const { PUT } = await import("@/app/api/settings/module-list-layout/route");
@@ -869,9 +869,11 @@ function entries(user: Pick<User, "id">): Entry[] {
route: "/api/settings/module-list-layout",
body: {
version: 1,
list: "illness",
view: "list",
list: "labs",
view: "cards",
sortDir: "alphaAsc",
groupBy: "category",
groupsOpen: "collapsed",
},
},
read: {
@@ -881,11 +883,13 @@ function entries(user: Pick<User, "id">): Entry[] {
route: "/api/settings/module-list-layout",
},
pick: (res) => [
at(res.data, "lists.illness.view"),
at(res.data, "lists.illness.sortDir"),
at(res.data, "lists.labs.view"),
at(res.data, "lists.labs.sortDir"),
at(res.data, "lists.labs.groupBy"),
at(res.data, "lists.labs.groupsOpen"),
],
},
expected: ["list", "alphaAsc"],
expected: ["cards", "alphaAsc", "category", "collapsed"],
},
{
columns: ["activityGoalsJson"],