diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml
index 4b489f272..339c7b7c5 100644
--- a/docs/api/openapi.yaml
+++ b/docs/api/openapi.yaml
@@ -14543,6 +14543,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:
@@ -31521,6 +31596,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:
@@ -38963,6 +39080,8 @@ components:
medicationListLayoutJson: {}
moodTagLayoutJson: {}
documentsLayoutJson: {}
+ moduleListLayoutJson: {}
+ previewFeaturesJson: {}
reportSelectionJson: {}
globalExcludedInjectionSites:
type: array
@@ -69525,6 +69644,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/e2e/coach-layout.spec.ts b/e2e/coach-layout.spec.ts
index ae6bfbaec..b4cdc2871 100644
--- a/e2e/coach-layout.spec.ts
+++ b/e2e/coach-layout.spec.ts
@@ -807,12 +807,14 @@ test.describe("Coach page frame", () => {
const popover = page.locator('[data-slot="coach-settings-popover"]');
await expect(popover).toBeVisible();
await expect(popover).toHaveAttribute("role", "dialog");
+ // The model is chosen in Settings > AI; the overlay shows what is in
+ // effect and links there.
await expect(
popover.locator('[data-slot="coach-quick-provider"]'),
- ).toHaveValue("local");
+ ).toHaveAttribute("data-value", "local");
await expect(
popover.locator('[data-slot="coach-quick-model"]'),
- ).toHaveValue("qwen2.5");
+ ).toHaveAttribute("data-value", "qwen2.5");
await expect(
popover.locator('[data-slot="coach-sources-window"]'),
).toBeVisible();
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/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 28ce81634..2904f7cb8 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/messages/de.json b/messages/de.json
index 7a4de62b0..a72dcb006 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",
@@ -1836,7 +1836,7 @@
"phaseRed": "Rot",
"phaseMinutes": "Min",
"phasePercent": "%",
- "phaseResetDefaults": "Auf Defaults zurücksetzen",
+ "phaseResetDefaults": "Auf Standard zurücksetzen",
"phaseSaved": "Erinnerungsphasen gespeichert",
"sourceWeb": "Web",
"sourceApi": "iOS-App",
@@ -2754,7 +2754,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",
@@ -3097,8 +3097,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",
@@ -3220,7 +3220,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.",
@@ -3273,12 +3273,9 @@
"provider": "Antwortet mit",
"modelManaged": "Das Modell für diesen Anbieter legst du in den KI-Einstellungen fest.",
"noProvider": "Kein Anbieter verfügbar",
- "saving": "Wird gespeichert…",
- "saveFailed": "Die Änderung ließ sich nicht speichern.",
"allSettings": "Alle KI-Einstellungen",
- "providerNotActive": "{provider} konnte nicht übernehmen: Es fehlt eine funktionierende Anmeldung oder ein Schlüssel. Richte das in den KI-Einstellungen ein.",
"operatorCodex": "ChatGPT (vom Betreiber bereitgestellt)",
- "operatorManaged": "Diese Verbindung verwaltet der Betreiber deines Servers. Sobald einer deiner eigenen Anbieter eingerichtet ist, antwortet er zuerst."
+ "changeModel": "In Einstellungen > KI ändern"
},
"answer": {
"scrollToBottom": "Zur neuesten Nachricht",
@@ -4870,10 +4867,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",
@@ -4887,7 +4884,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)",
@@ -4903,7 +4900,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": {
@@ -4921,7 +4918,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",
@@ -5299,12 +5296,17 @@
"groups": {
"account": "Konto",
"yourData": "Deine Daten",
- "display": "Anzeige",
"healthProfile": "Gesundheitsprofil",
"ai": "KI",
"connectivity": "Konnektivität",
- "system": "System"
- }
+ "system": "System",
+ "tracking": "Tracking",
+ "arrangement": "Anordnung",
+ "notifications": "Benachrichtigungen",
+ "connections": "Verbindungen",
+ "data": "Daten"
+ },
+ "allSections": "Alle Bereiche"
},
"sharedRecord": {
"managedSettings": {
@@ -5445,11 +5447,25 @@
"remindersHeading": "Erinnerungen",
"matrixLink": "Benachrichtigungen je Kanal",
"channelsLink": "Zustellkanäle",
- "displayHeading": "Auf dem Handy"
+ "displayHeading": "Auf dem Handy",
+ "elsewhere": {
+ "title": "Dort eingestellt, wo sie hingehören",
+ "description": "Diese Erinnerungen stellst du direkt bei dem ein, worum es geht.",
+ "doses": {
+ "title": "Medikamenten-Einnahmen",
+ "description": "Einnahmezeiten und Erinnerungen stellst du beim jeweiligen Medikament ein.",
+ "link": "Medikamente öffnen"
+ },
+ "cycle": {
+ "title": "Zyklus",
+ "description": "Perioden- und Fruchtbarkeits-Erinnerungen schaltest du je Kanal in der Benachrichtigungsübersicht.",
+ "link": "Benachrichtigungsübersicht öffnen"
+ }
+ }
},
"layout": {
- "title": "Layout",
- "backToHub": "Zurück zu Layout",
+ "title": "Anordnung",
+ "backToHub": "Zurück zu Anordnung",
"dashboard": {
"title": "Dashboard",
"description": "Kachel-Layout und Reihenfolge."
@@ -5458,7 +5474,7 @@
"title": "Insights",
"description": "Reihenfolge der Übersicht und der Navigations-Pills."
},
- "subtitle": "Lege fest, was Dashboard, Auswertungen und Tracking-Module zeigen und in welcher Reihenfolge.",
+ "subtitle": "Lege Reihenfolge und Ansicht von Dashboard, Insights und Tracking-Listen fest.",
"medications": {
"title": "Medikamente",
"description": "Listenansicht und manuelle Reihenfolge."
@@ -5475,10 +5491,6 @@
"title": "Vorsorge",
"description": "Listenansicht und Reihenfolge der Erinnerungen."
},
- "mood": {
- "title": "Stimmungs-Tags",
- "description": "Gruppen, Tags und archivierte Tags der Stimmungs-Auswahl."
- },
"documents": {
"title": "Dokumente",
"description": "Ansicht und Anordnung der Monate."
@@ -5865,11 +5877,32 @@
"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",
- "subtitle": "Lege fest, welche Tracking-Bereiche HealthLog überall anzeigt."
+ "subtitle": "Lege fest, welche Tracking-Bereiche HealthLog überall anzeigt.",
+ "backToHub": "Zurück zu Module",
+ "openModuleSettings": "Moduleinstellungen",
+ "pages": {
+ "medications": {
+ "title": "Medikamente",
+ "description": "Injektionsstellen, die die Rotation auslässt."
+ },
+ "mood": {
+ "title": "Stimmungs-Tags",
+ "description": "Gruppen, eigene Tags und archivierte Tags."
+ },
+ "labs": {
+ "title": "Labor",
+ "description": "Biomarker und Texterkennung auf dem Gerät für Laborberichte."
+ },
+ "arrangementTitle": "Reihenfolge und Ansicht",
+ "arrangementDescription": "Wie diese Liste sortiert und angezeigt wird, legst du unter Anordnung fest.",
+ "openArrangement": "In Anordnung öffnen",
+ "moduleSettingsTitle": "Moduleinstellungen",
+ "moduleSettingsDescription": "Alles außer Reihenfolge und Ansicht findest du auf der Seite des Moduls unter Module."
+ }
},
"gesundheitsakte": {
"title": "Gesundheitsakte",
@@ -5988,12 +6021,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",
@@ -6012,6 +6045,10 @@
"access": {
"title": "Geteilter Zugriff",
"subtitle": "Wer deine Gesundheitsakte öffnen kann und wessen Akte du öffnen kannst."
+ },
+ "preview": {
+ "title": "Vorschau-Funktionen",
+ "subtitle": "Probier aus, was noch nicht fertig ist. Alles hier bleibt aus, bis du es einschaltest."
}
},
"profile": "Profil",
@@ -6175,7 +6212,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",
@@ -6213,8 +6250,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",
@@ -6222,7 +6259,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)",
@@ -6265,10 +6302,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.",
@@ -7025,21 +7062,19 @@
},
"integrationSetupGuide": "Einrichtungsanleitung",
"coach": {
- "activate": {
- "title": "Coach aktivieren",
- "description": "Zeigt den Coach-Button und das Coach-Panel.",
- "toggleAria": "Coach-Button und Coach-Panel aktivieren",
- "savedOn": "Coach ist aktiviert.",
- "savedOff": "Coach ist ausgeblendet.",
- "saveError": "Speichern fehlgeschlagen. Bitte erneut versuchen."
- },
"memory": {
"edit": "Bearbeiten",
"source": {
"user": "von dir",
"coach": "vom Coach"
}
- }
+ },
+ "state": {
+ "title": "Coach",
+ "description": "Ob der Coach-Button und das Coach-Panel erscheinen. Der Schalter liegt unter Module."
+ },
+ "nudgesMoved": "Stellst du mit deinen anderen Erinnerungen unter Benachrichtigungen ein.",
+ "openNudges": "In Benachrichtigungen öffnen"
},
"integrationRedirectGuide": {
"intro": "Die Verbindung läuft über deine eigene {provider}-Entwickler-App. Die Einrichtung dauert etwa 5 Minuten:",
@@ -7188,12 +7223,6 @@
"delivery": "Benachrichtigungsprotokoll: {days} Tage",
"unavailable": "Aufbewahrungsdetails sind derzeit nicht verfügbar."
},
- "export": {
- "title": "Meine Daten herunterladen",
- "description": "Deine Datensätze als CSV oder JSON oder eine vollständige Akte für die Praxis.",
- "openExport": "Export & Backup",
- "openHealthRecord": "Gesundheitsakte"
- },
"delete": {
"title": "Löschen oder zurücksetzen",
"description": "Lösche dein Konto vollständig oder lösche deine Gesundheitsdaten und behalte das Konto.",
@@ -7209,7 +7238,6 @@
"mcp": {
"enableTitle": "Remote-MCP-Endpunkt",
"enableDescription": "Erlaube einem MCP-fähigen Assistenten (Claude.ai, ChatGPT), deine Gesundheitsdaten über ein begrenztes Token zu lesen.",
- "enableToggleLabel": "MCP-Endpunkt aktivieren",
"endpointLabel": "Connector-URL",
"connectDescription": "Füge diese URL in die Einrichtung des eigenen Connectors deines Assistenten ein und autorisiere dich mit dem Token unten oder melde dich bei Aufforderung an.",
"tokensTitle": "Connector-Tokens",
@@ -7228,7 +7256,8 @@
"connectionRevokeFailed": "Assistent konnte nicht getrennt werden.",
"tokensDetail": "Das Token wird nur einmal angezeigt.",
"connectionsDetail": "Das Widerrufen einer Verbindung beendet den Zugriff sofort, einschließlich der Möglichkeit zur Erneuerung.",
- "tokenNamePlaceholder": "Name, z. B. Claude Desktop"
+ "tokenNamePlaceholder": "Name, z. B. Claude Desktop",
+ "offKeepsTokens": "Ausschalten pausiert den Endpunkt. Deine Tokens und Verbindungen bleiben und funktionieren wieder, sobald er an ist."
},
"strava": "Strava",
"stravaDescription": "Verbinde dein Strava-Konto, um deine Aktivitäten in deinen Trainingsverlauf zu übernehmen.",
@@ -7490,7 +7519,25 @@
"passkeyAddedToast": "Passkey hinzugefügt.",
"passkeyRemovedToast": "Passkey entfernt.",
"integrationDisconnected": "{provider} getrennt.",
- "integrationDisconnectFailed": "{provider} konnte nicht getrennt werden."
+ "integrationDisconnectFailed": "{provider} konnte nicht getrennt werden.",
+ "preview": {
+ "topics": {
+ "title": "Themen (Beta)",
+ "description": "Fügt einen eigenen Eintrag Themen hinzu, mit Übersichtsseiten über den einzelnen Insights-Seiten.",
+ "detail": "Deine Insights-Seiten, die Navigation und alle Links bleiben genau wie sie sind. Die Themenseiten kommen mit einem späteren Update; dieser Schalter legt fest, ob du sie siehst.",
+ "savedOn": "Themen-Vorschau eingeschaltet.",
+ "savedOff": "Themen-Vorschau ausgeschaltet."
+ },
+ "loadError": "Deine Vorschau-Einstellungen konnten nicht geladen werden.",
+ "feedbackLink": "Feedback in den GitHub-Diskussionen geben"
+ },
+ "timezoneSaved": "Zeitzone gespeichert.",
+ "moduleState": {
+ "on": "An",
+ "off": "Aus",
+ "change": "In Module ändern"
+ },
+ "timezoneUseDevice": "Zeitzone dieses Geräts übernehmen ({zone})"
},
"admin": {
"title": "Administration",
@@ -8881,6 +8928,10 @@
"detailedToast": "Benachrichtigungen zeigen jetzt Gesundheitswerte.",
"genericToast": "Benachrichtigungen zeigen keine Gesundheitswerte mehr.",
"saveError": "Speichern fehlgeschlagen. Bitte später erneut versuchen."
+ },
+ "checkupReminders": {
+ "title": "Vorsorge-Erinnerungen",
+ "description": "Schalte einzelne Vorsorge-Erinnerungen ein oder aus."
}
},
"i18n": {
@@ -9657,8 +9708,6 @@
"description": "Wert eintragen; die Erinnerung wird als erledigt markiert."
},
"manage": {
- "title": "Erinnerungen verwalten",
- "description": "Einzelne Erinnerungen aktivieren oder deaktivieren.",
"empty": "Noch keine Erinnerungen zum Anpassen."
},
"dashboard": {
@@ -12311,7 +12360,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 fe02a9baa..8ca1e04c3 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -3273,12 +3273,9 @@
"provider": "Answers with",
"modelManaged": "The model for this provider is set in AI settings.",
"noProvider": "No provider available",
- "saving": "Saving…",
- "saveFailed": "That change could not be saved.",
"allSettings": "All AI settings",
- "providerNotActive": "{provider} could not take over: it has no working sign-in or key. Set it up in AI settings.",
"operatorCodex": "ChatGPT (shared by the operator)",
- "operatorManaged": "Your server's operator manages this connection. Once one of your own providers is set up, it answers first."
+ "changeModel": "Change in Settings > AI"
},
"answer": {
"scrollToBottom": "Jump to the latest message",
@@ -5299,12 +5296,17 @@
"groups": {
"account": "Account",
"yourData": "Your data",
- "display": "Display",
"healthProfile": "Health profile",
"ai": "AI",
"connectivity": "Connectivity",
- "system": "System"
- }
+ "system": "System",
+ "tracking": "Tracking",
+ "arrangement": "Arrangement",
+ "notifications": "Notifications",
+ "connections": "Connections",
+ "data": "Data"
+ },
+ "allSections": "All sections"
},
"sharedRecord": {
"managedSettings": {
@@ -5445,11 +5447,25 @@
"remindersHeading": "Reminders",
"matrixLink": "Per-channel notifications",
"channelsLink": "Delivery channels",
- "displayHeading": "On your phone"
+ "displayHeading": "On your phone",
+ "elsewhere": {
+ "title": "Set where they belong",
+ "description": "These reminders are set next to what they are about.",
+ "doses": {
+ "title": "Medication doses",
+ "description": "Each medication's dose times and reminders are set on the medication.",
+ "link": "Open medications"
+ },
+ "cycle": {
+ "title": "Cycle",
+ "description": "Period and fertile-window reminders are switched per channel in the notification matrix.",
+ "link": "Open the notification matrix"
+ }
+ }
},
"layout": {
- "title": "Layout",
- "backToHub": "Back to Layout",
+ "title": "Arrangement",
+ "backToHub": "Back to Arrangement",
"dashboard": {
"title": "Dashboard",
"description": "Tile layout and order."
@@ -5458,7 +5474,7 @@
"title": "Insights",
"description": "Overview sections and navigation-pill order."
},
- "subtitle": "Choose what your dashboard, insights and tracking modules show, and in which order.",
+ "subtitle": "Choose the order and view of your dashboard, insights and tracking lists.",
"medications": {
"title": "Medications",
"description": "List view and manual order."
@@ -5475,10 +5491,6 @@
"title": "Checkups",
"description": "List view and reminder order."
},
- "mood": {
- "title": "Mood tags",
- "description": "Groups, tags, and archived tags for the mood picker."
- },
"documents": {
"title": "Documents",
"description": "View and month arrangement."
@@ -5513,7 +5525,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",
@@ -5869,7 +5881,28 @@
},
"manageIn": "Manage in {section}",
"operatorDisabled": "Disabled server-wide",
- "subtitle": "Choose which tracking areas HealthLog shows across the app."
+ "subtitle": "Choose which tracking areas HealthLog shows across the app.",
+ "backToHub": "Back to Modules",
+ "openModuleSettings": "Module settings",
+ "pages": {
+ "medications": {
+ "title": "Medications",
+ "description": "Injection sites the rotation leaves out."
+ },
+ "mood": {
+ "title": "Mood tags",
+ "description": "Groups, your own tags and archived tags."
+ },
+ "labs": {
+ "title": "Labs",
+ "description": "Biomarkers and on-device text recognition for lab reports."
+ },
+ "arrangementTitle": "Order and view",
+ "arrangementDescription": "How this list is sorted and shown lives under Arrangement.",
+ "openArrangement": "Open in Arrangement",
+ "moduleSettingsTitle": "Module settings",
+ "moduleSettingsDescription": "Everything beyond order and view lives on the module's page under Modules."
+ }
},
"gesundheitsakte": {
"title": "Health record",
@@ -6012,6 +6045,10 @@
"access": {
"title": "Shared access",
"subtitle": "Who can open your health record, and whose you can open."
+ },
+ "preview": {
+ "title": "Preview features",
+ "subtitle": "Try what is not finished yet. Everything here stays off until you switch it on."
}
},
"profile": "Profile",
@@ -7025,21 +7062,19 @@
},
"integrationSetupGuide": "Setup guide",
"coach": {
- "activate": {
- "title": "Activate Coach",
- "description": "Show the Coach button and drawer.",
- "toggleAria": "Activate the Coach button and drawer",
- "savedOn": "Coach activated.",
- "savedOff": "Coach hidden.",
- "saveError": "Could not save the change. Please try again."
- },
"memory": {
"edit": "Edit",
"source": {
"user": "from you",
"coach": "from the Coach"
}
- }
+ },
+ "state": {
+ "title": "Coach",
+ "description": "Whether the Coach button and drawer show. The switch is in Modules."
+ },
+ "nudgesMoved": "Set with your other reminders under Notifications.",
+ "openNudges": "Open in Notifications"
},
"integrationRedirectGuide": {
"intro": "This connects through your own {provider} developer app, about 5 minutes to set up:",
@@ -7188,12 +7223,6 @@
"delivery": "Notification delivery log: kept {days} days",
"unavailable": "Retention details are unavailable right now."
},
- "export": {
- "title": "Download my data",
- "description": "Your records as CSV or JSON, or a complete health record for your doctor.",
- "openExport": "Export & backup",
- "openHealthRecord": "Health record"
- },
"delete": {
"title": "Delete or reset",
"description": "Remove your account entirely, or wipe your health records while keeping the account.",
@@ -7209,7 +7238,6 @@
"mcp": {
"enableTitle": "Remote MCP endpoint",
"enableDescription": "Let an MCP-capable assistant (Claude.ai, ChatGPT) read your health data over a scoped token.",
- "enableToggleLabel": "Enable the MCP endpoint",
"endpointLabel": "Connector URL",
"connectDescription": "Paste this URL into your assistant's custom-connector setup, then authorize with the token below or sign in when prompted.",
"tokensTitle": "Connector tokens",
@@ -7228,7 +7256,8 @@
"connectionRevokeFailed": "Could not disconnect the assistant.",
"connectionsDetail": "Revoking a connection ends its access immediately, including its ability to refresh.",
"tokensDetail": "The token is shown once.",
- "tokenNamePlaceholder": "Name, e.g. Claude Desktop"
+ "tokenNamePlaceholder": "Name, e.g. Claude Desktop",
+ "offKeepsTokens": "Switching it off pauses the endpoint. Your tokens and connections stay and work again once it is back on."
},
"strava": "Strava",
"stravaDescription": "Connect your Strava account to sync your activities into your workout history.",
@@ -7490,7 +7519,25 @@
"passkeyAddedToast": "Passkey added.",
"passkeyRemovedToast": "Passkey removed.",
"integrationDisconnected": "{provider} disconnected.",
- "integrationDisconnectFailed": "Could not disconnect {provider}."
+ "integrationDisconnectFailed": "Could not disconnect {provider}.",
+ "preview": {
+ "topics": {
+ "title": "Topics (beta)",
+ "description": "Adds a separate Topics entry with overview pages above the single-metric Insights pages.",
+ "detail": "Your Insights pages, navigation and links stay exactly as they are. The topic pages arrive in a later update; this switch decides whether you see them.",
+ "savedOn": "Topics preview switched on.",
+ "savedOff": "Topics preview switched off."
+ },
+ "loadError": "Could not load your preview settings.",
+ "feedbackLink": "Share feedback in the GitHub discussions"
+ },
+ "timezoneSaved": "Timezone saved.",
+ "moduleState": {
+ "on": "On",
+ "off": "Off",
+ "change": "Change in Modules"
+ },
+ "timezoneUseDevice": "Use this device's timezone ({zone})"
},
"admin": {
"title": "Administration",
@@ -8881,6 +8928,10 @@
"detailedToast": "Notifications now include health figures.",
"genericToast": "Notifications no longer include health figures.",
"saveError": "Could not save. Please try again later."
+ },
+ "checkupReminders": {
+ "title": "Checkup reminders",
+ "description": "Switch individual checkup reminders on or off."
}
},
"i18n": {
@@ -9657,8 +9708,6 @@
"description": "Enter the reading; the reminder is marked done."
},
"manage": {
- "title": "Manage reminders",
- "description": "Enable or disable individual reminders.",
"empty": "No reminders to customize yet."
},
"dashboard": {
diff --git a/messages/es.json b/messages/es.json
index bb7e3666b..3239be811 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -3273,12 +3273,9 @@
"provider": "Responde con",
"modelManaged": "El modelo de este proveedor se configura en los ajustes de IA.",
"noProvider": "Ningún proveedor disponible",
- "saving": "Guardando…",
- "saveFailed": "No se pudo guardar el cambio.",
"allSettings": "Todos los ajustes de IA",
- "providerNotActive": "{provider} no pudo encargarse: no tiene un inicio de sesión ni una clave que funcionen. Configúralo en los ajustes de IA.",
"operatorCodex": "ChatGPT (compartido por el operador)",
- "operatorManaged": "El operador de tu servidor gestiona esta conexión. Cuando configures uno de tus propios proveedores, responderá primero."
+ "changeModel": "Cambiar en Ajustes > IA"
},
"answer": {
"scrollToBottom": "Ir al último mensaje",
@@ -5299,12 +5296,17 @@
"groups": {
"account": "Cuenta",
"yourData": "Tus datos",
- "display": "Visualización",
"healthProfile": "Perfil de salud",
"ai": "IA",
"connectivity": "Conectividad",
- "system": "Sistema"
- }
+ "system": "Sistema",
+ "tracking": "Seguimiento",
+ "arrangement": "Disposición",
+ "notifications": "Notificaciones",
+ "connections": "Conexiones",
+ "data": "Datos"
+ },
+ "allSections": "Todas las secciones"
},
"sharedRecord": {
"managedSettings": {
@@ -5445,11 +5447,25 @@
"remindersHeading": "Recordatorios",
"matrixLink": "Notificaciones por canal",
"channelsLink": "Canales de entrega",
- "displayHeading": "En tu teléfono"
+ "displayHeading": "En tu teléfono",
+ "elsewhere": {
+ "title": "Se ajustan donde corresponden",
+ "description": "Estos recordatorios se ajustan junto a aquello a lo que se refieren.",
+ "doses": {
+ "title": "Tomas de medicación",
+ "description": "Las horas de toma y los recordatorios se ajustan en cada medicamento.",
+ "link": "Abrir medicamentos"
+ },
+ "cycle": {
+ "title": "Ciclo",
+ "description": "Los recordatorios del periodo y de la ventana fértil se activan por canal en la matriz de notificaciones.",
+ "link": "Abrir la matriz de notificaciones"
+ }
+ }
},
"layout": {
- "title": "Diseño",
- "backToHub": "Volver a Diseño",
+ "title": "Disposición",
+ "backToHub": "Volver a Disposición",
"dashboard": {
"title": "Panel",
"description": "Diseño y orden de los mosaicos."
@@ -5458,7 +5474,7 @@
"title": "Análisis",
"description": "Orden de la vista general y de las pestañas de navegación."
},
- "subtitle": "Elige qué muestran tu panel, los análisis y los módulos de seguimiento, y en qué orden.",
+ "subtitle": "Elige el orden y la vista de tu panel, tus análisis y tus listas de seguimiento.",
"medications": {
"title": "Medicamentos",
"description": "Vista de lista y orden manual."
@@ -5475,10 +5491,6 @@
"title": "Revisiones",
"description": "Vista de lista y orden de recordatorios."
},
- "mood": {
- "title": "Etiquetas de ánimo",
- "description": "Grupos, etiquetas y etiquetas archivadas del selector de ánimo."
- },
"documents": {
"title": "Documentos",
"description": "Vista y disposición de los meses."
@@ -5869,7 +5881,28 @@
},
"manageIn": "Gestionar en {section}",
"operatorDisabled": "Desactivado en todo el servidor",
- "subtitle": "Elige qué áreas de seguimiento muestra HealthLog en la app."
+ "subtitle": "Elige qué áreas de seguimiento muestra HealthLog en la app.",
+ "backToHub": "Volver a Módulos",
+ "openModuleSettings": "Ajustes del módulo",
+ "pages": {
+ "medications": {
+ "title": "Medicamentos",
+ "description": "Zonas de inyección que la rotación omite."
+ },
+ "mood": {
+ "title": "Etiquetas de ánimo",
+ "description": "Grupos, tus propias etiquetas y las archivadas."
+ },
+ "labs": {
+ "title": "Laboratorio",
+ "description": "Biomarcadores y reconocimiento de texto en el dispositivo para informes de laboratorio."
+ },
+ "arrangementTitle": "Orden y vista",
+ "arrangementDescription": "Cómo se ordena y muestra esta lista se ajusta en Disposición.",
+ "openArrangement": "Abrir en Disposición",
+ "moduleSettingsTitle": "Ajustes del módulo",
+ "moduleSettingsDescription": "Todo lo demás está en la página del módulo, en Módulos."
+ }
},
"gesundheitsakte": {
"title": "Historial médico",
@@ -6012,6 +6045,10 @@
"access": {
"title": "Acceso compartido",
"subtitle": "Quién puede abrir tu historial de salud y cuál puedes abrir tú."
+ },
+ "preview": {
+ "title": "Funciones en vista previa",
+ "subtitle": "Prueba lo que aún no está terminado. Todo aquí sigue desactivado hasta que lo actives."
}
},
"profile": "Perfil",
@@ -7025,21 +7062,19 @@
},
"integrationSetupGuide": "Guía de configuración",
"coach": {
- "activate": {
- "title": "Activar el Coach",
- "description": "Muestra el botón y el panel del Coach.",
- "toggleAria": "Activar el botón y el panel del Coach",
- "savedOn": "Coach activado.",
- "savedOff": "Coach oculto.",
- "saveError": "No se pudo guardar el cambio. Inténtalo de nuevo."
- },
"memory": {
"edit": "Editar",
"source": {
"user": "de ti",
"coach": "del Coach"
}
- }
+ },
+ "state": {
+ "title": "Coach",
+ "description": "Si se muestran el botón y el panel del Coach. El interruptor está en Módulos."
+ },
+ "nudgesMoved": "Se ajustan con tus otros recordatorios en Notificaciones.",
+ "openNudges": "Abrir en Notificaciones"
},
"integrationRedirectGuide": {
"intro": "Esto se conecta mediante tu propia app de desarrollador de {provider}, con unos 5 minutos de configuración:",
@@ -7188,12 +7223,6 @@
"delivery": "Registro de envío de notificaciones: {days} días",
"unavailable": "Los detalles de retención no están disponibles en este momento."
},
- "export": {
- "title": "Descargar mis datos",
- "description": "Tus registros en CSV o JSON, o un historial completo para tu médico.",
- "openExport": "Exportar y copia de seguridad",
- "openHealthRecord": "Historial médico"
- },
"delete": {
"title": "Eliminar o restablecer",
"description": "Elimina tu cuenta por completo o borra tus registros de salud conservando la cuenta.",
@@ -7209,7 +7238,6 @@
"mcp": {
"enableTitle": "Endpoint MCP remoto",
"enableDescription": "Permite que un asistente compatible con MCP (Claude.ai, ChatGPT) lea tus datos de salud mediante un token limitado.",
- "enableToggleLabel": "Activar el endpoint MCP",
"endpointLabel": "URL del conector",
"connectDescription": "Pega esta URL en la configuración del conector personalizado de tu asistente y autoriza con el token de abajo o inicia sesión cuando se te solicite.",
"tokensTitle": "Tokens del conector",
@@ -7228,7 +7256,8 @@
"connectionRevokeFailed": "No se pudo desconectar el asistente.",
"tokensDetail": "El token se muestra una sola vez.",
"connectionsDetail": "Revocar una conexión finaliza su acceso de inmediato, incluida su capacidad de renovación.",
- "tokenNamePlaceholder": "Nombre, p. ej. Claude Desktop"
+ "tokenNamePlaceholder": "Nombre, p. ej. Claude Desktop",
+ "offKeepsTokens": "Desactivarlo pausa el punto de acceso. Tus tokens y conexiones se mantienen y vuelven a funcionar al activarlo."
},
"strava": "Strava",
"stravaDescription": "Conecta tu cuenta de Strava para sincronizar tus actividades con tu historial de entrenamientos.",
@@ -7490,7 +7519,25 @@
"passkeyAddedToast": "Passkey añadida.",
"passkeyRemovedToast": "Passkey eliminada.",
"integrationDisconnected": "{provider} desconectado.",
- "integrationDisconnectFailed": "No se pudo desconectar {provider}."
+ "integrationDisconnectFailed": "No se pudo desconectar {provider}.",
+ "preview": {
+ "topics": {
+ "title": "Temas (beta)",
+ "description": "Añade una entrada aparte, Temas, con páginas de resumen por encima de las páginas de cada métrica.",
+ "detail": "Tus páginas de análisis, la navegación y los enlaces se quedan exactamente como están. Las páginas de temas llegan en una actualización posterior; este interruptor decide si las ves.",
+ "savedOn": "Vista previa de Temas activada.",
+ "savedOff": "Vista previa de Temas desactivada."
+ },
+ "loadError": "No se pudieron cargar tus ajustes de vista previa.",
+ "feedbackLink": "Comparte tu opinión en las discusiones de GitHub"
+ },
+ "timezoneSaved": "Zona horaria guardada.",
+ "moduleState": {
+ "on": "Activado",
+ "off": "Desactivado",
+ "change": "Cambiar en Módulos"
+ },
+ "timezoneUseDevice": "Usar la zona horaria de este dispositivo ({zone})"
},
"admin": {
"title": "Administration",
@@ -8881,6 +8928,10 @@
"detailedToast": "Las notificaciones ahora incluyen valores de salud.",
"genericToast": "Las notificaciones ya no incluyen valores de salud.",
"saveError": "No se pudo guardar. Inténtalo de nuevo más tarde."
+ },
+ "checkupReminders": {
+ "title": "Recordatorios de chequeos",
+ "description": "Activa o desactiva recordatorios de chequeos concretos."
}
},
"i18n": {
@@ -9657,8 +9708,6 @@
"description": "Introduce el valor; el recordatorio se marca como hecho."
},
"manage": {
- "title": "Gestionar recordatorios",
- "description": "Activa o desactiva recordatorios individuales.",
"empty": "Aún no hay recordatorios para personalizar."
},
"dashboard": {
diff --git a/messages/fr.json b/messages/fr.json
index 6a3bdb2a7..7f395b5e5 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -3273,12 +3273,9 @@
"provider": "Répond avec",
"modelManaged": "Le modèle de ce fournisseur se règle dans les paramètres IA.",
"noProvider": "Aucun fournisseur disponible",
- "saving": "Enregistrement…",
- "saveFailed": "Impossible d’enregistrer cette modification.",
"allSettings": "Tous les paramètres IA",
- "providerNotActive": "{provider} n'a pas pu prendre le relais : aucune connexion ni clé valide. Configurez-le dans les réglages IA.",
"operatorCodex": "ChatGPT (partagé par l'opérateur)",
- "operatorManaged": "L'opérateur de votre serveur gère cette connexion. Dès qu'un de vos propres fournisseurs est configuré, c'est lui qui répond en premier."
+ "changeModel": "Modifier dans Réglages > IA"
},
"answer": {
"scrollToBottom": "Aller au dernier message",
@@ -5299,12 +5296,17 @@
"groups": {
"account": "Compte",
"yourData": "Vos données",
- "display": "Affichage",
"healthProfile": "Profil de santé",
"ai": "IA",
"connectivity": "Connectivité",
- "system": "Système"
- }
+ "system": "Système",
+ "tracking": "Suivi",
+ "arrangement": "Disposition",
+ "notifications": "Notifications",
+ "connections": "Connexions",
+ "data": "Données"
+ },
+ "allSections": "Toutes les sections"
},
"sharedRecord": {
"managedSettings": {
@@ -5445,7 +5447,21 @@
"remindersHeading": "Rappels",
"matrixLink": "Notifications par canal",
"channelsLink": "Canaux de diffusion",
- "displayHeading": "Sur ton téléphone"
+ "displayHeading": "Sur ton téléphone",
+ "elsewhere": {
+ "title": "Réglés là où ils se rattachent",
+ "description": "Ces rappels se règlent à côté de ce qu’ils concernent.",
+ "doses": {
+ "title": "Prises de médicaments",
+ "description": "Les heures de prise et les rappels se règlent sur chaque médicament.",
+ "link": "Ouvrir les médicaments"
+ },
+ "cycle": {
+ "title": "Cycle",
+ "description": "Les rappels de règles et de fenêtre fertile s’activent par canal dans la grille des notifications.",
+ "link": "Ouvrir la grille des notifications"
+ }
+ }
},
"layout": {
"title": "Disposition",
@@ -5458,7 +5474,7 @@
"title": "Analyses",
"description": "Ordre de la vue d'ensemble et des onglets de navigation."
},
- "subtitle": "Choisissez ce qu'affichent le tableau de bord, les analyses et les modules de suivi, et dans quel ordre.",
+ "subtitle": "Choisissez l’ordre et l’affichage du tableau de bord, des analyses et des listes de suivi.",
"medications": {
"title": "Médicaments",
"description": "Vue en liste et ordre manuel."
@@ -5475,10 +5491,6 @@
"title": "Examens",
"description": "Vue en liste et ordre des rappels."
},
- "mood": {
- "title": "Étiquettes d’humeur",
- "description": "Groupes, étiquettes et étiquettes archivées du sélecteur d’humeur."
- },
"documents": {
"title": "Documents",
"description": "Affichage et disposition des mois."
@@ -5869,7 +5881,28 @@
},
"manageIn": "Gérer dans {section}",
"operatorDisabled": "Désactivé sur tout le serveur",
- "subtitle": "Choisissez les domaines de suivi affichés par HealthLog."
+ "subtitle": "Choisissez les domaines de suivi affichés par HealthLog.",
+ "backToHub": "Retour à Modules",
+ "openModuleSettings": "Réglages du module",
+ "pages": {
+ "medications": {
+ "title": "Médicaments",
+ "description": "Sites d’injection que la rotation laisse de côté."
+ },
+ "mood": {
+ "title": "Étiquettes d’humeur",
+ "description": "Groupes, vos propres étiquettes et les étiquettes archivées."
+ },
+ "labs": {
+ "title": "Analyses",
+ "description": "Biomarqueurs et reconnaissance de texte sur l’appareil pour les comptes rendus d’analyses."
+ },
+ "arrangementTitle": "Ordre et affichage",
+ "arrangementDescription": "Le tri et l’affichage de cette liste se règlent dans Disposition.",
+ "openArrangement": "Ouvrir dans Disposition",
+ "moduleSettingsTitle": "Réglages du module",
+ "moduleSettingsDescription": "Tout le reste se trouve sur la page du module, dans Modules."
+ }
},
"gesundheitsakte": {
"title": "Dossier médical",
@@ -6012,6 +6045,10 @@
"access": {
"title": "Accès partagé",
"subtitle": "Qui peut ouvrir votre dossier de santé, et lequel vous pouvez ouvrir."
+ },
+ "preview": {
+ "title": "Fonctions en aperçu",
+ "subtitle": "Essayez ce qui n’est pas encore terminé. Tout reste désactivé ici tant que vous ne l’activez pas."
}
},
"profile": "Profil",
@@ -7025,21 +7062,19 @@
},
"integrationSetupGuide": "Guide de configuration",
"coach": {
- "activate": {
- "title": "Activer le Coach",
- "description": "Affiche le bouton et le panneau du Coach.",
- "toggleAria": "Activer le bouton et le panneau du Coach",
- "savedOn": "Coach activé.",
- "savedOff": "Coach masqué.",
- "saveError": "Impossible d enregistrer la modification. Veuillez réessayer."
- },
"memory": {
"edit": "Modifier",
"source": {
"user": "de toi",
"coach": "du Coach"
}
- }
+ },
+ "state": {
+ "title": "Coach",
+ "description": "Si le bouton et le panneau du Coach s’affichent. L’interrupteur se trouve dans Modules."
+ },
+ "nudgesMoved": "Se règlent avec vos autres rappels dans Notifications.",
+ "openNudges": "Ouvrir dans Notifications"
},
"integrationRedirectGuide": {
"intro": "Cette connexion passe par votre propre application développeur {provider}, environ 5 minutes de configuration :",
@@ -7188,12 +7223,6 @@
"delivery": "Journal d'envoi des notifications : {days} jours",
"unavailable": "Les détails de conservation sont indisponibles pour le moment."
},
- "export": {
- "title": "Télécharger mes données",
- "description": "Vos enregistrements en CSV ou JSON, ou un dossier complet pour votre médecin.",
- "openExport": "Export et sauvegarde",
- "openHealthRecord": "Dossier médical"
- },
"delete": {
"title": "Supprimer ou réinitialiser",
"description": "Supprimez complètement votre compte, ou effacez vos données de santé tout en conservant le compte.",
@@ -7209,7 +7238,6 @@
"mcp": {
"enableTitle": "Point de terminaison MCP distant",
"enableDescription": "Autorisez un assistant compatible MCP (Claude.ai, ChatGPT) à lire vos données de santé via un jeton restreint.",
- "enableToggleLabel": "Activer le point de terminaison MCP",
"endpointLabel": "URL du connecteur",
"connectDescription": "Collez cette URL dans la configuration du connecteur personnalisé de votre assistant, puis autorisez avec le jeton ci-dessous ou connectez-vous lorsque vous y êtes invité.",
"tokensTitle": "Jetons du connecteur",
@@ -7228,7 +7256,8 @@
"connectionRevokeFailed": "Impossible de déconnecter l'assistant.",
"tokensDetail": "Le jeton n'est affiché qu'une seule fois.",
"connectionsDetail": "Révoquer une connexion met fin à son accès immédiatement, y compris sa capacité à se renouveler.",
- "tokenNamePlaceholder": "Nom, p. ex. Claude Desktop"
+ "tokenNamePlaceholder": "Nom, p. ex. Claude Desktop",
+ "offKeepsTokens": "Le désactiver met le point d’accès en pause. Vos jetons et connexions restent et fonctionnent de nouveau dès sa réactivation."
},
"strava": "Strava",
"stravaDescription": "Connecte ton compte Strava pour synchroniser tes activités avec ton historique d'entraînement.",
@@ -7490,7 +7519,25 @@
"passkeyAddedToast": "Passkey ajoutée.",
"passkeyRemovedToast": "Passkey supprimée.",
"integrationDisconnected": "{provider} déconnecté.",
- "integrationDisconnectFailed": "Impossible de déconnecter {provider}."
+ "integrationDisconnectFailed": "Impossible de déconnecter {provider}.",
+ "preview": {
+ "topics": {
+ "title": "Thèmes (bêta)",
+ "description": "Ajoute une entrée Thèmes distincte, avec des pages de synthèse au-dessus des pages par mesure.",
+ "detail": "Vos pages d’analyse, la navigation et les liens restent exactement tels quels. Les pages de thèmes arrivent dans une mise à jour ultérieure ; cet interrupteur décide si vous les voyez.",
+ "savedOn": "Aperçu des thèmes activé.",
+ "savedOff": "Aperçu des thèmes désactivé."
+ },
+ "loadError": "Impossible de charger vos réglages d’aperçu.",
+ "feedbackLink": "Donner votre avis dans les discussions GitHub"
+ },
+ "timezoneSaved": "Fuseau horaire enregistré.",
+ "moduleState": {
+ "on": "Activé",
+ "off": "Désactivé",
+ "change": "Modifier dans Modules"
+ },
+ "timezoneUseDevice": "Utiliser le fuseau horaire de cet appareil ({zone})"
},
"admin": {
"title": "Administration",
@@ -8881,6 +8928,10 @@
"detailedToast": "Les notifications incluent désormais des valeurs de santé.",
"genericToast": "Les notifications n'incluent plus de valeurs de santé.",
"saveError": "Enregistrement impossible. Réessaie plus tard."
+ },
+ "checkupReminders": {
+ "title": "Rappels de bilans",
+ "description": "Activez ou désactivez chaque rappel de bilan."
}
},
"i18n": {
@@ -9657,8 +9708,6 @@
"description": "Saisissez la valeur ; le rappel est marqué comme fait."
},
"manage": {
- "title": "Gérer les rappels",
- "description": "Activer ou désactiver des rappels individuels.",
"empty": "Aucun rappel à personnaliser pour l'instant."
},
"dashboard": {
diff --git a/messages/it.json b/messages/it.json
index 13a74ea66..85e890708 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -3273,12 +3273,9 @@
"provider": "Risponde con",
"modelManaged": "Il modello di questo fornitore si imposta nelle impostazioni IA.",
"noProvider": "Nessun fornitore disponibile",
- "saving": "Salvataggio…",
- "saveFailed": "Impossibile salvare la modifica.",
"allSettings": "Tutte le impostazioni IA",
- "providerNotActive": "{provider} non è subentrato: manca un accesso o una chiave funzionante. Configuralo nelle impostazioni IA.",
"operatorCodex": "ChatGPT (condiviso dall'operatore)",
- "operatorManaged": "Questa connessione è gestita dall'operatore del tuo server. Quando configuri uno dei tuoi fornitori, risponde per primo."
+ "changeModel": "Cambia in Impostazioni > IA"
},
"answer": {
"scrollToBottom": "Vai all'ultimo messaggio",
@@ -5299,12 +5296,17 @@
"groups": {
"account": "Account",
"yourData": "I tuoi dati",
- "display": "Visualizzazione",
"healthProfile": "Profilo sanitario",
"ai": "IA",
"connectivity": "Connettività",
- "system": "Sistema"
- }
+ "system": "Sistema",
+ "tracking": "Monitoraggio",
+ "arrangement": "Disposizione",
+ "notifications": "Notifiche",
+ "connections": "Connessioni",
+ "data": "Dati"
+ },
+ "allSections": "Tutte le sezioni"
},
"sharedRecord": {
"managedSettings": {
@@ -5445,11 +5447,25 @@
"remindersHeading": "Promemoria",
"matrixLink": "Notifiche per canale",
"channelsLink": "Canali di recapito",
- "displayHeading": "Sul telefono"
+ "displayHeading": "Sul telefono",
+ "elsewhere": {
+ "title": "Impostati dove appartengono",
+ "description": "Questi promemoria si impostano accanto a ciò che riguardano.",
+ "doses": {
+ "title": "Assunzioni di farmaci",
+ "description": "Orari e promemoria si impostano su ciascun farmaco.",
+ "link": "Apri i farmaci"
+ },
+ "cycle": {
+ "title": "Ciclo",
+ "description": "I promemoria del ciclo e della finestra fertile si attivano per canale nella griglia delle notifiche.",
+ "link": "Apri la griglia delle notifiche"
+ }
+ }
},
"layout": {
- "title": "Layout",
- "backToHub": "Torna a Layout",
+ "title": "Disposizione",
+ "backToHub": "Torna a Disposizione",
"dashboard": {
"title": "Cruscotto",
"description": "Layout e ordine dei riquadri."
@@ -5458,7 +5474,7 @@
"title": "Approfondimenti",
"description": "Ordine della panoramica e delle schede di navigazione."
},
- "subtitle": "Scegli cosa mostrano dashboard, approfondimenti e moduli di monitoraggio, e in quale ordine.",
+ "subtitle": "Scegli ordine e vista della dashboard, delle analisi e degli elenchi di monitoraggio.",
"medications": {
"title": "Farmaci",
"description": "Vista elenco e ordine manuale."
@@ -5475,10 +5491,6 @@
"title": "Controlli",
"description": "Vista elenco e ordine dei promemoria."
},
- "mood": {
- "title": "Tag dell’umore",
- "description": "Gruppi, tag e tag archiviati del selettore dell’umore."
- },
"documents": {
"title": "Documenti",
"description": "Visualizzazione e disposizione dei mesi."
@@ -5869,7 +5881,28 @@
},
"manageIn": "Gestisci in {section}",
"operatorDisabled": "Disattivato a livello di server",
- "subtitle": "Scegli quali aree di monitoraggio HealthLog mostra nell'app."
+ "subtitle": "Scegli quali aree di monitoraggio HealthLog mostra nell'app.",
+ "backToHub": "Torna a Moduli",
+ "openModuleSettings": "Impostazioni del modulo",
+ "pages": {
+ "medications": {
+ "title": "Farmaci",
+ "description": "Siti di iniezione esclusi dalla rotazione."
+ },
+ "mood": {
+ "title": "Tag dell’umore",
+ "description": "Gruppi, i tuoi tag e i tag archiviati."
+ },
+ "labs": {
+ "title": "Esami",
+ "description": "Biomarcatori e riconoscimento del testo sul dispositivo per i referti."
+ },
+ "arrangementTitle": "Ordine e vista",
+ "arrangementDescription": "Ordinamento e vista di questo elenco si impostano in Disposizione.",
+ "openArrangement": "Apri in Disposizione",
+ "moduleSettingsTitle": "Impostazioni del modulo",
+ "moduleSettingsDescription": "Tutto il resto è nella pagina del modulo, in Moduli."
+ }
},
"gesundheitsakte": {
"title": "Cartella clinica",
@@ -6012,6 +6045,10 @@
"access": {
"title": "Accesso condiviso",
"subtitle": "Chi può aprire la tua cartella sanitaria e quale puoi aprire tu."
+ },
+ "preview": {
+ "title": "Funzioni in anteprima",
+ "subtitle": "Prova ciò che non è ancora finito. Qui tutto resta spento finché non lo attivi."
}
},
"profile": "Profilo",
@@ -7025,21 +7062,19 @@
},
"integrationSetupGuide": "Guida alla configurazione",
"coach": {
- "activate": {
- "title": "Attiva il Coach",
- "description": "Mostra il pulsante e il pannello del Coach.",
- "toggleAria": "Attiva il pulsante e il pannello del Coach",
- "savedOn": "Coach attivato.",
- "savedOff": "Coach nascosto.",
- "saveError": "Impossibile salvare la modifica. Riprova."
- },
"memory": {
"edit": "Modifica",
"source": {
"user": "da te",
"coach": "dal Coach"
}
- }
+ },
+ "state": {
+ "title": "Coach",
+ "description": "Se il pulsante e il pannello del Coach sono visibili. L’interruttore è in Moduli."
+ },
+ "nudgesMoved": "Si impostano con gli altri promemoria in Notifiche.",
+ "openNudges": "Apri in Notifiche"
},
"integrationRedirectGuide": {
"intro": "Questa connessione passa dalla tua app sviluppatore {provider} (circa 5 minuti di configurazione):",
@@ -7188,12 +7223,6 @@
"delivery": "Registro di invio delle notifiche: {days} giorni",
"unavailable": "I dettagli di conservazione non sono disponibili al momento."
},
- "export": {
- "title": "Scarica i miei dati",
- "description": "I tuoi record in CSV o JSON, o una cartella completa per il medico.",
- "openExport": "Esportazione e backup",
- "openHealthRecord": "Fascicolo sanitario"
- },
"delete": {
"title": "Elimina o reimposta",
"description": "Elimina completamente il tuo account oppure cancella i tuoi dati sanitari mantenendo l'account.",
@@ -7209,7 +7238,6 @@
"mcp": {
"enableTitle": "Endpoint MCP remoto",
"enableDescription": "Consenti a un assistente compatibile con MCP (Claude.ai, ChatGPT) di leggere i tuoi dati sulla salute tramite un token limitato.",
- "enableToggleLabel": "Attiva l'endpoint MCP",
"endpointLabel": "URL del connettore",
"connectDescription": "Incolla questo URL nella configurazione del connettore personalizzato del tuo assistente e autorizza con il token qui sotto o accedi quando richiesto.",
"tokensTitle": "Token del connettore",
@@ -7228,7 +7256,8 @@
"connectionRevokeFailed": "Impossibile disconnettere l'assistente.",
"tokensDetail": "Il token viene mostrato una sola volta.",
"connectionsDetail": "Revocare una connessione ne termina subito l'accesso, inclusa la capacità di rinnovarsi.",
- "tokenNamePlaceholder": "Nome, ad es. Claude Desktop"
+ "tokenNamePlaceholder": "Nome, ad es. Claude Desktop",
+ "offKeepsTokens": "Spegnerlo mette in pausa l’endpoint. Token e connessioni restano e tornano a funzionare quando lo riaccendi."
},
"strava": "Strava",
"stravaDescription": "Collega il tuo account Strava per sincronizzare le tue attività con la cronologia degli allenamenti.",
@@ -7490,7 +7519,25 @@
"passkeyAddedToast": "Passkey aggiunta.",
"passkeyRemovedToast": "Passkey rimossa.",
"integrationDisconnected": "{provider} disconnesso.",
- "integrationDisconnectFailed": "Impossibile disconnettere {provider}."
+ "integrationDisconnectFailed": "Impossibile disconnettere {provider}.",
+ "preview": {
+ "topics": {
+ "title": "Temi (beta)",
+ "description": "Aggiunge una voce Temi separata, con pagine di panoramica sopra le pagine delle singole metriche.",
+ "detail": "Le pagine delle analisi, la navigazione e i link restano esattamente come sono. Le pagine dei temi arrivano con un aggiornamento successivo; questo interruttore decide se le vedi.",
+ "savedOn": "Anteprima dei temi attivata.",
+ "savedOff": "Anteprima dei temi disattivata."
+ },
+ "loadError": "Impossibile caricare le impostazioni di anteprima.",
+ "feedbackLink": "Lascia un parere nelle discussioni su GitHub"
+ },
+ "timezoneSaved": "Fuso orario salvato.",
+ "moduleState": {
+ "on": "Attivo",
+ "off": "Disattivo",
+ "change": "Cambia in Moduli"
+ },
+ "timezoneUseDevice": "Usa il fuso orario di questo dispositivo ({zone})"
},
"admin": {
"title": "Administration",
@@ -8881,6 +8928,10 @@
"detailedToast": "Le notifiche ora includono valori di salute.",
"genericToast": "Le notifiche non includono più valori di salute.",
"saveError": "Impossibile salvare. Riprova più tardi."
+ },
+ "checkupReminders": {
+ "title": "Promemoria dei controlli",
+ "description": "Attiva o disattiva i singoli promemoria dei controlli."
}
},
"i18n": {
@@ -9657,8 +9708,6 @@
"description": "Inserisci il valore; il promemoria viene segnato come fatto."
},
"manage": {
- "title": "Gestisci promemoria",
- "description": "Attiva o disattiva singoli promemoria.",
"empty": "Nessun promemoria da personalizzare."
},
"dashboard": {
diff --git a/messages/ko.json b/messages/ko.json
index 23f86443f..0b844ffae 100644
--- a/messages/ko.json
+++ b/messages/ko.json
@@ -3273,12 +3273,9 @@
"provider": "응답 제공자",
"modelManaged": "이 제공자의 모델은 AI 설정에서 정해요.",
"noProvider": "사용할 수 있는 제공자가 없어요",
- "saving": "저장 중…",
- "saveFailed": "변경 사항을 저장하지 못했어요.",
"allSettings": "모든 AI 설정",
- "providerNotActive": "{provider}(으)로 전환하지 못했습니다. 작동하는 로그인이나 키가 없습니다. AI 설정에서 설정하세요.",
"operatorCodex": "ChatGPT (운영자 공유)",
- "operatorManaged": "이 연결은 서버 운영자가 관리합니다. 내 공급자를 하나라도 설정하면 그 공급자가 먼저 답합니다."
+ "changeModel": "설정 > AI에서 변경"
},
"answer": {
"scrollToBottom": "최신 메시지로 이동",
@@ -5299,12 +5296,17 @@
"groups": {
"account": "계정",
"yourData": "내 데이터",
- "display": "화면",
"healthProfile": "건강 프로필",
"ai": "AI",
"connectivity": "연결",
- "system": "시스템"
- }
+ "system": "시스템",
+ "tracking": "기록",
+ "arrangement": "배치",
+ "notifications": "알림",
+ "connections": "연결",
+ "data": "데이터"
+ },
+ "allSections": "모든 항목"
},
"sharedRecord": {
"managedSettings": {
@@ -5445,11 +5447,25 @@
"remindersHeading": "알림",
"matrixLink": "채널별 알림",
"channelsLink": "전송 채널",
- "displayHeading": "휴대폰에서"
+ "displayHeading": "휴대폰에서",
+ "elsewhere": {
+ "title": "해당 위치에서 설정",
+ "description": "이 리마인더는 관련 항목 옆에서 설정합니다.",
+ "doses": {
+ "title": "약 복용",
+ "description": "복용 시간과 리마인더는 각 약에서 설정합니다.",
+ "link": "약 열기"
+ },
+ "cycle": {
+ "title": "주기",
+ "description": "생리와 가임기 리마인더는 알림 표에서 채널별로 켭니다.",
+ "link": "알림 표 열기"
+ }
+ }
},
"layout": {
- "title": "화면 구성",
- "backToHub": "화면 구성으로 돌아가기",
+ "title": "배치",
+ "backToHub": "배치로 돌아가기",
"dashboard": {
"title": "대시보드",
"description": "타일 배치와 순서."
@@ -5458,7 +5474,7 @@
"title": "인사이트",
"description": "개요 섹션과 내비게이션 칩 순서."
},
- "subtitle": "대시보드, 인사이트, 기록 모듈에 무엇을 어떤 순서로 보여줄지 정해요.",
+ "subtitle": "대시보드, 인사이트, 기록 목록의 순서와 보기를 정하세요.",
"medications": {
"title": "복약",
"description": "목록 보기와 수동 순서."
@@ -5475,10 +5491,6 @@
"title": "건강검진",
"description": "목록 보기와 알림 순서."
},
- "mood": {
- "title": "기분 태그",
- "description": "기분 선택기의 그룹, 태그, 보관된 태그."
- },
"documents": {
"title": "문서",
"description": "보기 방식과 월 배치."
@@ -5869,7 +5881,28 @@
},
"manageIn": "{section}에서 관리",
"operatorDisabled": "서버 전체에서 꺼짐",
- "subtitle": "HealthLog가 앱 전체에서 보여 줄 기록 영역을 골라요."
+ "subtitle": "HealthLog가 앱 전체에서 보여 줄 기록 영역을 골라요.",
+ "backToHub": "모듈로 돌아가기",
+ "openModuleSettings": "모듈 설정",
+ "pages": {
+ "medications": {
+ "title": "약",
+ "description": "순환에서 제외할 주사 부위입니다."
+ },
+ "mood": {
+ "title": "기분 태그",
+ "description": "그룹, 직접 만든 태그, 보관된 태그입니다."
+ },
+ "labs": {
+ "title": "검사",
+ "description": "바이오마커와 검사 보고서용 기기 내 텍스트 인식입니다."
+ },
+ "arrangementTitle": "순서와 보기",
+ "arrangementDescription": "이 목록의 정렬과 보기는 배치에서 설정합니다.",
+ "openArrangement": "배치에서 열기",
+ "moduleSettingsTitle": "모듈 설정",
+ "moduleSettingsDescription": "순서와 보기 외의 설정은 모듈의 페이지에 있습니다."
+ }
},
"gesundheitsakte": {
"title": "건강 기록",
@@ -6012,6 +6045,10 @@
"access": {
"title": "공유 접근",
"subtitle": "내 건강 기록을 누가 열 수 있고, 내가 누구의 기록을 열 수 있는지예요."
+ },
+ "preview": {
+ "title": "미리 보기 기능",
+ "subtitle": "아직 완성되지 않은 기능을 써 보세요. 직접 켜기 전까지는 모두 꺼져 있습니다."
}
},
"profile": "프로필",
@@ -7025,21 +7062,19 @@
},
"integrationSetupGuide": "설정 가이드",
"coach": {
- "activate": {
- "title": "코치 활성화",
- "description": "코치 버튼과 패널을 표시해요.",
- "toggleAria": "코치 버튼과 패널 활성화",
- "savedOn": "코치를 켰어요.",
- "savedOff": "코치를 숨겼어요.",
- "saveError": "변경 사항을 저장하지 못했어요. 다시 시도해 주세요."
- },
"memory": {
"edit": "편집",
"source": {
"user": "내가 알려 줌",
"coach": "코치가 기억함"
}
- }
+ },
+ "state": {
+ "title": "코치",
+ "description": "코치 버튼과 패널 표시 여부입니다. 스위치는 모듈에 있습니다."
+ },
+ "nudgesMoved": "알림에서 다른 리마인더와 함께 설정합니다.",
+ "openNudges": "알림에서 열기"
},
"integrationRedirectGuide": {
"intro": "직접 만든 {provider} 개발자 앱으로 연결해요. 설정에 5분쯤 걸려요:",
@@ -7188,12 +7223,6 @@
"delivery": "알림 발송 기록: {days}일 보관",
"unavailable": "지금은 보관 기간 정보를 볼 수 없어요."
},
- "export": {
- "title": "내 데이터 내려받기",
- "description": "기록을 CSV나 JSON으로, 또는 진료용 전체 건강 기록으로 받을 수 있어요.",
- "openExport": "내보내기·백업",
- "openHealthRecord": "건강 기록"
- },
"delete": {
"title": "삭제 또는 초기화",
"description": "계정을 완전히 없애거나, 계정은 두고 건강 기록만 지워요.",
@@ -7209,7 +7238,6 @@
"mcp": {
"enableTitle": "원격 MCP 엔드포인트",
"enableDescription": "MCP를 지원하는 어시스턴트(Claude.ai, ChatGPT)가 범위가 제한된 토큰으로 건강 데이터를 읽게 해요.",
- "enableToggleLabel": "MCP 엔드포인트 켜기",
"endpointLabel": "커넥터 URL",
"connectDescription": "어시스턴트의 커스텀 커넥터 설정에 이 URL을 붙여 넣고, 아래 토큰으로 인증하거나 안내가 나오면 로그인하세요.",
"tokensTitle": "커넥터 토큰",
@@ -7228,7 +7256,8 @@
"connectionRevokeFailed": "어시스턴트 연결을 해제하지 못했어요.",
"connectionsDetail": "연결을 해지하면 갱신 권한까지 포함해 접근이 즉시 끊겨요.",
"tokensDetail": "토큰은 한 번만 보여줘요.",
- "tokenNamePlaceholder": "이름 (예: Claude Desktop)"
+ "tokenNamePlaceholder": "이름 (예: Claude Desktop)",
+ "offKeepsTokens": "끄면 엔드포인트가 일시 중지됩니다. 토큰과 연결은 그대로 남고 다시 켜면 다시 작동합니다."
},
"strava": "Strava",
"stravaDescription": "Strava 계정을 연결해 활동을 운동 기록으로 동기화해요.",
@@ -7490,7 +7519,25 @@
"passkeyAddedToast": "패스키를 추가했어요.",
"passkeyRemovedToast": "패스키를 삭제했어요.",
"integrationDisconnected": "{provider} 연결을 해제했어요.",
- "integrationDisconnectFailed": "{provider} 연결을 해제하지 못했어요."
+ "integrationDisconnectFailed": "{provider} 연결을 해제하지 못했어요.",
+ "preview": {
+ "topics": {
+ "title": "주제 (베타)",
+ "description": "개별 지표 페이지 위에 개요 페이지가 있는 별도의 주제 항목을 추가합니다.",
+ "detail": "인사이트 페이지, 메뉴, 링크는 지금 그대로 유지됩니다. 주제 페이지는 이후 업데이트에서 제공되며, 이 스위치로 표시 여부를 정합니다.",
+ "savedOn": "주제 미리 보기를 켰습니다.",
+ "savedOff": "주제 미리 보기를 껐습니다."
+ },
+ "loadError": "미리 보기 설정을 불러오지 못했습니다.",
+ "feedbackLink": "GitHub 토론에서 의견 남기기"
+ },
+ "timezoneSaved": "시간대를 저장했습니다.",
+ "moduleState": {
+ "on": "켜짐",
+ "off": "꺼짐",
+ "change": "모듈에서 변경"
+ },
+ "timezoneUseDevice": "이 기기의 시간대 사용 ({zone})"
},
"admin": {
"title": "관리자",
@@ -8881,6 +8928,10 @@
"detailedToast": "이제 알림에 건강 수치가 포함됩니다.",
"genericToast": "이제 알림에 건강 수치가 포함되지 않습니다.",
"saveError": "저장하지 못했어요. 나중에 다시 시도해 주세요."
+ },
+ "checkupReminders": {
+ "title": "검진 리마인더",
+ "description": "검진 리마인더를 개별로 켜거나 끕니다."
}
},
"i18n": {
@@ -9657,8 +9708,6 @@
"description": "측정값을 입력하면 알림이 완료로 표시돼요."
},
"manage": {
- "title": "알림 관리",
- "description": "알림을 하나씩 켜거나 끌 수 있어요.",
"empty": "아직 설정할 알림이 없어요."
},
"dashboard": {
diff --git a/messages/nl.json b/messages/nl.json
index d337c50b9..c44560507 100644
--- a/messages/nl.json
+++ b/messages/nl.json
@@ -3273,12 +3273,9 @@
"provider": "Antwoordt met",
"modelManaged": "Het model voor deze provider stel je in bij de AI-instellingen.",
"noProvider": "Geen provider beschikbaar",
- "saving": "Opslaan…",
- "saveFailed": "Die wijziging kon niet worden opgeslagen.",
"allSettings": "Alle AI-instellingen",
- "providerNotActive": "{provider} kon het niet overnemen: er is geen werkende aanmelding of sleutel. Stel deze provider in bij de AI-instellingen.",
"operatorCodex": "ChatGPT (gedeeld door de serverbeheerder)",
- "operatorManaged": "De beheerder van je server beheert deze verbinding. Zodra een van je eigen providers is ingesteld, antwoordt die als eerste."
+ "changeModel": "Wijzigen in Instellingen > AI"
},
"answer": {
"scrollToBottom": "Naar het nieuwste bericht",
@@ -5299,12 +5296,17 @@
"groups": {
"account": "Account",
"yourData": "Je gegevens",
- "display": "Weergave",
"healthProfile": "Gezondheidsprofiel",
"ai": "AI",
"connectivity": "Verbindingen",
- "system": "Systeem"
- }
+ "system": "Systeem",
+ "tracking": "Bijhouden",
+ "arrangement": "Indeling",
+ "notifications": "Meldingen",
+ "connections": "Verbindingen",
+ "data": "Gegevens"
+ },
+ "allSections": "Alle onderdelen"
},
"sharedRecord": {
"managedSettings": {
@@ -5445,7 +5447,21 @@
"remindersHeading": "Herinneringen",
"matrixLink": "Meldingen per kanaal",
"channelsLink": "Bezorgkanalen",
- "displayHeading": "Op je telefoon"
+ "displayHeading": "Op je telefoon",
+ "elsewhere": {
+ "title": "Ingesteld waar ze thuishoren",
+ "description": "Deze herinneringen stel je in bij waar ze over gaan.",
+ "doses": {
+ "title": "Medicatie-innames",
+ "description": "Innametijden en herinneringen stel je per medicijn in.",
+ "link": "Medicatie openen"
+ },
+ "cycle": {
+ "title": "Cyclus",
+ "description": "Herinneringen voor menstruatie en vruchtbare dagen zet je per kanaal aan in het meldingenoverzicht.",
+ "link": "Meldingenoverzicht openen"
+ }
+ }
},
"layout": {
"title": "Indeling",
@@ -5458,7 +5474,7 @@
"title": "Inzichten",
"description": "Overzichtsonderdelen en volgorde van de navigatieknoppen."
},
- "subtitle": "Kies wat je dashboard, inzichten en registratiemodules laten zien, en in welke volgorde.",
+ "subtitle": "Kies de volgorde en weergave van je dashboard, inzichten en lijsten.",
"medications": {
"title": "Medicatie",
"description": "Lijstweergave en handmatige volgorde."
@@ -5475,10 +5491,6 @@
"title": "Preventieve controles",
"description": "Lijstweergave en volgorde van herinneringen."
},
- "mood": {
- "title": "Stemmingstags",
- "description": "Groepen, tags en gearchiveerde tags voor de stemmingskiezer."
- },
"documents": {
"title": "Documenten",
"description": "Weergave en indeling per maand."
@@ -5869,7 +5881,28 @@
},
"manageIn": "Beheren in {section}",
"operatorDisabled": "Op de hele server uitgeschakeld",
- "subtitle": "Kies welke onderdelen HealthLog in de hele app toont."
+ "subtitle": "Kies welke onderdelen HealthLog in de hele app toont.",
+ "backToHub": "Terug naar Modules",
+ "openModuleSettings": "Module-instellingen",
+ "pages": {
+ "medications": {
+ "title": "Medicatie",
+ "description": "Injectieplaatsen die de rotatie overslaat."
+ },
+ "mood": {
+ "title": "Stemmingstags",
+ "description": "Groepen, je eigen tags en gearchiveerde tags."
+ },
+ "labs": {
+ "title": "Labwaarden",
+ "description": "Biomarkers en tekstherkenning op het apparaat voor labuitslagen."
+ },
+ "arrangementTitle": "Volgorde en weergave",
+ "arrangementDescription": "Hoe deze lijst wordt gesorteerd en getoond, stel je in bij Indeling.",
+ "openArrangement": "Openen in Indeling",
+ "moduleSettingsTitle": "Module-instellingen",
+ "moduleSettingsDescription": "Al het andere staat op de pagina van de module onder Modules."
+ }
},
"gesundheitsakte": {
"title": "Gezondheidsdossier",
@@ -6012,6 +6045,10 @@
"access": {
"title": "Gedeelde toegang",
"subtitle": "Wie je gezondheidsdossier kan openen, en wiens dossier jij kunt openen."
+ },
+ "preview": {
+ "title": "Previewfuncties",
+ "subtitle": "Probeer wat nog niet af is. Alles hier blijft uit tot je het aanzet."
}
},
"profile": "Profiel",
@@ -7025,21 +7062,19 @@
},
"integrationSetupGuide": "Installatiehandleiding",
"coach": {
- "activate": {
- "title": "Coach activeren",
- "description": "De Coach-knop en het Coach-paneel tonen.",
- "toggleAria": "De Coach-knop en het Coach-paneel activeren",
- "savedOn": "Coach geactiveerd.",
- "savedOff": "Coach verborgen.",
- "saveError": "De wijziging kon niet worden opgeslagen. Probeer het opnieuw."
- },
"memory": {
"edit": "Bewerken",
"source": {
"user": "van jou",
"coach": "van de Coach"
}
- }
+ },
+ "state": {
+ "title": "Coach",
+ "description": "Of de Coach-knop en het Coach-paneel zichtbaar zijn. De schakelaar staat bij Modules."
+ },
+ "nudgesMoved": "Stel je in bij je andere herinneringen onder Meldingen.",
+ "openNudges": "Openen in Meldingen"
},
"integrationRedirectGuide": {
"intro": "Dit koppelt via je eigen {provider}-ontwikkelaarsapp; het instellen duurt ongeveer 5 minuten:",
@@ -7188,12 +7223,6 @@
"delivery": "Logboek van bezorgde meldingen: {days} dagen bewaard",
"unavailable": "Details over de bewaartermijn zijn op dit moment niet beschikbaar."
},
- "export": {
- "title": "Mijn gegevens downloaden",
- "description": "Je gegevens als CSV of JSON, of een volledig medisch dossier voor je arts.",
- "openExport": "Export en back-up",
- "openHealthRecord": "Medisch dossier"
- },
"delete": {
"title": "Verwijderen of wissen",
"description": "Verwijder je account volledig, of wis je gezondheidsgegevens en behoud je account.",
@@ -7209,7 +7238,6 @@
"mcp": {
"enableTitle": "Extern MCP-endpoint",
"enableDescription": "Laat een assistent met MCP-ondersteuning (Claude.ai, ChatGPT) je gezondheidsgegevens lezen via een token met beperkte rechten.",
- "enableToggleLabel": "Het MCP-endpoint inschakelen",
"endpointLabel": "Connector-URL",
"connectDescription": "Plak deze URL in de instellingen voor aangepaste connectors van je assistent en autoriseer daarna met het token hieronder, of log in wanneer daarom wordt gevraagd.",
"tokensTitle": "Connectortokens",
@@ -7228,7 +7256,8 @@
"connectionRevokeFailed": "De assistent kon niet worden ontkoppeld.",
"connectionsDetail": "Als je een koppeling intrekt, eindigt de toegang direct, inclusief de mogelijkheid om die te vernieuwen.",
"tokensDetail": "Het token wordt één keer getoond.",
- "tokenNamePlaceholder": "Naam, bijv. Claude Desktop"
+ "tokenNamePlaceholder": "Naam, bijv. Claude Desktop",
+ "offKeepsTokens": "Uitzetten pauzeert het eindpunt. Je tokens en verbindingen blijven en werken weer zodra het aan staat."
},
"strava": "Strava",
"stravaDescription": "Koppel je Strava-account om je activiteiten naar je trainingsgeschiedenis te synchroniseren.",
@@ -7490,7 +7519,25 @@
"passkeyAddedToast": "Passkey toegevoegd.",
"passkeyRemovedToast": "Passkey verwijderd.",
"integrationDisconnected": "{provider} ontkoppeld.",
- "integrationDisconnectFailed": "{provider} kon niet worden ontkoppeld."
+ "integrationDisconnectFailed": "{provider} kon niet worden ontkoppeld.",
+ "preview": {
+ "topics": {
+ "title": "Onderwerpen (bèta)",
+ "description": "Voegt een apart item Onderwerpen toe, met overzichtspagina’s boven de pagina’s per meting.",
+ "detail": "Je inzichtpagina’s, navigatie en links blijven precies zoals ze zijn. De onderwerppagina’s komen met een latere update; deze schakelaar bepaalt of je ze ziet.",
+ "savedOn": "Preview van onderwerpen ingeschakeld.",
+ "savedOff": "Preview van onderwerpen uitgeschakeld."
+ },
+ "loadError": "Je preview-instellingen konden niet worden geladen.",
+ "feedbackLink": "Geef feedback in de GitHub-discussies"
+ },
+ "timezoneSaved": "Tijdzone opgeslagen.",
+ "moduleState": {
+ "on": "Aan",
+ "off": "Uit",
+ "change": "Wijzigen in Modules"
+ },
+ "timezoneUseDevice": "Tijdzone van dit apparaat gebruiken ({zone})"
},
"admin": {
"title": "Beheer",
@@ -8881,6 +8928,10 @@
"detailedToast": "Meldingen bevatten nu gezondheidscijfers.",
"genericToast": "Meldingen bevatten geen gezondheidscijfers meer.",
"saveError": "Opslaan mislukt. Probeer het later opnieuw."
+ },
+ "checkupReminders": {
+ "title": "Herinneringen voor controles",
+ "description": "Zet afzonderlijke herinneringen voor controles aan of uit."
}
},
"i18n": {
@@ -9657,8 +9708,6 @@
"description": "Voer de meetwaarde in; de herinnering wordt als gedaan gemarkeerd."
},
"manage": {
- "title": "Herinneringen beheren",
- "description": "Zet afzonderlijke herinneringen aan of uit.",
"empty": "Nog geen herinneringen om aan te passen."
},
"dashboard": {
diff --git a/messages/pl.json b/messages/pl.json
index a93389fb0..c1812715f 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -3273,12 +3273,9 @@
"provider": "Odpowiada przez",
"modelManaged": "Model tego dostawcy ustawisz w ustawieniach AI.",
"noProvider": "Brak dostępnego dostawcy",
- "saving": "Zapisywanie…",
- "saveFailed": "Nie udało się zapisać zmiany.",
"allSettings": "Wszystkie ustawienia AI",
- "providerNotActive": "{provider} nie mógł przejąć odpowiedzi: brak działającego logowania lub klucza. Skonfiguruj go w ustawieniach AI.",
"operatorCodex": "ChatGPT (udostępniony przez operatora)",
- "operatorManaged": "Tym połączeniem zarządza operator Twojego serwera. Gdy skonfigurujesz własnego dostawcę, to on odpowie jako pierwszy."
+ "changeModel": "Zmień w Ustawieniach > AI"
},
"answer": {
"scrollToBottom": "Przejdź do najnowszej wiadomości",
@@ -5299,12 +5296,17 @@
"groups": {
"account": "Konto",
"yourData": "Twoje dane",
- "display": "Wyświetlanie",
"healthProfile": "Profil zdrowotny",
"ai": "AI",
"connectivity": "Łączność",
- "system": "System"
- }
+ "system": "System",
+ "tracking": "Śledzenie",
+ "arrangement": "Układ",
+ "notifications": "Powiadomienia",
+ "connections": "Połączenia",
+ "data": "Dane"
+ },
+ "allSections": "Wszystkie sekcje"
},
"sharedRecord": {
"managedSettings": {
@@ -5445,11 +5447,25 @@
"remindersHeading": "Przypomnienia",
"matrixLink": "Powiadomienia wg kanału",
"channelsLink": "Kanały dostarczania",
- "displayHeading": "Na telefonie"
+ "displayHeading": "Na telefonie",
+ "elsewhere": {
+ "title": "Ustawiane tam, gdzie należą",
+ "description": "Te przypomnienia ustawiasz obok tego, czego dotyczą.",
+ "doses": {
+ "title": "Dawki leków",
+ "description": "Godziny przyjmowania i przypomnienia ustawiasz przy każdym leku.",
+ "link": "Otwórz leki"
+ },
+ "cycle": {
+ "title": "Cykl",
+ "description": "Przypomnienia o miesiączce i dniach płodnych włączasz dla każdego kanału w tabeli powiadomień.",
+ "link": "Otwórz tabelę powiadomień"
+ }
+ }
},
"layout": {
"title": "Układ",
- "backToHub": "Powrót do układu",
+ "backToHub": "Wróć do Układu",
"dashboard": {
"title": "Pulpit",
"description": "Układ i kolejność kafelków."
@@ -5458,7 +5474,7 @@
"title": "Analizy",
"description": "Kolejność przeglądu i pigułek nawigacyjnych."
},
- "subtitle": "Wybierz, co pokazują pulpit, analizy i moduły śledzenia oraz w jakiej kolejności.",
+ "subtitle": "Wybierz kolejność i widok pulpitu, analiz i list śledzenia.",
"medications": {
"title": "Leki",
"description": "Widok listy i ręczna kolejność."
@@ -5475,10 +5491,6 @@
"title": "Badania kontrolne",
"description": "Widok listy i kolejność przypomnień."
},
- "mood": {
- "title": "Tagi nastroju",
- "description": "Grupy, tagi i zarchiwizowane tagi w wyborze nastroju."
- },
"documents": {
"title": "Dokumenty",
"description": "Widok i układ miesięcy."
@@ -5869,7 +5881,28 @@
},
"manageIn": "Zarządzaj w {section}",
"operatorDisabled": "Wyłączone na całym serwerze",
- "subtitle": "Wybierz, które obszary śledzenia pokazuje HealthLog w aplikacji."
+ "subtitle": "Wybierz, które obszary śledzenia pokazuje HealthLog w aplikacji.",
+ "backToHub": "Wróć do Modułów",
+ "openModuleSettings": "Ustawienia modułu",
+ "pages": {
+ "medications": {
+ "title": "Leki",
+ "description": "Miejsca wstrzyknięć pomijane w rotacji."
+ },
+ "mood": {
+ "title": "Tagi nastroju",
+ "description": "Grupy, własne tagi i tagi zarchiwizowane."
+ },
+ "labs": {
+ "title": "Badania",
+ "description": "Biomarkery i rozpoznawanie tekstu na urządzeniu dla wyników badań."
+ },
+ "arrangementTitle": "Kolejność i widok",
+ "arrangementDescription": "Sortowanie i widok tej listy ustawiasz w Układzie.",
+ "openArrangement": "Otwórz w Układzie",
+ "moduleSettingsTitle": "Ustawienia modułu",
+ "moduleSettingsDescription": "Wszystko poza kolejnością i widokiem jest na stronie modułu w Modułach."
+ }
},
"gesundheitsakte": {
"title": "Dokumentacja zdrowotna",
@@ -6012,6 +6045,10 @@
"access": {
"title": "Udostępniony dostęp",
"subtitle": "Kto może otworzyć twoją kartę zdrowia i czyją kartę możesz otworzyć ty."
+ },
+ "preview": {
+ "title": "Funkcje w podglądzie",
+ "subtitle": "Wypróbuj to, co nie jest jeszcze gotowe. Wszystko tutaj pozostaje wyłączone, dopóki tego nie włączysz."
}
},
"profile": "Profil",
@@ -7025,21 +7062,19 @@
},
"integrationSetupGuide": "Przewodnik konfiguracji",
"coach": {
- "activate": {
- "title": "Włącz Coacha",
- "description": "Pokazuje przycisk i panel Coacha.",
- "toggleAria": "Włącz przycisk i panel Coacha",
- "savedOn": "Coach włączony.",
- "savedOff": "Coach ukryty.",
- "saveError": "Nie udało się zapisać zmiany. Spróbuj ponownie."
- },
"memory": {
"edit": "Edytuj",
"source": {
"user": "od ciebie",
"coach": "od Coacha"
}
- }
+ },
+ "state": {
+ "title": "Coach",
+ "description": "Czy przycisk i panel Coacha są widoczne. Przełącznik jest w Modułach."
+ },
+ "nudgesMoved": "Ustawiasz je razem z innymi przypomnieniami w Powiadomieniach.",
+ "openNudges": "Otwórz w Powiadomieniach"
},
"integrationRedirectGuide": {
"intro": "To połączenie działa przez Twoją własną aplikację deweloperską {provider}, około 5 minut konfiguracji:",
@@ -7188,12 +7223,6 @@
"delivery": "Dziennik wysyłki powiadomień: {days} dni",
"unavailable": "Szczegóły przechowywania są obecnie niedostępne."
},
- "export": {
- "title": "Pobierz moje dane",
- "description": "Twoje rekordy jako CSV lub JSON albo pełna dokumentacja dla lekarza.",
- "openExport": "Eksport i kopia zapasowa",
- "openHealthRecord": "Dokumentacja medyczna"
- },
"delete": {
"title": "Usuń lub zresetuj",
"description": "Usuń całe konto albo wyczyść dane zdrowotne, zachowując konto.",
@@ -7209,7 +7238,6 @@
"mcp": {
"enableTitle": "Zdalny punkt końcowy MCP",
"enableDescription": "Zezwól asystentowi obsługującemu MCP (Claude.ai, ChatGPT) na odczyt danych zdrowotnych za pomocą ograniczonego tokenu.",
- "enableToggleLabel": "Włącz punkt końcowy MCP",
"endpointLabel": "Adres URL łącznika",
"connectDescription": "Wklej ten adres URL w konfiguracji własnego łącznika asystenta, a następnie autoryzuj za pomocą poniższego tokenu lub zaloguj się po wyświetleniu monitu.",
"tokensTitle": "Tokeny łącznika",
@@ -7228,7 +7256,8 @@
"connectionRevokeFailed": "Nie udało się rozłączyć asystenta.",
"tokensDetail": "Token jest wyświetlany tylko raz.",
"connectionsDetail": "Cofnięcie połączenia natychmiast kończy dostęp, w tym możliwość odnawiania.",
- "tokenNamePlaceholder": "Nazwa, np. Claude Desktop"
+ "tokenNamePlaceholder": "Nazwa, np. Claude Desktop",
+ "offKeepsTokens": "Wyłączenie wstrzymuje punkt końcowy. Tokeny i połączenia zostają i działają ponownie po włączeniu."
},
"strava": "Strava",
"stravaDescription": "Połącz konto Strava, aby synchronizować aktywności z historią treningów.",
@@ -7490,7 +7519,25 @@
"passkeyAddedToast": "Dodano passkey.",
"passkeyRemovedToast": "Usunięto passkey.",
"integrationDisconnected": "Rozłączono {provider}.",
- "integrationDisconnectFailed": "Nie udało się rozłączyć {provider}."
+ "integrationDisconnectFailed": "Nie udało się rozłączyć {provider}.",
+ "preview": {
+ "topics": {
+ "title": "Tematy (beta)",
+ "description": "Dodaje osobną pozycję Tematy ze stronami przeglądowymi nad stronami poszczególnych pomiarów.",
+ "detail": "Strony analiz, nawigacja i linki pozostają dokładnie takie, jakie są. Strony tematów pojawią się w późniejszej aktualizacji; ten przełącznik decyduje, czy je zobaczysz.",
+ "savedOn": "Podgląd tematów włączony.",
+ "savedOff": "Podgląd tematów wyłączony."
+ },
+ "loadError": "Nie udało się wczytać ustawień podglądu.",
+ "feedbackLink": "Podziel się opinią w dyskusjach na GitHubie"
+ },
+ "timezoneSaved": "Strefa czasowa zapisana.",
+ "moduleState": {
+ "on": "Włączone",
+ "off": "Wyłączone",
+ "change": "Zmień w Modułach"
+ },
+ "timezoneUseDevice": "Użyj strefy czasowej tego urządzenia ({zone})"
},
"admin": {
"title": "Administracja",
@@ -8881,6 +8928,10 @@
"detailedToast": "Powiadomienia zawierają teraz wartości zdrowotne.",
"genericToast": "Powiadomienia nie zawierają już wartości zdrowotnych.",
"saveError": "Nie udało się zapisać. Spróbuj ponownie później."
+ },
+ "checkupReminders": {
+ "title": "Przypomnienia o badaniach kontrolnych",
+ "description": "Włączaj lub wyłączaj pojedyncze przypomnienia o badaniach."
}
},
"i18n": {
@@ -9657,8 +9708,6 @@
"description": "Wpisz wartość; przypomnienie zostanie oznaczone jako wykonane."
},
"manage": {
- "title": "Zarządzaj przypomnieniami",
- "description": "Włącz lub wyłącz poszczególne przypomnienia.",
"empty": "Brak przypomnień do dostosowania."
},
"dashboard": {
diff --git a/next.config.ts b/next.config.ts
index 0af3eb7aa..a844e2081 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -196,9 +196,16 @@ const nextConfig: NextConfig = {
destination: "/settings/layout/medications",
permanent: true,
},
+ // v1.44 — the mood page was tag management, which lives under
+ // Settings > Modules > Mood now; both older addresses go straight there.
{
source: "/settings/mood",
- destination: "/settings/layout/mood",
+ destination: "/settings/modules/mood",
+ permanent: true,
+ },
+ {
+ source: "/settings/layout/mood",
+ destination: "/settings/modules/mood",
permanent: true,
},
{
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 5e7dab8ef..254c052d1 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -367,6 +367,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/__tests__/ai-input-invalidation-guard.test.ts b/src/__tests__/ai-input-invalidation-guard.test.ts
index 6b890ceed..322dedbb3 100644
--- a/src/__tests__/ai-input-invalidation-guard.test.ts
+++ b/src/__tests__/ai-input-invalidation-guard.test.ts
@@ -104,8 +104,12 @@ describe("AI-input writes evict the capability answer", () => {
it("finds the write sites it is meant to hold (an empty sweep is a broken matcher)", () => {
const total = sites.reduce((n, s) => n + s.writes.length, 0);
- expect(sites.length).toBeGreaterThanOrEqual(21);
- expect(total).toBeGreaterThanOrEqual(22);
+ // v1.44 — three write sites left on purpose: the Coach's second switch
+ // and the MCP page's second switch (the one switch is in Modules), and
+ // the Coach overlay's copy of the provider and model selects (chosen in
+ // Settings > AI only).
+ expect(sites.length).toBeGreaterThanOrEqual(19);
+ expect(total).toBeGreaterThanOrEqual(19);
});
it.each(sites.map((s) => [s.rel, s] as const))(
diff --git a/src/__tests__/customize-shortcut-guard.test.ts b/src/__tests__/customize-shortcut-guard.test.ts
index 9e4f93341..4fe9624ea 100644
--- a/src/__tests__/customize-shortcut-guard.test.ts
+++ b/src/__tests__/customize-shortcut-guard.test.ts
@@ -39,8 +39,15 @@ import { describe, expect, it } from "vitest";
const SRC = join(process.cwd(), "src");
-/** The prefix every customise shortcut points at. */
-const LAYOUT_PREFIX = "/settings/layout/";
+/**
+ * The prefixes a customise shortcut points at: a module's order and view
+ * under Arrangement, or (since v1.44) its own page under Modules.
+ */
+const SHORTCUT_PREFIXES = ["/settings/layout/", "/settings/modules/"];
+
+function isShortcut(text: string): boolean {
+ return SHORTCUT_PREFIXES.some((prefix) => text.startsWith(prefix));
+}
/** The answer a shared-record surface withholds these on: `!inSharedRecord`. */
const GUARD = "inSharedRecord";
@@ -62,6 +69,8 @@ const NOT_A_SHARED_DESTINATION: Record = {
"The Insights page itself. Same flag, same reason.",
"components/insights/insights-edit-mode.tsx":
"The Insights arrange mode, reachable only from the page above.",
+ "components/settings/settings-anchor-forward.tsx":
+ "Not a shortcut: the table of old Settings anchors and where they moved, read on a Settings page, which the record gate already answers for.",
};
function allSourceFiles(): string[] {
@@ -127,7 +136,7 @@ function unguardedShortcuts(source: string, fileName: string): string[] {
const visit = (node: ts.Node): void => {
if (
ts.isStringLiteralLike(node) &&
- node.text.startsWith(LAYOUT_PREFIX) &&
+ isShortcut(node.text) &&
!guardedByOwnRecord(node)
) {
found.push(node.text);
@@ -140,7 +149,9 @@ function unguardedShortcuts(source: string, fileName: string): string[] {
describe("the customise shortcut is withheld inside somebody else's record", () => {
const carrying = allSourceFiles().filter((rel) =>
- readFileSync(join(SRC, rel), "utf8").includes(LAYOUT_PREFIX),
+ SHORTCUT_PREFIXES.some((prefix) =>
+ readFileSync(join(SRC, rel), "utf8").includes(prefix),
+ ),
);
it("finds the surfaces that carry one", () => {
diff --git a/src/__tests__/destructive-control-guard.test.ts b/src/__tests__/destructive-control-guard.test.ts
index e183eb18c..d95443811 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,19 +156,19 @@ 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",
destroys: "an MCP client grant or an MCP token",
recovery: "permanent",
- confirm: ["AlertDialog"],
+ confirm: ["ConfirmButton"],
},
{
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__/managed-record-navigation.test.tsx b/src/__tests__/managed-record-navigation.test.tsx
index 8653150cf..d6c1f14be 100644
--- a/src/__tests__/managed-record-navigation.test.tsx
+++ b/src/__tests__/managed-record-navigation.test.tsx
@@ -14,6 +14,7 @@ const recordRef: { value: { kind: "managed" | "shared"; level: string } } = {
vi.mock("next/navigation", () => ({
usePathname: () => pathnameRef.value,
+ useRouter: () => ({ replace: () => {}, push: () => {} }),
}));
vi.mock("@/hooks/use-auth", () => ({
@@ -112,7 +113,9 @@ describe("managed record navigation", () => {
expect(
html.match(new RegExp(`href="/settings/${section.slug}"`, "g"))
?.length ?? 0,
- ).toBe(2);
+ // The sidebar carries the link; the phone's section sheet mounts its
+ // list only when opened (v1.44).
+ ).toBe(1);
}
for (const section of unavailableDestinations) {
diff --git a/src/__tests__/resolved-field-consumer-guard.test.ts b/src/__tests__/resolved-field-consumer-guard.test.ts
index 397a6af7f..bd75538a8 100644
--- a/src/__tests__/resolved-field-consumer-guard.test.ts
+++ b/src/__tests__/resolved-field-consumer-guard.test.ts
@@ -142,8 +142,6 @@ const RULES: ResolvedFieldRule[] = [
'import { resolveAiCapability } from "@/lib/ai/capabilities/resolve";',
],
allow: {
- "src/components/settings/ai/disable-coach-card.tsx":
- "the Activate-Coach switch: it edits the stored opt-out, so its checked state is that value",
"src/hooks/use-module-toggle.ts":
"the module switch for the Coach (Settings hub and command palette): its checked state is the stored opt-out it writes",
"src/components/settings/managed-record-settings-section.tsx":
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/__tests__/settings-legacy-routes.test.ts b/src/__tests__/settings-legacy-routes.test.ts
new file mode 100644
index 000000000..ffba71623
--- /dev/null
+++ b/src/__tests__/settings-legacy-routes.test.ts
@@ -0,0 +1,191 @@
+/**
+ * Every Settings address a person ever had keeps working.
+ *
+ * Settings has been regrouped many times (v1.4 split, v1.17 layout hub,
+ * v1.25 Darstellung, v1.44 nine groups), and every bookmark, cross-link in
+ * an older notification and docs page still points at one of those shapes.
+ * This walks the full list of historical `/settings/*` addresses through the
+ * redirects in `next.config.ts` and asserts each one lands on a route that
+ * renders: a section slug, an Arrangement sub-page or a module page. It does
+ * the same for the anchors a page forwards client-side when a card moved
+ * while its page stayed (`settings-anchor-forward.tsx`).
+ *
+ * A route removed without a redirect, a redirect pointing at a page that no
+ * longer exists, or a forward whose target 404s fails here by name.
+ */
+import { describe, expect, it } from "vitest";
+
+import nextConfig from "../../next.config";
+import { SETTINGS_SECTION_SLUGS } from "@/components/settings/section-slugs";
+import { LAYOUT_GROUP_IDS } from "@/components/settings/layout-groups";
+import { MODULE_SETTINGS_PAGE_IDS } from "@/components/settings/module-settings-page-ids";
+import {
+ SETTINGS_ANCHOR_FORWARDS,
+ forwardedAnchorTarget,
+} from "@/components/settings/settings-anchor-forward";
+
+/** Every Settings path that was ever reachable, newest shapes included. */
+const HISTORICAL_SETTINGS_PATHS: readonly string[] = [
+ "/settings",
+ // Section slugs of every release since the v1.4 split.
+ "/settings/account",
+ "/settings/security",
+ "/settings/access",
+ "/settings/modules",
+ "/settings/integrations",
+ "/settings/sources",
+ "/settings/channels",
+ "/settings/notifications",
+ "/settings/reminders",
+ "/settings/layout",
+ "/settings/environment",
+ "/settings/anamnesis",
+ "/settings/score",
+ "/settings/thresholds",
+ "/settings/ai",
+ "/settings/coach",
+ "/settings/api",
+ "/settings/mcp",
+ "/settings/gesundheitsakte",
+ "/settings/sharing",
+ "/settings/export",
+ "/settings/advanced",
+ "/settings/privacy",
+ "/settings/about",
+ // The per-module personalisation slugs before the layout hub.
+ "/settings/dashboard",
+ "/settings/insights",
+ "/settings/medications",
+ "/settings/mood",
+ "/settings/labs",
+ "/settings/illness",
+ "/settings/vorsorge",
+ // Layout hub sub-pages since v1.25.11.
+ "/settings/layout/dashboard",
+ "/settings/layout/insights",
+ "/settings/layout/medications",
+ "/settings/layout/mood",
+ "/settings/layout/labs",
+ "/settings/layout/illness",
+ "/settings/layout/documents",
+ "/settings/layout/vorsorge",
+];
+
+interface Redirect {
+ source: string;
+ destination: string;
+}
+
+async function settingsRedirects(): Promise {
+ const all = (await nextConfig.redirects?.()) ?? [];
+ return all
+ .filter((r) => r.source.startsWith("/settings"))
+ .map((r) => ({ source: r.source, destination: r.destination }));
+}
+
+/** Follow exact-match redirects until none applies; the hash is dropped. */
+function resolve(path: string, redirects: readonly Redirect[]): string {
+ let current = path;
+ for (let hop = 0; hop < 5; hop += 1) {
+ const next = redirects.find((r) => r.source === current);
+ if (!next) return current;
+ current = next.destination.split("#")[0]!;
+ }
+ throw new Error(`redirect loop from ${path}`);
+}
+
+/** Does a path (no hash) render a Settings page? */
+function renders(path: string): boolean {
+ if (path === "/settings") return true; // the page redirects to account
+ const parts = path.split("/").filter(Boolean);
+ if (parts[0] !== "settings") return false;
+ if (parts.length === 2) {
+ return (SETTINGS_SECTION_SLUGS as readonly string[]).includes(parts[1]!);
+ }
+ if (parts.length === 3 && parts[1] === "layout") {
+ return LAYOUT_GROUP_IDS.includes(parts[2]!);
+ }
+ if (parts.length === 3 && parts[1] === "modules") {
+ return (MODULE_SETTINGS_PAGE_IDS as readonly string[]).includes(parts[2]!);
+ }
+ return false;
+}
+
+describe("historical Settings addresses", () => {
+ it("reads the redirects from next.config.ts (an empty read is a broken matcher)", async () => {
+ const redirects = await settingsRedirects();
+ expect(redirects.length).toBeGreaterThanOrEqual(10);
+ });
+
+ it.each(HISTORICAL_SETTINGS_PATHS)(
+ "%s still lands on a page",
+ async (path) => {
+ const redirects = await settingsRedirects();
+ const landed = resolve(path, redirects);
+ expect(renders(landed), `${path} → ${landed}`).toBe(true);
+ },
+ );
+
+ it("every redirect into Settings points at a page that renders", async () => {
+ for (const r of await settingsRedirects()) {
+ const target = r.destination.split("#")[0]!;
+ expect(renders(target), `${r.source} → ${r.destination}`).toBe(true);
+ }
+ });
+
+ it("every routable section, sub-page and module page is on the list", () => {
+ const routable = [
+ ...SETTINGS_SECTION_SLUGS.map((s) => `/settings/${s}`),
+ ...LAYOUT_GROUP_IDS.map((id) => `/settings/layout/${id}`),
+ ...MODULE_SETTINGS_PAGE_IDS.map((id) => `/settings/modules/${id}`),
+ ];
+ // New routes join the list when they ship, so a later move cannot drop
+ // one silently. Module pages are new in v1.44 and are allowed here.
+ const listed = new Set(HISTORICAL_SETTINGS_PATHS);
+ const unlisted = routable.filter(
+ (path) =>
+ !listed.has(path) &&
+ !path.startsWith("/settings/modules/") &&
+ path !== "/settings/preview",
+ );
+ expect(unlisted).toEqual([]);
+ });
+
+ it("keeps the v1.44 moves addressable", async () => {
+ const redirects = await settingsRedirects();
+ expect(resolve("/settings/layout/mood", redirects)).toBe(
+ "/settings/modules/mood",
+ );
+ expect(resolve("/settings/mood", redirects)).toBe("/settings/modules/mood");
+ });
+});
+
+describe("anchors forwarded after a card moved", () => {
+ const forwards = Object.entries(SETTINGS_ANCHOR_FORWARDS).flatMap(
+ ([page, anchors]) =>
+ Object.entries(anchors).map(([anchor, target]) => ({
+ page,
+ anchor,
+ target,
+ })),
+ );
+
+ it("has forwards to check", () => {
+ expect(forwards.length).toBeGreaterThanOrEqual(8);
+ });
+
+ it.each(forwards)(
+ "$page#$anchor forwards to a page that renders",
+ ({ page, anchor, target }) => {
+ expect(renders(page), page).toBe(true);
+ expect(forwardedAnchorTarget(page, `#${anchor}`)).toBe(target);
+ expect(renders(target.split("#")[0]!), target).toBe(true);
+ },
+ );
+
+ it("leaves an anchor that did not move alone", () => {
+ expect(forwardedAnchorTarget("/settings/privacy", "#retention")).toBeNull();
+ expect(forwardedAnchorTarget("/settings/security", "#sessions")).toBeNull();
+ expect(forwardedAnchorTarget("/settings/privacy", "")).toBeNull();
+ });
+});
diff --git a/src/__tests__/success-affordance-guard.test.ts b/src/__tests__/success-affordance-guard.test.ts
index a41563ed7..858a950ab 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,
@@ -383,13 +382,12 @@ const PINNED_AFFORDANCES: Record<
},
"src/components/settings/mcp-section.tsx": {
"text-success": 4,
- "toast.success": 2,
+ "toast.success": 1,
},
"src/components/settings/notification-status-card.tsx": {
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/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/app/labs/page.tsx b/src/app/labs/page.tsx
index 2fde494b0..74316b483 100644
--- a/src/app/labs/page.tsx
+++ b/src/app/labs/page.tsx
@@ -153,7 +153,7 @@ export default function LabsPage() {
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
>
diff --git a/src/app/mood/page-client.tsx b/src/app/mood/page-client.tsx
index a5cf60065..33f62f98c 100644
--- a/src/app/mood/page-client.tsx
+++ b/src/app/mood/page-client.tsx
@@ -150,7 +150,7 @@ export default function MoodPageClient() {
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
>
diff --git a/src/app/settings/[section]/page.tsx b/src/app/settings/[section]/page.tsx
index e4834c44b..c58be6aeb 100644
--- a/src/app/settings/[section]/page.tsx
+++ b/src/app/settings/[section]/page.tsx
@@ -44,7 +44,7 @@ export default async function SettingsSectionPage({ params }: PageProps) {
}
// Each section is its own chunk (see `SettingsSectionLoader`), so the route
- // ships the one it shows rather than all twenty-one. Integrations is the
+ // ships the one it shows rather than all of them. Integrations is the
// one section that needs a server-resolved value: the OAuth callback URL
// each provider registers, read from the runtime env here, per request,
// because a `NEXT_PUBLIC_*` read inside a client module is inlined at build
diff --git a/src/app/settings/layout/[module]/page.tsx b/src/app/settings/layout/[module]/page.tsx
index b0e82d825..a220b1265 100644
--- a/src/app/settings/layout/[module]/page.tsx
+++ b/src/app/settings/layout/[module]/page.tsx
@@ -9,6 +9,8 @@ import {
isLayoutGroupId,
} from "@/components/settings/layout-groups";
import { SettingsShell } from "@/components/settings/settings-shell";
+import { ModuleSettingsLinkCard } from "@/components/settings/module-settings-pages";
+import { isModuleSettingsPageId } from "@/components/settings/module-settings-page-ids";
import { SettingsHubBackLink } from "@/components/settings/settings-hub-back-link";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
import { getServerTranslator } from "@/lib/i18n/server-translator";
@@ -86,6 +88,11 @@ export default async function SettingsLayoutModulePage({ params }: PageProps) {
+ {/* v1.44 — what the module needs beyond order and view lives
+ on its page under Modules; say where. */}
+ {isModuleSettingsPageId(group.id) ? (
+
+ ) : null}
diff --git a/src/app/settings/modules/[module]/page.tsx b/src/app/settings/modules/[module]/page.tsx
new file mode 100644
index 000000000..9eb38daa9
--- /dev/null
+++ b/src/app/settings/modules/[module]/page.tsx
@@ -0,0 +1,72 @@
+import { notFound } from "next/navigation";
+
+import { LayoutModuleGate } from "@/components/settings/layout-module-gate";
+import { RecordSettingsSectionGate } from "@/components/settings/record-settings-section-gate";
+import { ModuleSettingsPageBody } from "@/components/settings/module-settings-pages";
+import {
+ MODULE_SETTINGS_PAGE_IDS,
+ isModuleSettingsPageId,
+ moduleSettingsPageKeys,
+ moduleSettingsPageModule,
+} from "@/components/settings/module-settings-page-ids";
+import { SettingsShell } from "@/components/settings/settings-shell";
+import { SettingsHubBackLink } from "@/components/settings/settings-hub-back-link";
+import { resolveServerLocale } from "@/lib/i18n/server-locale";
+import { getServerTranslator } from "@/lib/i18n/server-translator";
+
+/**
+ * A module's own settings: `/settings/modules/`.
+ *
+ * What a module needs beyond order and view (the labs biomarker catalog and
+ * on-device text recognition, the mood tags, the injection sites) lives here,
+ * under Settings > Modules, with a back link to the Modules list. Pre-rendered
+ * for the known ids; any other segment 404s at routing time.
+ */
+
+export const dynamicParams = false;
+
+export function generateStaticParams() {
+ return MODULE_SETTINGS_PAGE_IDS.map((module) => ({ module }));
+}
+
+interface PageProps {
+ params: Promise<{ module: string }>;
+}
+
+export default async function SettingsModulePage({ params }: PageProps) {
+ const { module } = await params;
+ if (!isModuleSettingsPageId(module)) notFound();
+
+ const locale = await resolveServerLocale();
+ const { t } = getServerTranslator(locale);
+ const { titleKey, descriptionKey } = moduleSettingsPageKeys(module);
+ const headingId = `settings-section-modules-${module}-title`;
+
+ return (
+
+ ),
+ }}
+ >
+ {/* The cards here lived under Arrangement until v1.44 and keep its
+ posture inside somebody else's record, so the gate is asked about
+ `layout`, not about the Modules switches. */}
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/insights/coach-panel/__tests__/coach-model-picker.test.tsx b/src/components/insights/coach-panel/__tests__/coach-model-picker.test.tsx
index d24b0fca0..be9b6a4f5 100644
--- a/src/components/insights/coach-panel/__tests__/coach-model-picker.test.tsx
+++ b/src/components/insights/coach-panel/__tests__/coach-model-picker.test.tsx
@@ -1,24 +1,8 @@
import { describe, expect, it } from "vitest";
-import type {
- ProviderChainData,
- UserAIProvider,
-} from "@/components/settings/ai/shared";
+import type { UserAIProvider } from "@/components/settings/ai/shared";
-import {
- chainWithFirst,
- modelControlFor,
- selectableProviders,
- switchDidNotTake,
-} from "../coach-model-picker";
-
-const CHAIN: ProviderChainData["configuredChain"] = [
- { providerType: "codex", enabled: true, available: false },
- { providerType: "openai", enabled: true, available: true },
- { providerType: "anthropic", enabled: false, available: true },
- { providerType: "local", enabled: true, available: true },
- { providerType: "admin-openai", enabled: true, available: true },
-];
+import { modelControlFor } from "../coach-model-picker";
function userProvider(over: Partial = {}): UserAIProvider {
return {
@@ -40,55 +24,6 @@ function userProvider(over: Partial = {}): UserAIProvider {
};
}
-describe("chainWithFirst", () => {
- it("moves the chosen provider to the front and keeps the rest in order", () => {
- expect(chainWithFirst(CHAIN, "local")).toEqual([
- { providerType: "local", priority: 1, enabled: true },
- { providerType: "codex", priority: 2, enabled: true },
- { providerType: "openai", priority: 3, enabled: true },
- { providerType: "anthropic", priority: 4, enabled: false },
- { providerType: "admin-openai", priority: 5, enabled: true },
- ]);
- });
-
- it("never sends a reasoning setting, so the server keeps the stored one", () => {
- for (const entry of chainWithFirst(CHAIN, "openai")) {
- expect(Object.keys(entry).sort()).toEqual([
- "enabled",
- "priority",
- "providerType",
- ]);
- }
- });
-});
-
-describe("selectableProviders", () => {
- it("offers only enabled providers that can answer", () => {
- expect(selectableProviders(CHAIN)).toEqual([
- "openai",
- "local",
- "admin-openai",
- ]);
- });
-});
-
-describe("switchDidNotTake", () => {
- it("is quiet when the chosen provider leads the chain the server resolves", () => {
- expect(switchDidNotTake("openai", { activeProvider: "openai" })).toBe(
- false,
- );
- });
-
- it("speaks up when the server still answers with another provider", () => {
- // A provider without a working key was saved to the front of the chain;
- // the resolver skipped it and the select would jump back silently.
- expect(switchDidNotTake("anthropic", { activeProvider: "openai" })).toBe(
- true,
- );
- expect(switchDidNotTake("anthropic", { activeProvider: null })).toBe(true);
- });
-});
-
describe("modelControlFor", () => {
it("offers the presets for the provider whose key the person saved", () => {
const control = modelControlFor("openai", userProvider());
diff --git a/src/components/insights/coach-panel/__tests__/coach-settings-overlay.test.tsx b/src/components/insights/coach-panel/__tests__/coach-settings-overlay.test.tsx
index 726f4b5d3..dfd13f529 100644
--- a/src/components/insights/coach-panel/__tests__/coach-settings-overlay.test.tsx
+++ b/src/components/insights/coach-panel/__tests__/coach-settings-overlay.test.tsx
@@ -70,41 +70,32 @@ describe("", () => {
expect(html).toMatch(/href="\/settings\/ai"[^>]*>All AI settings);
});
- it("offers only the providers that can answer, with the active one selected", () => {
+ it("shows the provider and model in effect and links to the one place they are chosen", () => {
const html = render();
- const select = html.match(
- /
)}
- setConfirmOpen(true)}
+ variant="destructive"
+ size="sm"
+ disabled={deleting}
+ className="min-h-11 w-full shrink-0 sm:min-h-9 sm:w-auto"
+ >
+ {deleting ? (
+
+ ) : (
+
+ )}
+ {t("settings.dangerZone")}
+
+ {
- // Hold the dialog open while the destructive mutation is in
- // flight so the in-dialog pending state stays visible and a
- // stray backdrop tap can't dismiss it mid-request.
+ // Hold the dialog open while the request runs so the pending
+ // state stays on screen and a stray dismissal cannot fire it twice.
if (deleting) return;
setConfirmOpen(open);
}}
- >
-
-
-
-
-
-
- {t("settings.dangerZoneConfirm")}
-
-
- {t("settings.dangerZoneConfirmDescription")}
-
-
-
-
- {t("common.cancel")}
-
- {
- e.preventDefault();
- void handleDeleteAllData();
- }}
- disabled={deleting}
- aria-busy={deleting || undefined}
- data-slot="settings-data-reset-confirm"
- >
- {deleting && (
-
- )}
- {t("settings.finalDelete")}
-
-
-
-
+ title={t("settings.dangerZoneConfirm")}
+ body={t("settings.dangerZoneConfirmDescription")}
+ confirmLabel={t("settings.finalDelete")}
+ onConfirm={() => void handleDeleteAllData()}
+ pending={deleting}
+ slot="settings-data-reset"
+ holdOpen
+ destructive
+ />
);
@@ -291,63 +257,39 @@ function AccountDeleteCard() {
)}
- setConfirmOpen(true)}
+ variant="destructive"
+ size="sm"
+ disabled={deleting}
+ className="min-h-11 w-full shrink-0 sm:min-h-9 sm:w-auto"
+ data-slot="settings-account-delete-trigger"
+ >
+ {deleting ? (
+
+ ) : (
+
+ )}
+ {t("settings.deleteAccountCta")}
+
+ {
- // Hold the dialog open while the irreversible delete runs so
- // the pending state stays on screen and a stray dismissal
- // can't fire a second request.
+ // Hold the dialog open while the request runs so the pending
+ // state stays on screen and a stray dismissal cannot fire it twice.
if (deleting) return;
setConfirmOpen(open);
}}
- >
-
-
-
-
-
-
- {t("settings.deleteAccountConfirmTitle")}
-
-
- {t("settings.deleteAccountConfirmDescription")}
-
-
-
-
- {t("common.cancel")}
-
- {
- e.preventDefault();
- void handleDeleteAccount();
- }}
- disabled={deleting}
- aria-busy={deleting || undefined}
- data-slot="settings-account-delete-confirm"
- >
- {deleting && (
-
- )}
- {t("settings.deleteAccountFinal")}
-
-
-
-
+ title={t("settings.deleteAccountConfirmTitle")}
+ body={t("settings.deleteAccountConfirmDescription")}
+ confirmLabel={t("settings.deleteAccountFinal")}
+ onConfirm={() => void handleDeleteAccount()}
+ pending={deleting}
+ slot="settings-account-delete"
+ holdOpen
+ destructive
+ />
);
diff --git a/src/components/settings/ai/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/ai/fallback-chain-card.tsx b/src/components/settings/ai/fallback-chain-card.tsx
index c563d943a..1c6631ec0 100644
--- a/src/components/settings/ai/fallback-chain-card.tsx
+++ b/src/components/settings/ai/fallback-chain-card.tsx
@@ -18,18 +18,8 @@ import {
X,
} from "lucide-react";
-import {
- AlertDialog,
- AlertDialogAction,
- AlertDialogCancel,
- AlertDialogContent,
- AlertDialogDescription,
- AlertDialogFooter,
- AlertDialogHeader,
- AlertDialogTitle,
- AlertDialogTrigger,
-} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
+import { ConfirmButton } from "@/components/ui/confirm-button";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { SettingsCardHeader } from "@/components/settings/_card-header";
@@ -246,30 +236,17 @@ export function FallbackChainCard({
)}
-
-
-
-
-
-
-
- {t("settings.ai.providerChain.resetConfirmTitle")}
-
-
- {t("settings.ai.providerChain.resetConfirmBody")}
-
-
-
- {t("common.cancel")}
-
- {t("settings.ai.providerChain.resetDefaults")}
-
-
-
-
+ void resetToDefaults()}
+ size="sm"
+ className="min-h-11 sm:min-h-9"
+ icon={}
+ destructive
+ />
+ {/* 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). */}
+
+
);
@@ -690,39 +660,17 @@ function ApiTokensCard() {
? formatDateTime(tok.lastUsedAt)
: t("settings.tokenNeverUsed")}
-
-
-
-
-
-
-
- {t("settings.tokenRevoke")}
-
-
- {t("settings.tokenRevokeDescription")}
-
-
-
-
- {t("common.cancel")}
-
- handleRevoke(tok.id)}
- >
- {t("settings.tokenRevokeAction")}
-
-
-
-
+ handleRevoke(tok.id)}
+ size="sm"
+ className="min-h-11 w-full"
+ icon={}
+ destructive
+ />
);
})}
diff --git a/src/components/settings/channel-form-dirty.ts b/src/components/settings/channel-form-dirty.ts
index 24fd4d928..cc2a77210 100644
--- a/src/components/settings/channel-form-dirty.ts
+++ b/src/components/settings/channel-form-dirty.ts
@@ -7,16 +7,29 @@
* note left over from an earlier save would say the opposite of the truth.
* A secret field (header value, auth token, bot token) is write-only: the
* server never returns it, so any typed value counts as a change.
+ *
+ * The enable switch is a field of the same form: it saves with the one Save
+ * button like every other field, so a flipped switch is a change too.
*/
+function enabledChanged(
+ form: { enabled: boolean },
+ saved: { enabled?: boolean },
+): boolean {
+ return form.enabled !== (saved.enabled ?? false);
+}
+
export function isWebhookFormDirty(
form: {
url: string;
headerName: string;
headerValue: string;
format: "generic" | "gotify";
+ enabled: boolean;
},
- saved: { url: string; headerName: string; format?: string } | undefined,
+ saved:
+ | { url: string; headerName: string; format?: string; enabled?: boolean }
+ | undefined,
): boolean {
if (!saved) return false;
const savedFormat = saved.format === "gotify" ? "gotify" : "generic";
@@ -24,34 +37,45 @@ export function isWebhookFormDirty(
form.url !== saved.url ||
form.headerName !== saved.headerName ||
form.headerValue !== "" ||
- form.format !== savedFormat
+ form.format !== savedFormat ||
+ enabledChanged(form, saved)
);
}
export function isNtfyFormDirty(
- form: { serverUrl: string; topic: string; authToken: string },
- saved: { serverUrl: string; topic: string } | undefined,
+ form: {
+ serverUrl: string;
+ topic: string;
+ authToken: string;
+ enabled: boolean;
+ },
+ saved: { serverUrl: string; topic: string; enabled?: boolean } | undefined,
): boolean {
if (!saved) return false;
return (
form.serverUrl !== saved.serverUrl ||
form.topic !== saved.topic ||
- form.authToken !== ""
+ form.authToken !== "" ||
+ enabledChanged(form, saved)
);
}
export function isEmailFormDirty(
- form: { recipient: string },
- saved: { recipient: string } | undefined,
+ form: { recipient: string; enabled: boolean },
+ saved: { recipient: string; enabled?: boolean } | undefined,
): boolean {
if (!saved) return false;
- return form.recipient !== saved.recipient;
+ return form.recipient !== saved.recipient || enabledChanged(form, saved);
}
export function isTelegramFormDirty(
- form: { botToken: string; chatId: string },
- saved: { chatId?: string | null } | undefined,
+ form: { botToken: string; chatId: string; enabled: boolean },
+ saved: { chatId?: string | null; enabled?: boolean } | undefined,
): boolean {
if (!saved) return false;
- return form.botToken.trim() !== "" || form.chatId !== (saved.chatId ?? "");
+ return (
+ form.botToken.trim() !== "" ||
+ form.chatId !== (saved.chatId ?? "") ||
+ enabledChanged(form, saved)
+ );
}
diff --git a/src/components/settings/coach-memory-section.tsx b/src/components/settings/coach-memory-section.tsx
index c5fa86302..9a991caca 100644
--- a/src/components/settings/coach-memory-section.tsx
+++ b/src/components/settings/coach-memory-section.tsx
@@ -34,17 +34,6 @@ import { useMemo, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Brain, Loader2, Pencil, Trash2 } 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 { QueryErrorRow } from "@/components/ui/query-error-row";
import { SettingsCardActions } from "@/components/settings/_card-actions";
@@ -375,54 +364,20 @@ export function CoachMemorySection({
{facts.length > 0 ? (
-
-
-
-
-
-
-
- {t("settings.ai.coachMemory.forgetAllConfirmTitle")}
-
-
- {t("settings.ai.coachMemory.forgetAllConfirmBody")}
-
-
-
-
- {t("settings.ai.coachMemory.cancel")}
-
- forgetAll.mutate()}
- >
- {forgetAll.isPending && (
-
- )}
- {t("settings.ai.coachMemory.forgetAllConfirmAction")}
-
-
-
-
+ forgetAll.mutate()}
+ pending={forgetAll.isPending}
+ disabled={!isAuthenticated}
+ size="sm"
+ className="min-h-11 sm:min-h-9"
+ icon={}
+ testId="settings-coach-memory-forget-all"
+ destructive
+ />
) : null}
diff --git a/src/components/settings/coach-section.tsx b/src/components/settings/coach-section.tsx
index bb4d07090..a63782c75 100644
--- a/src/components/settings/coach-section.tsx
+++ b/src/components/settings/coach-section.tsx
@@ -7,13 +7,14 @@
* their own dedicated entry so the AI section owns only provider / model /
* BYOK configuration. This section gathers the Coach-specific surface:
*
- * 1. Disable Coach — the per-user opt-out (kept first so a user who
- * wants the surface gone finds it immediately).
+ * 1. Coach state — on or off, with a link to the one switch in
+ * Settings > 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 && (
-