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(/ ))} -

- {msg ?? t("settings.dateFormat.description")} +

+ {t("settings.dateFormat.description")}

); diff --git a/src/components/settings/email-card.tsx b/src/components/settings/email-card.tsx index 5cd80fe91..0ea1b1657 100644 --- a/src/components/settings/email-card.tsx +++ b/src/components/settings/email-card.tsx @@ -13,11 +13,8 @@ import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { TestConnectionButton } from "@/components/settings/test-connection-button"; import { isEmailFormDirty } from "@/components/settings/channel-form-dirty"; -import { - optimisticallySetChannelEnabled, - persistChannelEnabled, - rollbackChannelEnabled, -} from "@/components/settings/notification-channel-toggle"; +import { toastWrittenOutcome } from "@/components/outcome/outcome-toast"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch"; @@ -33,10 +30,9 @@ export function EmailCard({ isAuthenticated }: { isAuthenticated: boolean }) { const { t } = useTranslations(); const queryClient = useQueryClient(); const [recipient, setRecipient] = useState(""); - const [saveMsg, setSaveMsg] = useState(null); - const [saveMsgType, setSaveMsgType] = useState<"success" | "error" | null>( - null, - ); + // The enable switch is a draft field: it saves with the form's one Save. + const [enabled, setEnabled] = useState(false); + const [saveError, setSaveError] = useState(null); const { data: settings } = useQuery({ queryKey: queryKeys.settingsEmail(), @@ -47,16 +43,19 @@ export function EmailCard({ isAuthenticated }: { isAuthenticated: boolean }) { }); // React-recommended sync-from-server pattern (no setState-in-effect). - const settingsKey = settings ? settings.recipient : null; + const settingsKey = settings + ? `${settings.recipient}|${settings.enabled}` + : null; const [seededKey, setSeededKey] = useState(null); if (settingsKey !== null && settingsKey !== seededKey) { setSeededKey(settingsKey); setRecipient(settings!.recipient); + setEnabled(settings!.enabled); } - const dirty = isEmailFormDirty({ recipient }, settings); + const dirty = isEmailFormDirty({ recipient, enabled }, settings); const save = useMutation({ - mutationFn: async (enabled: boolean) => { + mutationFn: async () => { const res = await apiFetchRaw("/api/settings/email", { method: "PUT", headers: { "Content-Type": "application/json" }, @@ -69,45 +68,12 @@ export function EmailCard({ isAuthenticated }: { isAuthenticated: boolean }) { }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: queryKeys.settingsEmail() }); - setSaveMsg(t("settings.saved")); - setSaveMsgType("success"); + setSaveError(null); + toastWrittenOutcome("success", t("settings.saved")); }, onError: (err: Error) => { - setSaveMsg(err.message); - setSaveMsgType("error"); - }, - }); - - const toggleEnabled = useMutation< - void, - Error, - boolean, - EmailSettings | undefined - >({ - mutationFn: (enabled) => - persistChannelEnabled("/api/settings/email", enabled, t("common.error")), - onMutate: async (enabled) => { - const queryKey = queryKeys.settingsEmail(); - await queryClient.cancelQueries({ queryKey }); - setSaveMsg(null); - setSaveMsgType(null); - return optimisticallySetChannelEnabled( - queryClient, - queryKey, - enabled, - ); - }, - onSuccess: () => { - setSaveMsg(t("settings.saved")); - setSaveMsgType("success"); - }, - onError: (err, _enabled, previous) => { - rollbackChannelEnabled(queryClient, queryKeys.settingsEmail(), previous); - setSaveMsg(err.message); - setSaveMsgType("error"); - }, - onSettled: () => { - queryClient.invalidateQueries({ queryKey: queryKeys.settingsEmail() }); + setSaveError(err.message); + showErrorToast(err.message, { cause: err }); }, }); @@ -128,16 +94,16 @@ export function EmailCard({ isAuthenticated }: { isAuthenticated: boolean }) { toggleEnabled.mutate(checked)} - disabled={save.isPending || toggleEnabled.isPending} + checked={enabled} + onCheckedChange={setEnabled} + disabled={save.isPending} />
{ e.preventDefault(); - save.mutate(settings?.enabled ?? false); + save.mutate(); }} className="space-y-4" > @@ -156,12 +122,9 @@ export function EmailCard({ isAuthenticated }: { isAuthenticated: boolean }) { /> - {saveMsg && (saveMsgType === "error" || !dirty) && ( -

- {saveMsg} + {saveError && ( +

+ {saveError}

)} @@ -173,7 +136,7 @@ export function EmailCard({ isAuthenticated }: { isAuthenticated: boolean }) { /> - - - - - - - - {t("settings.deletePasskey")} - - - {t("settings.deletePasskeyDescription")} - - - - - {t("common.cancel")} - - remove.mutate(pk.id)} - > - {remove.isPending && ( - - )} - {t("common.delete")} - - - - + remove.mutate(pk.id)} + pending={remove.isPending && remove.variables === pk.id} + disabled={remove.isPending} + size="icon" + variant="ghost" + className="text-muted-foreground hover:text-foreground min-h-11 min-w-11 sm:h-8 sm:min-h-0 sm:w-8 sm:min-w-0" + icon={} + destructive + /> )} diff --git a/src/components/settings/security-section/security-keys-card.tsx b/src/components/settings/security-section/security-keys-card.tsx index ce4b99415..8bb5c0a8a 100644 --- a/src/components/settings/security-section/security-keys-card.tsx +++ b/src/components/settings/security-section/security-keys-card.tsx @@ -13,18 +13,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 { Input } from "@/components/ui/input"; import { EmptyState } from "@/components/ui/empty-state"; import { SettingsCard } from "@/components/settings/settings-card"; @@ -308,46 +298,23 @@ export function SecurityKeysCard({ > - - - - - - - - {t("settings.security.keys.remove")} - - - {t("settings.security.keys.removeConfirm")} - - - - - {t("common.cancel")} - - remove.mutate(key.id)} - > - {remove.isPending && ( - - )} - {t("common.delete")} - - - - + remove.mutate(key.id)} + pending={ + remove.isPending && remove.variables === key.id + } + disabled={remove.isPending} + size="icon" + variant="ghost" + className="text-muted-foreground hover:text-foreground min-h-11 min-w-11 sm:h-8 sm:min-h-0 sm:w-8 sm:min-w-0" + icon={} + destructive + /> )} diff --git a/src/components/settings/security-section/totp-card.tsx b/src/components/settings/security-section/totp-card.tsx index 17e62753f..2acac8456 100644 --- a/src/components/settings/security-section/totp-card.tsx +++ b/src/components/settings/security-section/totp-card.tsx @@ -498,6 +498,7 @@ export function TotpCard({ {t("common.cancel")} - - - - - - - {t("settings.sharing.revoke")} - - - {t("settings.sharing.revokeDescription")} - - - - - {t("common.cancel")} - - revokeMutation.mutate(link.id)} - > - {t("settings.sharing.revoke")} - - - - + revokeMutation.mutate(link.id)} + pending={ + revokeMutation.isPending && + revokeMutation.variables === link.id + } + size="sm" + className="ml-auto flex min-h-11 w-fit sm:min-h-9" + icon={} + destructive + /> ))} diff --git a/src/components/settings/sources-section.tsx b/src/components/settings/sources-section.tsx index 1860af9dc..4846c99eb 100644 --- a/src/components/settings/sources-section.tsx +++ b/src/components/settings/sources-section.tsx @@ -697,6 +697,7 @@ export function SourcesSection() { disabled={saveMutation.isPending} pending={resetMutation.isPending} onConfirm={() => resetMutation.mutate()} + destructive /> {dirty && priority ? ( <> diff --git a/src/components/settings/telegram-card.tsx b/src/components/settings/telegram-card.tsx index 3c685ce6b..551974655 100644 --- a/src/components/settings/telegram-card.tsx +++ b/src/components/settings/telegram-card.tsx @@ -1,7 +1,7 @@ "use client"; import { useState } from "react"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { Info, Loader2, MessageCircle, Save } from "lucide-react"; import { Button } from "@/components/ui/button"; @@ -15,11 +15,8 @@ import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { TestConnectionButton } from "@/components/settings/test-connection-button"; import { isTelegramFormDirty } from "@/components/settings/channel-form-dirty"; -import { - optimisticallySetChannelEnabled, - persistChannelEnabled, - rollbackChannelEnabled, -} from "@/components/settings/notification-channel-toggle"; +import { toastWrittenOutcome } from "@/components/outcome/outcome-toast"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch"; @@ -39,9 +36,10 @@ export function TelegramCard({ const queryClient = useQueryClient(); const [botToken, setBotToken] = useState(""); const [chatId, setChatId] = useState(""); + // The enable switch is a draft field: it saves with the form's one Save. + const [enabled, setEnabled] = useState(false); const [saving, setSaving] = useState(false); - const [msg, setMsg] = useState(null); - const [msgType, setMsgType] = useState<"success" | "error" | null>(null); + const [error, setError] = useState(null); const { data: settings } = useQuery({ queryKey: queryKeys.telegramSettings(), @@ -52,67 +50,25 @@ export function TelegramCard({ }); // Sync from server using the React-recommended "previous-payload-id" - // pattern instead of setState-in-effect. This avoids the strict - // `react-hooks/set-state-in-effect` lint failure that the legacy file - // disabled at module scope. - const settingsKey = settings ? (settings.chatId ?? "") : null; + // pattern instead of setState-in-effect. Reseeds after every save, so the + // switch reads what the server stored. + const settingsKey = settings + ? `${settings.chatId ?? ""}|${settings.enabled}` + : null; const [seededKey, setSeededKey] = useState(null); if (settingsKey && settingsKey !== seededKey) { setSeededKey(settingsKey); if (settings!.chatId) setChatId(settings!.chatId); + setEnabled(settings!.enabled); } - const dirty = isTelegramFormDirty({ botToken, chatId }, settings); - - const toggleEnabled = useMutation< - void, - Error, - boolean, - TelegramSettings | undefined - >({ - mutationFn: (enabled) => - persistChannelEnabled( - "/api/settings/telegram", - enabled, - t("settings.savingError"), - ), - onMutate: async (enabled) => { - const queryKey = queryKeys.telegramSettings(); - await queryClient.cancelQueries({ queryKey }); - setMsg(null); - setMsgType(null); - return optimisticallySetChannelEnabled( - queryClient, - queryKey, - enabled, - ); - }, - onSuccess: () => { - setMsg(t("settings.saved")); - setMsgType("success"); - }, - onError: (err, _enabled, previous) => { - rollbackChannelEnabled( - queryClient, - queryKeys.telegramSettings(), - previous, - ); - setMsg(err.message); - setMsgType("error"); - }, - onSettled: () => { - queryClient.invalidateQueries({ queryKey: queryKeys.telegramSettings() }); - }, - }); + const dirty = isTelegramFormDirty({ botToken, chatId, enabled }, settings); async function handleSave(e: React.FormEvent) { e.preventDefault(); setSaving(true); - setMsg(null); - setMsgType(null); + setError(null); - const body: Record = { - enabled: settings?.enabled ?? false, - }; + const body: Record = { enabled }; if (botToken.trim()) body.botToken = botToken.trim(); if (chatId !== (settings?.chatId ?? "")) body.chatId = chatId; @@ -126,16 +82,16 @@ export function TelegramCard({ }).catch(() => null); if (res?.ok) { - setMsg(t("settings.saved")); - setMsgType("success"); + toastWrittenOutcome("success", t("settings.saved")); setBotToken(""); queryClient.invalidateQueries({ queryKey: queryKeys.telegram() }); } else { const json = (await res?.json().catch(() => null)) as { error?: string; } | null; - setMsg(json?.error || t("settings.savingError")); - setMsgType("error"); + const message = json?.error || t("settings.savingError"); + setError(message); + showErrorToast(message); } } finally { setSaving(false); @@ -192,9 +148,9 @@ export function TelegramCard({
toggleEnabled.mutate(checked)} - disabled={saving || toggleEnabled.isPending} + checked={enabled} + onCheckedChange={setEnabled} + disabled={saving} />
diff --git a/src/components/settings/time-format-select.tsx b/src/components/settings/time-format-select.tsx index d8c0d823e..528c5e37d 100644 --- a/src/components/settings/time-format-select.tsx +++ b/src/components/settings/time-format-select.tsx @@ -1,8 +1,11 @@ "use client"; -import { useEffect, useRef, useState } from "react"; +import { useState } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { toastWrittenOutcome } from "@/components/outcome/outcome-toast"; +import { showErrorToast } from "@/lib/errors/error-toast"; + import { Label } from "@/components/ui/label"; import { NativeSelect } from "@/components/ui/native-select"; import { useAuth } from "@/hooks/use-auth"; @@ -39,30 +42,6 @@ export function TimeFormatSelect({ const [optimistic, setOptimistic] = useState( null, ); - const [msg, setMsg] = useState(null); - const [msgType, setMsgType] = useState<"success" | "error" | null>(null); - const clearTimerRef = useRef | null>(null); - - useEffect(() => { - return () => { - if (clearTimerRef.current !== null) { - clearTimeout(clearTimerRef.current); - clearTimerRef.current = null; - } - }; - }, []); - - function scheduleClear() { - if (clearTimerRef.current !== null) { - clearTimeout(clearTimerRef.current); - } - clearTimerRef.current = setTimeout(() => { - clearTimerRef.current = null; - setMsg(null); - setMsgType(null); - }, 3000); - } - const value: TimeFormatPreference = optimistic ?? user?.timeFormat ?? "AUTO"; const mutation = useMutation({ @@ -84,31 +63,19 @@ export function TimeFormatSelect({ // Repaint every formatter consumer right away; the /me refetch below // re-asserts the same value once it settles. storeTimeFormat(next); - setMsg(t("settings.timeFormat.saved")); - setMsgType("success"); + toastWrittenOutcome("success", t("settings.timeFormat.saved")); queryClient.invalidateQueries({ queryKey: queryKeys.authMe() }); setOptimistic(null); - scheduleClear(); }, onError: (err) => { setOptimistic(null); - setMsg( - err instanceof Error ? err.message : t("settings.timeFormat.saveError"), - ); - setMsgType("error"); - scheduleClear(); + showErrorToast(t("settings.timeFormat.saveError"), { cause: err }); }, }); function handleSelect(next: TimeFormatPreference) { if (next === value || mutation.isPending || !isAuthenticated) return; setOptimistic(next); - setMsg(null); - setMsgType(null); - if (clearTimerRef.current !== null) { - clearTimeout(clearTimerRef.current); - clearTimerRef.current = null; - } mutation.mutate(next); } @@ -126,16 +93,8 @@ export function TimeFormatSelect({ -

- {msg ?? t("settings.timeFormat.description")} +

+ {t("settings.timeFormat.description")}

); diff --git a/src/components/settings/unit-preference-select.tsx b/src/components/settings/unit-preference-select.tsx index 43a435715..0ba6d4712 100644 --- a/src/components/settings/unit-preference-select.tsx +++ b/src/components/settings/unit-preference-select.tsx @@ -1,8 +1,11 @@ "use client"; -import { useEffect, useRef, useState } from "react"; +import { useState } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { toastWrittenOutcome } from "@/components/outcome/outcome-toast"; +import { showErrorToast } from "@/lib/errors/error-toast"; + import { Label } from "@/components/ui/label"; import { NativeSelect } from "@/components/ui/native-select"; import { useAuth } from "@/hooks/use-auth"; @@ -36,30 +39,6 @@ export function UnitPreferenceSelect({ const { user } = useAuth(); const [optimistic, setOptimistic] = useState(null); - const [msg, setMsg] = useState(null); - const [msgType, setMsgType] = useState<"success" | "error" | null>(null); - const clearTimerRef = useRef | null>(null); - - useEffect(() => { - return () => { - if (clearTimerRef.current !== null) { - clearTimeout(clearTimerRef.current); - clearTimerRef.current = null; - } - }; - }, []); - - function scheduleClear() { - if (clearTimerRef.current !== null) { - clearTimeout(clearTimerRef.current); - } - clearTimerRef.current = setTimeout(() => { - clearTimerRef.current = null; - setMsg(null); - setMsgType(null); - }, 3000); - } - const value: UnitPreference = optimistic ?? user?.unitPreference ?? "metric"; const mutation = useMutation({ @@ -78,36 +57,22 @@ export function UnitPreferenceSelect({ return next; }, onSuccess: () => { - setMsg(t("settings.dashboard.units.saved")); - setMsgType("success"); + toastWrittenOutcome("success", t("settings.dashboard.units.saved")); queryClient.invalidateQueries({ queryKey: queryKeys.authMe() }); queryClient.invalidateQueries({ queryKey: queryKeys.userUnitPreference(), }); setOptimistic(null); - scheduleClear(); }, onError: (err) => { setOptimistic(null); - setMsg( - err instanceof Error - ? err.message - : t("settings.dashboard.units.saveError"), - ); - setMsgType("error"); - scheduleClear(); + showErrorToast(t("settings.dashboard.units.saveError"), { cause: err }); }, }); function handleSelect(next: UnitPreference) { if (next === value || mutation.isPending || !isAuthenticated) return; setOptimistic(next); - setMsg(null); - setMsgType(null); - if (clearTimerRef.current !== null) { - clearTimeout(clearTimerRef.current); - clearTimerRef.current = null; - } mutation.mutate(next); } @@ -126,16 +91,8 @@ export function UnitPreferenceSelect({ {t("settings.dashboard.units.imperial")} -

- {msg ?? t("settings.dashboard.units.description")} +

+ {t("settings.dashboard.units.description")}

); diff --git a/src/components/settings/webhook-card.tsx b/src/components/settings/webhook-card.tsx index f09528c7e..de11154fc 100644 --- a/src/components/settings/webhook-card.tsx +++ b/src/components/settings/webhook-card.tsx @@ -15,11 +15,8 @@ import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { TestConnectionButton } from "@/components/settings/test-connection-button"; import { isWebhookFormDirty } from "@/components/settings/channel-form-dirty"; -import { - optimisticallySetChannelEnabled, - persistChannelEnabled, - rollbackChannelEnabled, -} from "@/components/settings/notification-channel-toggle"; +import { toastWrittenOutcome } from "@/components/outcome/outcome-toast"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch"; @@ -41,10 +38,9 @@ export function WebhookCard({ isAuthenticated }: { isAuthenticated: boolean }) { const [headerName, setHeaderName] = useState(""); const [headerValue, setHeaderValue] = useState(""); const [format, setFormat] = useState("generic"); - const [saveMsg, setSaveMsg] = useState(null); - const [saveMsgType, setSaveMsgType] = useState<"success" | "error" | null>( - null, - ); + // The enable switch is a draft field: it saves with the form's one Save. + const [enabled, setEnabled] = useState(false); + const [saveError, setSaveError] = useState(null); const { data: settings } = useQuery({ queryKey: queryKeys.settingsWebhook(), @@ -56,7 +52,7 @@ export function WebhookCard({ isAuthenticated }: { isAuthenticated: boolean }) { // React-recommended sync-from-server pattern (no setState-in-effect). const settingsKey = settings - ? `${settings.url}|${settings.headerName}|${settings.format ?? "generic"}` + ? `${settings.url}|${settings.headerName}|${settings.format ?? "generic"}|${settings.enabled}` : null; const [seededKey, setSeededKey] = useState(null); if (settingsKey && settingsKey !== seededKey) { @@ -64,14 +60,15 @@ export function WebhookCard({ isAuthenticated }: { isAuthenticated: boolean }) { setUrl(settings!.url); setHeaderName(settings!.headerName); setFormat(settings!.format === "gotify" ? "gotify" : "generic"); + setEnabled(settings!.enabled); } const dirty = isWebhookFormDirty( - { url, headerName, headerValue, format }, + { url, headerName, headerValue, format, enabled }, settings, ); const save = useMutation({ - mutationFn: async (enabled: boolean) => { + mutationFn: async () => { const res = await apiFetchRaw("/api/settings/webhook", { method: "PUT", headers: { "Content-Type": "application/json" }, @@ -102,54 +99,13 @@ export function WebhookCard({ isAuthenticated }: { isAuthenticated: boolean }) { }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: queryKeys.settingsWebhook() }); - setSaveMsg(t("settings.saved")); - setSaveMsgType("success"); + setSaveError(null); + toastWrittenOutcome("success", t("settings.saved")); setHeaderValue(""); }, onError: (err: Error) => { - setSaveMsg(err.message); - setSaveMsgType("error"); - }, - }); - - const toggleEnabled = useMutation< - void, - Error, - boolean, - WebhookSettings | undefined - >({ - mutationFn: (enabled) => - persistChannelEnabled( - "/api/settings/webhook", - enabled, - t("common.error"), - ), - onMutate: async (enabled) => { - const queryKey = queryKeys.settingsWebhook(); - await queryClient.cancelQueries({ queryKey }); - setSaveMsg(null); - setSaveMsgType(null); - return optimisticallySetChannelEnabled( - queryClient, - queryKey, - enabled, - ); - }, - onSuccess: () => { - setSaveMsg(t("settings.saved")); - setSaveMsgType("success"); - }, - onError: (err, _enabled, previous) => { - rollbackChannelEnabled( - queryClient, - queryKeys.settingsWebhook(), - previous, - ); - setSaveMsg(err.message); - setSaveMsgType("error"); - }, - onSettled: () => { - queryClient.invalidateQueries({ queryKey: queryKeys.settingsWebhook() }); + setSaveError(err.message); + showErrorToast(err.message, { cause: err }); }, }); @@ -166,16 +122,16 @@ export function WebhookCard({ isAuthenticated }: { isAuthenticated: boolean }) { toggleEnabled.mutate(checked)} - disabled={save.isPending || toggleEnabled.isPending} + checked={enabled} + onCheckedChange={setEnabled} + disabled={save.isPending} /> { e.preventDefault(); - save.mutate(settings?.enabled ?? false); + save.mutate(); }} className="space-y-4" > @@ -244,12 +200,9 @@ export function WebhookCard({ isAuthenticated }: { isAuthenticated: boolean }) { - {saveMsg && (saveMsgType === "error" || !dirty) && ( -

- {saveMsg} + {saveError && ( +

+ {saveError}

)} @@ -261,7 +214,7 @@ export function WebhookCard({ isAuthenticated }: { isAuthenticated: boolean }) { /> - - - ); -} diff --git a/src/components/labs/labs-settings.tsx b/src/components/labs/labs-settings.tsx index 8988fd20b..12ca02e37 100644 --- a/src/components/labs/labs-settings.tsx +++ b/src/components/labs/labs-settings.tsx @@ -1,17 +1,16 @@ "use client"; /** - * v1.18.6 — the Labs module's own settings page body. + * The Labs module's settings, in two places since v1.44: * - * Reached from the wrench beside the page's "hinzufügen" button. Lets the - * user: - * - pick the card-vs-list page view (MOD-03), - * - choose the sort order (by most-recent reading, descending / ascending, - * or manual) — MOD-04, - * - reorder biomarkers when sort = manual (MOD-03/04), - * - and create / edit / delete biomarkers via the existing - * `BiomarkerManager` (MOD-04). - * View / sort / order persist client-side via `useModuleListPrefs("labs")`. + * `LabsSettings` — Settings > Arrangement > Labs: the card-vs-list view + * (MOD-03), the sort (recency, alphabetical or manual, MOD-04) and the manual + * order of the biomarkers. Order and view only, persisted per account through + * `useModuleListPrefs("labs")`. + * + * `LabsModuleSettings` — Settings > Modules > Labs: the on-device text + * recognition opt-in and the biomarker catalog (`BiomarkerManager`). Reached + * from the wrench on the labs page. */ import { useQuery } from "@tanstack/react-query"; import { toastWrittenOutcome } from "@/components/outcome/outcome-toast"; @@ -52,12 +51,6 @@ export function LabsSettings() { const { t } = useTranslations(); const { prefs, setView, setOrder, setSortDir } = useModuleListPrefs("labs"); - // v1.18.10 — local (in-browser) OCR opt-in. Lets a user whose AI provider - // can't read images still scan a report: the image is OCR'd on-device and - // only the extracted text is sent to the text-only provider. - const localOcr = useLabsLocalOcr(); - const updateLocalOcr = useUpdateLabsLocalOcr(); - const { data, isLoading } = useQuery({ queryKey: queryKeys.biomarkers(), queryFn: () => apiGet("/api/biomarkers"), @@ -130,7 +123,20 @@ export function LabsSettings() { )} ) : null} + + ); +} +export function LabsModuleSettings() { + const { t } = useTranslations(); + // v1.18.10 — local (in-browser) OCR opt-in. Lets a user whose AI provider + // can't read images still scan a report: the image is OCR'd on-device and + // only the extracted text is sent to the text-only provider. + const localOcr = useLabsLocalOcr(); + const updateLocalOcr = useUpdateLabsLocalOcr(); + + return ( +
{/* v1.18.10 — local (in-browser) OCR opt-in for lab scans. */} Arrangement > Checkups: the card-vs-list + * view and the manual order, persisted per account through + * `useModuleListPrefs("vorsorge")`. + * + * `VorsorgeReminderSwitchesCard` — the per-reminder on/off switches, under + * Settings > Notifications with the other reminders, persisted through the * reminder PATCH route. */ import { ArrowUpDown, LayoutGrid, ListChecks } from "lucide-react"; @@ -47,7 +48,6 @@ function resolveLabel( export function VorsorgeSettings() { const { t } = useTranslations(); const { data: reminders, isLoading } = useMeasurementReminders(true); - const { update } = useMeasurementReminderMutations(); const { prefs, setView, setOrder } = useModuleListPrefs("vorsorge"); const ordered = applyOrder(reminders ?? [], prefs.order, (r) => r.id); @@ -59,7 +59,7 @@ export function VorsorgeSettings() { : t("measurementReminders.selfPlanned"), })); - // v1.18.10 — the three Vorsorge settings groups adopt the shared + // v1.18.10 — the Vorsorge settings groups adopt the shared // `SettingsCard` + `SettingsCardHeader` contract (rounded-xl bordered // surface, neutral muted icon, `text-lg` title, short muted description) // so this surface reads like every other Settings section instead of bare @@ -96,67 +96,80 @@ export function VorsorgeSettings() { )}
- - - -
- {isLoading ? ( - - ) : (reminders?.length ?? 0) === 0 ? ( - - ) : ( - - - {ordered.map((reminder) => ( -
- - {resolveLabel(reminder, t)} - - - update.mutate( - { id: reminder.id, body: { enabled: next } }, - { - onSuccess: () => - toastWrittenOutcome( - "success", - t("settings.saved"), - ), - onError: (failure) => - showErrorToast(t("settings.savingError"), { - cause: failure, - }), - }, - ) - } - disabled={update.isPending} - // Label each row's Switch by its own name span so SR - // users hear the reminder name (plus the switch role's - // on/off state) instead of N identical generic labels. - aria-labelledby={`vorsorge-reminder-label-${reminder.id}`} - /> -
- ))} -
-
- )} -
-
); } + +/** + * The on/off switch of each preventive-care reminder. A reminder, so since + * v1.44 it lives with the other reminders under Settings > Notifications + * (`#vorsorge-reminders`, the wrapper there); the checkups page links + * there. + */ +export function VorsorgeReminderSwitchesCard() { + const { t } = useTranslations(); + const { data: reminders, isLoading } = useMeasurementReminders(true); + const { update } = useMeasurementReminderMutations(); + const { prefs } = useModuleListPrefs("vorsorge"); + const ordered = applyOrder(reminders ?? [], prefs.order, (r) => r.id); + + return ( + + +
+ {isLoading ? ( + + ) : (reminders?.length ?? 0) === 0 ? ( + + ) : ( + + + {ordered.map((reminder) => ( +
+ + {resolveLabel(reminder, t)} + + + update.mutate( + { id: reminder.id, body: { enabled: next } }, + { + onSuccess: () => + toastWrittenOutcome("success", t("settings.saved")), + onError: (failure) => + showErrorToast(t("settings.savingError"), { + cause: failure, + }), + }, + ) + } + disabled={update.isPending} + // Label each row's Switch by its own name span so SR + // users hear the reminder name (plus the switch role's + // on/off state) instead of N identical generic labels. + aria-labelledby={`vorsorge-reminder-label-${reminder.id}`} + /> +
+ ))} +
+
+ )} +
+
+ ); +} diff --git a/src/components/records/screening-hints-card.tsx b/src/components/records/screening-hints-card.tsx index 76b7859c1..fa76cec15 100644 --- a/src/components/records/screening-hints-card.tsx +++ b/src/components/records/screening-hints-card.tsx @@ -2,10 +2,11 @@ /** * v1.43 (#614) — screening hints from the life circumstances, in Settings → - * Vorsorge. Low-dose CT for lung cancer and the abdominal-aorta ultrasound, - * each with its status, the reasons in the account's language and the rule's - * source and country. A hint, never a reminder: nothing is scheduled here, - * and the card says so. Resolved on the server; rendered as delivered. + * Health profile → Medical history (under Checkups until v1.44). Low-dose CT + * for lung cancer and the abdominal-aorta ultrasound, each with its status, + * the reasons in the account's language and the rule's source and country. + * A hint, never a reminder: nothing is scheduled here, and the card says so. + * Resolved on the server; rendered as delivered. * * The card stays away until it has something to say: the person has said * something about smoking, or a hint already applies without it (the German diff --git a/src/components/settings/__tests__/ai-section.test.tsx b/src/components/settings/__tests__/ai-section.test.tsx index b647fc056..24a3327e7 100644 --- a/src/components/settings/__tests__/ai-section.test.tsx +++ b/src/components/settings/__tests__/ai-section.test.tsx @@ -243,6 +243,6 @@ describe(" — dropdown-driven UX (B2)", () => { it("no longer mounts the Coach cards (moved to the Coach section)", () => { activeProviderQuery = "codex"; const html = render(); - expect(html).not.toContain('data-testid="settings-disable-coach-card"'); + expect(html).not.toContain('data-slot="settings-coach-state-card"'); }); }); diff --git a/src/components/settings/__tests__/anamnesis-about-me-relocation.test.tsx b/src/components/settings/__tests__/anamnesis-about-me-relocation.test.tsx index 40ae5dc22..0383d285b 100644 --- a/src/components/settings/__tests__/anamnesis-about-me-relocation.test.tsx +++ b/src/components/settings/__tests__/anamnesis-about-me-relocation.test.tsx @@ -35,6 +35,9 @@ vi.mock("@/components/records/ai-profile-inclusion-manager", () => ({ vi.mock("@/components/records/conditions-manager", () => ({ ConditionsManager: () => null, })); +vi.mock("@/components/records/screening-hints-card", () => ({ + ScreeningHintsCard: () => null, +})); vi.mock("@/components/records/life-circumstances-manager", () => ({ LifeCircumstancesManager: () => null, })); diff --git a/src/components/settings/__tests__/anamnesis-ai-inclusion-gate.test.tsx b/src/components/settings/__tests__/anamnesis-ai-inclusion-gate.test.tsx index 83b4a1e14..2db363219 100644 --- a/src/components/settings/__tests__/anamnesis-ai-inclusion-gate.test.tsx +++ b/src/components/settings/__tests__/anamnesis-ai-inclusion-gate.test.tsx @@ -18,6 +18,9 @@ vi.mock("@/components/records/ai-profile-inclusion-manager", () => ({ vi.mock("@/components/records/conditions-manager", () => ({ ConditionsManager: () =>
, })); +vi.mock("@/components/records/screening-hints-card", () => ({ + ScreeningHintsCard: () => null, +})); vi.mock("@/components/records/life-circumstances-manager", () => ({ LifeCircumstancesManager: () => null, })); diff --git a/src/components/settings/__tests__/coach-memory-section.test.tsx b/src/components/settings/__tests__/coach-memory-section.test.tsx index d93d4b891..aaf49c186 100644 --- a/src/components/settings/__tests__/coach-memory-section.test.tsx +++ b/src/components/settings/__tests__/coach-memory-section.test.tsx @@ -12,8 +12,7 @@ * The mutation wiring itself (DELETE `/facts/{id}` and DELETE `/facts` * → `queryKeys.coachFacts()` invalidation + toast) is pinned by the * route-shape contract in the component plus the typecheck gate; this - * suite guards the render contract the way the sibling - * `disable-coach-card.test.tsx` does for the toggle. + * suite guards the render contract. */ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { renderToStaticMarkup } from "react-dom/server"; diff --git a/src/components/settings/__tests__/coach-section.test.tsx b/src/components/settings/__tests__/coach-section.test.tsx index d7811ef97..101f834fa 100644 --- a/src/components/settings/__tests__/coach-section.test.tsx +++ b/src/components/settings/__tests__/coach-section.test.tsx @@ -1,10 +1,10 @@ /** * v1.18.0 — Settings → Coach section. * - * The Coach preference cards (disable toggle, preferences, memory) moved - * out of the AI / Assistant section into their own dedicated, module-gated - * entry. SSR-only smoke test — interactive contracts live in the cards' - * own suites (`disable-coach-card.test.tsx`, `coach-memory-section.test.tsx`). + * The Coach preference cards (state, preferences, memory) moved out of the + * AI / Assistant section into their own dedicated, module-gated entry. + * SSR-only smoke test — interactive contracts live in the cards' own suites + * (`coach-memory-section.test.tsx`; the switch in `modules-section.test.tsx`). */ import { describe, expect, it, vi } from "vitest"; import { renderToStaticMarkup } from "react-dom/server"; @@ -78,34 +78,51 @@ function render(locale: "en" | "de" = "en") { } describe(" — SSR smoke", () => { - it("renders the section scaffold + the Activate-Coach toggle card", () => { + it("shows the Coach state with a link to the one switch, not a second switch", () => { authState.disableCoach = false; const html = render(); expect(html).toContain('id="settings-section-coach-title"'); - expect(html).toContain('data-testid="settings-disable-coach-card"'); - expect(html).toContain('data-testid="settings-disable-coach-switch"'); - // v1.18.1 — polarity flipped to activate/default-on. - expect(html).toContain("Activate Coach"); - expect(html).toContain("Show the Coach button and drawer"); - // v1.18.6 — the proactive Coach nudge card moved here from - // Benachrichtigungen; its anchor renders under the enabled-coach gate. - expect(html).toContain('id="coach-nudge"'); + expect(html).toContain('data-slot="settings-coach-state-card"'); + expect(html).toContain('data-enabled="true"'); + expect(html).toContain('href="/settings/modules#module-coach"'); + // v1.44 — no switch of its own: one state, one control, in Modules. + const card = html.match( + /data-slot="settings-coach-state-card"[\s\S]*?<\/a>/, + )?.[0]; + expect(card).toBeDefined(); + expect(card).not.toContain('role="switch"'); + expect(html).not.toContain("settings-disable-coach"); // Raw key never leaks past i18n. expect(html).not.toContain("settings.sections.coach."); - expect(html).not.toContain("settings.coach.activate."); + expect(html).not.toContain("settings.coach.state."); }); - it("renders the German Activate-Coach copy", () => { - authState.disableCoach = false; + it("reads off once the account has the Coach off", () => { + mountState.mounted = true; + authState.disableCoach = true; + try { + expect(render()).toContain('data-enabled="false"'); + } finally { + mountState.mounted = false; + authState.disableCoach = false; + } + }); + + it("points the nudges at Settings > Notifications", () => { + const html = render(); + expect(html).toContain('href="/settings/notifications#coach-nudge"'); + expect(html).not.toContain('id="coach-nudge"'); + }); + + it("renders the German copy", () => { const html = render("de"); - expect(html).toContain("Coach aktivieren"); expect(html).not.toContain("settings.sections.coach."); - expect(html).not.toContain("settings.coach.activate."); + expect(html).not.toContain("settings.coach.state."); }); }); describe(" — the tuning follows the record, not the account", () => { - it("hides the nudge when the record has the Coach off, whatever the viewer's own opt-out says", () => { + it("hides the nudge pointer when the record has the Coach off, whatever the viewer's own opt-out says", () => { // A guardian with the Coach on, inside a managed record that has it off: // `/me` publishes the viewer's own `disableCoach` and the RECORD's module // map. The record's answer decides. @@ -113,10 +130,10 @@ describe(" — the tuning follows the record, not the account", () authState.disableCoach = false; authState.modules = { coach: false }; try { - expect(render()).not.toContain('id="coach-nudge"'); + expect(render()).not.toContain('data-slot="settings-coach-nudge-link"'); authState.disableCoach = true; authState.modules = { coach: true }; - expect(render()).toContain('id="coach-nudge"'); + expect(render()).toContain('data-slot="settings-coach-nudge-link"'); } finally { mountState.mounted = false; authState.disableCoach = false; diff --git a/src/components/settings/__tests__/confirm-style-guard.test.ts b/src/components/settings/__tests__/confirm-style-guard.test.ts index a83d4f0c3..b78389e39 100644 --- a/src/components/settings/__tests__/confirm-style-guard.test.ts +++ b/src/components/settings/__tests__/confirm-style-guard.test.ts @@ -24,8 +24,6 @@ const ALLOWED: Readonly> = { "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. */ diff --git a/src/components/settings/__tests__/disable-coach-card.test.tsx b/src/components/settings/__tests__/disable-coach-card.test.tsx deleted file mode 100644 index 87acdf78b..000000000 --- a/src/components/settings/__tests__/disable-coach-card.test.tsx +++ /dev/null @@ -1,155 +0,0 @@ -/** - * v1.4.47 — Settings → Coach activation toggle persistence contract. - * - * v1.18.1 — the card reads "Activate Coach" (default ON). The persisted - * column is still `User.disableCoach`; the Switch shows the activated view - * (`checked = !disableCoach`). The SSR render is `mounted=false`, where the - * card paints the default-ON (checked) state so the default-on contract holds - * before the wire value resolves — both default and opted-out users render - * checked under `renderToStaticMarkup`. - * - * The card is a small client-only `` that PATCHes - * `/api/auth/me/disable-coach` and invalidates `queryKeys.authMe()` so - * every Coach mount point in the app reacts on the next /me refetch. - * - * Test strategy: use the real `useMutation` + `QueryClient` from - * `@tanstack/react-query`, stub `fetch`, and drive the Switch via a - * `renderToStaticMarkup` pass plus a hand-invoked mutation call. The - * file SSR-renders inside a `` so the hook - * branches execute and the spies fire. - */ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { renderToStaticMarkup } from "react-dom/server"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; - -import { I18nProvider } from "@/lib/i18n/context"; -import type { AuthUser } from "@/hooks/use-auth"; - -const authSpy = vi.fn< - () => { user: AuthUser | null; isAuthenticated: boolean } ->(() => ({ user: buildUser(false), isAuthenticated: true })); -vi.mock("@/hooks/use-auth", async () => { - const actual = - await vi.importActual( - "@/hooks/use-auth", - ); - return { - ...actual, - useAuth: () => authSpy(), - }; -}); - -// Stub the navigation hooks the parent `` (and its child -// cards) reach for so the SSR pass doesn't blow up. The disable-coach card -// itself doesn't trigger any GET — it only writes through PATCH on click. -// -// v1.18.0 — the Coach cards moved from `` to the dedicated -// ``; this contract test renders the new parent. -vi.mock("next/navigation", () => ({ - useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), - usePathname: () => "/settings/coach", - useSearchParams: () => new URLSearchParams(""), -})); - -function buildUser(disableCoach: boolean): AuthUser { - return { - id: "user-1", - username: "testuser", - email: null, - role: "USER", - heightCm: null, - dateOfBirth: null, - gender: null, - timezone: "Europe/Berlin", - onboardingCompletedAt: null, - onboardingTourCompleted: true, - onboardingTourProgress: null, - avatarUrl: null, - glucoseUnit: null, - unitPreference: "metric", - timeFormat: "AUTO", - dateFormat: "AUTO", - disableCoach, - fullName: null, - insurerName: null, - insurerIkNumber: null, - insuranceNumber: null, - lastReportPracticeName: null, - reportSelection: null, - cycleTrackingEnabled: false, - modules: {}, - }; -} - -import { CoachSection } from "../coach-section"; - -function makeFetch() { - return vi.fn(async (_url: RequestInfo | URL, init?: RequestInit) => { - const body = init?.body ? JSON.parse(init.body as string) : {}; - return new Response( - JSON.stringify({ - data: { disableCoach: body.disableCoach ?? false }, - }), - { - status: 200, - headers: { "Content-Type": "application/json" }, - }, - ); - }); -} - -beforeEach(() => { - authSpy.mockClear(); - authSpy.mockImplementation(() => ({ - user: buildUser(false), - isAuthenticated: true, - })); - vi.stubGlobal("fetch", makeFetch()); -}); - -afterEach(() => { - vi.unstubAllGlobals(); -}); - -function render(): string { - const client = new QueryClient({ - defaultOptions: { queries: { retry: 0 } }, - }); - return renderToStaticMarkup( - - - - - , - ); -} - -describe("Settings — DisableCoachCard", () => { - it("renders the activation Switch checked at SSR (default-on contract)", () => { - const html = render(); - // v1.18.1 — the SSR render is `mounted=false`, where the card paints - // the default-ON (activated → checked) state regardless of the wire value, - // so the Switch never flashes off before /me resolves. - expect(html).toContain('data-testid="settings-disable-coach-switch"'); - expect(html).toContain('data-state="checked"'); - }); - - it("paints the activated (checked) SSR state even for an opted-out user", () => { - authSpy.mockImplementation(() => ({ - user: buildUser(true), - isAuthenticated: true, - })); - const html = render(); - expect(html).toContain('data-testid="settings-disable-coach-switch"'); - // The activated state resolves on the first client re-render once - // `mounted` flips; the SSR pass stays on the default-ON shape. - expect(html).toContain('data-state="checked"'); - }); - - it("renders the Coach-activation title + description", () => { - const html = render(); - // v1.18.1 — polarity flipped to activate/default-on. - expect(html).toContain("Activate Coach"); - expect(html).toContain("Show the Coach button and drawer"); - }); -}); diff --git a/src/components/settings/__tests__/layout-groups.test.ts b/src/components/settings/__tests__/layout-groups.test.ts index b028df7dc..cb8e2b113 100644 --- a/src/components/settings/__tests__/layout-groups.test.ts +++ b/src/components/settings/__tests__/layout-groups.test.ts @@ -16,7 +16,6 @@ import { describe, expect, it, vi } from "vitest"; vi.mock("../dashboard-section", () => ({ DashboardSection: () => null })); vi.mock("../insights-section", () => ({ InsightsSection: () => null })); vi.mock("../medications-section", () => ({ MedicationsSection: () => null })); -vi.mock("../mood-section", () => ({ MoodSection: () => null })); vi.mock("../labs-section", () => ({ LabsSection: () => null })); vi.mock("../illness-section", () => ({ IllnessSection: () => null })); vi.mock("../vorsorge-section", () => ({ VorsorgeSection: () => null })); @@ -29,12 +28,13 @@ import { } from "../layout-groups"; describe("layout-groups registry", () => { - it("exposes the eight Appearance modules as statically-generated subpage params", () => { + it("exposes the seven Arrangement modules as statically-generated subpage params", () => { + // v1.44 — mood left: its page was tag management, which lives under + // Settings > Modules > Mood now (`/settings/layout/mood` redirects). expect(LAYOUT_GROUP_IDS).toEqual([ "dashboard", "insights", "medications", - "mood", "labs", "illness", "documents", @@ -45,7 +45,6 @@ describe("layout-groups registry", () => { { module: "dashboard" }, { module: "insights" }, { module: "medications" }, - { module: "mood" }, { module: "labs" }, { module: "illness" }, { module: "documents" }, @@ -66,7 +65,7 @@ describe("layout-groups registry", () => { ? layoutGroupModule(id) : "not-a-group"; expect(gate("medications")).toBe("medications"); - expect(gate("mood")).toBe("mood"); + expect(gate("mood")).toBe("not-a-group"); expect(gate("labs")).toBe("labs"); expect(gate("illness")).toBe("illness"); // dashboard / insights / vorsorge are never gated. diff --git a/src/components/settings/__tests__/layout-section.test.tsx b/src/components/settings/__tests__/layout-section.test.tsx index 5b4935d64..97d29dadd 100644 --- a/src/components/settings/__tests__/layout-section.test.tsx +++ b/src/components/settings/__tests__/layout-section.test.tsx @@ -54,7 +54,6 @@ describe(" hub", () => { "dashboard", "insights", "medications", - "mood", "labs", "illness", "vorsorge", @@ -68,7 +67,7 @@ describe(" hub", () => { expect(html).not.toContain('href="/settings/layout/medications"'); expect(html).not.toContain('href="/settings/layout/labs"'); // Other modules unaffected; Vorsorge is never gated. - expect(html).toContain('href="/settings/layout/mood"'); + expect(html).toContain('href="/settings/layout/illness"'); expect(html).toContain('href="/settings/layout/vorsorge"'); }); @@ -82,6 +81,7 @@ describe(" hub", () => { expect(html).toContain('href="/settings/layout/dashboard"'); expect(html).toContain('href="/settings/layout/insights"'); expect(html).toContain('href="/settings/layout/vorsorge"'); + // v1.44 — mood is tag management and left Arrangement for Modules. expect(html).not.toContain('href="/settings/layout/mood"'); }); diff --git a/src/components/settings/__tests__/medications-section.test.tsx b/src/components/settings/__tests__/medications-section.test.tsx index 65e5e7db5..141437220 100644 --- a/src/components/settings/__tests__/medications-section.test.tsx +++ b/src/components/settings/__tests__/medications-section.test.tsx @@ -137,11 +137,11 @@ describe(" — SSR smoke", () => { expect(lastActive).toBeLessThan(html.indexOf("Amoxicillin")); }); - it("gathers the injection-sites card (moved from the account profile)", () => { - // v1.18.0 — injection-site exclusions are a medication setting and - // now live on this screen alongside the list view + order. + it("holds order and view only; the injection sites live under Modules", () => { + // v1.44 — Arrangement holds order and view; the injection-site + // exclusions moved to Settings > Modules > Medications. const html = render(); - expect(html).toContain("Globally excluded injection sites"); + expect(html).not.toContain("Globally excluded injection sites"); }); it("feeds the editor the saved order (active block re-sorted by layout.order)", () => { diff --git a/src/components/settings/__tests__/mood-section.test.tsx b/src/components/settings/__tests__/mood-section.test.tsx index 2735bb59c..2d2fbbb76 100644 --- a/src/components/settings/__tests__/mood-section.test.tsx +++ b/src/components/settings/__tests__/mood-section.test.tsx @@ -131,7 +131,6 @@ vi.mock("@/hooks/use-auth", () => ({ })); import { I18nProvider } from "@/lib/i18n/context"; -import { SettingsSectionFrame } from "./section-frame-harness"; import { MoodSection } from "../mood-section"; import { primeLocales } from "@/__tests__/helpers/prime-locales"; @@ -142,9 +141,7 @@ function render(locale: "en" | "de" = "en"): string { // shared frame the route wraps the section in. return renderToStaticMarkup( - - - + , ); } @@ -159,7 +156,8 @@ describe(" — SSR smoke", () => { // v1.18.1 — the section blurb was dropped; the sr-only heading + the // card content carry the meaning. const html = render(); - expect(html).toContain("settings-section-mood-title"); + // v1.44 — the page lives under Settings > Modules > Mood; its heading + // comes from the route, not from a frame here. expect(html).not.toContain("settings.sections."); expect(html).not.toContain("mood.manage."); }); diff --git a/src/components/settings/__tests__/notifications-section-module-gating.test.tsx b/src/components/settings/__tests__/notifications-section-module-gating.test.tsx index c5cbbd4cc..0416810c0 100644 --- a/src/components/settings/__tests__/notifications-section-module-gating.test.tsx +++ b/src/components/settings/__tests__/notifications-section-module-gating.test.tsx @@ -1,14 +1,12 @@ /** - * v1.18.0 — module-gated per-type visibility on the consolidated - * "Benachrichtigungen" reminder-types home. + * Settings > Notifications is the one home for every reminder kind, each + * card gated on its module and failing open. * - * The mood reminder shows only when the `mood` module is enabled. Low-stock - * maps to the medications domain (shown unless explicitly disabled). The gate - * fails OPEN, so a `/me` payload without a module map keeps every card - * visible. - * - * v1.18.6 — the proactive Coach nudge card moved to Settings → Coach, so - * it no longer appears on this screen. + * v1.18.0 — mood reminder on `mood`, low-stock on `medications`. + * v1.44 — the Coach nudges came back (gated on the Coach surface), the + * checkup reminder switches joined (not a module, always shown), and a card + * names the reminders set where their subject lives (dose times on the + * medication, the cycle reminders in the per-event matrix). */ import { describe, expect, it, vi } from "vitest"; import { renderToStaticMarkup } from "react-dom/server"; @@ -25,8 +23,9 @@ vi.mock("@tanstack/react-query", () => ({ useMutation: () => ({ mutate: vi.fn(), isPending: false }), })); -// The auth mock is driven per-test via a mutable ref so each render can pick -// a different module map without re-mocking the module. +// Mounted, so the Coach gate reads the module map like a hydrated page. +vi.mock("@/hooks/use-mounted", () => ({ useMounted: () => true })); + const authRef: { modules?: Record } = {}; vi.mock("@/hooks/use-auth", () => ({ useAuth: () => ({ @@ -49,38 +48,43 @@ function render(modules?: Record) { ); } -describe(" module-gated reminder types", () => { - it("shows mood + low-stock cards when no module map (fails open); no coach nudge", () => { +describe(" reminder kinds", () => { + it("shows every reminder card when no module map (fails open)", () => { const html = render(undefined); expect(html).toContain('id="mood-reminder"'); expect(html).toContain('id="low-stock"'); - // v1.18.6 — the coach nudge moved to Settings → Coach. - expect(html).not.toContain('id="coach-nudge"'); + expect(html).toContain('id="coach-nudge"'); + expect(html).toContain('id="vorsorge-reminders"'); + expect(html).toContain('data-slot="notifications-elsewhere"'); + expect(html).toContain('id="cycle-reminders"'); }); it("hides the mood card when the mood module is disabled", () => { const html = render({ mood: false }); expect(html).not.toContain('id="mood-reminder"'); - // Low-stock is unaffected. expect(html).toContain('id="low-stock"'); }); - it("always shows low-stock unless medications is disabled", () => { - const html = render({ mood: false }); - expect(html).toContain('id="low-stock"'); - expect(html).not.toContain('id="mood-reminder"'); - expect(html).not.toContain('id="coach-nudge"'); + it("hides the low-stock card and the dose row when medications is disabled", () => { + const html = render({ medications: false }); + expect(html).not.toContain('id="low-stock"'); + expect(html).not.toContain('href="/medications"'); }); - it("drops the channels / inbox cross-links and the Vorsorge block (D5)", () => { - // v1.18.1 — the page is lean: no section blurb, no channels/inbox - // cross-links, and no embedded Vorsorge editor (it has its own page). + it("hides the Coach nudges when the Coach is off", () => { + expect(render({ coach: false })).not.toContain('id="coach-nudge"'); + }); + + it("drops the elsewhere card when neither of its reminders applies", () => { + const html = render({ medications: false, cycle: false }); + expect(html).not.toContain('data-slot="notifications-elsewhere"'); + // The checkup reminders are not a module and stay. + expect(html).toContain('id="vorsorge-reminders"'); + }); + + it("links to the per-event matrix and to the delivery channels", () => { const html = render({}); - expect(html).not.toContain('data-slot="notifications-channels-cross-link"'); - expect(html).not.toContain('data-slot="notifications-inbox-cross-link"'); - // The remaining reminder-type cards (no coach nudge here anymore). - expect(html).toContain('id="mood-reminder"'); - expect(html).toContain('id="low-stock"'); - expect(html).not.toContain('id="coach-nudge"'); + expect(html).toContain('href="/notifications"'); + expect(html).toContain('href="/settings/integrations#channels"'); }); }); diff --git a/src/components/settings/__tests__/privacy-section.test.tsx b/src/components/settings/__tests__/privacy-section.test.tsx index 3104f1fe5..0f87f2d1e 100644 --- a/src/components/settings/__tests__/privacy-section.test.tsx +++ b/src/components/settings/__tests__/privacy-section.test.tsx @@ -55,7 +55,7 @@ function render(node: React.ReactNode, locale: "en" | "de" = "en") { } describe(" — assembled Data & Privacy dashboard", () => { - it("renders the assembled sections (encryption, retention, export, delete, sessions, activity)", () => { + it("renders the assembled sections (encryption, retention, delete)", () => { const html = render(); // Encryption-at-rest block driven from the seeded summary. expect(html).toContain("AES-256-GCM"); @@ -63,15 +63,14 @@ describe(" — assembled Data & Privacy dashboard", () => { expect(html).toContain("365"); // The honest backup↔deletion lag disclosure is present. expect(html.toLowerCase()).toContain("backup"); - // Links to the existing export + destructive surfaces (assembly, not rebuild). - expect(html).toContain('href="/settings/export"'); - expect(html).toContain('href="/settings/gesundheitsakte"'); + // Links to the destructive surface (assembly, not rebuild). expect(html).toContain('href="/settings/advanced"'); - // Embedded active-session + security-activity cards. - expect(html).toContain("settings-security-sessions-card"); - // Trusted devices now lives here (and only here) — its single home is the - // Privacy dashboard, not the Security section. - expect(html).toContain("settings-trusted-devices-card"); + // v1.44 — sessions, trusted devices and the activity feed live under + // Security, and the duplicate export card is gone: Export & import and + // the Health record sit beside this page in the Data group. + expect(html).not.toContain("settings-security-sessions-card"); + expect(html).not.toContain("settings-trusted-devices-card"); + expect(html).not.toContain('href="/settings/export"'); }); it("shows a loading placeholder for the retention block before the summary resolves", () => { diff --git a/src/components/settings/__tests__/sections.test.tsx b/src/components/settings/__tests__/sections.test.tsx index ace887480..e6420d6a3 100644 --- a/src/components/settings/__tests__/sections.test.tsx +++ b/src/components/settings/__tests__/sections.test.tsx @@ -246,7 +246,8 @@ describe("settings sections — SSR smoke", () => { // card is gone. expect(html).toContain('id="mood-reminder"'); expect(html).toContain('id="low-stock"'); - expect(html).not.toContain('id="coach-nudge"'); + // v1.44 — the Coach nudges are back with the other reminders. + expect(html).toContain('id="coach-nudge"'); // v1.25.7 — delivery channels moved to Settings → Integrationen, so the // channels group + panel no longer render on this surface. expect(html).not.toContain('id="channels"'); diff --git a/src/components/settings/__tests__/settings-shell.test.tsx b/src/components/settings/__tests__/settings-shell.test.tsx index 963048306..dfeed9e79 100644 --- a/src/components/settings/__tests__/settings-shell.test.tsx +++ b/src/components/settings/__tests__/settings-shell.test.tsx @@ -11,6 +11,7 @@ import { join } from "node:path"; const mockPathnameRef = { value: "/settings/account" }; vi.mock("next/navigation", () => ({ usePathname: () => mockPathnameRef.value, + useRouter: () => ({ replace: () => {}, push: () => {} }), })); // v1.18.0 — the shell module-gates per-submodule nav entries off @@ -169,6 +170,8 @@ describe("SETTINGS_SECTION_SLUGS", () => { // v1.23 — "Data & Privacy" assembles the export / deletion / retention / // encryption / session / activity pieces into one pane, after `advanced`. "privacy", + // v1.44 — per-user preview opt-ins, in the System group before About. + "preview", "about", ]); }); @@ -193,23 +196,59 @@ describe("SETTINGS_SECTION_SLUGS", () => { }); describe("", () => { - it("keeps meaningful non-interactive desktop groups while the mobile strip uses quiet separators", () => { + it("renders the nine groups as non-interactive sidebar headers, in order", () => { const html = renderShell({ active: "account" }); - for (const label of [ + const labels = [ "Account", - "Your data", - "Display", + "Tracking", + "Arrangement", + "Notifications", "Health profile", "AI", - "Connectivity", + "Connections", + "Data", "System", - ]) { - expect(html).toContain(`>${label}`); + ]; + let cursor = -1; + for (const label of labels) { + const at = html.indexOf(`>${label}`); + expect(at, label).toBeGreaterThan(cursor); + cursor = at; } - // Seven groups produce six quiet separators in the mobile strip. - expect( - html.match(/aria-hidden="true"/g)?.length ?? 0, - ).toBeGreaterThanOrEqual(6); + }); + + it("puts every section in the group the plan names", () => { + const groupOf = Object.fromEntries( + SETTINGS_SECTIONS.map((section) => [section.slug, section.group]), + ); + expect(groupOf).toEqual({ + account: "account", + security: "account", + access: "account", + modules: "tracking", + layout: "arrangement", + notifications: "notifications", + score: "healthProfile", + thresholds: "healthProfile", + anamnesis: "healthProfile", + environment: "healthProfile", + ai: "ai", + coach: "ai", + integrations: "connections", + sources: "connections", + api: "connections", + mcp: "connections", + export: "data", + gesundheitsakte: "data", + privacy: "data", + advanced: "data", + preview: "system", + about: "system", + }); + // Every routable section has a nav entry, and nothing else does. + expect([...SETTINGS_SECTIONS.map((s) => s.slug)].sort()).toEqual( + [...SETTINGS_SECTION_SLUGS].sort(), + ); }); it("starts the sticky desktop sidebar on the heading row", () => { @@ -230,19 +269,14 @@ describe("", () => { expect(source).not.toMatch(/ { + it("renders every navigable section link in the sidebar", () => { const html = renderShell({ active: "account" }); - // Every slug — including `about`, which returned to the shell nav - // as the last entry after living dropdown-only since v1.4.33 IW7 — - // renders in both layouts. + // The phone's section sheet mounts its list only when opened, so the + // server markup carries each link once, in the desktop sidebar. const navigableSlugs = SETTINGS_SECTIONS.map((section) => section.slug); for (const slug of navigableSlugs) { const matches = html.match(new RegExp(`href="/settings/${slug}"`, "g")); - // Two renders — mobile strip + desktop sidebar — guarantee the link - // exists in both layouts. Tablet/desktop hide the strip with `md:`, - // but the markup is always present so it's keyboard-discoverable - // before media queries resolve. - expect(matches?.length ?? 0).toBe(2); + expect(matches?.length ?? 0).toBe(1); } }); @@ -259,9 +293,9 @@ describe("", () => { it('marks the active section with aria-current="page" (and only that one)', () => { const html = renderShell({ active: "notifications" }); - // Both layouts emit the active link, so two `aria-current="page"`. + // The sidebar marks the active link; the phone sheet is closed. const occurrences = html.match(/aria-current="page"/g); - expect(occurrences?.length ?? 0).toBe(2); + expect(occurrences?.length ?? 0).toBe(1); // And the active link is on the notifications href. Attribute order // in React SSR is alphabetic, so `aria-current` precedes `href` — @@ -269,28 +303,38 @@ describe("", () => { // future React renderer changes. const activeLinkRegex = /]*\baria-current="page"[^>]*\bhref="\/settings\/notifications"|]*\bhref="\/settings\/notifications"[^>]*\baria-current="page"/g; - expect(html.match(activeLinkRegex)?.length ?? 0).toBe(2); + expect(html.match(activeLinkRegex)?.length ?? 0).toBe(1); }); it("derives the active slug from the current pathname when `active` is omitted", () => { const html = renderShell({ pathname: "/settings/api" }); const activeLinkRegex = /]*\baria-current="page"[^>]*\bhref="\/settings\/api"|]*\bhref="\/settings\/api"[^>]*\baria-current="page"/g; - expect(html.match(activeLinkRegex)?.length ?? 0).toBe(2); + expect(html.match(activeLinkRegex)?.length ?? 0).toBe(1); }); - it("highlights the Layout hub when on a Layout child editor (v1.17.1 F-2)", () => { - // On `/settings/dashboard` (a Layout child reached through the hub) the - // Layout nav entry must read active even though Dashboard has no nav - // entry of its own. Both layouts emit the active link → two matches. - for (const child of ["/settings/dashboard", "/settings/insights"]) { + it("highlights the Arrangement hub on a legacy child path and on its sub-pages", () => { + // `/settings/dashboard` (a redirected legacy child) and the sub-pages + // under `/settings/layout/` both read as the Arrangement entry. + for (const child of [ + "/settings/dashboard", + "/settings/insights", + "/settings/layout/labs", + ]) { const html = renderShell({ pathname: child }); const layoutActive = /]*\baria-current="page"[^>]*\bhref="\/settings\/layout"|]*\bhref="\/settings\/layout"[^>]*\baria-current="page"/g; - expect(html.match(layoutActive)?.length ?? 0).toBe(2); + expect(html.match(layoutActive)?.length ?? 0).toBe(1); } }); + it("highlights Modules on a module's own settings page", () => { + const html = renderShell({ pathname: "/settings/modules/labs" }); + const modulesActive = + /]*\baria-current="page"[^>]*\bhref="\/settings\/modules"|]*\bhref="\/settings\/modules"[^>]*\baria-current="page"/g; + expect(html.match(modulesActive)?.length ?? 0).toBe(1); + }); + // v1.37.19 — the Layout children left `SETTINGS_SECTION_SLUGS`, so no // page can pass one as the `active` override anymore; the pathname-based // case above is the remaining (defensive) mapping. @@ -424,7 +468,7 @@ describe("", () => { const html = renderShell({ pathname: "/settings/totally-bogus" }); const activeLinkRegex = /]*\baria-current="page"[^>]*\bhref="\/settings\/account"|]*\bhref="\/settings\/account"[^>]*\baria-current="page"/g; - expect(html.match(activeLinkRegex)?.length ?? 0).toBe(2); + expect(html.match(activeLinkRegex)?.length ?? 0).toBe(1); }); it("resolves every section title via the i18n provider — English", () => { @@ -440,7 +484,8 @@ describe("", () => { // "Layout": it arranges and picks what each module shows and holds no // theme, so "Appearance" promised a control it does not have. expect(html).toContain('href="/settings/layout"'); - expect(html).toContain(">Layout"); + // v1.44 — the hub holds only order and view, and says so. + expect(html).toContain(">Arrangement"); // v1.25.3 — Channels folded into Notifications, so it is no longer a // left-side entry; Sources keeps its own. expect(html).not.toContain('href="/settings/channels"'); @@ -484,7 +529,7 @@ describe("", () => { // (route stays `/settings/layout`). v1.42 — "Layout" in German too; // "Darstellung" now names the account page's language-and-units card. expect(html).toContain('href="/settings/layout"'); - expect(html).toContain(">Layout"); + expect(html).toContain(">Anordnung"); // v1.25.3 — Channels ("Kanäle") folded into Notifications; Sources keeps // its own left-side entry. expect(html).not.toContain('href="/settings/channels"'); @@ -512,17 +557,14 @@ describe("", () => { expect(html).not.toContain("settings.sections."); }); - it("mobile section strip uses `no-scrollbar` so the swipe area doesn't paint a horizontal scrollbar", () => { - // The 10-section strip is wider than 393 CSS px, so without - // `no-scrollbar` Chromium/WebKit paints an always-on horizontal - // scrollbar at the top of every settings page. The class is - // defined in `globals.css` and combines `scrollbar-width: none` - // (Firefox) with `::-webkit-scrollbar { display: none }` (everyone - // else) — scroll behaviour is preserved. - const html = renderShell({ active: "account" }); - const nav = html.match(/]*md:hidden[^>]*>/); + it("gives the phone one button naming the section, not a strip of every section", () => { + const html = renderShell({ active: "security" }); + const nav = html.match(/]*md:hidden[^>]*>[\s\S]*?<\/nav>/); expect(nav).not.toBeNull(); - expect(nav![0]).toMatch(/\bno-scrollbar\b/); - expect(nav![0]).toMatch(/\boverflow-x-auto\b/); + expect(nav![0]).toContain('data-slot="settings-mobile-nav-trigger"'); + expect(nav![0]).toContain("Security"); + expect(nav![0]).toContain("Account"); + expect(nav![0]).not.toContain("overflow-x-auto"); + expect(nav![0]).not.toContain('href="/settings/'); }); }); diff --git a/src/components/settings/account-section/__tests__/display-preferences-card.test.tsx b/src/components/settings/account-section/__tests__/display-preferences-card.test.tsx index c9e9a88a2..4f7dc0ae6 100644 --- a/src/components/settings/account-section/__tests__/display-preferences-card.test.tsx +++ b/src/components/settings/account-section/__tests__/display-preferences-card.test.tsx @@ -44,10 +44,15 @@ describe("Settings → Account display card", () => { "GlucoseUnitSelect", "TimeFormatSelect", "DateFormatSelect", + // v1.44 — the timezone saves on change too, in the display card. + "TimezoneSelect", + "TimezonePicker", ]) { expect(form).not.toContain(control); } - // The timezone saves with the form, so it stays inside it. - expect(form).toContain("TimezonePicker"); + const card = src.slice( + src.indexOf("export function DisplayPreferencesCard"), + ); + expect(card).toContain("(EMPTY_HEIGHT_DRAFT); const [dateOfBirth, setDateOfBirth] = useState(""); const [gender, setGender] = useState(""); - const [timezone, setTimezone] = useState(DEFAULT_TIMEZONE); // The name the app addresses the account by; set in the setup flow, and // until now not editable anywhere after it. const [displayName, setDisplayName] = useState(""); @@ -109,7 +102,6 @@ export function AccountSection() { height: EMPTY_HEIGHT_DRAFT, dateOfBirth: "", gender: "", - timezone: DEFAULT_TIMEZONE, fullName: "", insurerName: "", insuranceNumber: "", @@ -160,7 +152,6 @@ export function AccountSection() { ? dateOnlyKey(new Date(user.dateOfBirth)) : "", gender: user.gender ?? "", - timezone: resolveInitialTimezone(user.timezone, detectBrowserTimezone()), fullName: user.fullName ?? "", insurerName: user.insurerName ?? "", insuranceNumber: user.insuranceNumber ?? "", @@ -171,7 +162,6 @@ export function AccountSection() { setHeight(seed.height); setDateOfBirth(seed.dateOfBirth); setGender(seed.gender); - setTimezone(seed.timezone); setFullName(seed.fullName); setInsurerName(seed.insurerName); setInsuranceNumber(seed.insuranceNumber); @@ -203,7 +193,6 @@ export function AccountSection() { height, dateOfBirth, gender, - timezone, fullName, insurerName, insuranceNumber, @@ -268,55 +257,36 @@ export function AccountSection() { height, dateOfBirth, gender, - timezone, fullName: fullName.trim(), insurerName: insurerName.trim(), insuranceNumber: insuranceNumber.trim(), }; - // The timezone is owned by a dedicated route (v1.4.25 W7) so the - // resolver cache can be invalidated without piping the flag - // through the bigger profile patch path. Run the two PUTs in - // parallel — they're independent. - const [profileRes, tzRes] = await Promise.all([ - apiFetchRaw("/api/auth/profile", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - email: savedProfile.email || null, - // An emptied field clears the name; the greeting falls back to - // the username, as it does for an account that never set one. - displayName: savedProfile.displayName || null, - ...(emailChanged && emailPassword - ? { currentPassword: emailPassword } - : {}), - heightCm: heightAdapter.toCanonicalCm(savedProfile.height), - dateOfBirth: savedProfile.dateOfBirth || null, - gender: savedProfile.gender || null, - fullName: savedProfile.fullName || null, - insurerName: savedProfile.insurerName || null, - insuranceNumber: savedProfile.insuranceNumber || null, - }), + // v1.44 — the timezone left this form: it saves on change in the + // Display card (`TimezoneSelect`), like the other display preferences. + const profileRes = await apiFetchRaw("/api/auth/profile", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + email: savedProfile.email || null, + // An emptied field clears the name; the greeting falls back to + // the username, as it does for an account that never set one. + displayName: savedProfile.displayName || null, + ...(emailChanged && emailPassword + ? { currentPassword: emailPassword } + : {}), + heightCm: heightAdapter.toCanonicalCm(savedProfile.height), + dateOfBirth: savedProfile.dateOfBirth || null, + gender: savedProfile.gender || null, + fullName: savedProfile.fullName || null, + insurerName: savedProfile.insurerName || null, + insuranceNumber: savedProfile.insuranceNumber || null, }), - user && timezone && timezone !== user.timezone - ? apiFetchRaw("/api/auth/me/timezone", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ timezone }), - }) - : Promise.resolve({ ok: true } as Response), - ]); + }); setEmailPassword(""); - if (profileRes.ok && tzRes.ok) { - // Instant same-tab flip of every rendered timestamp (issue #490): - // mirror the accepted zone before the `/me` refetch confirms it — - // the same synchronous-update pattern the time/date-format selects - // use. `refetch()` → `fetchMe` re-writes the mirror authoritatively. - if (user && timezone && timezone !== user.timezone) { - storeTimezone(timezone); - } + if (profileRes.ok) { const profileJson = (await profileRes.json().catch(() => null)) as { data?: { rejectedFields?: RejectedProfileField[] }; } | null; @@ -351,22 +321,6 @@ export function AccountSection() { setProfileSeed(savedProfile); await refetch(); } - } else if (!tzRes.ok) { - // The dedicated tz endpoint owns the IANA validation error - // text. Surface its message verbatim so the user sees - // "Not a valid IANA timezone." instead of the generic save - // failure copy. - try { - const json = (await (tzRes as Response).json()) as { error?: string }; - setSaveMsg( - json.error - ? { text: json.error } - : { key: "settings.timezoneInvalid" }, - ); - } catch { - setSaveMsg({ key: "settings.timezoneInvalid" }); - } - setSaveMsgType("error"); } else { // The server's top-level `error` string is intentionally generic // (never a raw validator message); resolve the specific, localized @@ -584,10 +538,9 @@ export function AccountSection() {
- {/* Date of birth + timezone share one paired grid row. The - timezone stays on this card because it saves with the form - (its own PUT runs beside the profile PUT); the display - preferences that save on change live in their own card below. */} + {/* Date of birth shares the grid of the fields above. The timezone + moved to the Display card below in v1.44: it saves on change + there, like the other display preferences. */}
@@ -609,7 +562,6 @@ export function AccountSection() {

-
{/* v1.7.0 — optional patient-identity fields surfaced on the @@ -817,6 +769,7 @@ export function DisplayPreferencesCard({ + ); diff --git a/src/components/settings/ai/disable-coach-card.tsx b/src/components/settings/ai/disable-coach-card.tsx deleted file mode 100644 index 1d4a5c68d..000000000 --- a/src/components/settings/ai/disable-coach-card.tsx +++ /dev/null @@ -1,182 +0,0 @@ -"use client"; - -/** - * v1.4.47 — Coach activation toggle card. - * - * v1.18.0 — moved out of Settings → AI into the dedicated Coach - * section; its `
` id namespace is `settings-coach-activate-*` - * accordingly. (The file path stays under `ai/` as pre-existing drift.) - * - * v1.18.1 — polarity flip. The card now reads "Activate Coach" with the - * Switch ON by default (the Coach is on for everyone unless the user opts - * out). The persisted state is still the single `User.disableCoach` field — - * one source of truth — so the Switch's `checked` is `!disableCoach` and a - * flip writes `disableCoach: !next`. Presenting it as activate/default-on is - * a pure UI inversion over the same column. - * - * Persists via `PATCH /api/auth/me/disable-coach`; the response invalidates - * `aiInputDependentKeys` (the account payload first) so every Coach mount point on the client - * (``, ``, the inline `` - * pill, the `/targets` page CTAs) re-renders with the new `user.disableCoach` - * value on the next React Query refetch tick — no full reload required. - * - * The optimistic-update pattern mirrors ``: the Switch - * flips immediately, the mutation rolls back on error. - * - * v1.16.4 — header follows the shared `SettingsCardHeader` contract (icon - * column + title + switch in the status slot) so the card reads like its - * siblings instead of carrying its own bespoke header row. - */ - -import { useEffect, useRef, useState } from "react"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { MessageCircleHeart } from "lucide-react"; - -import { Switch } from "@/components/ui/switch"; -import { SettingsCard } from "@/components/settings/settings-card"; -import { SettingsCardHeader } from "@/components/settings/_card-header"; -import { apiPatch } from "@/lib/api/api-fetch"; -import { useAuth } from "@/hooks/use-auth"; -import { useMounted } from "@/hooks/use-mounted"; -import { useTranslations } from "@/lib/i18n/context"; -import { aiInputDependentKeys, invalidateKeys } from "@/lib/query-keys"; - -export function DisableCoachCard({ - isAuthenticated, -}: { - isAuthenticated: boolean; -}) { - const { t } = useTranslations(); - const queryClient = useQueryClient(); - const { user } = useAuth(); - - // Optimistic-update flag — null means "no in-flight change", - // otherwise it overrides the wire value until the mutation - // settles. Mirrors the `` pattern so the Switch - // reacts instantly to the user's tap. - const [optimistic, setOptimistic] = useState(null); - const [msg, setMsg] = useState(null); - const [msgType, setMsgType] = useState<"success" | "error" | null>(null); - - // v1.4.48 — auto-clear the inline `

` line 3 s - // after the mutation settles so a Settings card the user scrolled - // past minutes earlier doesn't keep echoing a stale "Coach hidden" - // banner. The ref tracks the in-flight timer so we can clear it on - // unmount + on a follow-up toggle (otherwise a rapid double-tap - // could leave a stray timer pointing at a stale message). - const clearTimerRef = useRef | null>(null); - - useEffect(() => { - return () => { - if (clearTimerRef.current !== null) { - clearTimeout(clearTimerRef.current); - clearTimerRef.current = null; - } - }; - }, []); - - function scheduleClear() { - if (clearTimerRef.current !== null) { - clearTimeout(clearTimerRef.current); - } - clearTimerRef.current = setTimeout(() => { - clearTimerRef.current = null; - setMsg(null); - setMsgType(null); - }, 3000); - } - - // `mounted`-gated: `user` comes from the auth query, which can resolve - // before this boundary hydrates — the Switch state must match the SSR HTML - // during hydration (React #418) and pick up the wire value on the first - // client re-render. v1.18.1 (D7) — the toggle now reads "Coach aktivieren" - // (default ON), so the Switch is checked when the Coach is ACTIVE. The - // persisted column is still `disableCoach`; `optimistic` and `checked` are - // the activated-state view of it. The SSR default is ON (checked) so the - // default-on contract holds before the wire value resolves. - const mounted = useMounted(); - const activated = mounted - ? (optimistic ?? !(user?.disableCoach ?? false)) - : true; - - const mutation = useMutation({ - // `next` is the desired ACTIVATED state; the column stores the inverse. - mutationFn: async (next: boolean) => { - await apiPatch("/api/auth/me/disable-coach", { disableCoach: !next }); - return next; - }, - onSuccess: (next) => { - setMsg( - next - ? t("settings.coach.activate.savedOn") - : t("settings.coach.activate.savedOff"), - ); - setMsgType("success"); - // Surface the new value to every Coach gate via the shared - // `useAuth()` query — the gates re-render once React Query - // re-fetches /api/auth/me with the updated `disableCoach` field. - void invalidateKeys(queryClient, aiInputDependentKeys); - setOptimistic(null); - scheduleClear(); - }, - onError: (err) => { - setOptimistic(null); - setMsg( - err instanceof Error - ? err.message - : t("settings.coach.activate.saveError"), - ); - setMsgType("error"); - scheduleClear(); - }, - }); - - function handleToggle(next: boolean) { - setOptimistic(next); - setMsg(null); - setMsgType(null); - if (clearTimerRef.current !== null) { - clearTimeout(clearTimerRef.current); - clearTimerRef.current = null; - } - mutation.mutate(next); - } - - return ( - - - } - /> - {msg && ( -

- {msg} -

- )} - - ); -} diff --git a/src/components/settings/anamnesis-section.tsx b/src/components/settings/anamnesis-section.tsx index b94e7b294..780a09d24 100644 --- a/src/components/settings/anamnesis-section.tsx +++ b/src/components/settings/anamnesis-section.tsx @@ -41,6 +41,7 @@ import { EmergencyProfileManager } from "@/components/records/emergency-profile- import { FamilyHistoryManager } from "@/components/records/family-history-manager"; import { AiProfileInclusionManager } from "@/components/records/ai-profile-inclusion-manager"; import { LifeCircumstancesManager } from "@/components/records/life-circumstances-manager"; +import { ScreeningHintsCard } from "@/components/records/screening-hints-card"; export function AnamnesisSection() { const { t } = useTranslations(); @@ -112,6 +113,11 @@ export function AnamnesisSection() { + {/* v1.43 (#614) — screening hints from the life circumstances, with + their source. v1.44 — beside the circumstances they come from + (they sat on the checkups arrangement page before). */} + + Modules (v1.44: the second switch that + * used to sit here is gone; kept first so a person + * who wants the surface gone finds the way at once). * 2. Coach preferences — tone, verbosity, data scope (`useCoachPrefs`). - * 3. Proactive nudge — the daily-nudge opt-out + per-group frequency - * (moved here from Settings → Benachrichtigungen in - * v1.18.6: it is a Coach setting, not a generic - * notification). + * 3. Proactive nudges — a pointer to Settings > Notifications, where the + * nudge card lives with the other reminders since + * v1.44 (`#coach-nudge` forwards there). * 4. Coach memory — durable-fact review + forget controls. * * The nav entry is module-gated on `coach`. The preference + nudge cards @@ -34,19 +35,30 @@ * `` in the route; this body is the Coach cards only. */ -import { DisableCoachCard } from "@/components/settings/ai/disable-coach-card"; +import Link from "next/link"; +import { BellRing, ChevronRight, MessageCircleHeart } from "lucide-react"; + import { CoachMemorySection } from "@/components/settings/coach-memory-section"; import { CoachConversationsMemoryCard } from "@/components/settings/coach-conversations-memory-card"; import { CoachRemindersSection } from "@/components/settings/coach-reminders-section"; import { CoachPlansMemoryCard } from "@/components/settings/coach-plans-memory-card"; -import { CoachNudgeCard } from "@/components/settings/coach-nudge-card"; +import { ModuleStateCard } from "@/components/settings/module-state-card"; +import { SettingsCard } from "@/components/settings/settings-card"; +import { SettingsCardHeader } from "@/components/settings/_card-header"; +import { SettingsCardActions } from "@/components/settings/_card-actions"; +import { Button } from "@/components/ui/button"; import { CoachPrefsSection } from "@/components/settings/coach-prefs-section"; import { useAuth } from "@/hooks/use-auth"; import { useMounted } from "@/hooks/use-mounted"; +import { useModuleEnabledState } from "@/hooks/use-module-toggle"; +import { useTranslations } from "@/lib/i18n/context"; import { isSurfaceVisible } from "@/lib/modules/surface"; export function CoachSection() { const { isAuthenticated, user } = useAuth(); + const { t } = useTranslations(); + // The same reading the Modules switch shows, so both say one thing. + const moduleSwitchState = useModuleEnabledState(); // Mirror the AI section's hydration-safe gating: the auth query can // resolve before this boundary hydrates, so gate the child props on // `useMounted()` to keep the hydration render matching the SSR HTML. @@ -62,16 +74,38 @@ export function CoachSection() { return (
- + {/* Before hydration the state reads on, the default, so the server + render and the first client paint agree. */} + {coachEnabled && } - {/* Proactive Coach nudge — moved here from Benachrichtigungen (v1.18.6). - Keeps the `#coach-nudge` anchor so existing deep links resolve. */} + {/* The proactive nudges are a reminder: set under Notifications with + the others since v1.44. */} {coachEnabled && ( -
- -
+ + + + + + )} diff --git a/src/components/settings/injection-sites-card.tsx b/src/components/settings/injection-sites-card.tsx index 21e82c34f..53cd607cd 100644 --- a/src/components/settings/injection-sites-card.tsx +++ b/src/components/settings/injection-sites-card.tsx @@ -74,6 +74,7 @@ export function InjectionSitesCard({ return ( Modules + * (`module-settings-pages.tsx`); `/settings/layout/mood` redirects there. + * * Each module section keeps its module-gate: a disabled module's row / subpage * does not render, read from the resolved `useAuth().user.modules` map (the * same map the nav, the Modules hub, and the Insights pills gate off). The gate @@ -76,12 +80,6 @@ export const LAYOUT_GROUPS: ReadonlyArray = [ descriptionKey: "settings.sections.layout.medications.description", Body: MedicationsSection, }, - { - id: "mood", - titleKey: "settings.sections.layout.mood.title", - descriptionKey: "settings.sections.layout.mood.description", - Body: MoodSection, - }, { id: "labs", titleKey: "settings.sections.layout.labs.title", diff --git a/src/components/settings/mcp-section.tsx b/src/components/settings/mcp-section.tsx index 6b448a5d6..ca1eb59fd 100644 --- a/src/components/settings/mcp-section.tsx +++ b/src/components/settings/mcp-section.tsx @@ -17,22 +17,12 @@ * least-privilege read scope and nothing more. */ import { useState } from "react"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { Check, Copy, Key, Link2, Loader2, Plug, Trash2 } from "lucide-react"; import { toast } from "sonner"; import { showErrorToast } from "@/lib/errors/error-toast"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, - AlertDialogTrigger, -} from "@/components/ui/alert-dialog"; +import { ConfirmButton } from "@/components/ui/confirm-button"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { EmptyState } from "@/components/ui/empty-state"; @@ -42,15 +32,12 @@ import { Skeleton } from "@/components/ui/skeleton"; import { Switch } from "@/components/ui/switch"; import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; +import { ModuleStateCard } from "@/components/settings/module-state-card"; import { useAuth } from "@/hooks/use-auth"; import { formatDate, formatDateTime } from "@/lib/format"; import { useTranslations } from "@/lib/i18n/context"; -import { - aiInputDependentKeys, - invalidateKeys, - queryKeys, -} from "@/lib/query-keys"; -import { apiFetchRaw, apiGet, apiPatch } from "@/lib/api/api-fetch"; +import { queryKeys } from "@/lib/query-keys"; +import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch"; import { recentProofErrorMessage, throwIfReproofRequired, @@ -169,39 +156,17 @@ function McpConnectionsCard() { ? formatDateTime(conn.lastUsedAt) : t("settings.mcp.connectionNeverUsed")}

- - - - - - - - {t("settings.mcp.connectionRevoke")} - - - {t("settings.mcp.connectionRevokeDescription")} - - - - - {t("common.cancel")} - - handleRevoke(conn.id)} - > - {t("settings.mcp.connectionRevoke")} - - - - + void handleRevoke(conn.id)} + slot="mcp-connection-revoke" + icon={} + destructive + /> ))} @@ -211,10 +176,16 @@ function McpConnectionsCard() { ); } +/** + * The connector's state, with a link to the one switch in Settings > Modules. + * Turning the module off pauses the endpoint: it refuses every call, while the + * tokens and connections below stay as they are and work again once it is on. + * Nothing here or in Modules revokes a token as a side effect (#1170); a token + * goes only through its own revoke. + */ function McpEnableCard() { const { t } = useTranslations(); const { user } = useAuth(); - const queryClient = useQueryClient(); // The opt-in module is OFF until explicitly enabled — `user.modules.mcp` is // the resolved state (true only when the user turned it on). @@ -222,53 +193,29 @@ function McpEnableCard() { const endpoint = typeof window !== "undefined" ? `${window.location.origin}/mcp` : "/mcp"; - const toggle = useMutation({ - mutationKey: queryKeys.modulesPrefs(), - mutationFn: async (next: boolean) => - apiPatch("/api/auth/me/modules", { mcp: next }), - onSuccess: () => { - void invalidateKeys(queryClient, aiInputDependentKeys); - toast.success(t("settings.sections.modules.saved")); - }, - onError: (failure) => - showErrorToast(t("settings.sections.modules.error"), { cause: failure }), - }); - return ( - - - -
-
- - {t("settings.mcp.enableToggleLabel")} - - toggle.mutate(next)} - aria-label={t("settings.mcp.enableToggleLabel")} - /> -
- -
-

- {t("settings.mcp.endpointLabel")} -

- - {endpoint} - -

- {t("settings.mcp.connectDescription")} -

-
+ +

{t("settings.mcp.offKeepsTokens")}

+
+

+ {t("settings.mcp.endpointLabel")} +

+ + {endpoint} + +

+ {t("settings.mcp.connectDescription")} +

- +
); } @@ -508,39 +455,17 @@ function McpTokensCard() { ? formatDateTime(tok.lastUsedAt) : t("settings.tokenNeverUsed")}

- - - - - - - - {t("settings.tokenRevoke")} - - - {t("settings.tokenRevokeDescription")} - - - - - {t("common.cancel")} - - handleRevoke(tok.id)} - > - {t("settings.tokenRevokeAction")} - - - - + void handleRevoke(tok.id)} + slot="mcp-token-revoke" + icon={} + destructive + /> ); })} diff --git a/src/components/settings/medications-section.tsx b/src/components/settings/medications-section.tsx index ba552e221..67d897f94 100644 --- a/src/components/settings/medications-section.tsx +++ b/src/components/settings/medications-section.tsx @@ -11,8 +11,6 @@ import { MedicationViewToggle } from "@/components/medications/medication-view-t import { SettingsCard } from "@/components/settings/settings-card"; import { Skeleton } from "@/components/ui/skeleton"; import { SettingsCardHeader } from "@/components/settings/_card-header"; -import { InjectionSitesCard } from "@/components/settings/injection-sites-card"; -import { useAuth } from "@/hooks/use-auth"; import { apiGet } from "@/lib/api/api-fetch"; import { useTranslations } from "@/lib/i18n/context"; import { applyMedicationOrder } from "@/lib/medications/medication-order"; @@ -24,7 +22,7 @@ import { useMedicationListLayout } from "@/lib/queries/use-medication-list-layou * from a Layout-hub child to its own standalone nav entry and gathered the * medication-specific preferences here. v1.18.1 (D3) made medications a * toggleable fail-open module, so this nav entry hides when the account - * turns the module off (the medication data routes stay live). Hosts three + * turns the module off (the medication data routes stay live). Hosts two * blocks: * 1. The list VIEW preference — cards vs table, written optimistically * through the same `PUT /api/medications/layout` the page header @@ -32,8 +30,9 @@ import { useMedicationListLayout } from "@/lib/queries/use-medication-list-layou * 2. The manual ORDER editor — two grouped lists (Aktiv / Inaktiv) * with drag + arrow reordering, flushed by an explicit Save, * persisted as active-ids-then-inactive-ids on the same layout row. - * 3. Injection-site exclusions — moved here from the account profile so - * every medication-specific preference lives on one screen. + * + * v1.44 — Arrangement holds order and view only; the injection-site + * exclusions moved to the medications page under Modules. * * The list view + order both write through the same `/api/medications/layout` * contract the /medications page reads, so a save here repaints both views. @@ -49,7 +48,6 @@ interface MedicationListEntry { export function MedicationsSection() { const { t } = useTranslations(); - const { isAuthenticated } = useAuth(); const { layout, isLayoutLoading, setView } = useMedicationListLayout(); const { data: medications, isLoading } = useQuery({ @@ -120,11 +118,6 @@ export function MedicationsSection() { )} - - {/* v1.18.0 — injection-site exclusions are a medication setting; - they moved here from the account profile so all medication-specific - preferences live in one place. */} -
); } diff --git a/src/components/settings/module-settings-page-ids.ts b/src/components/settings/module-settings-page-ids.ts new file mode 100644 index 000000000..622a72d57 --- /dev/null +++ b/src/components/settings/module-settings-page-ids.ts @@ -0,0 +1,40 @@ +import type { ModuleKey } from "@/lib/modules/registry"; +import { surfaceModule } from "@/lib/modules/surface"; + +/** + * Server-safe ids and copy keys of the module pages under Settings > Modules + * (`/settings/modules/`). The route reads them in + * `generateStaticParams()`, so they live apart from the client bodies in + * `module-settings-pages.tsx`. + */ +export const MODULE_SETTINGS_PAGE_IDS = [ + "medications", + "mood", + "labs", +] as const; + +export type ModuleSettingsPageId = (typeof MODULE_SETTINGS_PAGE_IDS)[number]; + +export function isModuleSettingsPageId( + value: string, +): value is ModuleSettingsPageId { + return (MODULE_SETTINGS_PAGE_IDS as readonly string[]).includes(value); +} + +/** i18n keys of a page's heading, under `settings.sections.modules.pages`. */ +export function moduleSettingsPageKeys(id: ModuleSettingsPageId): { + titleKey: string; + descriptionKey: string; +} { + return { + titleKey: `settings.sections.modules.pages.${id}.title`, + descriptionKey: `settings.sections.modules.pages.${id}.description`, + }; +} + +/** The module a page belongs to, from the surface map. */ +export function moduleSettingsPageModule( + id: ModuleSettingsPageId, +): ModuleKey | undefined { + return surfaceModule(`settings-module:${id}`); +} diff --git a/src/components/settings/module-settings-pages.tsx b/src/components/settings/module-settings-pages.tsx new file mode 100644 index 000000000..4447a344c --- /dev/null +++ b/src/components/settings/module-settings-pages.tsx @@ -0,0 +1,117 @@ +"use client"; + +/** + * The pages a module's own settings live on: `/settings/modules/`. + * + * Settings > Arrangement holds order and view only. What a module needs + * beyond that (the biomarker catalog and on-device text recognition for + * labs, the mood tags, the injection sites a medication rotation leaves out) + * lives on the module's page under Modules, reached from the module's row in + * Settings > Modules and from the wrench on the module's own page. Each page + * links to the module's arrangement page and back. + * + * Every page is gated on its module through the surface map + * (`settings-module:`), fail-open like the rest of Settings. + */ + +import Link from "next/link"; +import { ArrowUpDown, ChevronRight, SlidersHorizontal } from "lucide-react"; + +import { LabsModuleSettings } from "@/components/labs/labs-settings"; +import { InjectionSitesCard } from "@/components/settings/injection-sites-card"; +import { MoodSection } from "@/components/settings/mood-section"; +import { SettingsCard } from "@/components/settings/settings-card"; +import { SettingsCardHeader } from "@/components/settings/_card-header"; +import { SettingsCardActions } from "@/components/settings/_card-actions"; +import { Button } from "@/components/ui/button"; +import { useAuth } from "@/hooks/use-auth"; +import { useTranslations } from "@/lib/i18n/context"; +import type { ModuleSettingsPageId } from "./module-settings-page-ids"; + +/** A card pointing at the module's order-and-view page under Arrangement. */ +function ArrangementLinkCard({ id }: { id: ModuleSettingsPageId }) { + const { t } = useTranslations(); + return ( + + + + + + + ); +} + +function MedicationsModulePage() { + const { isAuthenticated } = useAuth(); + return ( +
+ + +
+ ); +} + +function LabsModulePage() { + return ( +
+ + +
+ ); +} + +function MoodModulePage() { + return ; +} + +export const MODULE_SETTINGS_PAGE_BODIES: Record< + ModuleSettingsPageId, + () => React.JSX.Element +> = { + medications: MedicationsModulePage, + mood: MoodModulePage, + labs: LabsModulePage, +}; + +export function ModuleSettingsPageBody({ id }: { id: ModuleSettingsPageId }) { + const Body = MODULE_SETTINGS_PAGE_BODIES[id]; + return ; +} + +/** + * The way from a module's arrangement page to its own page under Modules, + * for the modules that have one. + */ +export function ModuleSettingsLinkCard({ id }: { id: ModuleSettingsPageId }) { + const { t } = useTranslations(); + return ( + + + + + + + ); +} diff --git a/src/components/settings/module-state-card.tsx b/src/components/settings/module-state-card.tsx new file mode 100644 index 000000000..410a46578 --- /dev/null +++ b/src/components/settings/module-state-card.tsx @@ -0,0 +1,72 @@ +"use client"; + +/** + * A module's on/off state, shown where the module is configured, with a link + * to the one switch in Settings > Modules. + * + * The Coach and the MCP connector each had a second switch on their own page + * beside the one in Modules: two controls for one state, and a person who + * flipped one never knew the other existed. The switch stays in Modules (the + * row carries `id="module-"`); the module's page says what the state is + * and where to change it. + */ + +import Link from "next/link"; +import { ChevronRight, type LucideIcon } from "lucide-react"; + +import { SettingsCard } from "@/components/settings/settings-card"; +import { SettingsCardHeader } from "@/components/settings/_card-header"; +import { SettingsCardActions } from "@/components/settings/_card-actions"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { useTranslations } from "@/lib/i18n/context"; +import type { ModuleKey } from "@/lib/modules/registry"; + +export function ModuleStateCard({ + moduleKey, + enabled, + icon, + title, + description, + anchor, + slot, + children, +}: { + moduleKey: ModuleKey; + enabled: boolean; + icon: LucideIcon; + title: string; + description: string; + anchor: string; + slot: string; + /** Body copy between the header and the link, e.g. what "off" keeps. */ + children?: React.ReactNode; +}) { + const { t } = useTranslations(); + return ( + + + {enabled + ? t("settings.moduleState.on") + : t("settings.moduleState.off")} + + } + /> + {children} + + + + + ); +} diff --git a/src/components/settings/module-toggle-row.tsx b/src/components/settings/module-toggle-row.tsx index 6fc032a41..9c7e7cd5c 100644 --- a/src/components/settings/module-toggle-row.tsx +++ b/src/components/settings/module-toggle-row.tsx @@ -92,7 +92,12 @@ export function ModuleToggleRow({ // is in flight. const interactive = !locked && disabledReason == null; return ( -
+ // `id="module-"` is the anchor other pages link to when they show a + // module's state and send the person here to change it. +
` — Settings → Notifications ("Benachrichtigungen"). * - * v1.18.0 — the single module-gated home for reminder TYPES. Earlier the - * settings carried two near-identical entries: this Notifications screen and a - * separate "Erinnerungen" hub that only deep-linked into the canonical editors - * ("doppelt gemoppelt"). The hub is gone (its route 301-redirects here); the - * reminder TYPES now live centrally on this one screen. + * The one home for every reminder kind. A reminder whose settings belong to + * one place is set here; a reminder that is set where its subject lives (a + * medication's dose times on the medication, the cycle reminders in the + * per-event matrix) gets a row here that says where, so nothing has to be + * found by luck. * - * Each reminder-type card is shown ONLY when its module is enabled, read from - * the resolved `useAuth().user.modules` map (the same `/auth/me` map nav and - * Insights pills gate off). Mood → `mood`, low-stock runway → `medications` - * (a toggleable fail-open module since D3, so a missing key reads as enabled - * and a stale `/me` payload never blanks a card). + * Each card is shown only while its module is on, read from the resolved + * `useAuth().user.modules` map and failing open, so a stale `/me` payload + * never blanks a card. * - * v1.25.7 — delivery channels move to Settings → Integrationen (channels are - * delivery providers, the same family as the connected services). This screen - * keeps ONLY the reminder-TYPE content; `/settings/channels` 301-redirects to - * `/settings/integrations#channels`. - * - * v1.18.6 — the page heading + subtitle come from the shared - * ``. The proactive-Coach nudge card lives under - * Settings → Coach (it is a Coach setting, not a generic notification). + * v1.18.0 — the separate "Erinnerungen" hub is gone; `/settings/reminders` + * redirects here. v1.25.7 — the delivery channels live under Integrations + * (they are delivery providers); `/settings/channels` redirects there and this + * page links to them. v1.44 — the Coach nudges came back from Settings > Coach + * and the checkup reminder switches from the checkups arrangement page; both + * old places link here (`/settings/coach#coach-nudge` forwards). */ export function NotificationsSection() { const { isAuthenticated, user } = useAuth(); const { t } = useTranslations(); + // The Coach gate reads the surface map like the Coach section does, after + // hydration, so the server render and the first client paint agree. + const mounted = useMounted(); - // v1.18.0 — module-gated per-type visibility. Fail OPEN (`!== false`) - // so a stale `/me` payload without the module map keeps every card visible. const moodEnabled = user?.modules?.mood !== false; const medsEnabled = user?.modules?.medications !== false; - const anyReminder = moodEnabled || medsEnabled; + const cycleEnabled = user?.modules?.cycle !== false; + const coachEnabled = + !mounted || isSurfaceVisible("settings:coach", user?.modules); return (
- {/* Group 1 — Reminders ("what you receive"). Rendered only when at - least one reminder-type card is visible, so a fully-disabled - account never shows an empty heading. */} - {anyReminder ? ( -
- + {/* Group 1 — Reminders ("what you receive"). The checkup reminders are + not a module and always show, so the group always has content. */} +
+ - {/* Mood check-in reminder — only when the mood module is enabled. */} - {moodEnabled ? ( -
- -
- ) : null} + {moodEnabled ? ( +
+ +
+ ) : null} - {/* Medication low-stock runway — only when the medications module - is enabled (toggleable, fail-open, since D3). */} - {medsEnabled ? ( -
- -
- ) : null} -
- ) : null} + {medsEnabled ? ( +
+ +
+ ) : null} + + {coachEnabled ? ( +
+ +
+ ) : null} + +
+ +
+ + {/* Reminders set where their subject lives. One row each, saying + where; nothing here writes. */} + {medsEnabled || cycleEnabled ? ( + + +
    + {medsEnabled ? ( +
  • +

    + {t("settings.sections.notifications.elsewhere.doses.title")} +

    +

    + {t( + "settings.sections.notifications.elsewhere.doses.description", + )} +

    + + + {t( + "settings.sections.notifications.elsewhere.doses.link", + )} + +
  • + ) : null} + {cycleEnabled ? ( +
  • +

    + {t("settings.sections.notifications.elsewhere.cycle.title")} +

    +

    + {t( + "settings.sections.notifications.elsewhere.cycle.description", + )} +

    + + + {t( + "settings.sections.notifications.elsewhere.cycle.link", + )} + +
  • + ) : null} +
+
+ ) : null} +
{/* Group 2 — How notifications look on the phone. Not module-gated: it applies to every notification the account receives. */} diff --git a/src/components/settings/preview-section.tsx b/src/components/settings/preview-section.tsx new file mode 100644 index 000000000..9df4789e5 --- /dev/null +++ b/src/components/settings/preview-section.tsx @@ -0,0 +1,116 @@ +"use client"; + +/** + * Settings > System > Preview features. + * + * One switch per preview feature, each off until the person turns it on, each + * saved the moment it changes (`PATCH /api/settings/preview-features`). A + * preview adds something beside what is there and never replaces a page. + * + * The `topics` flag is stored and shown here; the topic overview pages that + * read it arrive in a later release, and the card says so rather than + * promising a change the switch does not make yet. + */ + +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import Link from "next/link"; +import { LayoutDashboard } from "lucide-react"; + +import { toastWrittenOutcome } from "@/components/outcome/outcome-toast"; +import { SettingsCard } from "@/components/settings/settings-card"; +import { SettingsCardHeader } from "@/components/settings/_card-header"; +import { InlineAction } from "@/components/ui/inline-action"; +import { QueryErrorRow } from "@/components/ui/query-error-row"; +import { Switch } from "@/components/ui/switch"; +import { apiGet, apiPatch } 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 { + DEFAULT_PREVIEW_FEATURES, + type PreviewFeatures, +} from "@/lib/settings/preview-features"; + +const DISCUSSIONS_URL = "https://github.com/MBombeck/HealthLog/discussions"; + +export function PreviewSection() { + const { t } = useTranslations(); + const queryClient = useQueryClient(); + const key = queryKeys.previewFeatures(); + + const query = useQuery({ + queryKey: key, + queryFn: () => apiGet("/api/settings/preview-features"), + }); + const features = query.data ?? DEFAULT_PREVIEW_FEATURES; + + const toggle = useMutation({ + mutationKey: key, + mutationFn: (topics: boolean) => + apiPatch("/api/settings/preview-features", { topics }), + onMutate: async (topics) => { + await queryClient.cancelQueries({ queryKey: key }); + const previous = queryClient.getQueryData(key); + queryClient.setQueryData(key, { + ...(previous ?? DEFAULT_PREVIEW_FEATURES), + topics, + }); + return { previous }; + }, + onSuccess: (saved, topics) => { + if (saved) queryClient.setQueryData(key, saved); + toastWrittenOutcome( + "success", + topics + ? t("settings.preview.topics.savedOn") + : t("settings.preview.topics.savedOff"), + ); + }, + onError: (failure, _topics, context) => { + queryClient.setQueryData(key, context?.previous); + showErrorToast(t("settings.savingError"), { cause: failure }); + }, + }); + + return ( + + toggle.mutate(next)} + aria-labelledby="settings-preview-topics-title" + /> + } + /> + {query.isError ? ( + void query.refetch()} + /> + ) : null} +

{t("settings.preview.topics.detail")}

+

+ + + {t("settings.preview.feedbackLink")} + + +

+
+ ); +} diff --git a/src/components/settings/privacy-section.tsx b/src/components/settings/privacy-section.tsx index 193017392..2bb0cbb25 100644 --- a/src/components/settings/privacy-section.tsx +++ b/src/components/settings/privacy-section.tsx @@ -4,18 +4,20 @@ * v1.23 — "Data & Privacy" dashboard (P7). * * One coherent pane that ASSEMBLES already-shipped machinery — it does not - * rebuild any of it. Each block either embeds an existing card (active - * sessions, security activity, trusted devices) or links to the existing - * surface that owns the action (export incl. the passphrase option, account - * deletion / data reset, AI privacy mode, research mode). The retention + - * encryption facts are read from the server so the copy stays truthful, and - * the backup↔deletion lag is disclosed honestly. + * rebuild any of it. Each block states a fact or links to the surface that + * owns the action (account deletion / data reset, AI privacy mode). The + * retention + encryption facts are read from the server so the copy stays + * truthful, and the backup↔deletion lag is disclosed honestly. + * + * v1.44 — the active sessions, trusted devices and security activity moved + * to Settings > Security beside the sign-in controls, and the export card + * left: Export & import and the Health record sit next to this page in the + * Data group. The old anchors forward (`settings-anchor-forward.tsx`). */ import { useQuery } from "@tanstack/react-query"; import Link from "next/link"; import { Database, - Download, ShieldCheck, Trash2, Clock, @@ -29,9 +31,6 @@ import { Skeleton } from "@/components/ui/skeleton"; import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { SettingsInfoTile } from "@/components/settings/_info-tile"; -import { SecuritySessionsCard } from "@/components/settings/security-sessions-card"; -import { SecurityActivityCard } from "@/components/settings/security-activity-card"; -import { TrustedDevicesCard } from "@/components/settings/trusted-devices-card"; import { useAuth } from "@/hooks/use-auth"; import { useTranslations } from "@/lib/i18n/context"; import { MODULE_KEYS, MODULE_REGISTRY } from "@/lib/modules/registry"; @@ -177,28 +176,6 @@ export function PrivacySection() { )} - {/* Export */} - - - - - - - - {/* Delete / reset — with the honest backup lag disclosure */} - - {/* Active sessions + trusted devices + security activity (embedded) */} - - -
); } diff --git a/src/components/settings/section-slugs.ts b/src/components/settings/section-slugs.ts index 843203284..89d05e243 100644 --- a/src/components/settings/section-slugs.ts +++ b/src/components/settings/section-slugs.ts @@ -114,6 +114,9 @@ export const SETTINGS_SECTION_SLUGS = [ // pieces into one coherent privacy pane. Sits next to `advanced` (the // destructive-action home) and before `about`. "privacy", + // v1.44 — "Preview features": per-user opt-in to things finished enough to + // try and not yet the default. Sits in the System group before About. + "preview", "about", ] as const; diff --git a/src/components/settings/security-section/index.tsx b/src/components/settings/security-section/index.tsx index ed7e8dbfe..6008b7d75 100644 --- a/src/components/settings/security-section/index.tsx +++ b/src/components/settings/security-section/index.tsx @@ -7,6 +7,9 @@ import { useAuth } from "@/hooks/use-auth"; import { queryKeys } from "@/lib/query-keys"; import { apiGet } from "@/lib/api/api-fetch"; import { PasswordCard } from "@/components/settings/password-card"; +import { SecurityActivityCard } from "@/components/settings/security-activity-card"; +import { SecuritySessionsCard } from "@/components/settings/security-sessions-card"; +import { TrustedDevicesCard } from "@/components/settings/trusted-devices-card"; import { TotpCard } from "./totp-card"; import { SecurityKeysCard, type WebauthnKeyInfo } from "./security-keys-card"; import { PasskeyListSection } from "./passkey-list-section"; @@ -79,6 +82,15 @@ export function SecuritySection() { `/api/auth/me/mfa` that never answers must not take it down with the cards that do need the status. */} + + {/* v1.44 — who is signed in and what happened, beside the controls + that decide how one signs in. These lived on the privacy page; + `/settings/privacy#sessions` (and the other two anchors) forward + here. Outside the status conditional for the same reason as the + password: they read their own routes. */} + + +
); } diff --git a/src/components/settings/settings-anchor-forward.tsx b/src/components/settings/settings-anchor-forward.tsx new file mode 100644 index 000000000..247c4213b --- /dev/null +++ b/src/components/settings/settings-anchor-forward.tsx @@ -0,0 +1,70 @@ +"use client"; + +/** + * Old deep links into cards that moved to another Settings page. + * + * A path that moved is a server redirect (`next.config.ts`), but a card that + * moved while its page stayed cannot be: the `#anchor` never reaches the + * server. Bookmarks, cross-links in older notifications and the docs point at + * `/settings/privacy#sessions` and the like, so the shell forwards a known + * anchor on a known page to the card's new home, once, on arrival. + * + * `SETTINGS_ANCHOR_FORWARDS` is also read by the legacy-routes guard, which + * proves every target lands on a page that exists. + */ + +import { useEffect } from "react"; +import { usePathname, useRouter } from "next/navigation"; + +export const SETTINGS_ANCHOR_FORWARDS: Readonly< + Record>> +> = { + // v1.44 — sessions, trusted devices and the security activity joined the + // other sign-in controls under Security; the duplicate export card left. + "/settings/privacy": { + sessions: "/settings/security#sessions", + "trusted-devices": "/settings/security#trusted-devices", + "security-activity": "/settings/security#security-activity", + "your-data-export": "/settings/export", + }, + // v1.44 — the proactive nudges are a reminder and live with the others. + "/settings/coach": { + "coach-nudge": "/settings/notifications#coach-nudge", + }, + // v1.44 — Arrangement holds order and view only; each module's own + // settings moved to its page under Modules. + "/settings/layout/labs": { + "labs-local-ocr": "/settings/modules/labs#labs-local-ocr", + "labs-biomarkers": "/settings/modules/labs#labs-biomarkers", + }, + "/settings/layout/medications": { + "injection-sites": "/settings/modules/medications#injection-sites", + }, + "/settings/layout/vorsorge": { + "screening-hints": "/settings/anamnesis#screening-hints", + "vorsorge-reminders": "/settings/notifications#vorsorge-reminders", + }, +}; + +/** Where an anchor on a page now lives, or null when it did not move. */ +export function forwardedAnchorTarget( + pathname: string, + hash: string, +): string | null { + const anchor = hash.startsWith("#") ? hash.slice(1) : hash; + if (!anchor) return null; + return SETTINGS_ANCHOR_FORWARDS[pathname]?.[anchor] ?? null; +} + +export function SettingsAnchorForward() { + const pathname = usePathname(); + const router = useRouter(); + + useEffect(() => { + if (!pathname) return; + const target = forwardedAnchorTarget(pathname, window.location.hash); + if (target) router.replace(target); + }, [pathname, router]); + + return null; +} diff --git a/src/components/settings/settings-mobile-nav.tsx b/src/components/settings/settings-mobile-nav.tsx new file mode 100644 index 000000000..0eddd5fb3 --- /dev/null +++ b/src/components/settings/settings-mobile-nav.tsx @@ -0,0 +1,131 @@ +"use client"; + +/** + * Settings navigation below `md`: one button naming where the person is, and a + * bottom sheet listing every section under its group. + * + * The phone used to carry every section as a chip in one horizontal strip, + * more than twenty of them, so most of Settings sat off screen and the groups + * the desktop sidebar shows were reduced to thin dividers. The sheet shows the + * same groups and order as the sidebar, one 44 px row per section, and closes + * on choice. Both read the visible-section list the shell computes, so the + * module and record filters apply to both alike. + */ + +import * as React from "react"; +import Link from "next/link"; +import { ChevronsUpDown } from "lucide-react"; +import type { LucideIcon } from "lucide-react"; + +import { cn } from "@/lib/utils"; +import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; +import { useTranslations } from "@/lib/i18n/context"; + +export interface SettingsNavEntry { + slug: string; + title: string; + groupLabel: string; + icon: LucideIcon; +} + +export function SettingsMobileNav({ + entries, + activeSlug, +}: { + entries: readonly SettingsNavEntry[]; + activeSlug: string; +}) { + const { t } = useTranslations(); + const [open, setOpen] = React.useState(false); + const active = entries.find((entry) => entry.slug === activeSlug); + const ActiveIcon = active?.icon; + + // Group the flat, pre-ordered list for rendering; the order of first + // appearance is the group order. + const groups: { label: string; entries: SettingsNavEntry[] }[] = []; + for (const entry of entries) { + const last = groups.at(-1); + if (last && last.label === entry.groupLabel) last.entries.push(entry); + else groups.push({ label: entry.groupLabel, entries: [entry] }); + } + + return ( + + ); +} diff --git a/src/components/settings/settings-section-loader.tsx b/src/components/settings/settings-section-loader.tsx index 447a8b387..252c75905 100644 --- a/src/components/settings/settings-section-loader.tsx +++ b/src/components/settings/settings-section-loader.tsx @@ -11,8 +11,8 @@ import type { IntegrationCallbackUrls } from "@/lib/integrations/callback-urls"; /** * One settings section, loaded on its own. * - * The section route renders exactly one of twenty-one sections, but importing - * them statically put all twenty-one (and everything under them: the import + * The section route renders exactly one of the sections, but importing + * them statically put all of them (and everything under them: the import * panel, the integration cards, the MCP and API token tables, the score * editor) into the route's entry bundle, so opening "About" downloaded the * whole settings tree. Each section is now its own chunk. A first load still @@ -124,6 +124,10 @@ const LOADERS: Record< import("@/components/settings/privacy-section").then( (m) => m.PrivacySection, ), + preview: () => + import("@/components/settings/preview-section").then( + (m) => m.PreviewSection, + ), }; const SECTIONS: Record> = { @@ -158,6 +162,7 @@ const SECTIONS: Record> = { export: dynamic(LOADERS.export, { loading: () => }), advanced: dynamic(LOADERS.advanced, { loading: () => }), privacy: dynamic(LOADERS.privacy, { loading: () => }), + preview: dynamic(LOADERS.preview, { loading: () => }), }; /** diff --git a/src/components/settings/settings-shell.tsx b/src/components/settings/settings-shell.tsx index 1dbb1e32e..b260fa6d6 100644 --- a/src/components/settings/settings-shell.tsx +++ b/src/components/settings/settings-shell.tsx @@ -9,9 +9,10 @@ * by the page itself. * * Mobile-first per `docs/ui-guidelines.md`: - * - =md: sticky 220px sidebar + content column with `max-w-screen-xl`. + * - =md: sticky 220px sidebar, grouped the same way, beside a content + * column with `max-w-screen-xl`. */ import * as React from "react"; @@ -25,6 +26,7 @@ import { CloudSun, Download, FileHeart, + FlaskConical, Gauge, Info, KeyRound, @@ -56,6 +58,8 @@ import { type SettingsSectionSlug, } from "./section-slugs"; import { SettingsHubBackLink } from "./settings-hub-back-link"; +import { SettingsMobileNav } from "./settings-mobile-nav"; +import { SettingsAnchorForward } from "./settings-anchor-forward"; // Re-export so existing call-sites importing from this module keep working. // New server-side call-sites (e.g. `generateStaticParams()`) should import @@ -71,18 +75,24 @@ export { }; /** - * The sidebar's 19 entries render under one - * of these seven non-interactive group headers instead of as one flat - * list. Presentation only: no slug, route, or redirect changes. Order - * below is the render order of the groups themselves. + * The nine groups every section belongs to, in render order. The sidebar + * paints a non-interactive header above the first visible entry of each, and + * the phone's section sheet uses the same headers. + * + * Account: who you are and who can get in. Tracking: what the app records. + * Arrangement: order and view only. Notifications: every reminder. Health + * profile: what describes you. AI and Connections: what talks to the outside. + * Data: export, import, privacy and deletion. System: previews and about. */ export type SettingsSectionGroup = | "account" - | "yourData" - | "display" + | "tracking" + | "arrangement" + | "notifications" | "healthProfile" | "ai" - | "connectivity" + | "connections" + | "data" | "system"; /** i18n key for each group's header label, under `settings.shell.groups.*`. */ @@ -91,11 +101,13 @@ export const SETTINGS_SECTION_GROUP_LABEL_KEYS: Record< string > = { account: "settings.shell.groups.account", - yourData: "settings.shell.groups.yourData", - display: "settings.shell.groups.display", + tracking: "settings.shell.groups.tracking", + arrangement: "settings.shell.groups.arrangement", + notifications: "settings.shell.groups.notifications", healthProfile: "settings.shell.groups.healthProfile", ai: "settings.shell.groups.ai", - connectivity: "settings.shell.groups.connectivity", + connections: "settings.shell.groups.connections", + data: "settings.shell.groups.data", system: "settings.shell.groups.system", }; @@ -109,159 +121,84 @@ interface SettingsSection { } /** - * Source of truth for the section list that the settings shell renders - * in its sidebar + mobile chip-strip. + * Source of truth for the section list that the settings shell renders in + * its sidebar and in the phone's section sheet, pre-ordered by group. * - * The list used to render in a single flat - * order with no visual structure (19 entries deep, the AI cluster and the - * programmatic-access cluster interleaved, the health-profile pair split - * across the list). It is now clustered into the seven - * `SettingsSectionGroup`s, each entry carrying its `group`; the list is - * pre-ordered by group and the shell inserts a non-interactive header - * before the first VISIBLE entry of each group. This is a presentation-only - * reorder — no slug, route, or redirect changed, and `SETTINGS_SECTION_SLUGS` - * (`section-slugs.ts`, which also carries the page-only slugs and drives - * `generateStaticParams()`) is untouched. + * `SETTINGS_SECTION_SLUGS` (`section-slugs.ts`) is the route registry and + * keeps its own order (it drives `generateStaticParams()` and the record + * landing order); this list decides only where each entry is shown. Every + * slug a person ever reached keeps its URL: a section that moved between + * groups moved here, not in the route. * - * "About" sits at the end of the list (System group). v1.4.33 IW7 had - * folded it into the sidebar user-card dropdown only, which left - * `/settings/about` an orphaned route — reachable by URL but discoverable - * nowhere in the settings navigation. It is now both a regular shell entry - * and a user-card dropdown item ("Über HealthLog" / "About HealthLog"). + * The card titles people cite stay as they are: "Export & import", + * "Integrations", "API tokens", "Shared access", "Modules". */ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [ - // ── Account ── + // ── Account ── profile and display, sign-in and sessions, who else may open + // this record. { slug: "account", titleKey: "settings.sections.account.title", icon: User, group: "account", }, - // v1.23 — the account-security home: second factors, recovery codes, - // passkey management. + // Second factors, passkeys, password, and (since v1.44) the active + // sessions, trusted devices and security activity that used to sit on the + // privacy page. { slug: "security", titleKey: "settings.sections.security.title", icon: ShieldCheck, group: "account", }, - // v1.36.0 — who else may open this record, and whose this account may open. - // In the Account group beside Security: both answer "who can get at my - // account and what have they done", and a person looking for one will look - // where the other is. { slug: "access", titleKey: "settings.sections.access.title", icon: UsersRound, group: "account", }, - // v1.23 — "Data & Privacy" assembles the export / deletion / retention / - // encryption / session / activity pieces into one coherent privacy pane. - { - slug: "privacy", - titleKey: "settings.sections.privacy.title", - icon: Lock, - group: "account", - }, - // ── Your data ── - // v1.18.0 — the "Was du trackst" hub: the front door for enabling / - // disabling the secondary tracking domains. + // ── Tracking ── which areas are recorded; each module's own settings + // (biomarkers, mood tags, injection sites) open from its row. { slug: "modules", titleKey: "settings.sections.modules.title", icon: Blocks, - group: "yourData", + group: "tracking", }, - { - slug: "integrations", - titleKey: "settings.sections.integrations.title", - icon: Link2, - group: "yourData", - }, - // v1.18.1 — Quellen (source weighting): which connection wins when - // two report the same metric. v1.25.3 — Kanäle (delivery channels) folded - // back into Notifications as an in-page group; - // `/settings/channels` 301-redirects to `/settings/notifications#channels`. - { - slug: "sources", - titleKey: "settings.sections.sources.title", - icon: Layers, - group: "yourData", - }, - // v1.18.0 — the full health record (PDF + FHIR R4 + zip package). - // v1.18.6.1 — NOT module-gated: the health record is a flagship export - // capability; gating its only entry-point on the `doctorReport` opt-out - // meant a stray toggle in the Modules hub made the whole section vanish - // with no in-context way back. The `doctorReport` module still governs - // the data layer server-side (403 `module.disabled` over Bearer). - { - slug: "gesundheitsakte", - titleKey: "settings.sections.gesundheitsakte.title", - icon: FileHeart, - group: "yourData", - }, - // v1.25.7 — clinician share links fold into the Gesundheitsakte section as - // a labelled "Sharing" group; `/settings/sharing` 301-redirects there. - { - slug: "export", - titleKey: "settings.sections.export.title", - icon: Download, - group: "yourData", - }, - // ── Display ── - // v1.17.1 — one nav entry replaces the four scattered Dashboard / - // Insights / Medications / Mood "arrange" entries; those routes still - // resolve, reached through this hub. v1.25.3 — renamed to "Appearance" / - // "Darstellung" and widened into the index for every module's - // view/sort/order surface (the slug stays `layout`). v1.25.11 (#148) — - // each module lives on its own subpage at `/settings/layout/`; - // the old per-module routes 301-redirect there. + // ── Arrangement ── order and view, nothing else. The slug stays `layout`. { slug: "layout", titleKey: "settings.sections.layout.title", icon: Palette, - group: "display", + group: "arrangement", }, - // v1.25.7 — Vorsorge (preventive-care reminders) folded into - // "Darstellung"; `/settings/vorsorge` 301-redirects to - // `/settings/layout/vorsorge`. - { - slug: "thresholds", - titleKey: "settings.sections.thresholds.title", - icon: SlidersHorizontal, - group: "display", - }, - // v1.18.0 — "Benachrichtigungen" is the single module-gated - // reminder-types home; the standalone "Erinnerungen" hub is gone. + // ── Notifications ── every reminder kind, or the link to where it lives. { slug: "notifications", titleKey: "settings.sections.notifications.title", icon: Bell, - group: "display", + group: "notifications", }, // ── Health profile ── - // v1.35.0 — which pillars count toward the Health Score. Sits at the top - // of the health-profile cluster: it is a statement about the person's own - // health picture, not about how the app looks or what it records. { slug: "score", titleKey: "settings.sections.score.title", icon: Gauge, group: "healthProfile", }, - // v1.25 — Anamnese (medical history): the structured-record - // home for allergies + family history. NOT module-gated (like Vorsorge) — - // these are foundational health-profile records, always available. + { + slug: "thresholds", + titleKey: "settings.sections.thresholds.title", + icon: SlidersHorizontal, + group: "healthProfile", + }, { slug: "anamnesis", titleKey: "settings.sections.anamnesis.title", icon: ClipboardList, group: "healthProfile", }, - // v1.25 — Environmental context: home location, travel overrides, - // and the weather/daylight backfill. Module-gated on the opt-in - // `environment` module (`settings:environment` in the surface map), so the - // entry only appears once the user turns it on. + // Module-gated on the opt-in `environment` module. { slug: "environment", titleKey: "settings.sections.environment.title", @@ -275,37 +212,73 @@ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [ icon: Sparkles, group: "ai", }, - // v1.18.0 — Coach: the Coach preference cards get their own entry, - // shown only when the coach module is enabled (`settings:coach` in the - // surface map). The AI entry above keeps - // provider / model / BYOK configuration. + // Module-gated on the coach module (`settings:coach`). { slug: "coach", titleKey: "settings.sections.coach.title", icon: Bot, group: "ai", }, - // ── Connectivity ── + // ── Connections ── data coming in (integrations, which source wins) and + // programmatic access going out (tokens, the MCP connector). + { + slug: "integrations", + titleKey: "settings.sections.integrations.title", + icon: Link2, + group: "connections", + }, + { + slug: "sources", + titleKey: "settings.sections.sources.title", + icon: Layers, + group: "connections", + }, { slug: "api", titleKey: "settings.sections.api.title", icon: KeyRound, - group: "connectivity", + group: "connections", }, - // v1.22.0 — remote MCP connector. Not module-gated: the card carries its - // own enable toggle (mirroring `gesundheitsakte`), so the entry-point - // stays reachable even with the opt-in module off. + // Not module-gated: the page shows the connector's state and links to the + // one switch in Modules. { slug: "mcp", titleKey: "settings.sections.mcp.title", icon: Plug, - group: "connectivity", + group: "connections", + }, + // ── Data ── getting data out and in, what is stored, and deleting it. + { + slug: "export", + titleKey: "settings.sections.export.title", + icon: Download, + group: "data", + }, + // Not module-gated: the health record is a flagship export, and its only + // entry point must not vanish behind the `doctorReport` opt-out. + { + slug: "gesundheitsakte", + titleKey: "settings.sections.gesundheitsakte.title", + icon: FileHeart, + group: "data", + }, + { + slug: "privacy", + titleKey: "settings.sections.privacy.title", + icon: Lock, + group: "data", }, - // ── System ── { slug: "advanced", titleKey: "settings.sections.advanced.title", icon: Settings2, + group: "data", + }, + // ── System ── + { + slug: "preview", + titleKey: "settings.sections.preview.title", + icon: FlaskConical, group: "system", }, { @@ -453,40 +426,6 @@ export function SettingsShell({ (section) => section.slug === activeSlug, ); - // v1.4.33 — keep the active chip in view inside the horizontal - // mobile strip. On a 393 CSS px viewport the strip is wider than the - // viewport and the rightmost chips sit off-screen; a user who tapped - // one of those chips lands on the new route with the strip still - // scrolled to the leftmost chip, which reads as if the navigation is - // broken. We pin the active chip to the left edge of the strip. - // - // v1.7.0 — adjust only the strip's own horizontal scroll offset - // (via `scrollTo({ left })`) instead of `Element.scrollIntoView()`. - // `scrollIntoView` walks every scrollable ancestor and adjusts both - // axes, so on mobile it also nudged the whole document vertically on - // each route change — a dizzy auto-scroll that fired on every settings - // sub-page tap. Scrolling the strip's own `scrollLeft` confines the - // motion to its horizontal axis and never touches the page scroll - // position. - const mobileStripRef = React.useRef(null); - React.useEffect(() => { - if (typeof window === "undefined") return; - const strip = mobileStripRef.current; - if (!strip) return; - const active = strip.querySelector('[aria-current="page"]'); - if (!active) return; - // Land the active chip at the strip's left edge, clamped to the - // scrollable range. `offsetLeft` is relative to the strip's padding - // box, which is the origin for `scrollLeft`. - const maxScroll = strip.scrollWidth - strip.clientWidth; - const target = Math.max(0, Math.min(active.offsetLeft, maxScroll)); - // Position the active chip instantly. A smooth behaviour here animates - // the strip from its reset `scrollLeft: 0` to the target on every tap, - // which reads as an unwanted "scroll from the start" sweep when swapping - // sections. An instant jump keeps the chip in view without the sweep. - strip.scrollTo({ left: target, behavior: "auto" }); - }, [activeSlug]); - // v1.18.6.1 — resolve the heading once. Pages pass an explicit `heading` // (the canonical path); the slug fallback keeps the shell renderable on // its own (and in tests that mount it without a heading) by deriving the @@ -549,64 +488,18 @@ export function SettingsShell({
{headingBlock(true)}
) : null} - {/* Mobile section strip — horizontal scroll, hidden on md+. - `no-scrollbar` (defined in `globals.css`) suppresses the - painted scrollbar; the horizontal swipe + keyboard arrow - scrolling still work. Without it the 10-section strip - renders an always-on scrollbar at the top of every settings - page, which makes the page feel like every section card has - an overflow problem. - - v1.4.33 — `snap-x snap-mandatory` lets a swipe-flick land - on the next chip's leading edge instead of the in-between - dead zone. The auto `scrollIntoView({inline: "center"})` - effect above stays the canonical positioner for the active - chip; snap is the polish layer when the user manually flicks - through the strip without tapping. */} - + + {/* Below `md`: one button naming the current section, opening the + grouped section sheet. Hidden on md+, where the sidebar shows. */} + ({ + slug: section.slug, + title: t(section.titleKey), + groupLabel: t(SETTINGS_SECTION_GROUP_LABEL_KEYS[section.group]), + icon: section.icon, + }))} + /> {/* v1.18.6.1 — two-row grid on desktop: row 1 / col 2 = heading (content column only) diff --git a/src/components/settings/timezone-select.tsx b/src/components/settings/timezone-select.tsx new file mode 100644 index 000000000..e7b481304 --- /dev/null +++ b/src/components/settings/timezone-select.tsx @@ -0,0 +1,98 @@ +"use client"; + +import { useState } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { toastWrittenOutcome } from "@/components/outcome/outcome-toast"; +import { resolveInitialTimezone } from "@/components/settings/account-section/account-section-utils"; +import { TimezonePicker } from "@/components/settings/timezone-picker"; +import { InlineAction } from "@/components/ui/inline-action"; +import { useAuth } from "@/hooks/use-auth"; +import { useMounted } from "@/hooks/use-mounted"; +import { apiFetchRaw } 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 { storeTimezone } from "@/lib/timezone-mirror"; +import { DEFAULT_TIMEZONE, detectBrowserTimezone } from "@/lib/tz/format"; + +/** + * The profile timezone as a Display preference: it saves the moment it + * changes, like the language, units and formats beside it. + * + * Until v1.44 it sat in the profile form and waited for that form's Save, + * which made it the one display preference that did not take effect on + * change. Persistence is unchanged (`PUT /api/auth/me/timezone`, the route + * that owns the IANA validation). The local mirror is written on success so + * every rendered timestamp repaints in the same tab before the `/me` + * refetch confirms it (issue #490). + * + * The select shows the stored zone: a value on screen is a value that is + * saved. The form used to pre-fill the device's zone over the untouched + * default and save it with the next profile save; that suggestion stays, + * as a one-tap offer under the select (`resolveInitialTimezone` decides + * when), instead of an unsaved value dressed as the current one. + */ +export function TimezoneSelect({ + isAuthenticated, +}: { + isAuthenticated: boolean; +}) { + const { t } = useTranslations(); + const queryClient = useQueryClient(); + const { user } = useAuth(); + const [optimistic, setOptimistic] = useState(null); + + const value = optimistic ?? user?.timezone ?? DEFAULT_TIMEZONE; + + const mutation = useMutation({ + mutationFn: async (next: string) => { + const res = await apiFetchRaw("/api/auth/me/timezone", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ timezone: next }), + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return next; + }, + onSuccess: (next) => { + storeTimezone(next); + toastWrittenOutcome("success", t("settings.timezoneSaved")); + void queryClient.invalidateQueries({ queryKey: queryKeys.authMe() }); + setOptimistic(null); + }, + onError: (err) => { + setOptimistic(null); + showErrorToast(t("settings.timezoneInvalid"), { cause: err }); + }, + }); + + function handleChange(next: string) { + if (next === value || mutation.isPending || !isAuthenticated) return; + setOptimistic(next); + mutation.mutate(next); + } + + // The device zone is read after hydration so both renders agree. + const mounted = useMounted(); + const suggestion = mounted + ? resolveInitialTimezone(user?.timezone, detectBrowserTimezone()) + : value; + + return ( +
+ + {suggestion !== value && optimistic === null ? ( + handleChange(suggestion)} + > + {t("settings.timezoneUseDevice", { zone: suggestion })} + + ) : null} +
+ ); +} diff --git a/src/components/settings/vorsorge-section.tsx b/src/components/settings/vorsorge-section.tsx index c6413b905..f6376bba1 100644 --- a/src/components/settings/vorsorge-section.tsx +++ b/src/components/settings/vorsorge-section.tsx @@ -9,17 +9,18 @@ * The shell supplies the page chrome (left nav, heading, subtitle) so this * body is the preventive-care cards only. Preventive-care reminders are not * a toggleable module, so this entry carries no `moduleGate` (always shown). + * + * v1.44 — order and view only. The reminder switches moved to Settings > + * Notifications, the screening hints to Health profile > Medical history + * beside the life circumstances they come from. */ import { VorsorgeSettings } from "@/components/measurement-reminders/vorsorge-settings"; -import { ScreeningHintsCard } from "@/components/records/screening-hints-card"; export function VorsorgeSection() { return (
- {/* v1.43 (#614) — hints from the life circumstances, with their source. */} -
); } diff --git a/src/lib/command-palette/__tests__/settings-cards.test.ts b/src/lib/command-palette/__tests__/settings-cards.test.ts index 959e1bb04..8ea529b6f 100644 --- a/src/lib/command-palette/__tests__/settings-cards.test.ts +++ b/src/lib/command-palette/__tests__/settings-cards.test.ts @@ -7,6 +7,7 @@ import en from "../../../../messages/en.json"; import { SETTINGS_CARDS, SETTINGS_LAYOUT_PAGES, + SETTINGS_MODULE_PAGES, } from "@/lib/command-palette/settings-cards"; /** @@ -63,7 +64,11 @@ describe("SETTINGS_CARDS", () => { ); it("every title key resolves", () => { - const missing = [...SETTINGS_CARDS, ...SETTINGS_LAYOUT_PAGES] + const missing = [ + ...SETTINGS_CARDS, + ...SETTINGS_LAYOUT_PAGES, + ...SETTINGS_MODULE_PAGES, + ] .map((c) => c.titleKey) .filter((key) => !hasKey(key)); expect(missing).toEqual([]); diff --git a/src/lib/command-palette/build-index.ts b/src/lib/command-palette/build-index.ts index 6f6578593..68e2bf471 100644 --- a/src/lib/command-palette/build-index.ts +++ b/src/lib/command-palette/build-index.ts @@ -20,7 +20,11 @@ import { MODULE_REGISTRY, type ModuleKey } from "@/lib/modules/registry"; import { isSurfaceVisible, type SurfaceModuleMap } from "@/lib/modules/surface"; import type { ShareDomain } from "@/lib/sharing/scope"; -import { SETTINGS_CARDS, SETTINGS_LAYOUT_PAGES } from "./settings-cards"; +import { + SETTINGS_CARDS, + SETTINGS_LAYOUT_PAGES, + SETTINGS_MODULE_PAGES, +} from "./settings-cards"; import { PALETTE_SYNONYMS } from "./synonyms"; /** @@ -244,6 +248,22 @@ export function buildPaletteIndex(input: PaletteIndexInput): PaletteEntry[] { }); } } + if (sectionVisible.has("modules")) { + const modulesTitle = sectionTitle.get("modules") ?? ""; + for (const page of SETTINGS_MODULE_PAGES) { + if (modules?.[page.module] === false) continue; + const id = `settings:modules/${page.slug}`; + entries.push({ + id, + group: "settings", + title: t(page.titleKey), + hint: modulesTitle, + keywords: keywordsFor(id, [modulesTitle]), + icon: MODULE_ICONS[page.module], + run: { kind: "href", href: `/settings/modules/${page.slug}` }, + }); + } + } } // ── Module switches. ── diff --git a/src/lib/command-palette/settings-cards.ts b/src/lib/command-palette/settings-cards.ts index 0a6a248b0..3dd3a4191 100644 --- a/src/lib/command-palette/settings-cards.ts +++ b/src/lib/command-palette/settings-cards.ts @@ -95,11 +95,6 @@ export const SETTINGS_CARDS: ReadonlyArray = [ anchor: "retention", titleKey: "settings.privacy.retention.title", }, - { - section: "privacy", - anchor: "your-data-export", - titleKey: "settings.privacy.export.title", - }, { section: "privacy", anchor: "delete-data", @@ -111,17 +106,17 @@ export const SETTINGS_CARDS: ReadonlyArray = [ titleKey: "settings.privacy.posture.title", }, { - section: "privacy", + section: "security", anchor: "sessions", titleKey: "settings.security.sessionsTitle", }, { - section: "privacy", + section: "security", anchor: "trusted-devices", titleKey: "settings.security.trustedDevices.title", }, { - section: "privacy", + section: "security", anchor: "security-activity", titleKey: "settings.security.activityTitle", }, @@ -260,6 +255,11 @@ export const SETTINGS_CARDS: ReadonlyArray = [ titleKey: "notifications.lowStock.title", modules: ["medications"], }, + { + section: "notifications", + anchor: "vorsorge-reminders", + titleKey: "notifications.checkupReminders.title", + }, // ── Anamnesis ── { section: "anamnesis", @@ -355,7 +355,7 @@ export const SETTINGS_CARDS: ReadonlyArray = [ { section: "coach", anchor: "coach-activate", - titleKey: "settings.coach.activate.title", + titleKey: "settings.coach.state.title", }, { section: "coach", @@ -363,7 +363,7 @@ export const SETTINGS_CARDS: ReadonlyArray = [ titleKey: "insights.coach.settingsTitle", }, { - section: "coach", + section: "notifications", anchor: "coach-nudge", titleKey: "notifications.coachNudge.title", }, @@ -429,7 +429,7 @@ export const SETTINGS_CARDS: ReadonlyArray = [ ]; /** - * The Appearance hub's sub-pages, each its own entry. A module's page shows + * The Arrangement hub's sub-pages, each its own entry. A module's page shows * only while the module is on. */ export const SETTINGS_LAYOUT_PAGES: ReadonlyArray<{ @@ -444,11 +444,6 @@ export const SETTINGS_LAYOUT_PAGES: ReadonlyArray<{ titleKey: "settings.sections.layout.medications.title", module: "medications", }, - { - slug: "mood", - titleKey: "settings.sections.layout.mood.title", - module: "mood", - }, { slug: "labs", titleKey: "settings.sections.layout.labs.title", @@ -466,3 +461,29 @@ export const SETTINGS_LAYOUT_PAGES: ReadonlyArray<{ }, { slug: "vorsorge", titleKey: "settings.sections.layout.vorsorge.title" }, ]; + +/** + * The module pages under Settings > Modules (`/settings/modules/`), + * each its own entry while its module is on. + */ +export const SETTINGS_MODULE_PAGES: ReadonlyArray<{ + slug: string; + titleKey: string; + module: ModuleKey; +}> = [ + { + slug: "medications", + titleKey: "settings.sections.modules.pages.medications.title", + module: "medications", + }, + { + slug: "mood", + titleKey: "settings.sections.modules.pages.mood.title", + module: "mood", + }, + { + slug: "labs", + titleKey: "settings.sections.modules.pages.labs.title", + module: "labs", + }, +]; diff --git a/src/lib/modules/__tests__/surface-module-registry.test.ts b/src/lib/modules/__tests__/surface-module-registry.test.ts index 71e13e454..7bbf00579 100644 --- a/src/lib/modules/__tests__/surface-module-registry.test.ts +++ b/src/lib/modules/__tests__/surface-module-registry.test.ts @@ -48,6 +48,7 @@ import { NAV_DESTINATIONS } from "@/components/layout/nav-model"; import { CAPTURE_KIND_ORDER } from "@/components/layout/capture-picker"; import { SETTINGS_SECTIONS } from "@/components/settings/settings-shell"; import { LAYOUT_GROUP_IDS } from "@/components/settings/layout-groups"; +import { MODULE_SETTINGS_PAGE_IDS } from "@/components/settings/module-settings-page-ids"; import { DASHBOARD_WIDGET_CATALOGUE_IDS, SCORE_RING_IDS, @@ -160,6 +161,13 @@ describe("every surface id names a surface that exists", () => { } }); + it("settings-module: a module page under Settings > Modules", () => { + const ids = new Set(MODULE_SETTINGS_PAGE_IDS); + for (const id of localIds("settings-module")) { + expect(ids.has(id), id).toBe(true); + } + }); + it("day-section: a section the day view renders", () => { const keys = new Set(DAY_SECTION_KEYS); for (const id of localIds("day-section")) { diff --git a/src/lib/modules/surface.ts b/src/lib/modules/surface.ts index a11e848a2..8d0c9b029 100644 --- a/src/lib/modules/surface.ts +++ b/src/lib/modules/surface.ts @@ -46,6 +46,7 @@ import type { ModuleKey } from "@/lib/modules/registry"; * - `derived`: a derived score, by its `DerivedMetricId`. * - `settings`: a Settings section, by its slug. * - `settings-layout`: a Settings, Layout group, by its id. + * - `settings-module`: a module's own Settings page under Modules, by its id. * - `correlation`: a correlation-discovery channel, by its channel key. * - `score-ring`: a dashboard hero score ring, by its `ScoreRingId`. * - `day-section`: a section of the day view, by its `DaySectionKey`. The @@ -66,6 +67,7 @@ export const SURFACE_KINDS = [ "derived", "settings", "settings-layout", + "settings-module", "correlation", "score-ring", "day-section", @@ -200,11 +202,15 @@ const STATIC_SURFACE_MODULE = { // ── Settings, Layout groups ── "settings-layout:medications": "medications", - "settings-layout:mood": "mood", "settings-layout:labs": "labs", "settings-layout:illness": "illness", "settings-layout:documents": "inboundDocuments", + // ── Settings, a module's own page under Modules ── + "settings-module:medications": "medications", + "settings-module:mood": "mood", + "settings-module:labs": "labs", + // ── Correlation channels ── // Rated mood factors (`FACTOR:`) are mood's too; see // `correlationChannelSurfaceId`. diff --git a/src/lib/record-settings/classification.ts b/src/lib/record-settings/classification.ts index fbeda64b2..8d54e3eb1 100644 --- a/src/lib/record-settings/classification.ts +++ b/src/lib/record-settings/classification.ts @@ -98,6 +98,7 @@ export const SETTINGS_DESTINATION_INVENTORY = { export: PERSONAL, advanced: PERSONAL, privacy: PERSONAL, + preview: PERSONAL, about: PERSONAL, } as const satisfies Record< SettingsSectionSlug, diff --git a/src/lib/settings/module-list-layout.ts b/src/lib/settings/module-list-layout.ts index 2783db463..d7fa5fc7c 100644 --- a/src/lib/settings/module-list-layout.ts +++ b/src/lib/settings/module-list-layout.ts @@ -76,13 +76,6 @@ export function moduleListDefaults(list: ModuleListKey): ModuleListPrefs { 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" && From ec240bc07a42b47777ec1bb1491f97bf6997edbe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 21 Oct 2026 19:44:07 +0200 Subject: [PATCH 4/5] test(settings): follow the moved pages in e2e and cover the phone navigation Point the header, overscroll and timezone specs at the pages that hold those cards now, add the module pages and Preview features to the one-piece sweep, and add a 390 px journey through the grouped section sheet. Unit coverage for the Preview features card. --- e2e/settings-admin-one-piece.spec.ts | 8 ++- e2e/settings-mobile-nav.spec.ts | 47 +++++++++++++++++ e2e/settings-overscroll.spec.ts | 2 +- e2e/timezone-profile.spec.ts | 4 +- .../__tests__/preview-section.test.tsx | 51 +++++++++++++++++++ 5 files changed, 107 insertions(+), 5 deletions(-) create mode 100644 e2e/settings-mobile-nav.spec.ts create mode 100644 src/components/settings/__tests__/preview-section.test.tsx diff --git a/e2e/settings-admin-one-piece.spec.ts b/e2e/settings-admin-one-piece.spec.ts index 4fc1b69d2..3f10f6540 100644 --- a/e2e/settings-admin-one-piece.spec.ts +++ b/e2e/settings-admin-one-piece.spec.ts @@ -58,11 +58,13 @@ const SETTINGS_ROUTES = [ "/settings/api", "/settings/mcp", "/settings/advanced", + "/settings/preview", "/settings/about", ] as const; /** - * The Layout hub's seven children. They are the routes whose header block + * The Arrangement hub's children, plus (v1.44) the module pages under + * Modules, which use the same back-link header. They are the routes whose header block * changed MOST — the shell used to hand-roll a `space-y-6` between the * back-link and the title, and `PageHeader`'s documented contract is * `space-y-1.5`. Nothing measured them before, so the step went from 24 px @@ -73,10 +75,12 @@ const LAYOUT_CHILD_ROUTES = [ "/settings/layout/dashboard", "/settings/layout/insights", "/settings/layout/medications", - "/settings/layout/mood", "/settings/layout/labs", "/settings/layout/illness", "/settings/layout/vorsorge", + "/settings/modules/medications", + "/settings/modules/mood", + "/settings/modules/labs", ] as const; /** The `space-y-1.5` between a back-link and the title it sits above. */ diff --git a/e2e/settings-mobile-nav.spec.ts b/e2e/settings-mobile-nav.spec.ts new file mode 100644 index 000000000..e8a115c68 --- /dev/null +++ b/e2e/settings-mobile-nav.spec.ts @@ -0,0 +1,47 @@ +import { expect, test } from "./setup/test"; + +import { STORAGE_STATE_PATH } from "./setup/global-setup"; + +/** + * Settings navigation at 390 px: one button names the current section and + * opens a sheet with every section under its group. It replaced a strip of + * every section as chips, where most of Settings sat off screen. + */ +test.describe("settings navigation on a phone", () => { + test.use({ storageState: STORAGE_STATE_PATH }); + + test("names the section, opens the grouped list and moves on a tap", async ({ + page, + }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto("/settings/account", { waitUntil: "domcontentloaded" }); + + const trigger = page.locator('[data-slot="settings-mobile-nav-trigger"]'); + await expect(trigger).toBeVisible({ timeout: 15_000 }); + await expect(trigger).toContainText("Account"); + + // The click can land before hydration; retry until the sheet opens. + const list = page.locator('[data-slot="settings-mobile-nav-list"]'); + await expect(async () => { + await trigger.click(); + await expect(list).toBeVisible({ timeout: 2_000 }); + }).toPass({ timeout: 15_000 }); + + for (const group of ["Account", "Tracking", "Notifications", "System"]) { + await expect( + list.getByRole("heading", { name: group, exact: true }), + ).toBeVisible(); + } + + await list.getByRole("link", { name: "Security" }).click(); + await expect(page).toHaveURL(/\/settings\/security$/); + await expect(list).toHaveCount(0); + await expect(trigger).toContainText("Security"); + + // Nothing scrolls sideways. + const overflow = await page.evaluate( + () => document.documentElement.scrollWidth - window.innerWidth, + ); + expect(overflow).toBeLessThanOrEqual(1); + }); +}); diff --git a/e2e/settings-overscroll.spec.ts b/e2e/settings-overscroll.spec.ts index 59f858dfc..0df30bde2 100644 --- a/e2e/settings-overscroll.spec.ts +++ b/e2e/settings-overscroll.spec.ts @@ -33,7 +33,7 @@ const ROUTES = [ // one-scroll-floor assertion below). "/settings/layout/insights", "/settings/layout/medications", - "/settings/layout/mood", + "/settings/modules/mood", "/settings/modules", "/settings/account", "/settings/notifications", diff --git a/e2e/timezone-profile.spec.ts b/e2e/timezone-profile.spec.ts index f4865aa1d..7f4177b9a 100644 --- a/e2e/timezone-profile.spec.ts +++ b/e2e/timezone-profile.spec.ts @@ -6,7 +6,7 @@ import { STORAGE_STATE_PATH } from "./setup/global-setup"; * Issue #490 — client-rendered timestamps follow the PROFILE timezone. * * `/api/auth/me` is mocked with an `Asia/Manila` profile; the sessions - * card on /settings/privacy renders `lastActiveAt` through + * card on /settings/security renders `lastActiveAt` through * `useFormatters()`, which reads the localStorage timezone mirror that * `fetchMe` fills from the mocked payload. A fixed UTC instant must * therefore paint the MANILA wall clock (14:30), never the Berlin @@ -75,7 +75,7 @@ test.describe("profile-timezone display (#490)", () => { }), ); - await page.goto("/settings/privacy", { waitUntil: "domcontentloaded" }); + await page.goto("/settings/security", { waitUntil: "domcontentloaded" }); // The sessions card is collapsed by default — expand it first. The // toggle's onClick can be dropped if Playwright clicks before React has diff --git a/src/components/settings/__tests__/preview-section.test.tsx b/src/components/settings/__tests__/preview-section.test.tsx new file mode 100644 index 000000000..7dc3e11ba --- /dev/null +++ b/src/components/settings/__tests__/preview-section.test.tsx @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; +import { PreviewSection } from "../preview-section"; +import { primeLocales } from "@/__tests__/helpers/prime-locales"; + +primeLocales("de"); + +function render(topics: boolean | undefined, locale: "en" | "de" = "en") { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false, refetchOnMount: false } }, + }); + if (topics !== undefined) { + client.setQueryData(queryKeys.previewFeatures(), { topics }); + } + return renderToStaticMarkup( + + + + + , + ); +} + +describe("", () => { + it("shows the Topics switch off by default, saved on change", () => { + const html = render(false); + expect(html).toContain('data-slot="settings-preview-topics-switch"'); + expect(html).toMatch( + /data-slot="settings-preview-topics-switch"[^>]*aria-checked="false"|aria-checked="false"[^>]*data-slot="settings-preview-topics-switch"/, + ); + expect(html).toContain("Topics (beta)"); + // A switch, no Save: one save model per card. + expect(html).not.toContain('type="submit"'); + }); + + it("reads on once the person switched it on", () => { + expect(render(true)).toMatch( + /data-slot="settings-preview-topics-switch"[^>]*aria-checked="true"|aria-checked="true"[^>]*data-slot="settings-preview-topics-switch"/, + ); + }); + + it("says the existing pages stay as they are and nothing raw leaks", () => { + const html = render(false, "de"); + expect(html).toContain("Themen (Beta)"); + expect(html).not.toContain("settings.preview."); + }); +}); From 131b48cc49c89f03cf64e33a695acee02f5d3e2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 21 Oct 2026 19:48:29 +0200 Subject: [PATCH 5/5] fix(i18n): one German word per thing across Settings Anbieter instead of Provider, Standard instead of Default, Gesundheitsscore for every spelling of the score, and Insights where the area was called Auswertungen. The Coach's rate-limit notice no longer shows English in the German interface. A guard reads the Settings namespaces of the German catalog and fails on the retired spellings. --- messages/de.json | 44 +++++++-------- messages/en.json | 2 +- src/__tests__/settings-glossary-de.test.ts | 55 +++++++++++++++++++ .../settings/__tests__/ai-section.test.tsx | 2 +- 4 files changed, 79 insertions(+), 24 deletions(-) create mode 100644 src/__tests__/settings-glossary-de.test.ts diff --git a/messages/de.json b/messages/de.json index 8b03cdf9b..a21d25a7e 100644 --- a/messages/de.json +++ b/messages/de.json @@ -677,7 +677,7 @@ "same_time_baseline": "Aktivität zur gleichen Tageszeit", "upcoming_visit": "Termine" }, - "layoutReset": "Auf Defaults zurücksetzen", + "layoutReset": "Auf Standard zurücksetzen", "layoutResetTitle": "Dashboard-Layout zurücksetzen?", "layoutResetBody": "Dein Kachel-Layout und deine Auswahl für die Highlights für heute werden auf die Standardwerte zurückgesetzt.", "layoutResetConfirm": "Layout zurücksetzen", @@ -1827,7 +1827,7 @@ "phaseRed": "Rot", "phaseMinutes": "Min", "phasePercent": "%", - "phaseResetDefaults": "Auf Defaults zurücksetzen", + "phaseResetDefaults": "Auf Standard zurücksetzen", "phaseSaved": "Erinnerungsphasen gespeichert", "sourceWeb": "Web", "sourceApi": "iOS-App", @@ -2745,7 +2745,7 @@ "tooltip": "Persönlicher Rekord: {value} {unit} am {date}", "benchmark": "Typisch in dem Alter: {low} bis {high} {unit} ({source})" }, - "loadError": "Auswertungen konnten nicht geladen werden", + "loadError": "Insights konnten nicht geladen werden", "heroPersonalBaseline": "Basierend auf deinen letzten 90 Tagen", "heroRegenerating": "Wird generiert", "regenerateAnalysis": "Einschätzung neu erstellen", @@ -3088,8 +3088,8 @@ "settingsCancel": "Abbrechen", "dailyLimitTitle": "Heute-Limit erreicht", "dailyLimitBody": "Tageslimit erreicht. Das Budget setzt sich um Mitternacht in deiner Zeitzone zurück.", - "providerRateLimitTitle": "Provider rate-limited", - "providerRateLimitBody": "Provider ist temporär überlastet; Reset in ~5 min.", + "providerRateLimitTitle": "Anbieter ausgelastet", + "providerRateLimitBody": "Der Anbieter ist gerade überlastet. In etwa 5 Minuten geht es wieder.", "cluster": { "cardio": { "label": "Herz-Kreislauf", @@ -3211,7 +3211,7 @@ "workouts": "Geh mit mir meine letzten Workouts durch: Belastung, Häufigkeit und wie ich mich dazwischen erhole.", "recoveryPage": "Warum steht meine Erholung gerade dort, wo sie steht, und was treibt das?", "briefing": "Geh mit mir das heutige Briefing durch. Worauf sollte ich mich konzentrieren, und gibt es etwas, das einen Schritt wert ist?", - "healthScore": "Warum liegt mein Gesundheits-Score bei {score} von 100, und was würde ihn am stärksten bewegen?", + "healthScore": "Warum liegt mein Gesundheitsscore bei {score} von 100, und was würde ihn am stärksten bewegen?", "periodWeek": "Geh mit mir durch, wie meine letzte Woche über meine Gesundheitswerte hinweg lief. Was sticht heraus?", "periodMonth": "Geh mit mir durch, wie mein letzter Monat über meine Gesundheitswerte hinweg lief. Was sticht heraus?", "correlationBpCompliance": "Wie hängt meine Therapietreue mit meinem Blutdruck zusammen? Geh mit mir durch, was dieser Zusammenhang für mich bedeutet.", @@ -4857,10 +4857,10 @@ } }, "thresholds": { - "defaultLabel": "Default", + "defaultLabel": "Standard", "overrideToggleLabel": "Eigene Werte", - "resetAction": "Auf Default zurücksetzen", - "resetAllAction": "Alle auf Default zurücksetzen", + "resetAction": "Auf Standard zurücksetzen", + "resetAllAction": "Alle auf Standard zurücksetzen", "resetAllTitle": "Alle eigenen Bereiche zurücksetzen?", "resetAllBody": "Jede Messgröße kehrt zu ihrem Standardbereich zurück. Die von dir gesetzten Werte werden nirgends sonst gespeichert und lassen sich nicht wiederherstellen.", "resetAllConfirm": "Alle zurücksetzen", @@ -4874,7 +4874,7 @@ "overrideWarning": "Werte außerhalb der Leitlinien können zu ungesunden Zielen führen. Nur nach ärztlicher Rücksprache überschreiben.", "saveSuccess": "Zielwerte aktualisiert", "saveError": "Zielwerte konnten nicht gespeichert werden", - "resetSuccess": "Auf Default zurückgesetzt", + "resetSuccess": "Auf Standard zurückgesetzt", "sourceOverride": "Eigener Wert", "metricWeight": "Gewicht", "metricBpSys": "Blutdruck (systolisch)", @@ -4890,7 +4890,7 @@ "metricBodyWater": "Körperwasser", "metricBoneMass": "Knochenmasse", "metricOxygenSaturation": "Sauerstoffsättigung (SpO₂)", - "unsetExplanation": "Kein eigener Wert, berechneter Default aktiv.", + "unsetExplanation": "Kein eigener Wert, berechneter Standard aktiv.", "cardTitle": "Bereiche je Messgröße" }, "onboarding": { @@ -4908,7 +4908,7 @@ }, "quickAdd": { "title": "In Sekunden erfasst", - "body": "Über „+ Hinzufügen“ trägst du jeden Messwert in Sekunden ein: Gewicht, Blutdruck, Puls, Glukose. Versteckte Stärke: Werte lassen sich nachträglich datieren. Alles, was du hier einträgst, fließt in Trends, Health-Score und die KI-Insights." + "body": "Über „+ Hinzufügen“ trägst du jeden Messwert in Sekunden ein: Gewicht, Blutdruck, Puls, Glukose. Versteckte Stärke: Werte lassen sich nachträglich datieren. Alles, was du hier einträgst, fließt in Trends, Gesundheitsscore und die KI-Insights." }, "measurements": { "title": "Messwerte & Zielbereiche", @@ -5860,7 +5860,7 @@ "error": "Konnte nicht gespeichert werden. Bitte erneut versuchen.", "toggleable": { "title": "Was du trackst", - "description": "Deaktivierte Module verschwinden aus Navigation, Kacheln und Auswertungen, deine Daten bleiben erhalten." + "description": "Deaktivierte Module verschwinden aus Navigation, Kacheln und Insights, deine Daten bleiben erhalten." }, "manageIn": "In {section} verwalten", "operatorDisabled": "Serverweit deaktiviert", @@ -6004,12 +6004,12 @@ "omitted": "{count} Bereiche stehen nicht in der Liste, weil ihre Erfassung abgeschaltet ist. Schalte ein Modul wieder ein, dann kommt sein Bereich zurück.", "omittedLink": "Module öffnen", "refusal": { - "empty": "Ein Health Score braucht mindestens ein Feld der Gesundheit, das er bewerten kann. Behalte mindestens eines ausgewählt. Der Score nennt selbst, worauf er beruht." + "empty": "Ein Gesundheitsscore braucht mindestens ein Feld der Gesundheit, das er bewerten kann. Behalte mindestens eines ausgewählt. Der Score nennt selbst, worauf er beruht." }, "notice": { "upgrade": { "title": "Was zählt, entscheidest jetzt du", - "body": "Welche Bereiche in deinen Health Score einflossen, richtete sich früher danach, welche Module du eingeschaltet hattest. Das ist jetzt eine eigene Entscheidung, und hier triffst du sie. Deine Zahl hat sich dadurch nicht bewegt: Sie zählt weiterhin genau das, was sie vorher gezählt hat." + "body": "Welche Bereiche in deinen Gesundheitsscore einflossen, richtete sich früher danach, welche Module du eingeschaltet hattest. Das ist jetzt eine eigene Entscheidung, und hier triffst du sie. Deine Zahl hat sich dadurch nicht bewegt: Sie zählt weiterhin genau das, was sie vorher gezählt hat." }, "changed": { "title": "Du hast geändert, was zählt", @@ -6195,7 +6195,7 @@ "lastGeneratedAt": "Zuletzt generiert", "ai": { "chatgptConnectedBadge": "ChatGPT verbunden", - "adminAiActiveBadge": "Admin-Provider aktiv", + "adminAiActiveBadge": "Betreiber-Anbieter aktiv", "connectionExpiredBadge": "Verbindung abgelaufen", "connectedSince": "Verbunden seit {when}.", "deviceCodeHeading": "Verbindung auf chatgpt.com abschließen", @@ -6233,8 +6233,8 @@ "moveUp": "Nach oben", "moveDown": "Nach unten", "removeFromChain": "Aus Kette entfernen", - "addProvider": "Provider hinzufügen", - "addNoneAvailable": "Alle Provider sind bereits in der Kette", + "addProvider": "Anbieter hinzufügen", + "addNoneAvailable": "Alle Anbieter sind bereits in der Kette", "saveOrder": "Reihenfolge speichern", "saved": "Kette gespeichert", "saveFailed": "Speichern der Kette fehlgeschlagen", @@ -6242,7 +6242,7 @@ "resetConfirmTitle": "Kette auf Standard zurücksetzen?", "resetConfirmBody": "Die Kette wird auf Codex → OpenAI → Anthropic → Lokal → Admin-OpenAI zurückgesetzt. Gespeicherte Zugangsdaten bleiben erhalten." }, - "activeProviderHeading": "Aktiver Provider", + "activeProviderHeading": "Aktiver Anbieter", "activeProviderLabel": "Anbieter einrichten", "providerSelect": { "codex": "ChatGPT-Account (Codex)", @@ -6285,10 +6285,10 @@ }, "adminOpenai": { "title": "Admin-OpenAI", - "body": "Vom Betreiber bereitgestellter OpenAI-Key, genutzt als letzter Fallback ohne persönlichen Provider. Hier ist nichts einzustellen.", + "body": "Vom Betreiber bereitgestellter OpenAI-Key, genutzt als letzter Fallback ohne persönlichen Anbieter. Hier ist nichts einzustellen.", "notConfigured": "Der Betreiber hat keinen geteilten OpenAI-Key auf dieser Instanz konfiguriert." }, - "testProvider": "Aktiven Provider testen", + "testProvider": "Aktiven Anbieter testen", "testSuccess": "OK: {provider} ({model})", "testFailedShort": "Test fehlgeschlagen: {message}", "testUnexpectedResponse": "Verbindung zum KI-Anbieter fehlgeschlagen: unerwartete Antwort vom Server.", @@ -12334,7 +12334,7 @@ "scopeSomeCapability": "Alles, was du nicht auswählst, bleibt für sie verschlossen.", "scopeManageNote": "Verwaltender Zugriff umfasst immer die gesamte Akte.", "scopeLeakNote": "Ein Bereich kann in seinen Notizen und Bezeichnungen auch den Rest deiner Gesundheit erwähnen. Wer einen Bereich bekommt, bekommt alles, was darin steht.", - "scopeAggregateNote": "Die Übersicht auf dem Dashboard, der Health Score und die Tageszusammenfassung erscheinen nur, wenn die gesamte Akte geteilt ist.", + "scopeAggregateNote": "Die Übersicht auf dem Dashboard, der Gesundheitsscore und die Tageszusammenfassung erscheinen nur, wenn die gesamte Akte geteilt ist.", "scopeDocumentsNote": "Ein Dokument kann alles enthalten. Teile diesen Bereich nur, wenn die Person alles davon lesen darf.", "scopeEmptyHint": "Wähle mindestens einen Bereich aus oder teile wieder die gesamte Akte.", "scopeChooseHint": "Wähle, ob die gesamte Akte oder nur einzelne Bereiche geteilt werden.", diff --git a/messages/en.json b/messages/en.json index 1a795ba9f..c2ff4e024 100644 --- a/messages/en.json +++ b/messages/en.json @@ -5508,7 +5508,7 @@ "deviceTypeToggle": "Device-type priority ({count})", "deviceTypeHelp": "Within a source, which device wins. When an Apple Watch and an iPhone both record steps, the higher-ranked device-type counts as the canonical reading.", "deviceTypeDefault": "Default device-type ladder", - "resetDeviceTypes": "Reset device-types", + "resetDeviceTypes": "Reset device types", "metrics": { "steps": "Steps", "activeEnergy": "Active energy", diff --git a/src/__tests__/settings-glossary-de.test.ts b/src/__tests__/settings-glossary-de.test.ts new file mode 100644 index 000000000..57f031d5d --- /dev/null +++ b/src/__tests__/settings-glossary-de.test.ts @@ -0,0 +1,55 @@ +/** + * The German Settings glossary: one word per thing. + * + * Settings used "Provider" beside "Anbieter", "Default" beside "Standard", + * and the score as "Health Score", "Health-Score", "Gesundheits-Score" and + * "Gesundheitsscore", sometimes on one screen. The settled words are + * Anbieter, Standard and Gesundheitsscore; the Insights area is "Insights" + * (the name already dominant in the catalog). This reads the German catalog + * under the Settings namespaces and fails on the retired spellings. + * + * Product names are not glossary words: "ChatGPT", "OpenAI" and the like are + * left alone, and so is "Provider" inside a code-shaped token. + */ +import { describe, expect, it } from "vitest"; + +import de from "../../messages/de.json"; + +const NAMESPACES = ["settings", "thresholds"] as const; + +const RETIRED: ReadonlyArray<{ pattern: RegExp; use: string }> = [ + { pattern: /\bProvider\b/, use: "Anbieter" }, + { pattern: /\bDefaults?\b/, use: "Standard" }, + { pattern: /\bHealth[- ]?Score\b/i, use: "Gesundheitsscore" }, + { pattern: /\bGesundheits-Score\b/, use: "Gesundheitsscore" }, +]; + +function walk(node: unknown, path: string, out: Array<[string, string]>) { + if (typeof node === "string") out.push([path, node]); + else if (node && typeof node === "object") { + for (const [key, value] of Object.entries(node)) { + walk(value, path ? `${path}.${key}` : key, out); + } + } +} + +describe("German Settings glossary", () => { + const strings: Array<[string, string]> = []; + for (const ns of NAMESPACES) { + walk((de as Record)[ns], ns, strings); + } + + it("reads the catalog (an empty read is a broken walker)", () => { + expect(strings.length).toBeGreaterThan(500); + }); + + it("uses the settled words", () => { + const offenders: string[] = []; + for (const [key, value] of strings) { + for (const { pattern, use } of RETIRED) { + if (pattern.test(value)) offenders.push(`${key}: "${value}" → ${use}`); + } + } + expect(offenders).toEqual([]); + }); +}); diff --git a/src/components/settings/__tests__/ai-section.test.tsx b/src/components/settings/__tests__/ai-section.test.tsx index 24a3327e7..1d4df55bf 100644 --- a/src/components/settings/__tests__/ai-section.test.tsx +++ b/src/components/settings/__tests__/ai-section.test.tsx @@ -218,7 +218,7 @@ describe(" — dropdown-driven UX (B2)", () => { // both ship without the "KI"/"AI" prefix per the project-voice rule // ("Aktiver KI-Provider" → "Aktiver Provider"). The picker below it is // labelled for what it does: it chooses the form, not the order. - expect(html).toContain("Aktiver Provider"); + expect(html).toContain("Aktiver Anbieter"); expect(html).toContain("Anbieter einrichten"); });