From 542ca91be55e31c2b8fa9566adf77451ad8fd42c Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Fri, 14 Aug 2026 05:31:33 +0200
Subject: [PATCH] Finish the wiring polish: verbosity gate, upload flash,
/about links, dead settings slugs
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- The Coach verbosity picker is disabled while the concise tone forces
brief, with a hint saying so, instead of a live control that silently
does nothing.
- A fresh document upload flashes its new row like the duplicate path
always did; the settle decision is a pure exported helper with a test.
- /about gains its two inbound links: the /privacy footer and a
Settings→About credits row (the GeoLite2 EULA wants the attribution
reachable from the running app).
- The eight settings slugs shadowed by permanent redirects (the seven
layout children + sharing) leave the slug registry: no more
prerendering unreachable pages, no dead component wirings, and the
SectionPlaceholder fallback that could never render is gone. The
redirects, section components, and layout-hub subpages are unchanged.
---
messages/de.json | 2 +
messages/en.json | 2 +
messages/es.json | 2 +
messages/fr.json | 2 +
messages/it.json | 2 +
messages/pl.json | 2 +
src/app/privacy/page.tsx | 12 ++
src/app/settings/[section]/page.tsx | 46 ++-----
src/app/settings/page.tsx | 6 +-
.../use-document-upload-settle.test.ts | 44 +++++++
.../documents/use-document-upload.ts | 40 ++++--
.../integration-status-pill.test.tsx | 4 +-
.../__tests__/integrations-section.test.tsx | 4 +-
.../record-settings-section-gate.test.tsx | 6 +-
.../__tests__/section-frame-harness.tsx | 8 +-
.../settings/__tests__/sections.test.tsx | 3 +-
.../__tests__/settings-shell.test.tsx | 30 ++---
src/components/settings/about-section.tsx | 16 +++
.../settings/coach-prefs-section.tsx | 16 ++-
.../__tests__/import-result-view.test.tsx | 2 +-
.../settings/record-settings-section-gate.tsx | 1 -
.../settings/section-placeholder.tsx | 119 ------------------
src/components/settings/section-slugs.ts | 22 ++--
.../onboarding/__tests__/disclaimer.test.ts | 5 +-
src/lib/record-settings/classification.ts | 8 --
25 files changed, 181 insertions(+), 223 deletions(-)
create mode 100644 src/components/documents/__tests__/use-document-upload-settle.test.ts
delete mode 100644 src/components/settings/section-placeholder.tsx
diff --git a/messages/de.json b/messages/de.json
index 144d7869b..61ab350c6 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -2783,6 +2783,7 @@
"settingsVerbosityBrief": "Kurz",
"settingsVerbosityDefault": "Standard",
"settingsVerbosityDetailed": "Ausführlich",
+ "settingsVerbosityConciseHint": "Der knappe Tonfall hält Antworten ohnehin kurz — die Ausführlichkeit ist dabei fest auf „Kurz“ gesetzt.",
"settingsSourcesPointer": "Welche Datenquellen der Coach nutzen darf und das Standard-Analysefenster verwaltest du direkt im Coach-Chat (Leiste „Worauf ich zugreife\").",
"settingsSourcesPointerLink": "Coach öffnen",
"settingsExcludeLabel": "Metriken ausschließen",
@@ -5952,6 +5953,7 @@
"repository": "Quellcode",
"changelog": "Changelog",
"docs": "Dokumentation",
+ "credits": "Credits & Datenquellen",
"linksHeading": "Quellen & Dokumentation",
"newerAvailable": "Neue Version: {tag}"
},
diff --git a/messages/en.json b/messages/en.json
index 757706fb8..53dcb2ace 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -2783,6 +2783,7 @@
"settingsVerbosityBrief": "Brief",
"settingsVerbosityDefault": "Default",
"settingsVerbosityDetailed": "Detailed",
+ "settingsVerbosityConciseHint": "The concise tone already keeps replies short — verbosity is pinned to Brief while it is active.",
"settingsSourcesPointer": "Which data clusters the coach may draw on and the default analysis window are managed directly in the coach chat (\"What I draw on\" rail).",
"settingsSourcesPointerLink": "Open the coach",
"settingsExcludeLabel": "Exclude metrics",
@@ -5952,6 +5953,7 @@
"repository": "Source code",
"changelog": "Changelog",
"docs": "Documentation",
+ "credits": "Credits & data attributions",
"linksHeading": "Sources & docs",
"newerAvailable": "Newer version: {tag}"
},
diff --git a/messages/es.json b/messages/es.json
index 36bbb67b4..1ec276653 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -2783,6 +2783,7 @@
"settingsVerbosityBrief": "Breve",
"settingsVerbosityDefault": "Estándar",
"settingsVerbosityDetailed": "Detallado",
+ "settingsVerbosityConciseHint": "El tono conciso ya mantiene las respuestas cortas: la extensión queda fijada en «Breve» mientras esté activo.",
"settingsSourcesPointer": "Los grupos de datos que el coach puede usar y la ventana de análisis predeterminada se gestionan directamente en el chat del coach (panel «En qué me baso»).",
"settingsSourcesPointerLink": "Abrir el coach",
"settingsExcludeLabel": "Excluir métricas",
@@ -5952,6 +5953,7 @@
"repository": "Código fuente",
"changelog": "Changelog",
"docs": "Documentación",
+ "credits": "Créditos y atribuciones de datos",
"linksHeading": "Fuentes y documentación",
"newerAvailable": "Nueva versión: {tag}"
},
diff --git a/messages/fr.json b/messages/fr.json
index be0c38ba8..c98f3feff 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -2783,6 +2783,7 @@
"settingsVerbosityBrief": "Bref",
"settingsVerbosityDefault": "Standard",
"settingsVerbosityDetailed": "Détaillé",
+ "settingsVerbosityConciseHint": "Le ton concis garde déjà les réponses courtes — la longueur reste fixée sur « Bref » tant qu'il est actif.",
"settingsSourcesPointer": "Les groupes de données que le coach peut utiliser et la fenêtre d'analyse par défaut se gèrent directement dans le chat du coach (volet « Ce sur quoi je m'appuie »).",
"settingsSourcesPointerLink": "Ouvrir le coach",
"settingsExcludeLabel": "Exclure des métriques",
@@ -5952,6 +5953,7 @@
"repository": "Code source",
"changelog": "Changelog",
"docs": "Documentation",
+ "credits": "Crédits et attributions de données",
"linksHeading": "Sources et documentation",
"newerAvailable": "Nouvelle version : {tag}"
},
diff --git a/messages/it.json b/messages/it.json
index 71d04c96c..85ee1f7be 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -2783,6 +2783,7 @@
"settingsVerbosityBrief": "Breve",
"settingsVerbosityDefault": "Standard",
"settingsVerbosityDetailed": "Dettagliata",
+ "settingsVerbosityConciseHint": "Il tono conciso mantiene già le risposte brevi: la verbosità resta fissata su «Breve» finché è attivo.",
"settingsSourcesPointer": "I gruppi di dati che il coach può usare e la finestra di analisi predefinita si gestiscono direttamente nella chat del coach (pannello «Su cosa mi baso»).",
"settingsSourcesPointerLink": "Apri il coach",
"settingsExcludeLabel": "Escludi metriche",
@@ -5952,6 +5953,7 @@
"repository": "Codice sorgente",
"changelog": "Changelog",
"docs": "Documentazione",
+ "credits": "Crediti e attribuzioni dei dati",
"linksHeading": "Fonti e documentazione",
"newerAvailable": "Nuova versione: {tag}"
},
diff --git a/messages/pl.json b/messages/pl.json
index bb3fbb60f..58d4c731c 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -2783,6 +2783,7 @@
"settingsVerbosityBrief": "Krótka",
"settingsVerbosityDefault": "Standardowa",
"settingsVerbosityDetailed": "Szczegółowa",
+ "settingsVerbosityConciseHint": "Zwięzły ton i tak skraca odpowiedzi — szczegółowość pozostaje ustawiona na „Krótko”, dopóki jest aktywny.",
"settingsSourcesPointer": "Tym, z jakich grup danych może korzystać coach, oraz domyślnym oknem analizy zarządzasz bezpośrednio w czacie coacha (panel „Z czego korzystam”).",
"settingsSourcesPointerLink": "Otwórz coacha",
"settingsExcludeLabel": "Wyklucz metryki",
@@ -5952,6 +5953,7 @@
"repository": "Kod źródłowy",
"changelog": "Changelog",
"docs": "Dokumentacja",
+ "credits": "Autorzy i źródła danych",
"linksHeading": "Źródła i dokumentacja",
"newerAvailable": "Nowa wersja: {tag}"
},
diff --git a/src/app/privacy/page.tsx b/src/app/privacy/page.tsx
index 6be6417ef..915eb8666 100644
--- a/src/app/privacy/page.tsx
+++ b/src/app/privacy/page.tsx
@@ -2057,6 +2057,18 @@ export default function PrivacyPage() {
. Policy version {POLICY_VERSION}. Last updated {LAST_UPDATED}.
+ {/* The GeoLite2 EULA wants the attribution page reachable from the
+ running app — this and the Settings→About row are the two
+ inbound links to /about. */}
+
+
+ About HealthLog — credits & data attributions
+
+
diff --git a/src/app/settings/[section]/page.tsx b/src/app/settings/[section]/page.tsx
index ee1f341af..572123d39 100644
--- a/src/app/settings/[section]/page.tsx
+++ b/src/app/settings/[section]/page.tsx
@@ -10,27 +10,16 @@ import { AdvancedSection } from "@/components/settings/advanced-section";
import { AiSection } from "@/components/settings/ai-section";
import { ApiSection } from "@/components/settings/api-section";
import { CoachSection } from "@/components/settings/coach-section";
-import { DashboardSection } from "@/components/settings/dashboard-section";
import { EnvironmentSection } from "@/components/settings/environment-section";
import { ExportSection } from "@/components/settings/export-section";
import { GesundheitsakteSection } from "@/components/settings/gesundheitsakte-section";
-import { IllnessSection } from "@/components/settings/illness-section";
-import { InsightsSection } from "@/components/settings/insights-section";
import { IntegrationsSection } from "@/components/settings/integrations-section";
-import { LabsSection } from "@/components/settings/labs-section";
import { LayoutSection } from "@/components/settings/layout-section";
-import { MedicationsSection } from "@/components/settings/medications-section";
import { McpSection } from "@/components/settings/mcp-section";
import { ModulesSection } from "@/components/settings/modules-section";
-import { MoodSection } from "@/components/settings/mood-section";
import { NotificationsSection } from "@/components/settings/notifications-section";
import { PrivacySection } from "@/components/settings/privacy-section";
import { ScoreSection } from "@/components/settings/score-section";
-import { SectionPlaceholder } from "@/components/settings/section-placeholder";
-import { VorsorgeSection } from "@/components/settings/vorsorge-section";
-// v1.18.1 (D4) — `sources` is a standalone left-side entry (split out of the
-// Integrations sub-tabs). v1.25.3 — `channels` folded into Notifications.
-import { SharingSection } from "@/components/settings/sharing-section";
import { SourcesSection } from "@/components/settings/sources-section";
import { ThresholdsSection } from "@/components/settings/thresholds-section";
import {
@@ -72,21 +61,13 @@ const SECTION_COMPONENTS: Record<
sources: SourcesSection,
notifications: NotificationsSection,
layout: LayoutSection,
- dashboard: DashboardSection,
- insights: InsightsSection,
- medications: MedicationsSection,
- mood: MoodSection,
- labs: LabsSection,
- illness: IllnessSection,
environment: EnvironmentSection,
anamnesis: AnamnesisSection,
score: ScoreSection,
- vorsorge: VorsorgeSection,
thresholds: ThresholdsSection,
api: ApiSection,
mcp: McpSection,
gesundheitsakte: GesundheitsakteSection,
- sharing: SharingSection,
export: ExportSection,
advanced: AdvancedSection,
privacy: PrivacySection,
@@ -107,32 +88,25 @@ export default async function SettingsSectionPage({ params }: PageProps) {
notFound();
}
+ // `SECTION_COMPONENTS` is an exhaustive Record over the slug union, so a
+ // slug without a wired component is a compile error — the old runtime
+ // `` fallback could never render and was removed.
const SectionComponent = SECTION_COMPONENTS[section];
- // Defensive fallback: in theory unreachable since `isSettingsSectionSlug`
- // guards the slug, but a future slug added to `SETTINGS_SECTION_SLUGS`
- // without a wired component would otherwise crash silently — placeholder
- // surfaces the gap visually instead.
// v1.18.6.1 — the heading + subtitle (and the Layout-hub "← back" link)
- // now live in ``, which places them in their own grid row
+ // live in ``, which places them in their own grid row
// spanning only the content column so the left nav's first item lines up
// with the top of the first card. The page body is pure card content,
// wrapped in the labelled `` so the historic
// `settings-section--title` `aria-labelledby` linkage still resolves.
- const body = SectionComponent ? (
-
-
-
- ) : (
-
- );
-
return (
- {body}
+
+
+
);
diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx
index dd87ccfd6..ad45441fa 100644
--- a/src/app/settings/page.tsx
+++ b/src/app/settings/page.tsx
@@ -5,10 +5,8 @@ import { permanentRedirect } from "next/navigation";
*
* Pre-v1.4 the entire settings UI lived in this single 3000+ LOC file. As part
* of the v1.4 settings split (PR series A2), each concern moved to its own
- * route under `/settings/[section]/page.tsx`. The historic monolith is
- * preserved verbatim in `page.legacy.tsx` for section-by-section extraction in
- * the follow-up PRs (A2-account, A2-about, A2-ai, A2-integrations,
- * A2-notifications, A2-rest).
+ * route under `/settings/[section]/page.tsx`; the extraction finished long
+ * ago and the historic monolith is gone.
*
* 308 (`permanentRedirect`) keeps the request method intact and is the right
* answer for a permanent address change — bookmarks, deep-links, and any
diff --git a/src/components/documents/__tests__/use-document-upload-settle.test.ts b/src/components/documents/__tests__/use-document-upload-settle.test.ts
new file mode 100644
index 000000000..baf75b8a6
--- /dev/null
+++ b/src/components/documents/__tests__/use-document-upload-settle.test.ts
@@ -0,0 +1,44 @@
+/**
+ * The upload manager's settle decision (§3.2 + the fresh-upload flash).
+ *
+ * Watched red: with `settleDecision` returning `flashId: null` on the
+ * non-duplicate branch (the pre-v1.37.19 behaviour — only the duplicate
+ * path flashed the row), the fresh-upload case below fails. A fresh upload
+ * used to give no feedback beyond its queue row disappearing.
+ */
+import { describe, expect, it } from "vitest";
+
+import { settleDecision } from "../use-document-upload";
+import type { UploadResult } from "../vault-utils";
+
+function success(duplicate: boolean): UploadResult {
+ return {
+ ok: true,
+ duplicate,
+ document: { id: "doc-1" },
+ } as unknown as UploadResult;
+}
+
+describe("settleDecision", () => {
+ it("flashes the NEW row on a fresh upload (no toast)", () => {
+ expect(settleDecision(success(false))).toEqual({
+ removeFromQueue: true,
+ toastDuplicate: false,
+ flashId: "doc-1",
+ });
+ });
+
+ it("flashes the EXISTING row and toasts on a duplicate", () => {
+ expect(settleDecision(success(true))).toEqual({
+ removeFromQueue: true,
+ toastDuplicate: true,
+ flashId: "doc-1",
+ });
+ });
+
+ it("keeps a failed upload in the queue with no flash", () => {
+ expect(
+ settleDecision({ ok: false, reason: "generic" } as UploadResult),
+ ).toEqual({ removeFromQueue: false, toastDuplicate: false, flashId: null });
+ });
+});
diff --git a/src/components/documents/use-document-upload.ts b/src/components/documents/use-document-upload.ts
index 8f7977a29..ee6f5c1d2 100644
--- a/src/components/documents/use-document-upload.ts
+++ b/src/components/documents/use-document-upload.ts
@@ -136,6 +136,29 @@ function uploadViaXhr(
});
}
+/**
+ * The pure settle decision — what a finished upload does to the UI.
+ *
+ * Every success flashes the row (fresh upload → the new row, duplicate →
+ * the existing row); only the duplicate additionally toasts. Exported for
+ * the unit test: until v1.37.19 only the duplicate branch flashed, so a
+ * fresh upload gave no feedback beyond its queue row disappearing.
+ */
+export function settleDecision(result: UploadResult): {
+ removeFromQueue: boolean;
+ toastDuplicate: boolean;
+ flashId: string | null;
+} {
+ if (!result.ok) {
+ return { removeFromQueue: false, toastDuplicate: false, flashId: null };
+ }
+ return {
+ removeFromQueue: true,
+ toastDuplicate: result.duplicate,
+ flashId: result.document.id,
+ };
+}
+
export interface DocumentUploadManager {
/** Live queue — uploading entries first-in-first, then error entries. */
items: UploadQueueItem[];
@@ -196,16 +219,17 @@ export function useDocumentUpload(
const settle = useCallback(
(localId: string, result: UploadResult) => {
- if (result.ok) {
- removeItem(localId);
- void invalidateKeys(queryClient, [queryKeys.documents()]);
- if (result.duplicate) {
- toast.info(t("documents.toast.duplicate"));
- flashHighlight(result.document.id);
- }
+ if (!result.ok) {
+ patchItem(localId, { status: "error", failure: result, progress: 0 });
return;
}
- patchItem(localId, { status: "error", failure: result, progress: 0 });
+ const decision = settleDecision(result);
+ removeItem(localId);
+ void invalidateKeys(queryClient, [queryKeys.documents()]);
+ if (decision.toastDuplicate) {
+ toast.info(t("documents.toast.duplicate"));
+ }
+ if (decision.flashId) flashHighlight(decision.flashId);
},
[flashHighlight, patchItem, queryClient, removeItem, t],
);
diff --git a/src/components/settings/__tests__/integration-status-pill.test.tsx b/src/components/settings/__tests__/integration-status-pill.test.tsx
index 8837d489a..b409902db 100644
--- a/src/components/settings/__tests__/integration-status-pill.test.tsx
+++ b/src/components/settings/__tests__/integration-status-pill.test.tsx
@@ -105,7 +105,7 @@ describe("IntegrationStatusPill", () => {
,
);
expect(html).toContain("Paused");
- expect(html).toContain("reconnect manually");
+ expect(html).toContain("Paused — reconnect");
expect(html).toContain('data-state="parked"');
});
@@ -115,7 +115,7 @@ describe("IntegrationStatusPill", () => {
"de",
);
expect(html).toContain("Pausiert");
- expect(html).toContain("manuell wieder verbinden");
+ expect(html).toContain("Pausiert — neu verbinden");
});
// The three states the server verdict added. `stale` and `stalled` both
diff --git a/src/components/settings/__tests__/integrations-section.test.tsx b/src/components/settings/__tests__/integrations-section.test.tsx
index b06e412a4..929dfb29e 100644
--- a/src/components/settings/__tests__/integrations-section.test.tsx
+++ b/src/components/settings/__tests__/integrations-section.test.tsx
@@ -223,7 +223,7 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => {
// The pill flips to `data-state="parked"` and the resume banner
// renders with the reconnect CTA. The legacy `error` reconnect text
// (red) must NOT appear because the user-facing copy is the lighter
- // "Paused — reconnect manually" string, not "Error — reconnect".
+ // "Paused — reconnect" string, not "Error — reconnect".
it("Withings parked state surfaces the parked pill + resume CTA", () => {
setIntegrationStatus({
threshold: 3,
@@ -246,7 +246,7 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => {
const html = render();
expect(html).toContain('data-state="parked"');
expect(html).toContain("Paused");
- expect(html).toContain("reconnect manually");
+ expect(html).toContain("Paused — reconnect");
expect(html).toContain('data-testid="withings-parked-banner"');
expect(html).toContain('data-testid="withings-resume-button"');
// The lastError is surfaced under the pill so the operator sees
diff --git a/src/components/settings/__tests__/record-settings-section-gate.test.tsx b/src/components/settings/__tests__/record-settings-section-gate.test.tsx
index 9bac09382..86f73b0ea 100644
--- a/src/components/settings/__tests__/record-settings-section-gate.test.tsx
+++ b/src/components/settings/__tests__/record-settings-section-gate.test.tsx
@@ -44,7 +44,7 @@ vi.mock("../record-anamnesis-section", () => ({
import { RecordSettingsSectionGate } from "../record-settings-section-gate";
-function render(section: "modules" | "integrations" | "anamnesis" | "labs") {
+function render(section: "modules" | "integrations" | "anamnesis" | "score") {
return renderToStaticMarkup(
@@ -140,7 +140,7 @@ describe("RecordSettingsSectionGate", () => {
});
it("does not lend its page to another destination of the same kind", () => {
- // `labs` is `adult-shared-unavailable` today, so this passes for the
+ // `score` is `adult-shared-unavailable` today, so this passes for the
// right reason now; the claim it pins is that the branch asks WHICH
// page, not which category. A second `manage-writable` slug added
// without an arm falls through to the refusal panel.
@@ -153,7 +153,7 @@ describe("RecordSettingsSectionGate", () => {
canWrite: true,
};
- const html = render("labs");
+ const html = render("score");
expect(html).not.toContain("record-anamnesis");
expect(html).toContain("shared-record-settings-unavailable-title");
diff --git a/src/components/settings/__tests__/section-frame-harness.tsx b/src/components/settings/__tests__/section-frame-harness.tsx
index 33e0fd68e..c01640773 100644
--- a/src/components/settings/__tests__/section-frame-harness.tsx
+++ b/src/components/settings/__tests__/section-frame-harness.tsx
@@ -15,13 +15,17 @@
import * as React from "react";
import { useTranslations } from "@/lib/i18n/context";
-import type { SettingsSectionSlug } from "../section-slugs";
export function SettingsSectionFrame({
slug,
children,
}: {
- slug: SettingsSectionSlug;
+ /**
+ * A section slug OR a layout-hub child id (`dashboard`, `mood`, …) — the
+ * children left `SETTINGS_SECTION_SLUGS` in v1.37.19 but their heading
+ * keys and section bodies live on under `/settings/layout/`.
+ */
+ slug: string;
children: React.ReactNode;
}) {
const { t } = useTranslations();
diff --git a/src/components/settings/__tests__/sections.test.tsx b/src/components/settings/__tests__/sections.test.tsx
index 6f014ac4d..782bfe7ed 100644
--- a/src/components/settings/__tests__/sections.test.tsx
+++ b/src/components/settings/__tests__/sections.test.tsx
@@ -115,7 +115,8 @@ function render(node: React.ReactElement, locale: "en" | "de" = "en") {
// section in. The heading-level smoke checks render the body THROUGH the frame
// (mirroring production) so they assert what the user actually sees.
function renderFramed(
- slug: SettingsSectionSlug,
+ // A slug or a layout-hub child id — see the harness note.
+ slug: SettingsSectionSlug | "dashboard",
node: React.ReactElement,
locale: "en" | "de" = "en",
) {
diff --git a/src/components/settings/__tests__/settings-shell.test.tsx b/src/components/settings/__tests__/settings-shell.test.tsx
index 25f80a4c4..8dba31062 100644
--- a/src/components/settings/__tests__/settings-shell.test.tsx
+++ b/src/components/settings/__tests__/settings-shell.test.tsx
@@ -113,13 +113,12 @@ describe("SETTINGS_SECTION_SLUGS", () => {
"integrations",
"sources",
"notifications",
+ // v1.37.19 — the per-module personalization slugs (dashboard /
+ // insights / medications / mood / labs / illness / vorsorge) and
+ // `sharing` left the list: their URLs are owned by the permanent
+ // redirects in next.config.ts, so the dynamic route could never
+ // serve them.
"layout",
- "dashboard",
- "insights",
- "medications",
- "mood",
- "labs",
- "illness",
// v1.25 (W-ENV) — Environment (home location, travel overrides, backfill),
// module-gated on the opt-in `environment` module.
"environment",
@@ -127,14 +126,12 @@ describe("SETTINGS_SECTION_SLUGS", () => {
"anamnesis",
// v1.35.0 — the Health Score composition surface.
"score",
- "vorsorge",
"thresholds",
"ai",
"coach",
"api",
"mcp",
"gesundheitsakte",
- "sharing",
"export",
"advanced",
// v1.23 — "Data & Privacy" assembles the export / deletion / retention /
@@ -149,6 +146,9 @@ describe("SETTINGS_SECTION_SLUGS", () => {
expect(isSettingsSectionSlug(slug)).toBe(true);
}
expect(isSettingsSectionSlug("nope")).toBe(false);
+ // The redirect-shadowed module slugs are no longer sections.
+ expect(isSettingsSectionSlug("dashboard")).toBe(false);
+ expect(isSettingsSectionSlug("sharing")).toBe(false);
expect(isSettingsSectionSlug("")).toBe(false);
expect(isSettingsSectionSlug("Account")).toBe(false); // case-sensitive
});
@@ -259,17 +259,9 @@ describe("", () => {
}
});
- it("highlights the Layout hub when the active prop is a Layout child", () => {
- // The page passes `active={section}` explicitly; a child slug must
- // still resolve onto the Layout hub for nav highlighting.
- const html = renderShell({
- active: "dashboard",
- pathname: "/settings/dashboard",
- });
- const layoutActive =
- /]*\baria-current="page"[^>]*\bhref="\/settings\/layout"|]*\bhref="\/settings\/layout"[^>]*\baria-current="page"/g;
- expect(html.match(layoutActive)?.length ?? 0).toBe(2);
- });
+ // 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.
it("no longer surfaces the per-module nav entries (folded into Darstellung, v1.25.7)", () => {
// v1.25.7 — Medikamente / Stimmung / Labor / Krankheit / Vorsorge are no
diff --git a/src/components/settings/about-section.tsx b/src/components/settings/about-section.tsx
index 8161f39fd..5904130ed 100644
--- a/src/components/settings/about-section.tsx
+++ b/src/components/settings/about-section.tsx
@@ -1,6 +1,7 @@
"use client";
import { useEffect, useState } from "react";
+import Link from "next/link";
import { useQuery } from "@tanstack/react-query";
import {
ArrowUpCircle,
@@ -313,6 +314,21 @@ export function AboutSection() {
+ {/* /about carries the GeoLite2 attribution its EULA wants
+ reachable from the running app — this row and the /privacy
+ footer are its two inbound links. Same-app route, so no
+ external-link glyph. */}
+
)}
diff --git a/src/components/settings/coach-prefs-section.tsx b/src/components/settings/coach-prefs-section.tsx
index d18b2bd69..75d8e9404 100644
--- a/src/components/settings/coach-prefs-section.tsx
+++ b/src/components/settings/coach-prefs-section.tsx
@@ -170,7 +170,10 @@ export function CoachPrefsSection({ isAuthenticated }: CoachPrefsSectionProps) {
- {/* Verbosity */}
+ {/* Verbosity. The concise tone forces "brief" server-side
+ (system-prompt.ts, buildPrefsPrefix), so the picker is
+ disabled there — showing "brief" — with a hint instead of
+ a live control that silently does nothing. */}
diff --git a/src/components/settings/import-panel/__tests__/import-result-view.test.tsx b/src/components/settings/import-panel/__tests__/import-result-view.test.tsx
index de6506f2f..1cc073cff 100644
--- a/src/components/settings/import-panel/__tests__/import-result-view.test.tsx
+++ b/src/components/settings/import-panel/__tests__/import-result-view.test.tsx
@@ -107,7 +107,7 @@ describe(" — result states", () => {
);
expect(html).toContain('data-outcome="success"');
expect(html).toContain("text-success");
- expect(html).toContain("Imported 4 new and updated 1.");
+ expect(html).toContain("Imported 4 new and updated 1");
expect(html).not.toContain("skipped");
});
diff --git a/src/components/settings/record-settings-section-gate.tsx b/src/components/settings/record-settings-section-gate.tsx
index aaf697477..621112ccc 100644
--- a/src/components/settings/record-settings-section-gate.tsx
+++ b/src/components/settings/record-settings-section-gate.tsx
@@ -32,7 +32,6 @@ const MANAGED_RECORD_SETTINGS_FAMILY_BY_SECTION = {
account: "profile",
modules: "modules",
notifications: "notifications",
- insights: "insights",
thresholds: "thresholds",
coach: "coach",
} as const;
diff --git a/src/components/settings/section-placeholder.tsx b/src/components/settings/section-placeholder.tsx
deleted file mode 100644
index eff2f4430..000000000
--- a/src/components/settings/section-placeholder.tsx
+++ /dev/null
@@ -1,119 +0,0 @@
-"use client";
-
-/**
- * `` — the temporary body of every `/settings/[section]`
- * route until the matching extraction PR lands.
- *
- * The shell + routing surface ship in PR A2-shell so reviewers can validate
- * the URL structure in isolation. The follow-up PRs (A2-account, A2-about,
- * A2-ai, A2-integrations, A2-notifications, A2-rest) replace this placeholder
- * with the real content for each slug, copying from
- * `src/app/settings/page.legacy.tsx`.
- */
-
-import {
- Bell,
- Blocks,
- Bot,
- CalendarCheck,
- CloudSun,
- Download,
- ClipboardList,
- FileHeart,
- FlaskConical,
- Gauge,
- Info,
- KeyRound,
- Plug,
- Layers,
- LayoutDashboard,
- Link2,
- Lock,
- Pill,
- Settings2,
- Share2,
- ShieldCheck,
- SlidersHorizontal,
- Smile,
- Sparkles,
- Thermometer,
- TrendingUp,
- User,
- UsersRound,
- type LucideIcon,
-} from "lucide-react";
-
-import { EmptyState } from "@/components/ui/empty-state";
-import { useTranslations } from "@/lib/i18n/context";
-import type { SettingsSectionSlug } from "./section-slugs";
-
-const SLUG_ICON: Record = {
- account: User,
- security: ShieldCheck,
- access: UsersRound,
- modules: Blocks,
- integrations: Link2,
- sources: Layers,
- notifications: Bell,
- layout: LayoutDashboard,
- dashboard: LayoutDashboard,
- insights: TrendingUp,
- medications: Pill,
- mood: Smile,
- labs: FlaskConical,
- illness: Thermometer,
- environment: CloudSun,
- anamnesis: ClipboardList,
- score: Gauge,
- vorsorge: CalendarCheck,
- thresholds: SlidersHorizontal,
- ai: Sparkles,
- coach: Bot,
- api: KeyRound,
- mcp: Plug,
- gesundheitsakte: FileHeart,
- sharing: Share2,
- export: Download,
- advanced: Settings2,
- privacy: Lock,
- about: Info,
-};
-
-export interface SectionPlaceholderProps {
- slug: SettingsSectionSlug;
-}
-
-export function SectionPlaceholder({ slug }: SectionPlaceholderProps) {
- const { t } = useTranslations();
- const Icon = SLUG_ICON[slug];
- const sectionTitle = t(`settings.sections.${slug}.title`);
- const sectionDescription = t(`settings.sections.${slug}.description`);
-
- // v1.4.33 A4 — `` is a defensive fallback for
- // slugs added to SETTINGS_SECTION_SLUGS without a matching component.
- // The type system blocks that path today, so the EmptyState body is
- // effectively unreachable. We retire the dedicated locale key
- // (`settings.sections.placeholder.coming_soon`) and inherit the
- // section's own description so the guard still renders meaningful
- // copy if it ever does fire — no more dead-string maintenance across
- // six locale files.
- return (
-
-
-
- {sectionTitle}
-
-
{sectionDescription}
-
-
- }
- title={sectionTitle}
- description={sectionDescription}
- />
-
- );
-}
diff --git a/src/components/settings/section-slugs.ts b/src/components/settings/section-slugs.ts
index d73f5eb53..65ec120e8 100644
--- a/src/components/settings/section-slugs.ts
+++ b/src/components/settings/section-slugs.ts
@@ -71,13 +71,15 @@ export const SETTINGS_SECTION_SLUGS = [
// 301-redirects to `/settings/notifications#channels` (next.config.ts).
"sources",
"notifications",
+ // v1.37.19 — the per-module personalization slugs (`dashboard`,
+ // `insights`, `medications`, `mood`, `labs`, `illness`, `vorsorge`) and
+ // `sharing` left this list. Their URLs are owned by permanent redirects
+ // in `next.config.ts` (`/settings/` → `/settings/layout/`,
+ // `/settings/sharing` → `/settings/gesundheitsakte#sharing`), so the
+ // dynamic route could never serve them — prerendering eight unreachable
+ // pages and keeping eight dead component wirings. The redirects, the
+ // section components, and the layout-hub subpages are unchanged.
"layout",
- "dashboard",
- "insights",
- "medications",
- "mood",
- "labs",
- "illness",
// v1.25 (W-ENV) — "Umwelt" (environmental context): home location, travel
// overrides, and the weather/daylight backfill. Module-gated on the opt-in
// `environment` module (the nav entry only shows when it is on).
@@ -92,7 +94,6 @@ export const SETTINGS_SECTION_SLUGS = [
// shown) and folding the two together is what kept the score's
// composition invisible.
"score",
- "vorsorge",
"thresholds",
"ai",
// v1.18.0 (S5) — `coach` gathers the Coach preference cards (disable
@@ -106,13 +107,6 @@ export const SETTINGS_SECTION_SLUGS = [
// access surfaces). Not nav-gated — the card carries its own enable toggle.
"mcp",
"gesundheitsakte",
- // v1.18.7 — `sharing` (clinician share links) sits directly after
- // `gesundheitsakte`, before `export`: minting a time-boxed read-only link
- // to the health record belongs next to the health-record export. Always
- // available (no module gate), like account / export. The backing model,
- // the `/api/share-links` routes, and the public `/c/[token]` view are
- // unchanged — this restores only the owner Settings surface.
- "sharing",
"export",
"advanced",
// v1.23 — "Data & Privacy": a single surface that assembles the already-
diff --git a/src/lib/onboarding/__tests__/disclaimer.test.ts b/src/lib/onboarding/__tests__/disclaimer.test.ts
index d7a9cf2de..497de02f5 100644
--- a/src/lib/onboarding/__tests__/disclaimer.test.ts
+++ b/src/lib/onboarding/__tests__/disclaimer.test.ts
@@ -25,7 +25,10 @@ describe("isDisclaimerAcknowledgmentCurrent", () => {
it("re-prompts when the stored acknowledgment is for an older version", () => {
expect(
- isDisclaimerAcknowledgmentCurrent("2026-06-20T08:00:00.000Z", "2025-01-01"),
+ isDisclaimerAcknowledgmentCurrent(
+ "2026-06-20T08:00:00.000Z",
+ "2025-01-01",
+ ),
).toBe(false);
});
diff --git a/src/lib/record-settings/classification.ts b/src/lib/record-settings/classification.ts
index 2c600d42d..0e532fd16 100644
--- a/src/lib/record-settings/classification.ts
+++ b/src/lib/record-settings/classification.ts
@@ -78,23 +78,15 @@ export const SETTINGS_DESTINATION_INVENTORY = {
sources: UNAVAILABLE,
notifications: MANAGED_GUARDIAN,
layout: ADULT_SHARED_UNAVAILABLE,
- dashboard: ADULT_SHARED_UNAVAILABLE,
- insights: MANAGED_GUARDIAN,
- medications: ADULT_SHARED_UNAVAILABLE,
- mood: ADULT_SHARED_UNAVAILABLE,
- labs: ADULT_SHARED_UNAVAILABLE,
- illness: ADULT_SHARED_UNAVAILABLE,
environment: UNAVAILABLE,
anamnesis: MANAGE_WRITABLE,
score: ADULT_SHARED_UNAVAILABLE,
- vorsorge: ADULT_SHARED_UNAVAILABLE,
thresholds: MANAGED_GUARDIAN,
ai: UNAVAILABLE,
coach: MANAGED_GUARDIAN,
api: PERSONAL,
mcp: PERSONAL,
gesundheitsakte: PERSONAL,
- sharing: UNAVAILABLE,
export: PERSONAL,
advanced: PERSONAL,
privacy: PERSONAL,