From 93198017d68897ebe7daa9abb0d1d9b4ccd194a7 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Wed, 21 Oct 2026 19:11:23 +0200
Subject: [PATCH 1/5] feat(settings): keep list order and preview opt-ins on
the server
Add per-user columns for the labs, illness and checkups list presentation
and for preview-feature opt-ins, each with a GET plus a preserve-when-absent
write under /api/settings. The list hook now reads and writes the server
value and carries a browser-stored order up once when the server holds
none, so an existing arrangement follows the person to every device instead
of resetting.
---
docs/api/openapi.yaml | 256 +++++++++++++
.../migration.sql | 17 +
prisma/schema.prisma | 13 +
.../api/settings/module-list-layout/route.ts | 94 +++++
.../api/settings/preview-features/route.ts | 75 ++++
src/lib/__tests__/module-list-prefs.test.ts | 274 +++++++++----
src/lib/data-wipe/wipe-plan.ts | 3 +
src/lib/export/account-settings-backup.ts | 2 +
src/lib/export/backup-plan.ts | 2 +
src/lib/module-list-prefs.ts | 359 +++++++++++-------
src/lib/openapi/routes/settings.ts | 165 ++++++++
src/lib/query-keys/settings.ts | 9 +
.../__tests__/module-list-layout.test.ts | 78 ++++
.../__tests__/preview-features.test.ts | 26 ++
src/lib/settings/module-list-layout.ts | 163 ++++++++
src/lib/settings/preview-features.ts | 31 ++
src/lib/validations/backup.ts | 2 +
src/lib/validations/settings-prefs.ts | 37 ++
.../integration/settings-prefs-routes.test.ts | 216 +++++++++++
19 files changed, 1614 insertions(+), 208 deletions(-)
create mode 100644 prisma/migrations/0440_settings_list_layout_and_preview_features/migration.sql
create mode 100644 src/app/api/settings/module-list-layout/route.ts
create mode 100644 src/app/api/settings/preview-features/route.ts
create mode 100644 src/lib/settings/__tests__/module-list-layout.test.ts
create mode 100644 src/lib/settings/__tests__/preview-features.test.ts
create mode 100644 src/lib/settings/module-list-layout.ts
create mode 100644 src/lib/settings/preview-features.ts
create mode 100644 src/lib/validations/settings-prefs.ts
create mode 100644 tests/integration/settings-prefs-routes.test.ts
diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml
index 9b4f13c5b..ed29ff678 100644
--- a/docs/api/openapi.yaml
+++ b/docs/api/openapi.yaml
@@ -14488,6 +14488,81 @@ paths:
$ref: "#/components/schemas/ErrorEnvelope"
"422": *a4
"429": *a5
+ /api/settings/module-list-layout:
+ get:
+ tags:
+ - Meta
+ summary: Get the list presentation of labs, illness and checkups
+ description: Every list resolved, defaults filled in, with `saved` per list. Never writes a row.
+ responses:
+ "200":
+ description: The resolved presentation of every list.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ModuleListLayoutEnvelope"
+ "401": *a1
+ "422": *a4
+ "429": *a5
+ put:
+ tags:
+ - Meta
+ summary: Change the presentation of one list
+ description: "Preserve-when-absent: the fields the body leaves out keep their stored value, and every other list stays
+ as stored. Returns every list resolved."
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ModuleListLayoutPutBody"
+ responses:
+ "200":
+ description: The presentation as stored.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ModuleListLayoutSavedEnvelope"
+ "401": *a1
+ "422": *a4
+ "429": *a5
+ /api/settings/preview-features:
+ get:
+ tags:
+ - Meta
+ summary: Get the preview-feature opt-ins
+ description: Every flag, off unless the caller switched it on.
+ responses:
+ "200":
+ description: The opt-ins.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/PreviewFeaturesEnvelope"
+ "401": *a1
+ "422": *a4
+ "429": *a5
+ patch:
+ tags:
+ - Meta
+ summary: Switch preview features on or off
+ description: Sets the flags the body names and keeps the rest. An unknown flag or an empty body is refused with 422.
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/PreviewFeaturesPatchBody"
+ responses:
+ "200":
+ description: The opt-ins as stored.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/PreviewFeaturesSavedEnvelope"
+ "401": *a1
+ "422": *a4
+ "429": *a5
/api/settings/privacy-summary:
get:
tags:
@@ -31354,6 +31429,48 @@ components:
required:
- confirm
description: Typed confirmation for erasing the record. No undo.
+ ModuleListLayoutPutBody:
+ type: object
+ properties:
+ version:
+ type: number
+ const: 1
+ list:
+ type: string
+ enum:
+ - labs
+ - illness
+ - vorsorge
+ view:
+ type: string
+ enum:
+ - cards
+ - list
+ order:
+ maxItems: 500
+ type: array
+ items:
+ type: string
+ minLength: 1
+ maxLength: 64
+ sortDir:
+ type: string
+ enum:
+ - recentDesc
+ - recentAsc
+ - alphaAsc
+ - alphaDesc
+ - manual
+ required:
+ - version
+ - list
+ additionalProperties: false
+ PreviewFeaturesPatchBody:
+ type: object
+ properties:
+ topics:
+ type: boolean
+ additionalProperties: false
ConsentPostBody:
type: object
properties:
@@ -38796,6 +38913,8 @@ components:
medicationListLayoutJson: {}
moodTagLayoutJson: {}
documentsLayoutJson: {}
+ moduleListLayoutJson: {}
+ previewFeaturesJson: {}
reportSelectionJson: {}
globalExcludedInjectionSites:
type: array
@@ -69269,6 +69388,143 @@ components:
- deletedRows
- models
additionalProperties: false
+ ModuleListLayoutEnvelope:
+ type: object
+ properties:
+ data:
+ $ref: "#/components/schemas/ModuleListLayout"
+ error:
+ type: "null"
+ meta:
+ type: object
+ properties:
+ requestId:
+ type: string
+ additionalProperties: false
+ required:
+ - data
+ - error
+ additionalProperties: false
+ ModuleListLayout:
+ type: object
+ properties:
+ version:
+ type: number
+ const: 1
+ lists:
+ type: object
+ properties:
+ labs:
+ $ref: "#/components/schemas/ModuleListPrefs"
+ illness:
+ $ref: "#/components/schemas/ModuleListPrefs"
+ vorsorge:
+ $ref: "#/components/schemas/ModuleListPrefs"
+ required:
+ - labs
+ - illness
+ - vorsorge
+ additionalProperties: false
+ required:
+ - version
+ - lists
+ additionalProperties: false
+ description: Per-user presentation of the labs, illness and checkups lists. Display-only; a client that does not render
+ a list ignores it.
+ ModuleListPrefs:
+ type: object
+ properties:
+ view:
+ type: string
+ enum:
+ - cards
+ - list
+ description: '`cards` renders the card grid, `list` the compact list. Default "list" for labs, "cards" otherwise.'
+ order:
+ type: array
+ items:
+ type: string
+ description: Manual order as an id allow-list. Ids missing from the live data are ignored; items missing from the list
+ follow in the server order.
+ sortDir:
+ type: string
+ enum:
+ - recentDesc
+ - recentAsc
+ - alphaAsc
+ - alphaDesc
+ - manual
+ description: Sort of the labs list; `manual` follows `order`. Default "recentDesc". 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.
+ required:
+ - view
+ - order
+ - sortDir
+ - saved
+ additionalProperties: false
+ ModuleListLayoutSavedEnvelope:
+ type: object
+ properties:
+ data:
+ $ref: "#/components/schemas/ModuleListLayout"
+ error:
+ type: "null"
+ meta:
+ type: object
+ properties:
+ requestId:
+ type: string
+ additionalProperties: false
+ required:
+ - data
+ - error
+ additionalProperties: false
+ PreviewFeaturesEnvelope:
+ type: object
+ properties:
+ data:
+ $ref: "#/components/schemas/PreviewFeatures"
+ error:
+ type: "null"
+ meta:
+ type: object
+ properties:
+ requestId:
+ type: string
+ additionalProperties: false
+ required:
+ - data
+ - error
+ additionalProperties: false
+ PreviewFeatures:
+ type: object
+ properties:
+ topics:
+ type: boolean
+ description: Topic overview pages above the single-metric Insights pages. Off by default.
+ required:
+ - topics
+ additionalProperties: false
+ description: Per-user opt-in to preview features, one boolean per flag, every flag off by default.
+ PreviewFeaturesSavedEnvelope:
+ type: object
+ properties:
+ data:
+ $ref: "#/components/schemas/PreviewFeatures"
+ error:
+ type: "null"
+ meta:
+ type: object
+ properties:
+ requestId:
+ type: string
+ additionalProperties: false
+ required:
+ - data
+ - error
+ additionalProperties: false
PrivacySummaryEnvelope:
type: object
properties:
diff --git a/prisma/migrations/0440_settings_list_layout_and_preview_features/migration.sql b/prisma/migrations/0440_settings_list_layout_and_preview_features/migration.sql
new file mode 100644
index 000000000..6e9beaa6c
--- /dev/null
+++ b/prisma/migrations/0440_settings_list_layout_and_preview_features/migration.sql
@@ -0,0 +1,17 @@
+-- Two per-user preference columns for Settings.
+--
+-- module_list_layout_json: the card/list view, manual order and labs sort for
+-- the labs, illness and checkups lists. These lived in the browser until now,
+-- so they did not follow a person to another device. The client copies a value
+-- it still holds in the browser up once, when the server has none for that list.
+--
+-- preview_features_json: the per-user opt-in to preview features, one boolean
+-- per flag, all off by default.
+--
+-- Nullable, no default, no backfill: null reads as the defaults.
+--
+-- Reversibility: dropping the columns returns every list to its default
+-- presentation and switches every preview feature off.
+
+ALTER TABLE "users" ADD COLUMN "module_list_layout_json" JSONB;
+ALTER TABLE "users" ADD COLUMN "preview_features_json" JSONB;
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 1d79d97b1..eb38163bd 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -364,6 +364,19 @@ model User {
// Shape: { "groupOrder": ["", ...],
// "placements": { "": ["", ...] } }
moodTagLayoutJson Json? @map("mood_tag_layout_json")
+ // Per-user list presentation for the labs, illness and checkups lists:
+ // card/list view, manual order, and (labs only) the sort. One blob keyed by
+ // list, resolved field by field on read (`src/lib/settings/module-list-layout.ts`),
+ // preserve-when-absent PUT on `/api/settings/module-list-layout`. Null = the
+ // defaults. Earlier releases kept the same choices in the browser; the client
+ // copies a stored browser value up once when the server has none for that list.
+ // Shape: { "version": 1, "labs": { "view": "list", "order": [""], "sortDir": "manual" }, ... }
+ moduleListLayoutJson Json? @map("module_list_layout_json")
+ /// Per-user opt-in to preview features, one boolean per flag, all off by
+ /// default (`src/lib/settings/preview-features.ts`). Read and written by
+ /// Settings > System through `/api/settings/preview-features`.
+ /// @pending-consumer(topics preview) the `topics` flag is stored and shown, but no surface reads it yet; the topic overview pages will.
+ previewFeaturesJson Json? @map("preview_features_json")
// Blood-glucose display unit preference. Canonical storage is mg/dL.
glucoseUnit String? @map("glucose_unit") // "mg/dL" | "mmol/L" | null = default "mg/dL"
// v1.18.6 — explicit "I have diabetes / clinician glucose targets" opt-in.
diff --git a/src/app/api/settings/module-list-layout/route.ts b/src/app/api/settings/module-list-layout/route.ts
new file mode 100644
index 000000000..7bd93d3b8
--- /dev/null
+++ b/src/app/api/settings/module-list-layout/route.ts
@@ -0,0 +1,94 @@
+/**
+ * GET / PUT the list presentation for the labs, illness and checkups lists.
+ *
+ * GET returns every list resolved, defaults filled in, with `saved` per list
+ * so a client can tell a stored choice from a default. PUT changes one list
+ * with preserve-when-absent semantics: a body carrying only `view` keeps the
+ * stored order and sort, and vice versa, the contract `/api/documents/inbound/layout`
+ * keeps for its fields. The blob lives on its own `User` column
+ * (`module_list_layout_json`).
+ *
+ * Both halves are the caller's own presentation. Inside somebody else's
+ * record the ids in a saved order simply do not match and the list keeps the
+ * server order, so no record gate is needed here.
+ */
+import { apiHandler, requireAuth } from "@/lib/api-handler";
+import { apiSuccess, returnAllZodIssues, safeJson } from "@/lib/api-response";
+import { prisma, toJson } from "@/lib/db";
+import { annotate } from "@/lib/logging/context";
+import {
+ MODULE_LIST_KEYS,
+ resolveModuleListLayout,
+ storedModuleListLayout,
+} from "@/lib/settings/module-list-layout";
+import { moduleListLayoutPutSchema } from "@/lib/validations/settings-prefs";
+
+export const dynamic = "force-dynamic";
+
+async function readStored(userId: string): Promise {
+ const row = await prisma.user.findUnique({
+ where: { id: userId },
+ select: { moduleListLayoutJson: true },
+ });
+ return row?.moduleListLayoutJson;
+}
+
+export const GET = apiHandler(async () => {
+ const { user } = await requireAuth();
+ const layout = resolveModuleListLayout(await readStored(user.id));
+ annotate({
+ action: { name: "settings.module-list-layout.read" },
+ meta: {
+ saved: MODULE_LIST_KEYS.filter((list) => layout.lists[list].saved),
+ },
+ });
+ return apiSuccess(layout);
+});
+
+export const PUT = apiHandler(async (request: Request) => {
+ const { user } = await requireAuth();
+
+ // 500 ids of up to 64 characters plus the envelope.
+ const { data: body, error: jsonError } = await safeJson(request, {
+ maxBytes: 48 * 1024,
+ });
+ if (jsonError) return jsonError;
+
+ const parsed = moduleListLayoutPutSchema.safeParse(body);
+ if (!parsed.success) {
+ annotate({
+ action: { name: "settings.module-list-layout.validation-failed" },
+ });
+ return returnAllZodIssues(parsed.error, 422);
+ }
+
+ const { list, view, order, sortDir } = 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.
+ const stored = storedModuleListLayout(await readStored(user.id));
+ const nextChoices = {
+ ...stored[list],
+ ...(view ? { view } : {}),
+ ...(order ? { order: [...new Set(order)] } : {}),
+ ...(sortDir ? { sortDir } : {}),
+ };
+ const nextStored = { ...stored, [list]: nextChoices };
+
+ await prisma.user.update({
+ where: { id: user.id },
+ data: { moduleListLayoutJson: toJson({ version: 1, ...nextStored }) },
+ });
+
+ const layout = resolveModuleListLayout(nextStored);
+ annotate({
+ action: { name: "settings.module-list-layout.update" },
+ meta: {
+ list,
+ view: layout.lists[list].view,
+ sortDir: layout.lists[list].sortDir,
+ orderLength: layout.lists[list].order.length,
+ },
+ });
+ return apiSuccess(layout);
+});
diff --git a/src/app/api/settings/preview-features/route.ts b/src/app/api/settings/preview-features/route.ts
new file mode 100644
index 000000000..1b48c75fa
--- /dev/null
+++ b/src/app/api/settings/preview-features/route.ts
@@ -0,0 +1,75 @@
+/**
+ * GET / PATCH the caller's preview-feature opt-ins.
+ *
+ * GET answers every flag, off unless the caller switched it on. PATCH sets
+ * the flags the body names and keeps the rest; unknown flags are refused
+ * with the multi-issue 422 so a typo cannot silently do nothing. The blob
+ * lives on its own `User` column (`preview_features_json`).
+ */
+import { apiHandler, requireAuth } from "@/lib/api-handler";
+import { apiSuccess, returnAllZodIssues, safeJson } from "@/lib/api-response";
+import { prisma, toJson } from "@/lib/db";
+import { annotate } from "@/lib/logging/context";
+import {
+ PREVIEW_FEATURE_KEYS,
+ resolvePreviewFeatures,
+ type PreviewFeatures,
+} from "@/lib/settings/preview-features";
+import { previewFeaturesPatchSchema } from "@/lib/validations/settings-prefs";
+
+export const dynamic = "force-dynamic";
+
+async function readStored(userId: string): Promise {
+ const row = await prisma.user.findUnique({
+ where: { id: userId },
+ select: { previewFeaturesJson: true },
+ });
+ return row?.previewFeaturesJson;
+}
+
+function enabledFlags(features: PreviewFeatures): string[] {
+ return PREVIEW_FEATURE_KEYS.filter((key) => features[key]);
+}
+
+export const GET = apiHandler(async () => {
+ const { user } = await requireAuth();
+ const features = resolvePreviewFeatures(await readStored(user.id));
+ annotate({
+ action: { name: "settings.preview-features.read" },
+ meta: { enabled: enabledFlags(features) },
+ });
+ return apiSuccess(features);
+});
+
+export const PATCH = apiHandler(async (request: Request) => {
+ const { user } = await requireAuth();
+
+ const { data: body, error: jsonError } = await safeJson(request, {
+ maxBytes: 1024,
+ });
+ if (jsonError) return jsonError;
+
+ const parsed = previewFeaturesPatchSchema.safeParse(body);
+ if (!parsed.success) {
+ annotate({
+ action: { name: "settings.preview-features.validation-failed" },
+ });
+ return returnAllZodIssues(parsed.error, 422);
+ }
+
+ const current = resolvePreviewFeatures(await readStored(user.id));
+ const next: PreviewFeatures = {
+ topics: parsed.data.topics ?? current.topics,
+ };
+
+ await prisma.user.update({
+ where: { id: user.id },
+ data: { previewFeaturesJson: toJson(next) },
+ });
+
+ annotate({
+ action: { name: "settings.preview-features.update" },
+ meta: { enabled: enabledFlags(next) },
+ });
+ return apiSuccess(next);
+});
diff --git a/src/lib/__tests__/module-list-prefs.test.ts b/src/lib/__tests__/module-list-prefs.test.ts
index 1c9eb58fe..a156e6303 100644
--- a/src/lib/__tests__/module-list-prefs.test.ts
+++ b/src/lib/__tests__/module-list-prefs.test.ts
@@ -1,82 +1,224 @@
-import { describe, expect, it } from "vitest";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { QueryClient } from "@tanstack/react-query";
-import { applyOrder, parseModuleListPrefs } from "@/lib/module-list-prefs";
+vi.mock("@/lib/api/api-fetch", () => ({
+ apiGet: vi.fn(),
+ apiPut: vi.fn(),
+}));
+vi.mock("@/components/outcome/outcome-toast", () => ({
+ toastWrittenOutcome: vi.fn(),
+}));
+vi.mock("@/lib/errors/error-toast", () => ({
+ showErrorToast: vi.fn(),
+}));
-describe("module-list-prefs", () => {
- describe("parseModuleListPrefs", () => {
- it("returns defaults for null / malformed input", () => {
- expect(parseModuleListPrefs(null)).toEqual({
- view: "cards",
- order: [],
- sortDir: "recentDesc",
- });
- expect(parseModuleListPrefs("{not json")).toEqual({
- view: "cards",
- order: [],
- sortDir: "recentDesc",
- });
- });
+import { apiPut } from "@/lib/api/api-fetch";
+import { showErrorToast } from "@/lib/errors/error-toast";
+import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
+import {
+ applyOrder,
+ carryOverLegacyModuleListPrefs,
+ legacyModuleListPatch,
+ legacyModuleListStorageKey,
+ parseModuleListPrefs,
+ resetModuleListCarryOverForTests,
+ runSetModuleListPrefs,
+} from "@/lib/module-list-prefs";
+import {
+ moduleListDefaults,
+ resolveModuleListLayout,
+} from "@/lib/settings/module-list-layout";
+import { queryKeys } from "@/lib/query-keys";
- it("coerces unknown view / sortDir to defaults but keeps a valid order", () => {
- const parsed = parseModuleListPrefs(
- JSON.stringify({ view: "grid", sortDir: "weird", order: ["a", "b"] }),
- );
- expect(parsed).toEqual({
- view: "cards",
- sortDir: "recentDesc",
- order: ["a", "b"],
- });
- });
+const store = new Map();
+const fakeStorage = {
+ getItem: (key: string) => store.get(key) ?? null,
+ setItem: (key: string, value: string) => void store.set(key, value),
+ removeItem: (key: string) => void store.delete(key),
+};
- it("round-trips valid values", () => {
- const parsed = parseModuleListPrefs(
- JSON.stringify({ view: "list", sortDir: "manual", order: ["x"] }),
- );
- expect(parsed).toEqual({
- view: "list",
- sortDir: "manual",
- order: ["x"],
- });
- });
+beforeEach(() => {
+ vi.resetAllMocks();
+ store.clear();
+ resetModuleListCarryOverForTests();
+ vi.stubGlobal("window", { localStorage: fakeStorage });
+});
- it("accepts the alphabetical sort directions (#43)", () => {
- for (const sortDir of ["alphaAsc", "alphaDesc"]) {
- const parsed = parseModuleListPrefs(JSON.stringify({ sortDir }));
- expect(parsed.sortDir).toBe(sortDir);
- }
- });
+afterEach(() => {
+ vi.unstubAllGlobals();
+});
- it("applies a per-call default when no blob is stored (Labs list view, #40)", () => {
- const labsDefaults = {
- view: "list" as const,
- order: [],
- sortDir: "recentDesc" as const,
- };
- expect(parseModuleListPrefs(null, labsDefaults)).toEqual(labsDefaults);
- // A stored view still wins over the default.
- expect(
- parseModuleListPrefs(JSON.stringify({ view: "cards" }), labsDefaults)
- .view,
- ).toBe("cards");
- });
+describe("parseModuleListPrefs", () => {
+ const defaults = moduleListDefaults("illness");
+
+ it("returns defaults for null / malformed input", () => {
+ expect(parseModuleListPrefs(null, defaults)).toEqual(defaults);
+ expect(parseModuleListPrefs("{not json", defaults)).toEqual(defaults);
});
- describe("applyOrder", () => {
- const items = [{ id: "a" }, { id: "b" }, { id: "c" }];
- const idOf = (i: { id: string }) => i.id;
+ it("coerces unknown view / sortDir to defaults but keeps a valid order", () => {
+ expect(
+ parseModuleListPrefs(
+ JSON.stringify({ view: "grid", sortDir: "weird", order: ["a", "b"] }),
+ defaults,
+ ),
+ ).toEqual({ view: "cards", sortDir: "recentDesc", order: ["a", "b"] });
+ });
- it("returns the input order when no order is persisted", () => {
- expect(applyOrder(items, [], idOf)).toEqual(items);
+ it("applies the labs default (list view, #40) when nothing is stored", () => {
+ const labs = moduleListDefaults("labs");
+ expect(parseModuleListPrefs(null, labs).view).toBe("list");
+ expect(
+ parseModuleListPrefs(JSON.stringify({ view: "cards" }), labs).view,
+ ).toBe("cards");
+ });
+});
+
+describe("legacyModuleListPatch", () => {
+ it("carries only what differs from the default", () => {
+ expect(
+ legacyModuleListPatch(
+ "labs",
+ JSON.stringify({ view: "list", order: [], sortDir: "manual" }),
+ ),
+ ).toEqual({ sortDir: "manual" });
+ expect(
+ legacyModuleListPatch(
+ "illness",
+ JSON.stringify({ view: "list", order: ["e2", "e1"] }),
+ ),
+ ).toEqual({ view: "list", order: ["e2", "e1"] });
+ expect(legacyModuleListPatch("vorsorge", null)).toEqual({});
+ });
+});
+
+describe("carryOverLegacyModuleListPrefs", () => {
+ it("sends a browser-stored order up once and removes the browser copy after the server confirmed it", async () => {
+ store.set(
+ legacyModuleListStorageKey("illness"),
+ JSON.stringify({ view: "cards", order: ["e2", "e1"] }),
+ );
+ const saved = resolveModuleListLayout({ illness: { order: ["e2", "e1"] } });
+ vi.mocked(apiPut).mockResolvedValue(saved);
+ const queryClient = new QueryClient();
+
+ await carryOverLegacyModuleListPrefs({
+ list: "illness",
+ layout: resolveModuleListLayout(null),
+ queryClient,
+ });
+
+ expect(apiPut).toHaveBeenCalledWith("/api/settings/module-list-layout", {
+ version: 1,
+ list: "illness",
+ order: ["e2", "e1"],
+ });
+ expect(store.has(legacyModuleListStorageKey("illness"))).toBe(false);
+ expect(queryClient.getQueryData(queryKeys.moduleListLayout())).toEqual(
+ saved,
+ );
+
+ // A second mount in the same page load does not send it again.
+ await carryOverLegacyModuleListPrefs({
+ list: "illness",
+ layout: saved,
+ queryClient,
+ });
+ expect(apiPut).toHaveBeenCalledTimes(1);
+ });
+
+ it("keeps the browser copy when the write fails, so the order is never lost", async () => {
+ store.set(
+ legacyModuleListStorageKey("labs"),
+ JSON.stringify({ order: ["m1"], sortDir: "manual" }),
+ );
+ vi.mocked(apiPut).mockRejectedValue(new Error("offline"));
+
+ await carryOverLegacyModuleListPrefs({
+ list: "labs",
+ layout: resolveModuleListLayout(null),
+ queryClient: new QueryClient(),
});
+
+ expect(store.has(legacyModuleListStorageKey("labs"))).toBe(true);
+ });
+
+ it("leaves a list the server already holds alone and drops the stale browser copy", async () => {
+ store.set(
+ legacyModuleListStorageKey("vorsorge"),
+ JSON.stringify({ order: ["r9"] }),
+ );
+ await carryOverLegacyModuleListPrefs({
+ list: "vorsorge",
+ layout: resolveModuleListLayout({ vorsorge: { order: ["r1"] } }),
+ queryClient: new QueryClient(),
+ });
+ expect(apiPut).not.toHaveBeenCalled();
+ expect(store.has(legacyModuleListStorageKey("vorsorge"))).toBe(false);
+ });
+});
+
+describe("runSetModuleListPrefs", () => {
+ const t = (key: string) => key;
- it("places ordered ids first, then the server-default tail", () => {
- const result = applyOrder(items, ["c", "a"], idOf);
- expect(result.map(idOf)).toEqual(["c", "a", "b"]);
+ it("applies the change at once and confirms a view change with a toast", async () => {
+ const queryClient = new QueryClient();
+ const saved = resolveModuleListLayout({ labs: { view: "cards" } });
+ vi.mocked(apiPut).mockResolvedValue(saved);
+ await runSetModuleListPrefs({
+ list: "labs",
+ patch: { view: "cards" },
+ queryClient,
+ t,
});
+ expect(toastWrittenOutcome).toHaveBeenCalledWith(
+ "success",
+ "settings.saved",
+ );
+ expect(queryClient.getQueryData(queryKeys.moduleListLayout())).toEqual(
+ saved,
+ );
+ });
- it("ignores ids in the order that are absent from the data", () => {
- const result = applyOrder(items, ["z", "b"], idOf);
- expect(result.map(idOf)).toEqual(["b", "a", "c"]);
+ it("rolls back and says so when the write fails", async () => {
+ const queryClient = new QueryClient();
+ const before = resolveModuleListLayout(null);
+ queryClient.setQueryData(queryKeys.moduleListLayout(), before);
+ vi.mocked(apiPut).mockRejectedValue(new Error("nope"));
+ await runSetModuleListPrefs({
+ list: "illness",
+ patch: { order: ["x"] },
+ queryClient,
+ t,
});
+ expect(queryClient.getQueryData(queryKeys.moduleListLayout())).toEqual(
+ before,
+ );
+ expect(showErrorToast).toHaveBeenCalled();
+ });
+});
+
+describe("applyOrder", () => {
+ const items = [{ id: "a" }, { id: "b" }, { id: "c" }];
+ const idOf = (i: { id: string }) => i.id;
+
+ it("keeps the input order with no saved order", () => {
+ expect(applyOrder(items, [], idOf)).toEqual(items);
+ });
+
+ it("puts ordered ids first and the rest after in input order", () => {
+ expect(applyOrder(items, ["c", "a"], idOf).map(idOf)).toEqual([
+ "c",
+ "a",
+ "b",
+ ]);
+ });
+
+ it("drops unknown ids", () => {
+ expect(applyOrder(items, ["z", "b"], idOf).map(idOf)).toEqual([
+ "b",
+ "a",
+ "c",
+ ]);
});
});
diff --git a/src/lib/data-wipe/wipe-plan.ts b/src/lib/data-wipe/wipe-plan.ts
index f02b0d0e7..601edaefd 100644
--- a/src/lib/data-wipe/wipe-plan.ts
+++ b/src/lib/data-wipe/wipe-plan.ts
@@ -484,6 +484,7 @@ export const USER_RESET = {
medicationListLayoutJson: Prisma.DbNull,
moodTagLayoutJson: Prisma.DbNull,
documentsLayoutJson: Prisma.DbNull,
+ moduleListLayoutJson: Prisma.DbNull,
reportSelectionJson: Prisma.DbNull,
modulePreferencesJson: Prisma.DbNull,
healthScoreConfigJson: Prisma.DbNull,
@@ -510,6 +511,8 @@ export const USER_KEPT_FIELDS: Readonly> = {
oidcIssuer: "a sign-in credential — the external identity binding",
oidcSub: "a sign-in credential — the external identity binding",
createdAt: "when the account was created; the account is preserved",
+ previewFeaturesJson:
+ "which preview features the person opted into — how the interface is shown, not record content",
updatedAt: "row bookkeeping maintained by Prisma",
managedProfileAt:
"whether this record is one somebody else runs — account shape, not record content. Clearing it would strip the guardian's reach from a record that has no credentials of its own, leaving an account nobody can open, administer or delete. Emptying a record is not emancipating the person in it.",
diff --git a/src/lib/export/account-settings-backup.ts b/src/lib/export/account-settings-backup.ts
index 2585a2c11..6fb173682 100644
--- a/src/lib/export/account-settings-backup.ts
+++ b/src/lib/export/account-settings-backup.ts
@@ -503,6 +503,8 @@ const ACCOUNT_SETTING_CODECS: Readonly<
medicationListLayoutJson: json("medicationListLayoutJson"),
moodTagLayoutJson: json("moodTagLayoutJson"),
documentsLayoutJson: json("documentsLayoutJson"),
+ moduleListLayoutJson: json("moduleListLayoutJson"),
+ previewFeaturesJson: json("previewFeaturesJson"),
reportSelectionJson: json("reportSelectionJson"),
globalExcludedInjectionSites: excludedSites,
healthKitConfigJson: json("healthKitConfigJson"),
diff --git a/src/lib/export/backup-plan.ts b/src/lib/export/backup-plan.ts
index b3c9ddef3..b2abed39c 100644
--- a/src/lib/export/backup-plan.ts
+++ b/src/lib/export/backup-plan.ts
@@ -1010,6 +1010,8 @@ export const USER_COLUMN_BACKUP_CLASS = {
medicationListLayoutJson: "SETTING",
moodTagLayoutJson: "SETTING",
documentsLayoutJson: "SETTING",
+ moduleListLayoutJson: "SETTING",
+ previewFeaturesJson: "SETTING",
reportSelectionJson: "SETTING",
globalExcludedInjectionSites: "SETTING",
healthKitConfigJson: "SETTING",
diff --git a/src/lib/module-list-prefs.ts b/src/lib/module-list-prefs.ts
index ff5e45e11..bbed3e12f 100644
--- a/src/lib/module-list-prefs.ts
+++ b/src/lib/module-list-prefs.ts
@@ -1,109 +1,103 @@
"use client";
/**
- * v1.18.6 — client-side per-module list preferences for the
- * three secondary modules (Vorsorge, Illness, Labs).
+ * Per-list presentation for the labs, illness and checkups lists: card or
+ * list view, a manual order, and (labs only) the sort.
*
- * These modules do not carry a server-side list-layout column the way
- * Medications does (`/api/medications/layout`). Rather than mint three new
- * Prisma columns + routes — which a parallel backend phase owns — the
- * reorder + view choice live in `localStorage`, keyed per module. The choice
- * is presentational only: it reorders / reshapes what the page already
- * fetched and never feeds a server read, so a missing or stale blob degrades
- * to the server's own default 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
+ * v1.44 they lived in this browser's `localStorage`, one key per list. Those
+ * values are carried over, never reset: when the server holds nothing for a
+ * list and this browser still has a value, the value is sent up once and the
+ * browser copy is removed after the server confirmed it. While the first read
+ * is in flight the browser copy (or the default) paints, so a person who
+ * arranged a list does not see it jump back to the default order.
*
- * Shape per module (one localStorage key each):
- * { view: "cards" | "list", order: string[] }
- * `order` is an id allow-list; ids absent from the live data are ignored and
- * ids present in the data but absent from `order` sort after the ordered
- * block (the server default order is preserved for the tail).
+ * The choice is presentational only: it reorders or reshapes what the page
+ * already fetched, so a missing or stale value degrades to the server order.
*/
-import { useCallback, useMemo, useSyncExternalStore } from "react";
+import { useCallback, useEffect, useMemo } from "react";
+import {
+ useQuery,
+ useQueryClient,
+ type QueryClient,
+} from "@tanstack/react-query";
-export const MODULE_LIST_VIEWS = ["cards", "list"] as const;
-export type ModuleListView = (typeof MODULE_LIST_VIEWS)[number];
+import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
+import { useMounted } from "@/hooks/use-mounted";
+import { apiGet, apiPut } from "@/lib/api/api-fetch";
+import { showErrorToast } from "@/lib/errors/error-toast";
+import { useTranslations } from "@/lib/i18n/context";
+import { queryKeys } from "@/lib/query-keys";
+import {
+ isModuleListView,
+ isModuleSortDir,
+ moduleListDefaults,
+ normaliseModuleListOrder,
+ resolveModuleListLayout,
+ type ModuleListKey,
+ type ModuleListLayout,
+ type ModuleListPrefs,
+ type ModuleListView,
+ type ModuleSortDir,
+} from "@/lib/settings/module-list-layout";
-export type ModuleListKey = "vorsorge" | "illness" | "labs";
+export type {
+ ModuleListKey,
+ ModuleListPrefs,
+ ModuleListView,
+ ModuleSortDir,
+} from "@/lib/settings/module-list-layout";
+
+const ENDPOINT = "/api/settings/module-list-layout";
+
+/** The browser key earlier releases stored a list's choices under. */
+export function legacyModuleListStorageKey(list: ModuleListKey): string {
+ return `healthlog:module-list-prefs:${list}`;
+}
/**
- * Sort choice for the Labs surface (MOD-04): order biomarker groups by the
- * recency of their most-recent reading, alphabetically by analyte name
- * (#43), or `manual`, which defers to the persisted `order` array.
+ * Parse a browser-stored blob from before the server column. Anything
+ * malformed collapses onto the defaults field by field.
*/
-export const MODULE_SORT_DIRS = [
- "recentDesc",
- "recentAsc",
- "alphaAsc",
- "alphaDesc",
- "manual",
-] as const;
-export type ModuleSortDir = (typeof MODULE_SORT_DIRS)[number];
-
-export interface ModuleListPrefs {
- view: ModuleListView;
- order: string[];
- /** Only consumed by the Labs surface; defaults to most-recent-first. */
- sortDir: ModuleSortDir;
-}
-
-const DEFAULT_PREFS: ModuleListPrefs = {
- view: "cards",
- order: [],
- sortDir: "recentDesc",
-};
-
-/**
- * Per-module default overrides applied when no preference blob is stored yet.
- * Labs defaults to the compact list view (#40) so the dense reading table is
- * the first thing the user sees; Vorsorge and Illness keep the card grid.
- */
-const MODULE_DEFAULTS: Partial<
- Record>
-> = {
- labs: { view: "list" },
-};
-
-function defaultsFor(module: ModuleListKey): ModuleListPrefs {
- return { ...DEFAULT_PREFS, ...MODULE_DEFAULTS[module] };
-}
-
-function storageKey(module: ModuleListKey): string {
- return `healthlog:module-list-prefs:${module}`;
-}
-
-function isView(value: unknown): value is ModuleListView {
- return (
- typeof value === "string" &&
- (MODULE_LIST_VIEWS as readonly string[]).includes(value)
- );
-}
-
-function isSortDir(value: unknown): value is ModuleSortDir {
- return (
- typeof value === "string" &&
- (MODULE_SORT_DIRS as readonly string[]).includes(value)
- );
-}
-
export function parseModuleListPrefs(
raw: string | null,
- defaults: ModuleListPrefs = DEFAULT_PREFS,
+ defaults: ModuleListPrefs,
): ModuleListPrefs {
if (!raw) return defaults;
try {
const blob = JSON.parse(raw) as Record;
- const view = isView(blob.view) ? blob.view : defaults.view;
- const order = Array.isArray(blob.order)
- ? blob.order.filter((v): v is string => typeof v === "string")
- : [];
- const sortDir = isSortDir(blob.sortDir) ? blob.sortDir : defaults.sortDir;
- return { view, order, sortDir };
+ return {
+ view: isModuleListView(blob.view) ? blob.view : defaults.view,
+ order: normaliseModuleListOrder(blob.order) ?? [],
+ sortDir: isModuleSortDir(blob.sortDir) ? blob.sortDir : defaults.sortDir,
+ };
} catch {
return defaults;
}
}
+/**
+ * The fields of a browser-stored value worth carrying over: the ones that
+ * differ from the default. The old blob always held all three fields, so a
+ * field equal to the default cannot be told apart from one never chosen and
+ * is left to follow the default.
+ */
+export function legacyModuleListPatch(
+ list: ModuleListKey,
+ raw: string | null,
+): Partial {
+ if (!raw) return {};
+ const defaults = moduleListDefaults(list);
+ const legacy = parseModuleListPrefs(raw, defaults);
+ return {
+ ...(legacy.view !== defaults.view ? { view: legacy.view } : {}),
+ ...(legacy.order.length > 0 ? { order: legacy.order } : {}),
+ ...(legacy.sortDir !== defaults.sortDir ? { sortDir: legacy.sortDir } : {}),
+ };
+}
+
/**
* Apply a persisted id order to a live list. Items whose id appears in
* `order` come first in that order; everything else keeps the input order
@@ -127,84 +121,165 @@ export function applyOrder(
return [...ordered, ...rest];
}
-// A module-scoped event so every mounted hook for the same module repaints
-// when one writer flushes a change (the settings page + the module page can
-// both be alive across a client navigation without a full reload).
-const listeners = new Set<() => void>();
-function emit() {
- for (const l of listeners) l();
+function readLegacy(list: ModuleListKey): string | null {
+ try {
+ return window.localStorage.getItem(legacyModuleListStorageKey(list));
+ } catch {
+ return null;
+ }
+}
+
+function removeLegacy(list: ModuleListKey): void {
+ try {
+ window.localStorage.removeItem(legacyModuleListStorageKey(list));
+ } catch {
+ // Storage unavailable: nothing to remove.
+ }
+}
+
+/** Lists already carried over (or checked) in this page load. */
+const carriedOver = new Set();
+
+/**
+ * Carry a browser-stored value up to the server, once, when the server holds
+ * nothing for the list. A failed write leaves the browser copy in place and
+ * tries again on the next mount; a list the server already holds keeps the
+ * server value and drops the stale browser copy.
+ */
+export async function carryOverLegacyModuleListPrefs(deps: {
+ list: ModuleListKey;
+ layout: ModuleListLayout;
+ queryClient: QueryClient;
+}): Promise {
+ const { list, layout, queryClient } = deps;
+ if (typeof window === "undefined" || carriedOver.has(list)) return;
+ carriedOver.add(list);
+ const raw = readLegacy(list);
+ if (raw === null) return;
+ if (layout.lists[list].saved) {
+ removeLegacy(list);
+ return;
+ }
+ const patch = legacyModuleListPatch(list, raw);
+ if (Object.keys(patch).length === 0) {
+ removeLegacy(list);
+ return;
+ }
+ try {
+ const saved = await apiPut(ENDPOINT, {
+ version: 1,
+ list,
+ ...patch,
+ });
+ if (saved) queryClient.setQueryData(queryKeys.moduleListLayout(), saved);
+ removeLegacy(list);
+ } catch {
+ carriedOver.delete(list);
+ }
+}
+
+/** Forget which lists were carried over. Tests only. */
+export function resetModuleListCarryOverForTests(): void {
+ carriedOver.clear();
+}
+
+type Translator = (
+ key: string,
+ params?: Record,
+) => string;
+
+/**
+ * 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.
+ */
+export async function runSetModuleListPrefs(deps: {
+ list: ModuleListKey;
+ patch: Partial;
+ queryClient: QueryClient;
+ t: Translator;
+}): Promise {
+ const { list, patch, queryClient, t } = deps;
+ const key = queryKeys.moduleListLayout();
+ if (queryClient.isFetching({ queryKey: key }) > 0) {
+ await queryClient.cancelQueries({ queryKey: key });
+ }
+ const previous = queryClient.getQueryData(key);
+ const base = previous ?? resolveModuleListLayout(null);
+ queryClient.setQueryData(key, {
+ ...base,
+ lists: {
+ ...base.lists,
+ [list]: { ...base.lists[list], ...patch, saved: true },
+ },
+ });
+ try {
+ const saved = await apiPut(ENDPOINT, {
+ version: 1,
+ list,
+ ...patch,
+ });
+ if (saved) queryClient.setQueryData(key, saved);
+ if (patch.view !== undefined || patch.sortDir !== undefined) {
+ toastWrittenOutcome("success", t("settings.saved"));
+ }
+ } catch (failure) {
+ queryClient.setQueryData(key, previous);
+ showErrorToast(t("settings.savingError"), { cause: failure });
+ }
}
/**
- * Read + write the per-module list preferences. SSR-safe via
- * `useSyncExternalStore` with a null server snapshot (parsed to defaults).
+ * Read + write one list's presentation. The server value once it is read;
+ * before that, the browser copy from an earlier release, or the default.
*/
-export function useModuleListPrefs(module: ModuleListKey): {
+export function useModuleListPrefs(list: ModuleListKey): {
prefs: ModuleListPrefs;
setView: (view: ModuleListView) => void;
setOrder: (order: string[]) => void;
setSortDir: (sortDir: ModuleSortDir) => void;
} {
- const key = storageKey(module);
- const defaults = useMemo(() => defaultsFor(module), [module]);
+ const queryClient = useQueryClient();
+ const { t } = useTranslations();
- const subscribe = useCallback(
- (onChange: () => void) => {
- listeners.add(onChange);
- const onStorage = (e: StorageEvent) => {
- if (e.key === null || e.key === key) onChange();
- };
- if (typeof window !== "undefined") {
- window.addEventListener("storage", onStorage);
- }
- return () => {
- listeners.delete(onChange);
- if (typeof window !== "undefined") {
- window.removeEventListener("storage", onStorage);
- }
- };
- },
- [key],
- );
+ const { data } = useQuery({
+ queryKey: queryKeys.moduleListLayout(),
+ queryFn: () => apiGet(ENDPOINT),
+ staleTime: 5 * 60 * 1000,
+ });
- const getSnapshot = useCallback(() => {
- if (typeof window === "undefined") return null;
- return window.localStorage.getItem(key);
- }, [key]);
+ useEffect(() => {
+ if (!data) return;
+ void carryOverLegacyModuleListPrefs({ list, layout: data, queryClient });
+ }, [data, list, queryClient]);
- const raw = useSyncExternalStore(subscribe, getSnapshot, () => null);
- // `useSyncExternalStore` returns a stable `raw` string when storage hasn't
- // changed, so keying the parse on it gives downstream `applyOrder`
- // memoisation a stable `prefs` reference instead of a fresh object + a
- // JSON.parse on every render.
- const prefs = useMemo(
- () => parseModuleListPrefs(raw, defaults),
- [raw, defaults],
- );
+ // The browser copy is read only after hydration, so the server render and
+ // the first client paint agree (React #418).
+ const mounted = useMounted();
+ const entry = data?.lists[list];
+ const prefs = useMemo(() => {
+ if (entry) {
+ return { view: entry.view, order: entry.order, sortDir: entry.sortDir };
+ }
+ const defaults = moduleListDefaults(list);
+ if (!mounted) return defaults;
+ return parseModuleListPrefs(readLegacy(list), defaults);
+ }, [entry, list, mounted]);
const write = useCallback(
- (next: ModuleListPrefs) => {
- if (typeof window === "undefined") return;
- window.localStorage.setItem(key, JSON.stringify(next));
- emit();
- },
- [key],
+ (patch: Partial) =>
+ void runSetModuleListPrefs({ list, patch, queryClient, t }),
+ [list, queryClient, t],
);
const setView = useCallback(
- (view: ModuleListView) =>
- write({ ...parseModuleListPrefs(getSnapshot(), defaults), view }),
- [write, getSnapshot, defaults],
- );
- const setOrder = useCallback(
- (order: string[]) =>
- write({ ...parseModuleListPrefs(getSnapshot(), defaults), order }),
- [write, getSnapshot, defaults],
+ (view: ModuleListView) => write({ view }),
+ [write],
);
+ const setOrder = useCallback((order: string[]) => write({ order }), [write]);
const setSortDir = useCallback(
- (sortDir: ModuleSortDir) =>
- write({ ...parseModuleListPrefs(getSnapshot(), defaults), sortDir }),
- [write, getSnapshot, defaults],
+ (sortDir: ModuleSortDir) => write({ sortDir }),
+ [write],
);
return { prefs, setView, setOrder, setSortDir };
diff --git a/src/lib/openapi/routes/settings.ts b/src/lib/openapi/routes/settings.ts
index a837dbdba..5b1ac7c2b 100644
--- a/src/lib/openapi/routes/settings.ts
+++ b/src/lib/openapi/routes/settings.ts
@@ -23,6 +23,68 @@ import {
stdResponses,
} from "./shared";
import { WEBHOOK_PAYLOAD_FORMATS } from "@/lib/notifications/types";
+import {
+ MODULE_LIST_VIEWS,
+ MODULE_SORT_DIRS,
+} from "@/lib/settings/module-list-layout";
+import {
+ moduleListLayoutPutSchema,
+ previewFeaturesPatchSchema,
+} from "@/lib/validations/settings-prefs";
+
+const moduleListPrefsEntry = z
+ .object({
+ view: z
+ .enum(MODULE_LIST_VIEWS)
+ .describe(
+ '`cards` renders the card grid, `list` the compact list. Default "list" for labs, "cards" otherwise.',
+ ),
+ order: z
+ .array(z.string())
+ .describe(
+ "Manual order as an id allow-list. Ids missing from the live data are ignored; items missing from the list follow in the server order.",
+ ),
+ sortDir: z
+ .enum(MODULE_SORT_DIRS)
+ .describe(
+ 'Sort of the labs list; `manual` follows `order`. Default "recentDesc". The other lists ignore it.',
+ ),
+ saved: z
+ .boolean()
+ .describe(
+ "True when the server holds a choice for this list; false means every field is a default.",
+ ),
+ })
+ .meta({ id: "ModuleListPrefs" });
+
+const moduleListLayoutSchema = z
+ .object({
+ version: z.literal(1),
+ lists: z.object({
+ labs: moduleListPrefsEntry,
+ illness: moduleListPrefsEntry,
+ vorsorge: moduleListPrefsEntry,
+ }),
+ })
+ .meta({
+ id: "ModuleListLayout",
+ description:
+ "Per-user presentation of the labs, illness and checkups lists. Display-only; a client that does not render a list ignores it.",
+ });
+
+const previewFeaturesSchema = z
+ .object({
+ topics: z
+ .boolean()
+ .describe(
+ "Topic overview pages above the single-metric Insights pages. Off by default.",
+ ),
+ })
+ .meta({
+ id: "PreviewFeatures",
+ description:
+ "Per-user opt-in to preview features, one boolean per flag, every flag off by default.",
+ });
// v1.16.11 — the one threshold read every dose-status consumer makes
// (cards, table, take-all-due derivation). `lateMinutes` /
@@ -856,6 +918,109 @@ export const settingsPaths: NonNullable = {
},
},
},
+ "/api/settings/module-list-layout": {
+ get: {
+ tags: ["Meta"],
+ summary: "Get the list presentation of labs, illness and checkups",
+ description:
+ "Every list resolved, defaults filled in, with `saved` per list. Never writes a row.",
+ responses: {
+ "200": {
+ description: "The resolved presentation of every list.",
+ content: {
+ "application/json": {
+ schema: dataEnvelope(
+ moduleListLayoutSchema,
+ "ModuleListLayoutEnvelope",
+ ),
+ },
+ },
+ },
+ ...stdResponses,
+ },
+ },
+ put: {
+ tags: ["Meta"],
+ summary: "Change the presentation of one list",
+ description:
+ "Preserve-when-absent: the fields the body leaves out keep their stored value, and every other list stays as stored. Returns every list resolved.",
+ requestBody: {
+ required: true,
+ content: {
+ "application/json": {
+ schema: moduleListLayoutPutSchema.meta({
+ id: "ModuleListLayoutPutBody",
+ }),
+ },
+ },
+ },
+ responses: {
+ "200": {
+ description: "The presentation as stored.",
+ content: {
+ "application/json": {
+ schema: dataEnvelope(
+ moduleListLayoutSchema,
+ "ModuleListLayoutSavedEnvelope",
+ ),
+ },
+ },
+ },
+ ...stdResponses,
+ },
+ },
+ },
+ "/api/settings/preview-features": {
+ get: {
+ tags: ["Meta"],
+ summary: "Get the preview-feature opt-ins",
+ description: "Every flag, off unless the caller switched it on.",
+ responses: {
+ "200": {
+ description: "The opt-ins.",
+ content: {
+ "application/json": {
+ schema: dataEnvelope(
+ previewFeaturesSchema,
+ "PreviewFeaturesEnvelope",
+ ),
+ },
+ },
+ },
+ ...stdResponses,
+ },
+ },
+ patch: {
+ tags: ["Meta"],
+ summary: "Switch preview features on or off",
+ description:
+ "Sets the flags the body names and keeps the rest. An unknown flag or an empty body is refused with 422.",
+ requestBody: {
+ required: true,
+ content: {
+ "application/json": {
+ schema: previewFeaturesPatchSchema.meta({
+ id: "PreviewFeaturesPatchBody",
+ }),
+ },
+ },
+ },
+ responses: {
+ "200": {
+ description: "The opt-ins as stored.",
+ content: {
+ "application/json": {
+ schema: dataEnvelope(
+ previewFeaturesSchema,
+ "PreviewFeaturesSavedEnvelope",
+ ),
+ },
+ },
+ },
+ ...stdResponses,
+ },
+ },
+ },
"/api/settings/privacy-summary": {
get: {
tags: ["Meta"],
diff --git a/src/lib/query-keys/settings.ts b/src/lib/query-keys/settings.ts
index 6f9f4e930..95c1a4f13 100644
--- a/src/lib/query-keys/settings.ts
+++ b/src/lib/query-keys/settings.ts
@@ -38,6 +38,15 @@ export const settingsKeys = {
*/
modulesPrefs: () => ["settings", "modules"] as const,
+ /**
+ * The labs / illness / checkups list presentation behind
+ * `GET/PUT /api/settings/module-list-layout` (view, manual order, labs sort).
+ */
+ moduleListLayout: () => ["settings", "module-list-layout"] as const,
+
+ /** The per-user preview-feature opt-ins behind `/api/settings/preview-features`. */
+ previewFeatures: () => ["settings", "preview-features"] as const,
+
/**
* v1.35.0 — which pillars count toward the Health Score, behind
* `GET/PATCH /api/auth/me/health-score-config`. Its own cell rather than
diff --git a/src/lib/settings/__tests__/module-list-layout.test.ts b/src/lib/settings/__tests__/module-list-layout.test.ts
new file mode 100644
index 000000000..c431a945b
--- /dev/null
+++ b/src/lib/settings/__tests__/module-list-layout.test.ts
@@ -0,0 +1,78 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ MODULE_LIST_ORDER_MAX,
+ normaliseModuleListOrder,
+ resolveModuleListLayout,
+ storedModuleListLayout,
+} from "../module-list-layout";
+
+describe("resolveModuleListLayout", () => {
+ it("answers the defaults, nothing saved, for an empty column", () => {
+ const layout = resolveModuleListLayout(null);
+ expect(layout.lists.labs).toEqual({
+ view: "list",
+ order: [],
+ sortDir: "recentDesc",
+ saved: false,
+ });
+ expect(layout.lists.illness.view).toBe("cards");
+ expect(layout.lists.vorsorge.view).toBe("cards");
+ expect(layout.lists.illness.saved).toBe(false);
+ });
+
+ it("fills a partial list field by field and marks it saved", () => {
+ const layout = resolveModuleListLayout({
+ version: 1,
+ illness: { order: ["b", "a"] },
+ });
+ expect(layout.lists.illness).toEqual({
+ view: "cards",
+ order: ["b", "a"],
+ sortDir: "recentDesc",
+ saved: true,
+ });
+ expect(layout.lists.labs.saved).toBe(false);
+ });
+
+ it("drops malformed values instead of failing", () => {
+ const layout = resolveModuleListLayout({
+ labs: { view: "grid", sortDir: 3, order: "x" },
+ vorsorge: "nonsense",
+ });
+ expect(layout.lists.labs).toEqual({
+ view: "list",
+ order: [],
+ sortDir: "recentDesc",
+ saved: false,
+ });
+ expect(layout.lists.vorsorge.saved).toBe(false);
+ });
+});
+
+describe("normaliseModuleListOrder", () => {
+ it("keeps strings, drops duplicates, empties and over-long ids", () => {
+ expect(
+ normaliseModuleListOrder(["a", "a", "", 4, "b", "x".repeat(65)]),
+ ).toEqual(["a", "b"]);
+ });
+
+ it("caps the length", () => {
+ const ids = Array.from({ length: MODULE_LIST_ORDER_MAX + 10 }, (_, i) =>
+ String(i),
+ );
+ expect(normaliseModuleListOrder(ids)).toHaveLength(MODULE_LIST_ORDER_MAX);
+ });
+
+ it("answers null for a non-array", () => {
+ expect(normaliseModuleListOrder("a")).toBeNull();
+ });
+});
+
+describe("storedModuleListLayout", () => {
+ it("lists only the lists that carry a choice", () => {
+ expect(
+ storedModuleListLayout({ labs: { sortDir: "manual" }, illness: {} }),
+ ).toEqual({ labs: { sortDir: "manual" } });
+ });
+});
diff --git a/src/lib/settings/__tests__/preview-features.test.ts b/src/lib/settings/__tests__/preview-features.test.ts
new file mode 100644
index 000000000..df5251b6d
--- /dev/null
+++ b/src/lib/settings/__tests__/preview-features.test.ts
@@ -0,0 +1,26 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ DEFAULT_PREVIEW_FEATURES,
+ resolvePreviewFeatures,
+} from "../preview-features";
+
+describe("resolvePreviewFeatures", () => {
+ it("is all off by default", () => {
+ expect(DEFAULT_PREVIEW_FEATURES).toEqual({ topics: false });
+ expect(resolvePreviewFeatures(null)).toEqual({ topics: false });
+ });
+
+ it("reads a flag only on an explicit true", () => {
+ expect(resolvePreviewFeatures({ topics: true })).toEqual({ topics: true });
+ expect(resolvePreviewFeatures({ topics: "true" })).toEqual({
+ topics: false,
+ });
+ });
+
+ it("drops unknown keys", () => {
+ expect(resolvePreviewFeatures({ topics: true, retired: true })).toEqual({
+ topics: true,
+ });
+ });
+});
diff --git a/src/lib/settings/module-list-layout.ts b/src/lib/settings/module-list-layout.ts
new file mode 100644
index 000000000..2783db463
--- /dev/null
+++ b/src/lib/settings/module-list-layout.ts
@@ -0,0 +1,163 @@
+/**
+ * List presentation for the labs, illness and checkups lists, persisted in
+ * `User.moduleListLayoutJson`.
+ *
+ * Three independent choices per list, all display-only:
+ *
+ * - `view`: the card grid or the compact list.
+ * - `order`: an id allow-list. Ids absent from the live data are ignored;
+ * ids present in the data but absent from `order` sort after the ordered
+ * block in the server's own order.
+ * - `sortDir`: only the labs list reads it (recency, alphabetical, or the
+ * manual `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
+ * that changes later still reaches every list nobody arranged. Mirrors
+ * `documents-layout.ts`: tolerant resolver on read, preserve-when-absent PUT.
+ *
+ * `saved` tells the client whether the server holds a choice for a list. The
+ * same choices lived in the browser before this column existed; the client
+ * copies a browser value up once, only while `saved` is false, so an order a
+ * person built is carried over instead of reset.
+ */
+
+export const MODULE_LIST_KEYS = ["labs", "illness", "vorsorge"] as const;
+export type ModuleListKey = (typeof MODULE_LIST_KEYS)[number];
+
+export const MODULE_LIST_VIEWS = ["cards", "list"] as const;
+export type ModuleListView = (typeof MODULE_LIST_VIEWS)[number];
+
+export const MODULE_SORT_DIRS = [
+ "recentDesc",
+ "recentAsc",
+ "alphaAsc",
+ "alphaDesc",
+ "manual",
+] as const;
+export type ModuleSortDir = (typeof MODULE_SORT_DIRS)[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). */
+export const MODULE_LIST_ID_MAX = 64;
+
+export interface ModuleListPrefs {
+ view: ModuleListView;
+ order: string[];
+ sortDir: ModuleSortDir;
+}
+
+export interface ModuleListPrefsEntry extends ModuleListPrefs {
+ /** True when the server holds at least one choice for this list. */
+ saved: boolean;
+}
+
+export interface ModuleListLayout {
+ version: 1;
+ lists: Record;
+}
+
+const BASE_DEFAULTS: ModuleListPrefs = {
+ view: "cards",
+ order: [],
+ sortDir: "recentDesc",
+};
+
+/** Labs opens on the compact list, the dense reading table (#40). */
+const LIST_DEFAULTS: Record = {
+ labs: { ...BASE_DEFAULTS, view: "list" },
+ illness: { ...BASE_DEFAULTS },
+ vorsorge: { ...BASE_DEFAULTS },
+};
+
+export function moduleListDefaults(list: ModuleListKey): ModuleListPrefs {
+ const defaults = LIST_DEFAULTS[list];
+ return { ...defaults, order: [...defaults.order] };
+}
+
+export function isModuleListKey(value: unknown): value is ModuleListKey {
+ return (
+ typeof value === "string" &&
+ (MODULE_LIST_KEYS as readonly string[]).includes(value)
+ );
+}
+
+export function isModuleListView(value: unknown): value is ModuleListView {
+ return (
+ typeof value === "string" &&
+ (MODULE_LIST_VIEWS as readonly string[]).includes(value)
+ );
+}
+
+export function isModuleSortDir(value: unknown): value is ModuleSortDir {
+ return (
+ typeof value === "string" &&
+ (MODULE_SORT_DIRS 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;
+ const seen = new Set();
+ const out: string[] = [];
+ for (const value of raw) {
+ if (typeof value !== "string") continue;
+ if (value.length === 0 || value.length > MODULE_LIST_ID_MAX) continue;
+ if (seen.has(value)) continue;
+ seen.add(value);
+ out.push(value);
+ if (out.length >= MODULE_LIST_ORDER_MAX) break;
+ }
+ return out;
+}
+
+/**
+ * The fields a stored blob explicitly carries for one list, each validated.
+ * Unknown or malformed values drop out, so the default fills them on read.
+ */
+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 order = normaliseModuleListOrder(blob.order);
+ return {
+ ...(isModuleListView(blob.view) ? { view: blob.view } : {}),
+ ...(order !== null ? { order } : {}),
+ ...(isModuleSortDir(blob.sortDir) ? { sortDir: blob.sortDir } : {}),
+ };
+}
+
+/** Every list's stored choices, keyed by list. */
+export function storedModuleListLayout(
+ raw: unknown,
+): Partial>> {
+ if (raw === null || raw === undefined || typeof raw !== "object") return {};
+ const blob = raw as Record;
+ const out: Partial>> = {};
+ for (const list of MODULE_LIST_KEYS) {
+ const choices = storedModuleListChoices(blob[list]);
+ if (Object.keys(choices).length > 0) out[list] = choices;
+ }
+ return out;
+}
+
+/**
+ * Any malformed, partial or legacy blob collapses onto the defaults field by
+ * field, so a GET never fails on a stored row.
+ */
+export function resolveModuleListLayout(raw: unknown): ModuleListLayout {
+ const stored = storedModuleListLayout(raw);
+ const lists = {} as Record;
+ for (const list of MODULE_LIST_KEYS) {
+ const choices = stored[list];
+ lists[list] = {
+ ...moduleListDefaults(list),
+ ...choices,
+ saved: choices !== undefined,
+ };
+ }
+ return { version: 1, lists };
+}
diff --git a/src/lib/settings/preview-features.ts b/src/lib/settings/preview-features.ts
new file mode 100644
index 000000000..cd184520d
--- /dev/null
+++ b/src/lib/settings/preview-features.ts
@@ -0,0 +1,31 @@
+/**
+ * Per-user opt-in to preview features, persisted in `User.previewFeaturesJson`.
+ *
+ * One boolean per flag, every flag off by default. A preview feature is
+ * something finished enough to try and not yet the default; switching one on
+ * adds it beside what is there and never replaces an existing page.
+ *
+ * Flags:
+ * - `topics`: topic overview pages above the single-metric Insights pages.
+ * Stored and shown in Settings > System; no surface reads it yet.
+ *
+ * Unknown keys in a stored blob are dropped on read, so a flag retired later
+ * disappears cleanly and a malformed blob reads as everything off.
+ */
+
+export const PREVIEW_FEATURE_KEYS = ["topics"] as const;
+export type PreviewFeatureKey = (typeof PREVIEW_FEATURE_KEYS)[number];
+
+export type PreviewFeatures = Record;
+
+export const DEFAULT_PREVIEW_FEATURES: PreviewFeatures = { topics: false };
+
+export function resolvePreviewFeatures(raw: unknown): PreviewFeatures {
+ const out: PreviewFeatures = { ...DEFAULT_PREVIEW_FEATURES };
+ if (raw === null || raw === undefined || typeof raw !== "object") return out;
+ const blob = raw as Record;
+ for (const key of PREVIEW_FEATURE_KEYS) {
+ if (blob[key] === true) out[key] = true;
+ }
+ return out;
+}
diff --git a/src/lib/validations/backup.ts b/src/lib/validations/backup.ts
index 957caaf9e..4d3aac9f4 100644
--- a/src/lib/validations/backup.ts
+++ b/src/lib/validations/backup.ts
@@ -2154,6 +2154,8 @@ const accountSettingsBackupSchema = z
medicationListLayoutJson: z.unknown().optional(),
moodTagLayoutJson: z.unknown().optional(),
documentsLayoutJson: z.unknown().optional(),
+ moduleListLayoutJson: z.unknown().optional(),
+ previewFeaturesJson: z.unknown().optional(),
reportSelectionJson: z.unknown().optional(),
globalExcludedInjectionSites: z.array(z.string()).optional(),
healthKitConfigJson: z.unknown().optional(),
diff --git a/src/lib/validations/settings-prefs.ts b/src/lib/validations/settings-prefs.ts
new file mode 100644
index 000000000..c3cc82112
--- /dev/null
+++ b/src/lib/validations/settings-prefs.ts
@@ -0,0 +1,37 @@
+/**
+ * Request schemas for the per-user Settings preferences that are not part of
+ * the account payload: the list presentation of the labs, illness and
+ * checkups lists, and the preview-feature opt-ins. Shared by the routes and
+ * the OpenAPI registry so the wire contract has one source.
+ */
+import { z } from "zod/v4";
+
+import {
+ MODULE_LIST_ID_MAX,
+ MODULE_LIST_KEYS,
+ MODULE_LIST_ORDER_MAX,
+ MODULE_LIST_VIEWS,
+ MODULE_SORT_DIRS,
+} from "@/lib/settings/module-list-layout";
+
+export const moduleListLayoutPutSchema = z
+ .object({
+ version: z.literal(1),
+ list: z.enum(MODULE_LIST_KEYS),
+ view: z.enum(MODULE_LIST_VIEWS).optional(),
+ order: z
+ .array(z.string().min(1).max(MODULE_LIST_ID_MAX))
+ .max(MODULE_LIST_ORDER_MAX)
+ .optional(),
+ sortDir: z.enum(MODULE_SORT_DIRS).optional(),
+ })
+ .strict();
+
+export const previewFeaturesPatchSchema = z
+ .object({
+ topics: z.boolean().optional(),
+ })
+ .strict()
+ .refine((body) => Object.keys(body).length > 0, {
+ message: "Name at least one preview feature.",
+ });
diff --git a/tests/integration/settings-prefs-routes.test.ts b/tests/integration/settings-prefs-routes.test.ts
new file mode 100644
index 000000000..5dcc4d7c5
--- /dev/null
+++ b/tests/integration/settings-prefs-routes.test.ts
@@ -0,0 +1,216 @@
+/**
+ * The two per-user Settings preferences that live outside the account
+ * payload, end to end against Postgres:
+ *
+ * - `/api/settings/module-list-layout`: defaults without a write, one list
+ * changed per PUT with preserve-when-absent fields and untouched neighbours,
+ * the multi-issue 422 for unknown values, and the column carrying only what
+ * was chosen.
+ * - `/api/settings/preview-features`: all off by default, PATCH keeps the
+ * flags it does not name, an unknown flag or an empty body is refused.
+ */
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+process.env.ENCRYPTION_KEY ??=
+ "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
+
+import { cookieJar, headerJar } from "./mock-next-headers";
+import { getPrismaClient, truncateAllTables } from "./setup";
+
+vi.mock("next/headers", async () => {
+ const { cookieJar, headerJar } = await import("./mock-next-headers");
+ return {
+ headers: vi.fn(async () => ({
+ get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
+ })),
+ cookies: vi.fn(async () => ({
+ get: (name: string) => {
+ const value = cookieJar.get(name);
+ return value ? { name, value } : undefined;
+ },
+ set: (name: string, value: string) => {
+ cookieJar.set(name, value);
+ },
+ delete: (name: string) => {
+ cookieJar.delete(name);
+ },
+ })),
+ };
+});
+
+vi.mock("@/lib/db-compat", () => ({
+ ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
+}));
+
+beforeEach(async () => {
+ await truncateAllTables(getPrismaClient());
+ cookieJar.clear();
+ headerJar.clear();
+});
+
+afterEach(() => {
+ vi.restoreAllMocks();
+});
+
+async function seedUser(): Promise<{ userId: string }> {
+ const prisma = getPrismaClient();
+ const user = await prisma.user.create({
+ data: {
+ username: "settings-prefs-user",
+ email: "settings-prefs@example.test",
+ role: "USER",
+ },
+ });
+ const session = await prisma.session.create({
+ data: { userId: user.id, expiresAt: new Date(Date.now() + 60_000) },
+ });
+ cookieJar.set("healthlog_session", session.id);
+ return { userId: user.id };
+}
+
+type Handler = (req: Request) => Promise;
+
+function json(method: string, url: string, body: unknown): Request {
+ return new Request(url, {
+ method,
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(body),
+ });
+}
+
+describe("/api/settings/module-list-layout — integration", () => {
+ const URL = "http://localhost/api/settings/module-list-layout";
+
+ it("answers the defaults without writing a row", async () => {
+ const { userId } = await seedUser();
+ const { GET } = await import("@/app/api/settings/module-list-layout/route");
+ const res = await (GET as Handler)(new Request(URL));
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as {
+ data: { lists: Record };
+ };
+ expect(body.data.lists.labs).toMatchObject({ view: "list", saved: false });
+ expect(body.data.lists.illness).toMatchObject({
+ view: "cards",
+ saved: false,
+ });
+ const row = await getPrismaClient().user.findUnique({
+ where: { id: userId },
+ select: { moduleListLayoutJson: true },
+ });
+ expect(row?.moduleListLayoutJson).toBeNull();
+ });
+
+ it("changes one list per PUT and keeps the fields and lists it does not name", async () => {
+ const { userId } = await seedUser();
+ const { GET, PUT } =
+ await import("@/app/api/settings/module-list-layout/route");
+
+ const first = await (PUT as Handler)(
+ json("PUT", URL, { version: 1, list: "labs", order: ["m2", "m1"] }),
+ );
+ expect(first.status).toBe(200);
+ const second = await (PUT as Handler)(
+ json("PUT", URL, { version: 1, list: "labs", sortDir: "manual" }),
+ );
+ expect(second.status).toBe(200);
+ await (PUT as Handler)(
+ json("PUT", URL, { version: 1, list: "illness", view: "list" }),
+ );
+
+ const res = await (GET as Handler)(new Request(URL));
+ const body = (await res.json()) as {
+ data: {
+ lists: Record<
+ string,
+ { view: string; order: string[]; sortDir: string; saved: boolean }
+ >;
+ };
+ };
+ expect(body.data.lists.labs).toEqual({
+ view: "list",
+ order: ["m2", "m1"],
+ sortDir: "manual",
+ saved: true,
+ });
+ expect(body.data.lists.illness).toMatchObject({
+ view: "list",
+ order: [],
+ saved: true,
+ });
+ expect(body.data.lists.vorsorge.saved).toBe(false);
+
+ // Only chosen fields are stored, so a default can still move later.
+ const row = await getPrismaClient().user.findUnique({
+ where: { id: userId },
+ select: { moduleListLayoutJson: true },
+ });
+ expect(row?.moduleListLayoutJson).toEqual({
+ version: 1,
+ labs: { order: ["m2", "m1"], sortDir: "manual" },
+ illness: { view: "list" },
+ });
+ });
+
+ 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");
+ const res = await (PUT as Handler)(
+ json("PUT", URL, {
+ version: 1,
+ list: "weight",
+ view: "grid",
+ extra: true,
+ }),
+ );
+ expect(res.status).toBe(422);
+ });
+
+ it("refuses a caller without a session", async () => {
+ const { GET } = await import("@/app/api/settings/module-list-layout/route");
+ const res = await (GET as Handler)(new Request(URL));
+ expect(res.status).toBe(401);
+ });
+});
+
+describe("/api/settings/preview-features — integration", () => {
+ const URL = "http://localhost/api/settings/preview-features";
+
+ it("is all off by default and keeps a flag once switched on", async () => {
+ const { userId } = await seedUser();
+ const { GET, PATCH } =
+ await import("@/app/api/settings/preview-features/route");
+
+ const before = await (GET as Handler)(new Request(URL));
+ expect(((await before.json()) as { data: unknown }).data).toEqual({
+ topics: false,
+ });
+
+ const on = await (PATCH as Handler)(json("PATCH", URL, { topics: true }));
+ expect(on.status).toBe(200);
+ expect(((await on.json()) as { data: unknown }).data).toEqual({
+ topics: true,
+ });
+
+ const after = await (GET as Handler)(new Request(URL));
+ expect(((await after.json()) as { data: unknown }).data).toEqual({
+ topics: true,
+ });
+ const row = await getPrismaClient().user.findUnique({
+ where: { id: userId },
+ select: { previewFeaturesJson: true },
+ });
+ expect(row?.previewFeaturesJson).toEqual({ topics: true });
+ });
+
+ it("refuses an unknown flag and an empty body", async () => {
+ await seedUser();
+ const { PATCH } = await import("@/app/api/settings/preview-features/route");
+ const unknown = await (PATCH as Handler)(
+ json("PATCH", URL, { topics: true, darkMagic: true }),
+ );
+ expect(unknown.status).toBe(422);
+ const empty = await (PATCH as Handler)(json("PATCH", URL, {}));
+ expect(empty.status).toBe(422);
+ });
+});
From 3ebef19ac3a6b7ef1d562eb7a70e6061e84b7a63 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Wed, 21 Oct 2026 19:42:53 +0200
Subject: [PATCH 2/5] fix(settings): one save model and one confirm per card
The Telegram, ntfy, webhook and email cards saved their enable switch on
tap and the rest of the form on Save; the switch is now part of the form
and the one Save sends it. The display selects confirm a change with a
toast. Destructive confirmations in Settings go through ConfirmButton or
ConfirmDialog with the destructive style. Two structural guards hold both
rules: a card may not mix instant and explicit saves, and no settings file
reaches for a raw AlertDialog without a written reason.
---
e2e/notification-dispatch-journey.spec.ts | 20 +-
.../destructive-control-guard.test.ts | 12 +-
.../success-affordance-guard.test.ts | 4 -
.../__tests__/channel-form-dirty.test.ts | 77 +++++-
.../__tests__/confirm-style-guard.test.ts | 77 ++++++
.../notification-channel-cards.test.tsx | 79 ++----
.../notification-channel-toggle.test.ts | 93 -------
.../__tests__/save-model-guard.test.ts | 256 ++++++++++++++++++
.../__tests__/token-revoke-labels.test.ts | 10 +-
src/components/settings/advanced-section.tsx | 170 ++++--------
.../settings/ai/fallback-chain-card.tsx | 47 +---
src/components/settings/api-section.tsx | 102 ++-----
src/components/settings/channel-form-dirty.ts | 46 +++-
.../settings/coach-memory-section.tsx | 73 +----
.../settings/dashboard-layout-section.tsx | 1 +
.../settings/date-format-select.tsx | 57 +---
src/components/settings/email-card.tsx | 83 ++----
.../settings/glucose-unit-select.tsx | 61 +----
.../settings/notification-channel-toggle.ts | 44 ---
src/components/settings/ntfy-card.tsx | 84 ++----
.../security-section/passkey-list-section.tsx | 56 ++--
.../security-section/security-keys-card.tsx | 69 ++---
.../settings/security-section/totp-card.tsx | 1 +
src/components/settings/sharing-section.tsx | 59 ++--
src/components/settings/sources-section.tsx | 1 +
src/components/settings/telegram-card.tsx | 105 ++-----
.../settings/thresholds-editor-section.tsx | 3 +
.../settings/time-format-select.tsx | 57 +---
.../settings/unit-preference-select.tsx | 59 +---
src/components/settings/webhook-card.tsx | 89 ++----
.../ui/__tests__/confirm-button.test.tsx | 32 +++
src/components/ui/confirm-button.tsx | 38 ++-
32 files changed, 830 insertions(+), 1135 deletions(-)
create mode 100644 src/components/settings/__tests__/confirm-style-guard.test.ts
delete mode 100644 src/components/settings/__tests__/notification-channel-toggle.test.ts
create mode 100644 src/components/settings/__tests__/save-model-guard.test.ts
delete mode 100644 src/components/settings/notification-channel-toggle.ts
diff --git a/e2e/notification-dispatch-journey.spec.ts b/e2e/notification-dispatch-journey.spec.ts
index ea8b68a90..b2b0e8061 100644
--- a/e2e/notification-dispatch-journey.spec.ts
+++ b/e2e/notification-dispatch-journey.spec.ts
@@ -270,21 +270,6 @@ async function saveCard(
return (await settled).status();
}
-/** Flip a channel card's enable switch and wait for the route to answer. */
-async function toggleCard(
- page: Page,
- switchId: string,
- endpoint: string,
-): Promise {
- const settled = page.waitForResponse(
- (res) =>
- new URL(res.url()).pathname === endpoint &&
- res.request().method() === "PUT",
- );
- await page.locator(switchId).click();
- return (await settled).status();
-}
-
/**
* Configure both channels the way a person would: email on with a recipient,
* ntfy saved and left off. Everything here happens on the settings surface.
@@ -292,11 +277,10 @@ async function toggleCard(
async function configureChannels(page: Page): Promise {
await openChannels(page);
+ // The enable switch is a field of the form: it saves with the one Save.
await fillField(page, "#email-recipient", EMAIL_RECIPIENT);
+ await page.locator("#email-toggle").click();
expect(await saveCard(page, "#email", "/api/settings/email")).toBe(200);
- expect(await toggleCard(page, "#email-toggle", "/api/settings/email")).toBe(
- 200,
- );
await expect(page.locator("#email-toggle")).toHaveAttribute(
"data-state",
"checked",
diff --git a/src/__tests__/destructive-control-guard.test.ts b/src/__tests__/destructive-control-guard.test.ts
index 6fe80d772..75ed78be4 100644
--- a/src/__tests__/destructive-control-guard.test.ts
+++ b/src/__tests__/destructive-control-guard.test.ts
@@ -110,7 +110,7 @@ const REGISTRY: DestructiveEntry[] = [
destroys:
"every health record on the account, or the whole account including passkeys and audit history",
recovery: "permanent",
- confirm: ["AlertDialog"],
+ confirm: ["ConfirmDialog"],
},
{
file: "components/settings/security-sessions-card.tsx",
@@ -129,13 +129,13 @@ const REGISTRY: DestructiveEntry[] = [
file: "components/settings/security-section/passkey-list-section.tsx",
destroys: "a passkey — hard-deleted, the credential cannot be re-derived",
recovery: "permanent",
- confirm: ["AlertDialog"],
+ confirm: ["ConfirmButton"],
},
{
file: "components/settings/security-section/security-keys-card.tsx",
destroys: "a WebAuthn second factor",
recovery: "permanent",
- confirm: ["AlertDialog"],
+ confirm: ["ConfirmButton"],
},
{
file: "components/settings/ai/remove-saved-key-button.tsx",
@@ -156,7 +156,7 @@ const REGISTRY: DestructiveEntry[] = [
file: "components/settings/api-section.tsx",
destroys: "an API token — revoked, and the secret is never shown again",
recovery: "permanent",
- confirm: ["AlertDialog"],
+ confirm: ["ConfirmButton"],
},
{
file: "components/settings/mcp-section.tsx",
@@ -168,7 +168,7 @@ const REGISTRY: DestructiveEntry[] = [
file: "components/settings/sharing-section.tsx",
destroys: "a clinician share link — the URL stays dead once revoked",
recovery: "permanent",
- confirm: ["AlertDialog"],
+ confirm: ["ConfirmButton"],
},
{
file: "components/settings/account-section/avatar-section.tsx",
@@ -214,7 +214,7 @@ const REGISTRY: DestructiveEntry[] = [
file: "components/settings/coach-memory-section.tsx",
destroys: "every remembered Coach fact at once",
recovery: "tombstoned-no-restore",
- confirm: ["AlertDialog"],
+ confirm: ["ConfirmButton"],
},
{
file: "components/insights/coach-panel/use-coach-facts.ts",
diff --git a/src/__tests__/success-affordance-guard.test.ts b/src/__tests__/success-affordance-guard.test.ts
index a41563ed7..2b78f8702 100644
--- a/src/__tests__/success-affordance-guard.test.ts
+++ b/src/__tests__/success-affordance-guard.test.ts
@@ -348,7 +348,6 @@ const PINNED_AFFORDANCES: Record<
"src/components/settings/dashboard-layout-section.tsx": {
"toast.success": 2,
},
- "src/components/settings/email-card.tsx": { "text-success": 1 },
"src/components/settings/environment-section.tsx": { "toast.success": 4 },
"src/components/settings/insights-pill-order-section.tsx": {
"toast.success": 1,
@@ -389,7 +388,6 @@ const PINNED_AFFORDANCES: Record<
CheckCircle2: 2,
"text-success": 1,
},
- "src/components/settings/ntfy-card.tsx": { "text-success": 1 },
// The one that used to sit inside `account-section/index.tsx` — the card
// moved to Settings → Security, the line moved with it.
"src/components/settings/password-card.tsx": { "text-success": 1 },
@@ -397,7 +395,6 @@ const PINNED_AFFORDANCES: Record<
"src/components/settings/share-link-create-form.tsx": { "text-success": 3 },
"src/components/settings/sharing-section.tsx": { "text-success": 1 },
"src/components/settings/sources-section.tsx": { "toast.success": 2 },
- "src/components/settings/telegram-card.tsx": { "text-success": 1 },
"src/components/settings/test-connection-button.tsx": {
CheckCircle2: 2,
"text-success": 1,
@@ -407,7 +404,6 @@ const PINNED_AFFORDANCES: Record<
},
"src/components/settings/trusted-devices-card.tsx": { "text-success": 1 },
"src/components/settings/web-push-card.tsx": { "text-success": 1 },
- "src/components/settings/webhook-card.tsx": { "text-success": 1 },
"src/components/targets/target-edit-sheet.tsx": { "toast.success": 2 },
"src/components/ui/password-strength.tsx": { "text-success": 2 },
// The success tone of the grade chip (a high confidence, a score that went
diff --git a/src/components/settings/__tests__/channel-form-dirty.test.ts b/src/components/settings/__tests__/channel-form-dirty.test.ts
index 63e57592c..cb20527dc 100644
--- a/src/components/settings/__tests__/channel-form-dirty.test.ts
+++ b/src/components/settings/__tests__/channel-form-dirty.test.ts
@@ -21,6 +21,7 @@ describe("channel form dirty state", () => {
headerName: "X-Gotify-Key",
headerValue: "",
format: "gotify" as const,
+ enabled: false,
};
it("webhook: a form that matches the saved config is clean", () => {
@@ -56,7 +57,12 @@ describe("channel form dirty state", () => {
it("ntfy: server, topic and a typed auth token count", () => {
const saved = { serverUrl: "https://ntfy.sh", topic: "t" };
- const form = { serverUrl: "https://ntfy.sh", topic: "t", authToken: "" };
+ const form = {
+ serverUrl: "https://ntfy.sh",
+ topic: "t",
+ authToken: "",
+ enabled: false,
+ };
expect(isNtfyFormDirty(form, saved)).toBe(false);
expect(isNtfyFormDirty({ ...form, topic: "u" }, saved)).toBe(true);
expect(isNtfyFormDirty({ ...form, serverUrl: "https://x" }, saved)).toBe(
@@ -67,39 +73,86 @@ describe("channel form dirty state", () => {
it("email: the recipient counts", () => {
expect(
- isEmailFormDirty({ recipient: "a@x.io" }, { recipient: "a@x.io" }),
+ isEmailFormDirty(
+ { recipient: "a@x.io", enabled: false },
+ { recipient: "a@x.io" },
+ ),
).toBe(false);
expect(
- isEmailFormDirty({ recipient: "b@x.io" }, { recipient: "a@x.io" }),
+ isEmailFormDirty(
+ { recipient: "b@x.io", enabled: false },
+ { recipient: "a@x.io" },
+ ),
).toBe(true);
});
it("telegram: a typed bot token or a changed chat id counts; whitespace alone does not", () => {
expect(
- isTelegramFormDirty({ botToken: "", chatId: "1" }, { chatId: "1" }),
- ).toBe(false);
- expect(
- isTelegramFormDirty({ botToken: " ", chatId: "" }, { chatId: null }),
+ isTelegramFormDirty(
+ { botToken: "", chatId: "1", enabled: false },
+ { chatId: "1" },
+ ),
).toBe(false);
expect(
isTelegramFormDirty(
- { botToken: "123:abc", chatId: "1" },
+ { botToken: " ", chatId: "", enabled: false },
+ { chatId: null },
+ ),
+ ).toBe(false);
+ expect(
+ isTelegramFormDirty(
+ { botToken: "123:abc", chatId: "1", enabled: false },
{ chatId: "1" },
),
).toBe(true);
expect(
- isTelegramFormDirty({ botToken: "", chatId: "2" }, { chatId: "1" }),
+ isTelegramFormDirty(
+ { botToken: "", chatId: "2", enabled: false },
+ { chatId: "1" },
+ ),
).toBe(true);
});
+ it("a flipped enable switch is a change on every channel, since it saves with the form", () => {
+ expect(
+ isWebhookFormDirty({ ...webhookForm, enabled: true }, webhookSaved),
+ ).toBe(true);
+ expect(
+ isNtfyFormDirty(
+ { serverUrl: "s", topic: "t", authToken: "", enabled: true },
+ { serverUrl: "s", topic: "t", enabled: false },
+ ),
+ ).toBe(true);
+ expect(
+ isEmailFormDirty(
+ { recipient: "a@x.io", enabled: false },
+ { recipient: "a@x.io", enabled: true },
+ ),
+ ).toBe(true);
+ expect(
+ isTelegramFormDirty(
+ { botToken: "", chatId: "1", enabled: true },
+ { chatId: "1", enabled: true },
+ ),
+ ).toBe(false);
+ });
+
it("nothing loaded yet is never a change", () => {
expect(isWebhookFormDirty(webhookForm, undefined)).toBe(false);
expect(
- isNtfyFormDirty({ serverUrl: "", topic: "", authToken: "x" }, undefined),
+ isNtfyFormDirty(
+ { serverUrl: "", topic: "", authToken: "x", enabled: true },
+ undefined,
+ ),
).toBe(false);
- expect(isEmailFormDirty({ recipient: "x" }, undefined)).toBe(false);
- expect(isTelegramFormDirty({ botToken: "x", chatId: "" }, undefined)).toBe(
+ expect(isEmailFormDirty({ recipient: "x", enabled: true }, undefined)).toBe(
false,
);
+ expect(
+ isTelegramFormDirty(
+ { botToken: "x", chatId: "", enabled: true },
+ undefined,
+ ),
+ ).toBe(false);
});
});
diff --git a/src/components/settings/__tests__/confirm-style-guard.test.ts b/src/components/settings/__tests__/confirm-style-guard.test.ts
new file mode 100644
index 000000000..a83d4f0c3
--- /dev/null
+++ b/src/components/settings/__tests__/confirm-style-guard.test.ts
@@ -0,0 +1,77 @@
+/**
+ * One confirm style across Settings.
+ *
+ * A destructive act (delete, revoke, remove, reset, sign out) confirms through
+ * `ConfirmButton` / `ConfirmDialog` (`src/components/ui/confirm-button.tsx`),
+ * which owns the dialog's anatomy and wears the destructive style when the
+ * caller says the act is destructive. A hand-built `AlertDialog` is how the
+ * same question ended up styled three different ways on neighbouring cards.
+ *
+ * So no file under `src/components/settings` imports the raw `AlertDialog`
+ * unless it is listed below with the reason the primitive does not fit.
+ */
+import { readdirSync, readFileSync, statSync } from "node:fs";
+import { join, relative } from "node:path";
+
+import { describe, expect, it } from "vitest";
+
+const ROOT = join(process.cwd(), "src", "components", "settings");
+
+const ALLOWED: Readonly> = {
+ "security-section/existing-factor-reauth-dialog.tsx":
+ "a re-authentication form, not a destructive confirm",
+ "security-section/passkey-list-section.tsx":
+ "the add-passkey re-authentication form; the delete confirm uses ConfirmButton",
+ "security-section/totp-card.tsx":
+ "the disable confirm asks for a current code and stays open until the server answers; both actions wear the destructive style",
+ "mcp-section.tsx":
+ "the connection and token revoke confirms; the MCP card is reshaped separately",
+};
+
+/** Owned by the integration cards, which follow their own card kit. */
+const ALLOWED_PREFIXES: readonly string[] = ["integrations/"];
+
+function walk(dir: string): string[] {
+ return readdirSync(dir).flatMap((name) => {
+ const path = join(dir, name);
+ if (statSync(path).isDirectory()) {
+ return name === "__tests__" ? [] : walk(path);
+ }
+ return /\.tsx?$/.test(name) ? [path] : [];
+ });
+}
+
+const IMPORTS_ALERT_DIALOG = /from\s+["']@\/components\/ui\/alert-dialog["']/;
+
+describe("settings confirm style", () => {
+ const files = walk(ROOT);
+
+ it("scans the settings tree", () => {
+ expect(files.length).toBeGreaterThan(50);
+ });
+
+ it("imports the raw AlertDialog only where the primitive does not fit", () => {
+ const offenders = files
+ .map((path) => relative(ROOT, path))
+ .filter((rel) =>
+ IMPORTS_ALERT_DIALOG.test(readFileSync(join(ROOT, rel), "utf8")),
+ )
+ .filter(
+ (rel) =>
+ !(rel in ALLOWED) &&
+ !ALLOWED_PREFIXES.some((prefix) => rel.startsWith(prefix)),
+ );
+ expect(
+ offenders,
+ "Confirm a destructive act through ConfirmButton / ConfirmDialog, or list the file here with the reason",
+ ).toEqual([]);
+ });
+
+ it("lists no file that no longer needs the exemption", () => {
+ const stale = Object.keys(ALLOWED).filter(
+ (rel) =>
+ !IMPORTS_ALERT_DIALOG.test(readFileSync(join(ROOT, rel), "utf8")),
+ );
+ expect(stale).toEqual([]);
+ });
+});
diff --git a/src/components/settings/__tests__/notification-channel-cards.test.tsx b/src/components/settings/__tests__/notification-channel-cards.test.tsx
index 85861ac18..26a2d5b2a 100644
--- a/src/components/settings/__tests__/notification-channel-cards.test.tsx
+++ b/src/components/settings/__tests__/notification-channel-cards.test.tsx
@@ -121,60 +121,27 @@ describe("notification channel cards", () => {
);
it.each([
- ["webhook", , 1],
- ["ntfy", , 1],
- ["email", , 1],
- ["telegram", , 0],
+ ["webhook", ],
+ ["ntfy", ],
+ ["email", ],
+ ["telegram", ],
] as const)(
- "%s switch invokes the enable-only mutation rather than the full save",
- async (channel, card, toggleMutationIndex) => {
- render(card);
-
- await mocks.mutationOptions[toggleMutationIndex]!.mutationFn(true);
-
- expect(mocks.apiFetchRaw).toHaveBeenCalledTimes(1);
- expect(mocks.apiFetchRaw).toHaveBeenCalledWith(
- `/api/settings/${channel}`,
- {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ enabled: true }),
- },
- );
- },
- );
-
- it.each([
- [, 1, ["settings", "webhook"]],
- [, 1, ["settings", "ntfy"]],
- [, 1, ["settings", "email"]],
- [
- ,
- 0,
- ["telegram", "settings"],
- ],
- ] as const)(
- "rolls each visible switch back to its previous server state on error",
- async (card, toggleMutationIndex, queryKey) => {
- const previous = { enabled: false, persistedConfig: "unchanged" };
- mocks.queryClient.getQueryData.mockReturnValue(previous);
- render(card);
- const toggle = mocks.mutationOptions[toggleMutationIndex]!;
-
- const context = await toggle.onMutate!(true);
- const optimisticUpdater = mocks.queryClient.setQueryData.mock
- .calls[0]![1] as (current: typeof previous) => typeof previous;
- expect(optimisticUpdater(previous)).toEqual({
- ...previous,
- enabled: true,
- });
-
- toggle.onError!(new Error("save failed"), true, context);
-
- expect(mocks.queryClient.setQueryData).toHaveBeenLastCalledWith(
- queryKey,
- previous,
- );
+ "%s enable switch is a form field: it writes nothing on its own",
+ async (_channel, card) => {
+ const html = render(card);
+ // Rendering the switch writes nothing, and the card mounts no
+ // switch-only mutation: every write it can make is the full form.
+ expect(html).toContain('role="switch"');
+ expect(mocks.apiFetchRaw).not.toHaveBeenCalled();
+ for (const mutation of mocks.mutationOptions) {
+ await mutation.mutationFn(true);
+ }
+ for (const [, init] of mocks.apiFetchRaw.mock.calls as Array<
+ [string, { body: string }]
+ >) {
+ const body = JSON.parse(init.body) as Record;
+ expect(Object.keys(body).length).toBeGreaterThan(1);
+ }
},
);
@@ -185,9 +152,9 @@ describe("notification channel cards", () => {
expect(html).toMatch(/
)}
- setConfirmOpen(true)}
+ variant="destructive"
+ size="sm"
+ disabled={deleting}
+ className="min-h-11 w-full shrink-0 sm:min-h-9 sm:w-auto"
+ >
+ {deleting ? (
+
+ ) : (
+
+ )}
+ {t("settings.dangerZone")}
+
+ {
- // Hold the dialog open while the destructive mutation is in
- // flight so the in-dialog pending state stays visible and a
- // stray backdrop tap can't dismiss it mid-request.
+ // Hold the dialog open while the request runs so the pending
+ // state stays on screen and a stray dismissal cannot fire it twice.
if (deleting) return;
setConfirmOpen(open);
}}
- >
-
-
-
-
-
-
- {t("settings.dangerZoneConfirm")}
-
-
- {t("settings.dangerZoneConfirmDescription")}
-
-
-
-
- {t("common.cancel")}
-
- {
- e.preventDefault();
- void handleDeleteAllData();
- }}
- disabled={deleting}
- aria-busy={deleting || undefined}
- data-slot="settings-data-reset-confirm"
- >
- {deleting && (
-
- )}
- {t("settings.finalDelete")}
-
-
-
-
+ title={t("settings.dangerZoneConfirm")}
+ body={t("settings.dangerZoneConfirmDescription")}
+ confirmLabel={t("settings.finalDelete")}
+ onConfirm={() => void handleDeleteAllData()}
+ pending={deleting}
+ slot="settings-data-reset"
+ holdOpen
+ destructive
+ />
);
@@ -291,63 +257,39 @@ function AccountDeleteCard() {
)}
- setConfirmOpen(true)}
+ variant="destructive"
+ size="sm"
+ disabled={deleting}
+ className="min-h-11 w-full shrink-0 sm:min-h-9 sm:w-auto"
+ data-slot="settings-account-delete-trigger"
+ >
+ {deleting ? (
+
+ ) : (
+
+ )}
+ {t("settings.deleteAccountCta")}
+
+ {
- // Hold the dialog open while the irreversible delete runs so
- // the pending state stays on screen and a stray dismissal
- // can't fire a second request.
+ // Hold the dialog open while the request runs so the pending
+ // state stays on screen and a stray dismissal cannot fire it twice.
if (deleting) return;
setConfirmOpen(open);
}}
- >
-
-
-
-
-
-
- {t("settings.deleteAccountConfirmTitle")}
-
-
- {t("settings.deleteAccountConfirmDescription")}
-
-
-
-
- {t("common.cancel")}
-
- {
- e.preventDefault();
- void handleDeleteAccount();
- }}
- disabled={deleting}
- aria-busy={deleting || undefined}
- data-slot="settings-account-delete-confirm"
- >
- {deleting && (
-
- )}
- {t("settings.deleteAccountFinal")}
-
-
-
-
+ title={t("settings.deleteAccountConfirmTitle")}
+ body={t("settings.deleteAccountConfirmDescription")}
+ confirmLabel={t("settings.deleteAccountFinal")}
+ onConfirm={() => void handleDeleteAccount()}
+ pending={deleting}
+ slot="settings-account-delete"
+ holdOpen
+ destructive
+ />
);
diff --git a/src/components/settings/ai/fallback-chain-card.tsx b/src/components/settings/ai/fallback-chain-card.tsx
index c563d943a..1c6631ec0 100644
--- a/src/components/settings/ai/fallback-chain-card.tsx
+++ b/src/components/settings/ai/fallback-chain-card.tsx
@@ -18,18 +18,8 @@ import {
X,
} from "lucide-react";
-import {
- AlertDialog,
- AlertDialogAction,
- AlertDialogCancel,
- AlertDialogContent,
- AlertDialogDescription,
- AlertDialogFooter,
- AlertDialogHeader,
- AlertDialogTitle,
- AlertDialogTrigger,
-} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
+import { ConfirmButton } from "@/components/ui/confirm-button";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { SettingsCardHeader } from "@/components/settings/_card-header";
@@ -246,30 +236,17 @@ export function FallbackChainCard({
)}
-
-
-
-
-
-
-
- {t("settings.ai.providerChain.resetConfirmTitle")}
-
-
- {t("settings.ai.providerChain.resetConfirmBody")}
-
-
-
- {t("common.cancel")}
-
- {t("settings.ai.providerChain.resetDefaults")}
-
-
-
-
+ void resetToDefaults()}
+ size="sm"
+ className="min-h-11 sm:min-h-9"
+ icon={}
+ destructive
+ />