diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 955c5652e..df9b1aa07 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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: diff --git a/src/app/api/settings/module-list-layout/route.ts b/src/app/api/settings/module-list-layout/route.ts index afb291c57..cbf362c86 100644 --- a/src/app/api/settings/module-list-layout/route.ts +++ b/src/app/api/settings/module-list-layout/route.ts @@ -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, }, }); diff --git a/src/lib/__tests__/module-list-prefs.test.ts b/src/lib/__tests__/module-list-prefs.test.ts index a156e6303..896e3d4a6 100644 --- a/src/lib/__tests__/module-list-prefs.test.ts +++ b/src/lib/__tests__/module-list-prefs.test.ts @@ -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", () => { diff --git a/src/lib/module-list-prefs.ts b/src/lib/module-list-prefs.ts index bbed3e12f..10e279b01 100644 --- a/src/lib/module-list-prefs.ts +++ b/src/lib/module-list-prefs.ts @@ -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; 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(() => { 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 }; } diff --git a/src/lib/openapi/routes/settings.ts b/src/lib/openapi/routes/settings.ts index 82bb91ffd..897c75a69 100644 --- a/src/lib/openapi/routes/settings.ts +++ b/src/lib/openapi/routes/settings.ts @@ -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( diff --git a/src/lib/settings/__tests__/module-list-layout.test.ts b/src/lib/settings/__tests__/module-list-layout.test.ts index c431a945b..afc43656c 100644 --- a/src/lib/settings/__tests__/module-list-layout.test.ts +++ b/src/lib/settings/__tests__/module-list-layout.test.ts @@ -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( diff --git a/src/lib/settings/module-list-layout.ts b/src/lib/settings/module-list-layout.ts index d7fa5fc7c..aa7cf8881 100644 --- a/src/lib/settings/module-list-layout.ts +++ b/src/lib/settings/module-list-layout.ts @@ -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 { 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 } + : {}), }; } diff --git a/src/lib/validations/settings-prefs.ts b/src/lib/validations/settings-prefs.ts index 26ab7745f..70e5c231a 100644 --- a/src/lib/validations/settings-prefs.ts +++ b/src/lib/validations/settings-prefs.ts @@ -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(); diff --git a/tests/integration/settings-prefs-routes.test.ts b/tests/integration/settings-prefs-routes.test.ts index b0b91739d..eda334939 100644 --- a/tests/integration/settings-prefs-routes.test.ts +++ b/tests/integration/settings-prefs-routes.test.ts @@ -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> }; + }; + 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"); diff --git a/tests/integration/settings-round-trip.test.ts b/tests/integration/settings-round-trip.test.ts index b961fbad2..a34f34ef3 100644 --- a/tests/integration/settings-round-trip.test.ts +++ b/tests/integration/settings-round-trip.test.ts @@ -869,9 +869,11 @@ function entries(user: Pick): 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): 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"],