Merge branch 'feat/v144-settings' into release/v1.44.0

# Conflicts:
#	src/components/settings/privacy-section.tsx
This commit is contained in:
Marc-André Bombeck committed 2026-10-21 20:51:39 +02:00
commit e1b70537cc
129 files changed
+5138 -3169

No files matched your search

+256
View File
@@ -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:
+4 -2
View File
@@ -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();
+2 -18
View File
@@ -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<number> {
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<void> {
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",
+6 -2
View File
@@ -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. */
+47
View File
@@ -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);
});
});
+1 -1
View File
@@ -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",
+2 -2
View File
@@ -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
+107 -58
View File
@@ -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.",
+86 -37
View File
@@ -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": {
+85 -36
View File
@@ -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": {
+83 -34
View File
@@ -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": {
+85 -36
View File
@@ -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": {
+85 -36
View File
@@ -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": {
+83 -34
View File
@@ -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": {
+84 -35
View File
@@ -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": {
+8 -1
View File
@@ -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,
},
{
@@ -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;
+13
View File
@@ -367,6 +367,19 @@ model User {
// Shape: { "groupOrder": ["<categoryKey>", ...],
// "placements": { "<categoryKey>": ["<tagKey>", ...] } }
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": ["<id>"], "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.
@@ -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))(
+15 -4
View File
@@ -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<string, string> = {
"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", () => {
@@ -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",
@@ -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) {
@@ -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":
@@ -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<string, unknown>)[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([]);
});
});
@@ -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<Redirect[]> {
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();
});
});
@@ -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
@@ -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<unknown> {
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);
});
@@ -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<unknown> {
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);
});
+1 -1
View File
@@ -153,7 +153,7 @@ export default function LabsPage() {
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
>
<Link
href="/settings/layout/labs"
href="/settings/modules/labs"
aria-label={t("labs.customize")}
title={t("labs.customize")}
>
+1 -1
View File
@@ -150,7 +150,7 @@ export default function MoodPageClient() {
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
>
<Link
href="/settings/layout/mood"
href="/settings/modules/mood"
aria-label={t("mood.customize")}
title={t("mood.customize")}
>
+1 -1
View File
@@ -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
@@ -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) {
<section aria-labelledby={headingId} className="space-y-6">
<LayoutModuleGate moduleKey={layoutGroupModule(group.id)}>
<Body />
{/* v1.44 — what the module needs beyond order and view lives
on its page under Modules; say where. */}
{isModuleSettingsPageId(group.id) ? (
<ModuleSettingsLinkCard id={group.id} />
) : null}
</LayoutModuleGate>
</section>
</RecordSettingsSectionGate>
@@ -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/<id>`.
*
* 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 (
<SettingsShell
active="modules"
heading={{
title: t(titleKey),
subtitle: t(descriptionKey),
headingId,
topSlot: (
<SettingsHubBackLink
href="/settings/modules"
labelKey="settings.sections.modules.backToHub"
/>
),
}}
>
{/* 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. */}
<RecordSettingsSectionGate section="layout">
<section aria-labelledby={headingId} className="space-y-6">
<LayoutModuleGate moduleKey={moduleSettingsPageModule(module)}>
<ModuleSettingsPageBody id={module} />
</LayoutModuleGate>
</section>
</RecordSettingsSectionGate>
</SettingsShell>
);
}
@@ -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> = {}): UserAIProvider {
return {
@@ -40,55 +24,6 @@ function userProvider(over: Partial<UserAIProvider> = {}): 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());
@@ -70,41 +70,32 @@ describe("<CoachSettingsBody>", () => {
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(<CoachSettingsBody />);
const select = html.match(
/<select[^>]*data-slot="coach-quick-provider"[\s\S]*?<\/select>/,
)?.[0];
expect(select).toBeDefined();
expect(select).toContain('value="local"');
expect(select).toContain('value="admin-openai"');
expect(select).not.toContain('value="codex"');
expect(html).toMatch(
/data-slot="coach-quick-provider"[^>]*data-value="local"/,
);
expect(html).toMatch(
/data-slot="coach-quick-model"[^>]*data-value="qwen2.5"/,
);
expect(html).toContain('href="/settings/ai#ai-provider"');
// v1.44 — no second copy of the selects here.
expect(html).not.toMatch(/<select[^>]*data-slot="coach-quick-provider"/);
expect(html).not.toMatch(/<select[^>]*data-slot="coach-quick-model"/);
expect(html).toContain('id="coach-quick-reasoning"');
// Keys and base URLs are not here.
expect(html).not.toContain("http://localhost:11434/v1");
expect(html).not.toMatch(/type="password"/);
});
it("shows the operator's central Codex as the active provider when it answers", () => {
// `admin-codex` is never a chain entry, so no option matched it and the
// select showed its first option as the one answering.
it("names the operator's central Codex when it answers", () => {
const html = render(<CoachSettingsBody />, {
...CHAIN,
activeProvider: "admin-codex" as ProviderChainData["activeProvider"],
});
const select = html.match(
/<select[^>]*data-slot="coach-quick-provider"[\s\S]*?<\/select>/,
)?.[0];
expect(select).toMatch(
/<option value="admin-codex" disabled="" selected="">ChatGPT \(shared by the operator\)<\/option>/,
expect(html).toMatch(
/data-slot="coach-quick-provider"[^>]*data-value="admin-codex"[^>]*>ChatGPT \(shared by the operator\)</,
);
expect(select).not.toMatch(/value="local"[^>]*selected/);
expect(html).toContain('data-slot="coach-quick-provider-operator"');
});
it("shows the model and reasoning controls the active provider owns", () => {
const html = render(<CoachSettingsBody />);
expect(html).toContain('data-slot="coach-quick-model"');
expect(html).toContain('id="coach-quick-reasoning"');
// Keys, base URLs and the chain order are not here.
expect(html).not.toContain("http://localhost:11434/v1");
expect(html).not.toMatch(/type="password"/);
});
it("locks the reasoning select while a change is saving", async () => {
@@ -1,13 +1,9 @@
"use client";
import { useState } from "react";
import { Loader2 } from "lucide-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import Link from "next/link";
import { ChevronRight, Loader2 } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { NativeSelect } from "@/components/ui/native-select";
import {
ANTHROPIC_MODEL_PRESETS,
LOCAL_MODEL_PRESETS,
@@ -17,85 +13,23 @@ import {
type ProviderType,
type UserAIProvider,
} from "@/components/settings/ai/shared";
import { apiGet, apiPatch, apiPut } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import {
aiInputDependentKeys,
invalidateKeys,
queryKeys,
} from "@/lib/query-keys";
import { InlineAction } from "@/components/ui/inline-action";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { apiGet } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
/**
* The quick model choice in the Coach's settings overlay: which provider
* answers and which model it uses. How hard the Coach thinks is the Coach's
* own setting since v1.41 (`coach-reasoning-field.tsx`); the provider's
* reasoning select stays in Settings → AI for everything else.
* Which provider answers the Coach and which model it uses, shown in the
* Coach's settings overlay with a link to where it is chosen.
*
* It is a shortcut onto the same settings Settings → AI writes, never a second
* copy of them. Keys, base URLs, the Codex sign-in and the order of the
* fallback chain stay there; this reads the same two queries and writes
* through the same two routes:
* - Provider: `PUT /api/insights/provider-chain` with the chosen entry moved
* to the front and every other entry in its current order. The entries'
* reasoning settings are not sent, so the server keeps what it stored.
* - Model: `PATCH /api/user/ai-provider`, only where a person
* owns the setting (their own key's provider, or their gateway). The
* operator's shared provider and Codex show their state read-only.
* Every write invalidates the AI input keys, so the capability on
* `/api/auth/me` comes back from the server rather than being guessed here.
* The model is chosen in one place, Settings > AI. Until v1.44 this overlay
* carried a second copy of the provider and model selects, writing through
* the same routes; two controls for one setting meant a change in one place
* surprised the person in the other. The overlay now reads the same two
* queries and says what is in effect.
*/
/** The configured chain with `provider` first; everything else keeps order. */
export function chainWithFirst(
chain: ProviderChainData["configuredChain"],
provider: ProviderType,
): { providerType: ProviderType; priority: number; enabled: boolean }[] {
const rest = chain.filter((entry) => entry.providerType !== provider);
const ordered = [
{ providerType: provider, enabled: true },
...rest.map((entry) => ({
providerType: entry.providerType,
enabled: entry.enabled,
})),
];
return ordered.map((entry, idx) => ({ ...entry, priority: idx + 1 }));
}
/** The providers a person can switch to: enabled and able to answer. */
export function selectableProviders(
chain: ProviderChainData["configuredChain"],
): ProviderType[] {
return chain
.filter((entry) => entry.enabled && entry.available)
.map((entry) => entry.providerType);
}
/**
* True when a saved switch did not take: the chain the server resolves after
* the save is not led by the provider the person chose. A provider whose key
* stops working between the read and the save lands here.
*/
export function switchDidNotTake(
chosen: ProviderType,
fresh: Pick<ProviderChainData, "activeProvider">,
): boolean {
return fresh.activeProvider !== chosen;
}
/**
* The provider the server answers with when the select has no option for it:
* the operator's central Codex (never a chain entry), or an entry that answers
* without being offered. Shown as a disabled option so the select tells the
* truth instead of showing its first option as active.
*/
export function activeOutsideOptions(
active: string | null,
options: readonly string[],
): string | null {
return active !== null && !options.includes(active) ? active : null;
}
export type ModelControl =
| { kind: "preset"; presets: readonly string[]; value: string | null }
| { kind: "gateway"; value: string | null }
@@ -130,7 +64,6 @@ export function modelControlFor(
export function CoachModelPicker() {
const { t } = useTranslations();
const queryClient = useQueryClient();
const chainQuery = useQuery({
queryKey: queryKeys.insightsProviderChain(),
@@ -141,59 +74,6 @@ export function CoachModelPicker() {
queryFn: () => apiGet<UserAIProvider>("/api/user/ai-provider"),
});
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const onSaved = () => {
setError(null);
void invalidateKeys(queryClient, aiInputDependentKeys);
};
const onFailed = (e: unknown) =>
setError(
e instanceof Error && e.message
? e.message
: t("insights.coach.frame.saveFailed"),
);
const switchProvider = useMutation({
mutationFn: async (provider: ProviderType) => {
setNotice(null);
await apiPut("/api/insights/provider-chain", {
chain: chainWithFirst(chainQuery.data?.configuredChain ?? [], provider),
});
// Read back what the server now resolves, so a switch that did not
// take is said rather than shown as the select jumping back.
return apiGet<ProviderChainData>("/api/insights/provider-chain");
},
onSuccess: (fresh, provider) => {
queryClient.setQueryData(queryKeys.insightsProviderChain(), fresh);
onSaved();
if (switchDidNotTake(provider, fresh)) {
setNotice(
t("insights.coach.frame.providerNotActive", {
provider: t(`settings.ai.providerChain.types.${provider}`),
}),
);
}
},
onError: onFailed,
});
const patchProvider = useMutation({
mutationFn: (body: Record<string, unknown>) =>
apiPatch("/api/user/ai-provider", body),
onSuccess: onSaved,
onError: onFailed,
});
const chain = chainQuery.data;
const userProvider = providerQuery.data;
const active = chain?.activeProvider ?? null;
const options = chain ? selectableProviders(chain.configuredChain) : [];
const model = modelControlFor(active, userProvider);
const busy = switchProvider.isPending || patchProvider.isPending;
// The server may answer with `admin-codex`, which the UI's type omits.
const unlisted = activeOutsideOptions(active as string | null, options);
if (chainQuery.isError || providerQuery.isError) {
return (
<QueryErrorRow
@@ -208,6 +88,8 @@ export function CoachModelPicker() {
);
}
const chain = chainQuery.data;
const userProvider = providerQuery.data;
if (!chain || !userProvider) {
return (
<p
@@ -223,159 +105,45 @@ export function CoachModelPicker() {
);
}
// The server may answer with `admin-codex`, which the UI's type omits.
const active = (chain.activeProvider ?? null) as string | null;
const model = modelControlFor(chain.activeProvider ?? null, userProvider);
const providerLabel =
active === null
? t("insights.coach.frame.noProvider")
: active === "admin-codex"
? t("insights.coach.frame.operatorCodex")
: t(`settings.ai.providerChain.types.${active}`);
const modelLabel =
model.kind === "managed"
? t("insights.coach.frame.modelManaged")
: (model.value ?? t("settings.ai.modelOptionDefault"));
return (
<div data-slot="coach-model-picker" className="flex flex-col gap-4">
<div>
<Label htmlFor="coach-quick-provider" noColon>
<div data-slot="coach-model-picker" className="flex flex-col gap-3">
<dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-2 text-sm">
<dt className="text-muted-foreground">
{t("insights.coach.frame.provider")}
</Label>
<NativeSelect
id="coach-quick-provider"
data-slot="coach-quick-provider"
className="mt-1"
value={active ?? ""}
disabled={busy || options.length === 0}
onChange={(e) => {
const next = e.target.value as ProviderType;
if (next && next !== active) switchProvider.mutate(next);
}}
</dt>
<dd data-slot="coach-quick-provider" data-value={active ?? ""}>
{providerLabel}
</dd>
<dt className="text-muted-foreground">{t("settings.ai.modelLabel")}</dt>
<dd
data-slot="coach-quick-model"
data-value={model.kind === "managed" ? "" : (model.value ?? "")}
>
{active === null ? (
<option value="" disabled>
{t("insights.coach.frame.noProvider")}
</option>
) : null}
{unlisted ? (
<option value={unlisted} disabled>
{unlisted === "admin-codex"
? t("insights.coach.frame.operatorCodex")
: t(`settings.ai.providerChain.types.${unlisted}`)}
</option>
) : null}
{options.map((p) => (
<option key={p} value={p}>
{t(`settings.ai.providerChain.types.${p}`)}
</option>
))}
</NativeSelect>
{unlisted === "admin-codex" ? (
<p
data-slot="coach-quick-provider-operator"
className="text-muted-foreground mt-1 text-xs"
>
{t("insights.coach.frame.operatorManaged")}
</p>
) : null}
</div>
{model.kind === "preset" ? (
<div>
<Label htmlFor="coach-quick-model" noColon>
{t("settings.ai.modelLabel")}
</Label>
<NativeSelect
id="coach-quick-model"
data-slot="coach-quick-model"
className="mt-1"
value={model.value ?? ""}
disabled={busy}
onChange={(e) =>
patchProvider.mutate({ model: e.target.value || null })
}
>
<option value="">{t("settings.ai.modelOptionDefault")}</option>
{model.value && !model.presets.includes(model.value) ? (
<option value={model.value}>{model.value}</option>
) : null}
{model.presets.map((m) => (
<option key={m} value={m}>
{m}
</option>
))}
</NativeSelect>
</div>
) : model.kind === "gateway" ? (
<GatewayModelField
key={model.value ?? ""}
value={model.value}
disabled={busy}
onSave={(next) => patchProvider.mutate({ compatModel: next })}
/>
) : (
<p
data-slot="coach-quick-model-managed"
className="text-muted-foreground text-xs"
>
{t("insights.coach.frame.modelManaged")}
</p>
)}
{busy ? (
<p role="status" className="text-muted-foreground text-xs">
{t("insights.coach.frame.saving")}
</p>
) : null}
{notice ? (
<p
role="status"
data-slot="coach-quick-provider-notice"
className="text-muted-foreground text-xs"
>
{notice}
</p>
) : null}
{error ? (
<p role="alert" className="text-destructive text-xs">
{error}
</p>
) : null}
{modelLabel}
</dd>
</dl>
<p>
<InlineAction asChild tone="muted">
<Link href="/settings/ai#ai-provider" data-slot="coach-quick-change">
{t("insights.coach.frame.changeModel")}
<ChevronRight className="size-3.5" aria-hidden="true" />
</Link>
</InlineAction>
</p>
</div>
);
}
/** A gateway names its own models, so its model is free text with a save. */
function GatewayModelField({
value,
disabled,
onSave,
}: {
value: string | null;
disabled: boolean;
onSave: (next: string | null) => void;
}) {
const { t } = useTranslations();
const [draft, setDraft] = useState(value ?? "");
const changed = draft.trim() !== (value ?? "");
return (
<form
className="flex flex-col gap-1"
onSubmit={(e) => {
e.preventDefault();
if (changed) onSave(draft.trim() || null);
}}
>
<Label htmlFor="coach-quick-gateway-model" noColon>
{t("settings.ai.modelLabel")}
</Label>
<div className="flex items-center gap-2">
<Input
id="coach-quick-gateway-model"
data-slot="coach-quick-gateway-model"
value={draft}
onChange={(e) => setDraft(e.target.value)}
disabled={disabled}
className="min-w-0 flex-1"
/>
<Button
type="submit"
size="sm"
variant="outline"
disabled={disabled || !changed}
className="min-h-11 sm:min-h-9"
>
{t("settings.ai.saveCta")}
</Button>
</div>
</form>
);
}
+22 -16
View File
@@ -1,17 +1,16 @@
"use client";
/**
* v1.18.6 — the Labs module's own settings page body.
* The Labs module's settings, in two places since v1.44:
*
* Reached from the wrench beside the page's "hinzufügen" button. Lets the
* user:
* - pick the card-vs-list page view (MOD-03),
* - choose the sort order (by most-recent reading, descending / ascending,
* or manual) — MOD-04,
* - reorder biomarkers when sort = manual (MOD-03/04),
* - and create / edit / delete biomarkers via the existing
* `BiomarkerManager` (MOD-04).
* View / sort / order persist client-side via `useModuleListPrefs("labs")`.
* `LabsSettings` — Settings > Arrangement > Labs: the card-vs-list view
* (MOD-03), the sort (recency, alphabetical or manual, MOD-04) and the manual
* order of the biomarkers. Order and view only, persisted per account through
* `useModuleListPrefs("labs")`.
*
* `LabsModuleSettings` — Settings > Modules > Labs: the on-device text
* recognition opt-in and the biomarker catalog (`BiomarkerManager`). Reached
* from the wrench on the labs page.
*/
import { useQuery } from "@tanstack/react-query";
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
@@ -52,12 +51,6 @@ export function LabsSettings() {
const { t } = useTranslations();
const { prefs, setView, setOrder, setSortDir } = useModuleListPrefs("labs");
// v1.18.10 — local (in-browser) OCR opt-in. Lets a user whose AI provider
// can't read images still scan a report: the image is OCR'd on-device and
// only the extracted text is sent to the text-only provider.
const localOcr = useLabsLocalOcr();
const updateLocalOcr = useUpdateLabsLocalOcr();
const { data, isLoading } = useQuery({
queryKey: queryKeys.biomarkers(),
queryFn: () => apiGet<BiomarkerListResponse>("/api/biomarkers"),
@@ -130,7 +123,20 @@ export function LabsSettings() {
)}
</SettingsCard>
) : null}
</div>
);
}
export function LabsModuleSettings() {
const { t } = useTranslations();
// v1.18.10 — local (in-browser) OCR opt-in. Lets a user whose AI provider
// can't read images still scan a report: the image is OCR'd on-device and
// only the extracted text is sent to the text-only provider.
const localOcr = useLabsLocalOcr();
const updateLocalOcr = useUpdateLabsLocalOcr();
return (
<div className="space-y-6">
{/* v1.18.10 — local (in-browser) OCR opt-in for lab scans. */}
<SettingsCard id="labs-local-ocr" className="scroll-mt-28">
<SettingsCardHeader
@@ -1,13 +1,14 @@
"use client";
/**
* v1.18.6 — the Vorsorge module's own settings page body.
* The checkups list in Settings, in two parts since v1.44:
*
* Reached from the wrench beside the page's "hinzufügen" button. Lets the
* user reorder reminders + pick the card-vs-list page view, and re-homes the
* per-reminder enable/disable toggles that used to live in the page's
* "Anpassen" sheet. Order + view persist client-side via
* `useModuleListPrefs("vorsorge")`; the toggles persist through the existing
* `VorsorgeSettings` — Settings > Arrangement > Checkups: the card-vs-list
* view and the manual order, persisted per account through
* `useModuleListPrefs("vorsorge")`.
*
* `VorsorgeReminderSwitchesCard` — the per-reminder on/off switches, under
* Settings > Notifications with the other reminders, persisted through the
* reminder PATCH route.
*/
import { ArrowUpDown, LayoutGrid, ListChecks } from "lucide-react";
@@ -47,7 +48,6 @@ function resolveLabel(
export function VorsorgeSettings() {
const { t } = useTranslations();
const { data: reminders, isLoading } = useMeasurementReminders(true);
const { update } = useMeasurementReminderMutations();
const { prefs, setView, setOrder } = useModuleListPrefs("vorsorge");
const ordered = applyOrder(reminders ?? [], prefs.order, (r) => r.id);
@@ -59,7 +59,7 @@ export function VorsorgeSettings() {
: t("measurementReminders.selfPlanned"),
}));
// v1.18.10 — the three Vorsorge settings groups adopt the shared
// v1.18.10 — the Vorsorge settings groups adopt the shared
// `SettingsCard` + `SettingsCardHeader` contract (rounded-xl bordered
// surface, neutral muted icon, `text-lg` title, short muted description)
// so this surface reads like every other Settings section instead of bare
@@ -96,67 +96,80 @@ export function VorsorgeSettings() {
)}
</div>
</SettingsCard>
<SettingsCard>
<SettingsCardHeader
icon={ListChecks}
title={t("measurementReminders.manage.title")}
description={t("measurementReminders.manage.description")}
/>
<div>
{isLoading ? (
<Skeleton className="h-24 w-full" />
) : (reminders?.length ?? 0) === 0 ? (
<EmptyState
variant="plain"
size="compact"
title={t("measurementReminders.manage.empty")}
/>
) : (
<Card>
<CardContent className="divide-border divide-y p-0">
{ordered.map((reminder) => (
<div
key={reminder.id}
className="flex items-center justify-between gap-3 px-4 py-3"
>
<span
id={`vorsorge-reminder-label-${reminder.id}`}
className="min-w-0 truncate text-sm"
>
{resolveLabel(reminder, t)}
</span>
<Switch
checked={reminder.enabled}
onCheckedChange={(next) =>
update.mutate(
{ id: reminder.id, body: { enabled: next } },
{
onSuccess: () =>
toastWrittenOutcome(
"success",
t("settings.saved"),
),
onError: (failure) =>
showErrorToast(t("settings.savingError"), {
cause: failure,
}),
},
)
}
disabled={update.isPending}
// Label each row's Switch by its own name span so SR
// users hear the reminder name (plus the switch role's
// on/off state) instead of N identical generic labels.
aria-labelledby={`vorsorge-reminder-label-${reminder.id}`}
/>
</div>
))}
</CardContent>
</Card>
)}
</div>
</SettingsCard>
</div>
);
}
/**
* The on/off switch of each preventive-care reminder. A reminder, so since
* v1.44 it lives with the other reminders under Settings > Notifications
* (`#vorsorge-reminders`, the wrapper there); the checkups page links
* there.
*/
export function VorsorgeReminderSwitchesCard() {
const { t } = useTranslations();
const { data: reminders, isLoading } = useMeasurementReminders(true);
const { update } = useMeasurementReminderMutations();
const { prefs } = useModuleListPrefs("vorsorge");
const ordered = applyOrder(reminders ?? [], prefs.order, (r) => r.id);
return (
<SettingsCard>
<SettingsCardHeader
icon={ListChecks}
title={t("notifications.checkupReminders.title")}
description={t("notifications.checkupReminders.description")}
/>
<div>
{isLoading ? (
<Skeleton className="h-24 w-full" />
) : (reminders?.length ?? 0) === 0 ? (
<EmptyState
variant="plain"
size="compact"
title={t("measurementReminders.manage.empty")}
/>
) : (
<Card>
<CardContent className="divide-border divide-y p-0">
{ordered.map((reminder) => (
<div
key={reminder.id}
className="flex items-center justify-between gap-3 px-4 py-3"
>
<span
id={`vorsorge-reminder-label-${reminder.id}`}
className="min-w-0 truncate text-sm"
>
{resolveLabel(reminder, t)}
</span>
<Switch
checked={reminder.enabled}
onCheckedChange={(next) =>
update.mutate(
{ id: reminder.id, body: { enabled: next } },
{
onSuccess: () =>
toastWrittenOutcome("success", t("settings.saved")),
onError: (failure) =>
showErrorToast(t("settings.savingError"), {
cause: failure,
}),
},
)
}
disabled={update.isPending}
// Label each row's Switch by its own name span so SR
// users hear the reminder name (plus the switch role's
// on/off state) instead of N identical generic labels.
aria-labelledby={`vorsorge-reminder-label-${reminder.id}`}
/>
</div>
))}
</CardContent>
</Card>
)}
</div>
</SettingsCard>
);
}
@@ -2,10 +2,11 @@
/**
* v1.43 (#614) — screening hints from the life circumstances, in Settings →
* Vorsorge. Low-dose CT for lung cancer and the abdominal-aorta ultrasound,
* each with its status, the reasons in the account's language and the rule's
* source and country. A hint, never a reminder: nothing is scheduled here,
* and the card says so. Resolved on the server; rendered as delivered.
* Health profile → Medical history (under Checkups until v1.44). Low-dose CT
* for lung cancer and the abdominal-aorta ultrasound, each with its status,
* the reasons in the account's language and the rule's source and country.
* A hint, never a reminder: nothing is scheduled here, and the card says so.
* Resolved on the server; rendered as delivered.
*
* The card stays away until it has something to say: the person has said
* something about smoking, or a hint already applies without it (the German
@@ -218,7 +218,7 @@ describe("<AiSection> — dropdown-driven UX (B2)", () => {
// both ship without the "KI"/"AI" prefix per the project-voice rule
// ("Aktiver KI-Provider" → "Aktiver Provider"). The picker below it is
// labelled for what it does: it chooses the form, not the order.
expect(html).toContain("Aktiver Provider");
expect(html).toContain("Aktiver Anbieter");
expect(html).toContain("Anbieter einrichten");
});
@@ -243,6 +243,6 @@ describe("<AiSection> — dropdown-driven UX (B2)", () => {
it("no longer mounts the Coach cards (moved to the Coach section)", () => {
activeProviderQuery = "codex";
const html = render();
expect(html).not.toContain('data-testid="settings-disable-coach-card"');
expect(html).not.toContain('data-slot="settings-coach-state-card"');
});
});
@@ -35,6 +35,9 @@ vi.mock("@/components/records/ai-profile-inclusion-manager", () => ({
vi.mock("@/components/records/conditions-manager", () => ({
ConditionsManager: () => null,
}));
vi.mock("@/components/records/screening-hints-card", () => ({
ScreeningHintsCard: () => null,
}));
vi.mock("@/components/records/life-circumstances-manager", () => ({
LifeCircumstancesManager: () => null,
}));
@@ -18,6 +18,9 @@ vi.mock("@/components/records/ai-profile-inclusion-manager", () => ({
vi.mock("@/components/records/conditions-manager", () => ({
ConditionsManager: () => <div data-slot="conditions" />,
}));
vi.mock("@/components/records/screening-hints-card", () => ({
ScreeningHintsCard: () => null,
}));
vi.mock("@/components/records/life-circumstances-manager", () => ({
LifeCircumstancesManager: () => null,
}));
@@ -21,6 +21,7 @@ describe("channel form dirty state", () => {
headerName: "X-Gotify-Key",
headerValue: "",
format: "gotify" as const,
enabled: false,
};
it("webhook: a form that matches the saved config is clean", () => {
@@ -56,7 +57,12 @@ describe("channel form dirty state", () => {
it("ntfy: server, topic and a typed auth token count", () => {
const saved = { serverUrl: "https://ntfy.sh", topic: "t" };
const form = { serverUrl: "https://ntfy.sh", topic: "t", authToken: "" };
const form = {
serverUrl: "https://ntfy.sh",
topic: "t",
authToken: "",
enabled: false,
};
expect(isNtfyFormDirty(form, saved)).toBe(false);
expect(isNtfyFormDirty({ ...form, topic: "u" }, saved)).toBe(true);
expect(isNtfyFormDirty({ ...form, serverUrl: "https://x" }, saved)).toBe(
@@ -67,39 +73,86 @@ describe("channel form dirty state", () => {
it("email: the recipient counts", () => {
expect(
isEmailFormDirty({ recipient: "a@x.io" }, { recipient: "a@x.io" }),
isEmailFormDirty(
{ recipient: "a@x.io", enabled: false },
{ recipient: "a@x.io" },
),
).toBe(false);
expect(
isEmailFormDirty({ recipient: "b@x.io" }, { recipient: "a@x.io" }),
isEmailFormDirty(
{ recipient: "b@x.io", enabled: false },
{ recipient: "a@x.io" },
),
).toBe(true);
});
it("telegram: a typed bot token or a changed chat id counts; whitespace alone does not", () => {
expect(
isTelegramFormDirty({ botToken: "", chatId: "1" }, { chatId: "1" }),
).toBe(false);
expect(
isTelegramFormDirty({ botToken: " ", chatId: "" }, { chatId: null }),
isTelegramFormDirty(
{ botToken: "", chatId: "1", enabled: false },
{ chatId: "1" },
),
).toBe(false);
expect(
isTelegramFormDirty(
{ botToken: "123:abc", chatId: "1" },
{ botToken: " ", chatId: "", enabled: false },
{ chatId: null },
),
).toBe(false);
expect(
isTelegramFormDirty(
{ botToken: "123:abc", chatId: "1", enabled: false },
{ chatId: "1" },
),
).toBe(true);
expect(
isTelegramFormDirty({ botToken: "", chatId: "2" }, { chatId: "1" }),
isTelegramFormDirty(
{ botToken: "", chatId: "2", enabled: false },
{ chatId: "1" },
),
).toBe(true);
});
it("a flipped enable switch is a change on every channel, since it saves with the form", () => {
expect(
isWebhookFormDirty({ ...webhookForm, enabled: true }, webhookSaved),
).toBe(true);
expect(
isNtfyFormDirty(
{ serverUrl: "s", topic: "t", authToken: "", enabled: true },
{ serverUrl: "s", topic: "t", enabled: false },
),
).toBe(true);
expect(
isEmailFormDirty(
{ recipient: "a@x.io", enabled: false },
{ recipient: "a@x.io", enabled: true },
),
).toBe(true);
expect(
isTelegramFormDirty(
{ botToken: "", chatId: "1", enabled: true },
{ chatId: "1", enabled: true },
),
).toBe(false);
});
it("nothing loaded yet is never a change", () => {
expect(isWebhookFormDirty(webhookForm, undefined)).toBe(false);
expect(
isNtfyFormDirty({ serverUrl: "", topic: "", authToken: "x" }, undefined),
isNtfyFormDirty(
{ serverUrl: "", topic: "", authToken: "x", enabled: true },
undefined,
),
).toBe(false);
expect(isEmailFormDirty({ recipient: "x" }, undefined)).toBe(false);
expect(isTelegramFormDirty({ botToken: "x", chatId: "" }, undefined)).toBe(
expect(isEmailFormDirty({ recipient: "x", enabled: true }, undefined)).toBe(
false,
);
expect(
isTelegramFormDirty(
{ botToken: "x", chatId: "", enabled: true },
undefined,
),
).toBe(false);
});
});
@@ -12,8 +12,7 @@
* The mutation wiring itself (DELETE `/facts/{id}` and DELETE `/facts`
* → `queryKeys.coachFacts()` invalidation + toast) is pinned by the
* route-shape contract in the component plus the typecheck gate; this
* suite guards the render contract the way the sibling
* `disable-coach-card.test.tsx` does for the toggle.
* suite guards the render contract.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
@@ -1,10 +1,10 @@
/**
* v1.18.0 — Settings → Coach section.
*
* The Coach preference cards (disable toggle, preferences, memory) moved
* out of the AI / Assistant section into their own dedicated, module-gated
* entry. SSR-only smoke test — interactive contracts live in the cards'
* own suites (`disable-coach-card.test.tsx`, `coach-memory-section.test.tsx`).
* The Coach preference cards (state, preferences, memory) moved out of the
* AI / Assistant section into their own dedicated, module-gated entry.
* SSR-only smoke test — interactive contracts live in the cards' own suites
* (`coach-memory-section.test.tsx`; the switch in `modules-section.test.tsx`).
*/
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
@@ -78,34 +78,51 @@ function render(locale: "en" | "de" = "en") {
}
describe("<CoachSection> — SSR smoke", () => {
it("renders the section scaffold + the Activate-Coach toggle card", () => {
it("shows the Coach state with a link to the one switch, not a second switch", () => {
authState.disableCoach = false;
const html = render();
expect(html).toContain('id="settings-section-coach-title"');
expect(html).toContain('data-testid="settings-disable-coach-card"');
expect(html).toContain('data-testid="settings-disable-coach-switch"');
// v1.18.1 — polarity flipped to activate/default-on.
expect(html).toContain("Activate Coach");
expect(html).toContain("Show the Coach button and drawer");
// v1.18.6 — the proactive Coach nudge card moved here from
// Benachrichtigungen; its anchor renders under the enabled-coach gate.
expect(html).toContain('id="coach-nudge"');
expect(html).toContain('data-slot="settings-coach-state-card"');
expect(html).toContain('data-enabled="true"');
expect(html).toContain('href="/settings/modules#module-coach"');
// v1.44 — no switch of its own: one state, one control, in Modules.
const card = html.match(
/data-slot="settings-coach-state-card"[\s\S]*?<\/a>/,
)?.[0];
expect(card).toBeDefined();
expect(card).not.toContain('role="switch"');
expect(html).not.toContain("settings-disable-coach");
// Raw key never leaks past i18n.
expect(html).not.toContain("settings.sections.coach.");
expect(html).not.toContain("settings.coach.activate.");
expect(html).not.toContain("settings.coach.state.");
});
it("renders the German Activate-Coach copy", () => {
authState.disableCoach = false;
it("reads off once the account has the Coach off", () => {
mountState.mounted = true;
authState.disableCoach = true;
try {
expect(render()).toContain('data-enabled="false"');
} finally {
mountState.mounted = false;
authState.disableCoach = false;
}
});
it("points the nudges at Settings > Notifications", () => {
const html = render();
expect(html).toContain('href="/settings/notifications#coach-nudge"');
expect(html).not.toContain('id="coach-nudge"');
});
it("renders the German copy", () => {
const html = render("de");
expect(html).toContain("Coach aktivieren");
expect(html).not.toContain("settings.sections.coach.");
expect(html).not.toContain("settings.coach.activate.");
expect(html).not.toContain("settings.coach.state.");
});
});
describe("<CoachSection> — the tuning follows the record, not the account", () => {
it("hides the nudge when the record has the Coach off, whatever the viewer's own opt-out says", () => {
it("hides the nudge pointer when the record has the Coach off, whatever the viewer's own opt-out says", () => {
// A guardian with the Coach on, inside a managed record that has it off:
// `/me` publishes the viewer's own `disableCoach` and the RECORD's module
// map. The record's answer decides.
@@ -113,10 +130,10 @@ describe("<CoachSection> — the tuning follows the record, not the account", ()
authState.disableCoach = false;
authState.modules = { coach: false };
try {
expect(render()).not.toContain('id="coach-nudge"');
expect(render()).not.toContain('data-slot="settings-coach-nudge-link"');
authState.disableCoach = true;
authState.modules = { coach: true };
expect(render()).toContain('id="coach-nudge"');
expect(render()).toContain('data-slot="settings-coach-nudge-link"');
} finally {
mountState.mounted = false;
authState.disableCoach = false;
@@ -0,0 +1,75 @@
/**
* One confirm style across Settings.
*
* A destructive act (delete, revoke, remove, reset, sign out) confirms through
* `ConfirmButton` / `ConfirmDialog` (`src/components/ui/confirm-button.tsx`),
* which owns the dialog's anatomy and wears the destructive style when the
* caller says the act is destructive. A hand-built `AlertDialog` is how the
* same question ended up styled three different ways on neighbouring cards.
*
* So no file under `src/components/settings` imports the raw `AlertDialog`
* unless it is listed below with the reason the primitive does not fit.
*/
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join, relative } from "node:path";
import { describe, expect, it } from "vitest";
const ROOT = join(process.cwd(), "src", "components", "settings");
const ALLOWED: Readonly<Record<string, string>> = {
"security-section/existing-factor-reauth-dialog.tsx":
"a re-authentication form, not a destructive confirm",
"security-section/passkey-list-section.tsx":
"the add-passkey re-authentication form; the delete confirm uses ConfirmButton",
"security-section/totp-card.tsx":
"the disable confirm asks for a current code and stays open until the server answers; both actions wear the destructive style",
};
/** Owned by the integration cards, which follow their own card kit. */
const ALLOWED_PREFIXES: readonly string[] = ["integrations/"];
function walk(dir: string): string[] {
return readdirSync(dir).flatMap((name) => {
const path = join(dir, name);
if (statSync(path).isDirectory()) {
return name === "__tests__" ? [] : walk(path);
}
return /\.tsx?$/.test(name) ? [path] : [];
});
}
const IMPORTS_ALERT_DIALOG = /from\s+["']@\/components\/ui\/alert-dialog["']/;
describe("settings confirm style", () => {
const files = walk(ROOT);
it("scans the settings tree", () => {
expect(files.length).toBeGreaterThan(50);
});
it("imports the raw AlertDialog only where the primitive does not fit", () => {
const offenders = files
.map((path) => relative(ROOT, path))
.filter((rel) =>
IMPORTS_ALERT_DIALOG.test(readFileSync(join(ROOT, rel), "utf8")),
)
.filter(
(rel) =>
!(rel in ALLOWED) &&
!ALLOWED_PREFIXES.some((prefix) => rel.startsWith(prefix)),
);
expect(
offenders,
"Confirm a destructive act through ConfirmButton / ConfirmDialog, or list the file here with the reason",
).toEqual([]);
});
it("lists no file that no longer needs the exemption", () => {
const stale = Object.keys(ALLOWED).filter(
(rel) =>
!IMPORTS_ALERT_DIALOG.test(readFileSync(join(ROOT, rel), "utf8")),
);
expect(stale).toEqual([]);
});
});
@@ -1,155 +0,0 @@
/**
* v1.4.47 — Settings → Coach activation toggle persistence contract.
*
* v1.18.1 — the card reads "Activate Coach" (default ON). The persisted
* column is still `User.disableCoach`; the Switch shows the activated view
* (`checked = !disableCoach`). The SSR render is `mounted=false`, where the
* card paints the default-ON (checked) state so the default-on contract holds
* before the wire value resolves — both default and opted-out users render
* checked under `renderToStaticMarkup`.
*
* The card is a small client-only `<Switch>` that PATCHes
* `/api/auth/me/disable-coach` and invalidates `queryKeys.authMe()` so
* every Coach mount point in the app reacts on the next /me refetch.
*
* Test strategy: use the real `useMutation` + `QueryClient` from
* `@tanstack/react-query`, stub `fetch`, and drive the Switch via a
* `renderToStaticMarkup` pass plus a hand-invoked mutation call. The
* file SSR-renders inside a `<QueryClientProvider>` so the hook
* branches execute and the spies fire.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import type { AuthUser } from "@/hooks/use-auth";
const authSpy = vi.fn<
() => { user: AuthUser | null; isAuthenticated: boolean }
>(() => ({ user: buildUser(false), isAuthenticated: true }));
vi.mock("@/hooks/use-auth", async () => {
const actual =
await vi.importActual<typeof import("@/hooks/use-auth")>(
"@/hooks/use-auth",
);
return {
...actual,
useAuth: () => authSpy(),
};
});
// Stub the navigation hooks the parent `<CoachSection>` (and its child
// cards) reach for so the SSR pass doesn't blow up. The disable-coach card
// itself doesn't trigger any GET — it only writes through PATCH on click.
//
// v1.18.0 — the Coach cards moved from `<AiSection>` to the dedicated
// `<CoachSection>`; this contract test renders the new parent.
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
usePathname: () => "/settings/coach",
useSearchParams: () => new URLSearchParams(""),
}));
function buildUser(disableCoach: boolean): AuthUser {
return {
id: "user-1",
username: "testuser",
email: null,
role: "USER",
heightCm: null,
dateOfBirth: null,
gender: null,
timezone: "Europe/Berlin",
onboardingCompletedAt: null,
onboardingTourCompleted: true,
onboardingTourProgress: null,
avatarUrl: null,
glucoseUnit: null,
unitPreference: "metric",
timeFormat: "AUTO",
dateFormat: "AUTO",
disableCoach,
fullName: null,
insurerName: null,
insurerIkNumber: null,
insuranceNumber: null,
lastReportPracticeName: null,
reportSelection: null,
cycleTrackingEnabled: false,
modules: {},
};
}
import { CoachSection } from "../coach-section";
function makeFetch() {
return vi.fn(async (_url: RequestInfo | URL, init?: RequestInit) => {
const body = init?.body ? JSON.parse(init.body as string) : {};
return new Response(
JSON.stringify({
data: { disableCoach: body.disableCoach ?? false },
}),
{
status: 200,
headers: { "Content-Type": "application/json" },
},
);
});
}
beforeEach(() => {
authSpy.mockClear();
authSpy.mockImplementation(() => ({
user: buildUser(false),
isAuthenticated: true,
}));
vi.stubGlobal("fetch", makeFetch());
});
afterEach(() => {
vi.unstubAllGlobals();
});
function render(): string {
const client = new QueryClient({
defaultOptions: { queries: { retry: 0 } },
});
return renderToStaticMarkup(
<QueryClientProvider client={client}>
<I18nProvider initialLocale="en">
<CoachSection />
</I18nProvider>
</QueryClientProvider>,
);
}
describe("Settings — DisableCoachCard", () => {
it("renders the activation Switch checked at SSR (default-on contract)", () => {
const html = render();
// v1.18.1 — the SSR render is `mounted=false`, where the card paints
// the default-ON (activated → checked) state regardless of the wire value,
// so the Switch never flashes off before /me resolves.
expect(html).toContain('data-testid="settings-disable-coach-switch"');
expect(html).toContain('data-state="checked"');
});
it("paints the activated (checked) SSR state even for an opted-out user", () => {
authSpy.mockImplementation(() => ({
user: buildUser(true),
isAuthenticated: true,
}));
const html = render();
expect(html).toContain('data-testid="settings-disable-coach-switch"');
// The activated state resolves on the first client re-render once
// `mounted` flips; the SSR pass stays on the default-ON shape.
expect(html).toContain('data-state="checked"');
});
it("renders the Coach-activation title + description", () => {
const html = render();
// v1.18.1 — polarity flipped to activate/default-on.
expect(html).toContain("Activate Coach");
expect(html).toContain("Show the Coach button and drawer");
});
});
@@ -16,7 +16,6 @@ import { describe, expect, it, vi } from "vitest";
vi.mock("../dashboard-section", () => ({ DashboardSection: () => null }));
vi.mock("../insights-section", () => ({ InsightsSection: () => null }));
vi.mock("../medications-section", () => ({ MedicationsSection: () => null }));
vi.mock("../mood-section", () => ({ MoodSection: () => null }));
vi.mock("../labs-section", () => ({ LabsSection: () => null }));
vi.mock("../illness-section", () => ({ IllnessSection: () => null }));
vi.mock("../vorsorge-section", () => ({ VorsorgeSection: () => null }));
@@ -29,12 +28,13 @@ import {
} from "../layout-groups";
describe("layout-groups registry", () => {
it("exposes the eight Appearance modules as statically-generated subpage params", () => {
it("exposes the seven Arrangement modules as statically-generated subpage params", () => {
// v1.44 — mood left: its page was tag management, which lives under
// Settings > Modules > Mood now (`/settings/layout/mood` redirects).
expect(LAYOUT_GROUP_IDS).toEqual([
"dashboard",
"insights",
"medications",
"mood",
"labs",
"illness",
"documents",
@@ -45,7 +45,6 @@ describe("layout-groups registry", () => {
{ module: "dashboard" },
{ module: "insights" },
{ module: "medications" },
{ module: "mood" },
{ module: "labs" },
{ module: "illness" },
{ module: "documents" },
@@ -66,7 +65,7 @@ describe("layout-groups registry", () => {
? layoutGroupModule(id)
: "not-a-group";
expect(gate("medications")).toBe("medications");
expect(gate("mood")).toBe("mood");
expect(gate("mood")).toBe("not-a-group");
expect(gate("labs")).toBe("labs");
expect(gate("illness")).toBe("illness");
// dashboard / insights / vorsorge are never gated.
@@ -54,7 +54,6 @@ describe("<LayoutSection> hub", () => {
"dashboard",
"insights",
"medications",
"mood",
"labs",
"illness",
"vorsorge",
@@ -68,7 +67,7 @@ describe("<LayoutSection> hub", () => {
expect(html).not.toContain('href="/settings/layout/medications"');
expect(html).not.toContain('href="/settings/layout/labs"');
// Other modules unaffected; Vorsorge is never gated.
expect(html).toContain('href="/settings/layout/mood"');
expect(html).toContain('href="/settings/layout/illness"');
expect(html).toContain('href="/settings/layout/vorsorge"');
});
@@ -82,6 +81,7 @@ describe("<LayoutSection> hub", () => {
expect(html).toContain('href="/settings/layout/dashboard"');
expect(html).toContain('href="/settings/layout/insights"');
expect(html).toContain('href="/settings/layout/vorsorge"');
// v1.44 — mood is tag management and left Arrangement for Modules.
expect(html).not.toContain('href="/settings/layout/mood"');
});
@@ -137,11 +137,11 @@ describe("<MedicationsSection> — SSR smoke", () => {
expect(lastActive).toBeLessThan(html.indexOf("Amoxicillin"));
});
it("gathers the injection-sites card (moved from the account profile)", () => {
// v1.18.0 — injection-site exclusions are a medication setting and
// now live on this screen alongside the list view + order.
it("holds order and view only; the injection sites live under Modules", () => {
// v1.44 — Arrangement holds order and view; the injection-site
// exclusions moved to Settings > Modules > Medications.
const html = render();
expect(html).toContain("Globally excluded injection sites");
expect(html).not.toContain("Globally excluded injection sites");
});
it("feeds the editor the saved order (active block re-sorted by layout.order)", () => {
@@ -131,7 +131,6 @@ vi.mock("@/hooks/use-auth", () => ({
}));
import { I18nProvider } from "@/lib/i18n/context";
import { SettingsSectionFrame } from "./section-frame-harness";
import { MoodSection } from "../mood-section";
import { primeLocales } from "@/__tests__/helpers/prime-locales";
@@ -142,9 +141,7 @@ function render(locale: "en" | "de" = "en"): string {
// shared frame the route wraps the section in.
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>
<SettingsSectionFrame slug="mood">
<MoodSection />
</SettingsSectionFrame>
<MoodSection />
</I18nProvider>,
);
}
@@ -159,7 +156,8 @@ describe("<MoodSection> — SSR smoke", () => {
// v1.18.1 — the section blurb was dropped; the sr-only heading + the
// card content carry the meaning.
const html = render();
expect(html).toContain("settings-section-mood-title");
// v1.44 — the page lives under Settings > Modules > Mood; its heading
// comes from the route, not from a frame here.
expect(html).not.toContain("settings.sections.");
expect(html).not.toContain("mood.manage.");
});
@@ -121,60 +121,27 @@ describe("notification channel cards", () => {
);
it.each([
["webhook", <WebhookCard key="webhook" isAuthenticated />, 1],
["ntfy", <NtfyCard key="ntfy" isAuthenticated />, 1],
["email", <EmailCard key="email" isAuthenticated />, 1],
["telegram", <TelegramCard key="telegram" isAuthenticated />, 0],
["webhook", <WebhookCard key="webhook" isAuthenticated />],
["ntfy", <NtfyCard key="ntfy" isAuthenticated />],
["email", <EmailCard key="email" isAuthenticated />],
["telegram", <TelegramCard key="telegram" isAuthenticated />],
] as const)(
"%s switch invokes the enable-only mutation rather than the full save",
async (channel, card, toggleMutationIndex) => {
render(card);
await mocks.mutationOptions[toggleMutationIndex]!.mutationFn(true);
expect(mocks.apiFetchRaw).toHaveBeenCalledTimes(1);
expect(mocks.apiFetchRaw).toHaveBeenCalledWith(
`/api/settings/${channel}`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled: true }),
},
);
},
);
it.each([
[<WebhookCard key="webhook" isAuthenticated />, 1, ["settings", "webhook"]],
[<NtfyCard key="ntfy" isAuthenticated />, 1, ["settings", "ntfy"]],
[<EmailCard key="email" isAuthenticated />, 1, ["settings", "email"]],
[
<TelegramCard key="telegram" isAuthenticated />,
0,
["telegram", "settings"],
],
] as const)(
"rolls each visible switch back to its previous server state on error",
async (card, toggleMutationIndex, queryKey) => {
const previous = { enabled: false, persistedConfig: "unchanged" };
mocks.queryClient.getQueryData.mockReturnValue(previous);
render(card);
const toggle = mocks.mutationOptions[toggleMutationIndex]!;
const context = await toggle.onMutate!(true);
const optimisticUpdater = mocks.queryClient.setQueryData.mock
.calls[0]![1] as (current: typeof previous) => typeof previous;
expect(optimisticUpdater(previous)).toEqual({
...previous,
enabled: true,
});
toggle.onError!(new Error("save failed"), true, context);
expect(mocks.queryClient.setQueryData).toHaveBeenLastCalledWith(
queryKey,
previous,
);
"%s enable switch is a form field: it writes nothing on its own",
async (_channel, card) => {
const html = render(card);
// Rendering the switch writes nothing, and the card mounts no
// switch-only mutation: every write it can make is the full form.
expect(html).toContain('role="switch"');
expect(mocks.apiFetchRaw).not.toHaveBeenCalled();
for (const mutation of mocks.mutationOptions) {
await mutation.mutationFn(true);
}
for (const [, init] of mocks.apiFetchRaw.mock.calls as Array<
[string, { body: string }]
>) {
const body = JSON.parse(init.body) as Record<string, unknown>;
expect(Object.keys(body).length).toBeGreaterThan(1);
}
},
);
@@ -185,9 +152,9 @@ describe("notification channel cards", () => {
expect(html).toMatch(/<option value="generic" selected="">/);
expect(html).toContain('<option value="gotify">');
// The full save is the first mutation of the last render, whose closure
// holds the values seeded from the server.
const save = mocks.mutationOptions[mocks.mutationOptions.length - 2]!;
// The full save is the mutation of the last render, whose closure holds
// the values seeded from the server, the enable switch included.
const save = mocks.mutationOptions.at(-1)!;
await save.mutationFn(false);
const [, init] = mocks.apiFetchRaw.mock.calls[0] as [
@@ -1,93 +0,0 @@
import { QueryClient } from "@tanstack/react-query";
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/api/api-fetch", () => ({
apiFetchRaw: vi.fn(),
}));
import { apiFetchRaw } from "@/lib/api/api-fetch";
import {
optimisticallySetChannelEnabled,
persistChannelEnabled,
rollbackChannelEnabled,
} from "../notification-channel-toggle";
beforeEach(() => {
vi.clearAllMocks();
});
describe("notification channel enable-only mutation", () => {
it.each([
"/api/settings/webhook",
"/api/settings/ntfy",
"/api/settings/email",
"/api/settings/telegram",
])("sends only enabled to %s", async (endpoint) => {
vi.mocked(apiFetchRaw).mockResolvedValue(
new Response(JSON.stringify({ data: { saved: true } }), { status: 200 }),
);
await persistChannelEnabled(endpoint, true, "save failed");
expect(apiFetchRaw).toHaveBeenCalledWith(endpoint, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled: true }),
});
expect(
JSON.parse(vi.mocked(apiFetchRaw).mock.calls[0]![1]!.body as string),
).toEqual({ enabled: true });
});
it("changes only cached enabled state and leaves configuration fields intact", () => {
const client = new QueryClient();
const key = ["settings", "webhook"] as const;
const settings = {
enabled: false,
url: "https://saved.example/hook",
topic: "saved-topic",
recipient: "saved@example.com",
hasHeaderValue: true,
};
client.setQueryData(key, settings);
const previous = optimisticallySetChannelEnabled(client, key, true);
expect(previous).toEqual(settings);
expect(client.getQueryData(key)).toEqual({ ...settings, enabled: true });
});
it("restores the exact previous cached settings after a failed toggle", () => {
const client = new QueryClient();
const key = ["settings", "telegram"] as const;
const previousSettings = {
enabled: false,
hasBotToken: true,
chatId: "123456",
};
client.setQueryData(key, previousSettings);
const previous = optimisticallySetChannelEnabled(client, key, true);
expect(client.getQueryData<{ enabled: boolean }>(key)?.enabled).toBe(true);
rollbackChannelEnabled(client, key, previous);
expect(client.getQueryData(key)).toEqual(previousSettings);
expect(client.getQueryData<{ enabled: boolean }>(key)?.enabled).toBe(false);
});
it("surfaces the route error so the card mutation can roll back", async () => {
vi.mocked(apiFetchRaw).mockResolvedValue(
new Response(
JSON.stringify({ error: "Stored configuration is missing" }),
{
status: 422,
},
),
);
await expect(
persistChannelEnabled("/api/settings/ntfy", true, "save failed"),
).rejects.toThrow("Stored configuration is missing");
});
});
@@ -1,14 +1,12 @@
/**
* v1.18.0 — module-gated per-type visibility on the consolidated
* "Benachrichtigungen" reminder-types home.
* Settings > Notifications is the one home for every reminder kind, each
* card gated on its module and failing open.
*
* The mood reminder shows only when the `mood` module is enabled. Low-stock
* maps to the medications domain (shown unless explicitly disabled). The gate
* fails OPEN, so a `/me` payload without a module map keeps every card
* visible.
*
* v1.18.6 — the proactive Coach nudge card moved to Settings → Coach, so
* it no longer appears on this screen.
* v1.18.0 — mood reminder on `mood`, low-stock on `medications`.
* v1.44 — the Coach nudges came back (gated on the Coach surface), the
* checkup reminder switches joined (not a module, always shown), and a card
* names the reminders set where their subject lives (dose times on the
* medication, the cycle reminders in the per-event matrix).
*/
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
@@ -25,8 +23,9 @@ vi.mock("@tanstack/react-query", () => ({
useMutation: () => ({ mutate: vi.fn(), isPending: false }),
}));
// The auth mock is driven per-test via a mutable ref so each render can pick
// a different module map without re-mocking the module.
// Mounted, so the Coach gate reads the module map like a hydrated page.
vi.mock("@/hooks/use-mounted", () => ({ useMounted: () => true }));
const authRef: { modules?: Record<string, boolean> } = {};
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
@@ -49,38 +48,43 @@ function render(modules?: Record<string, boolean>) {
);
}
describe("<NotificationsSection> module-gated reminder types", () => {
it("shows mood + low-stock cards when no module map (fails open); no coach nudge", () => {
describe("<NotificationsSection> reminder kinds", () => {
it("shows every reminder card when no module map (fails open)", () => {
const html = render(undefined);
expect(html).toContain('id="mood-reminder"');
expect(html).toContain('id="low-stock"');
// v1.18.6 — the coach nudge moved to Settings → Coach.
expect(html).not.toContain('id="coach-nudge"');
expect(html).toContain('id="coach-nudge"');
expect(html).toContain('id="vorsorge-reminders"');
expect(html).toContain('data-slot="notifications-elsewhere"');
expect(html).toContain('id="cycle-reminders"');
});
it("hides the mood card when the mood module is disabled", () => {
const html = render({ mood: false });
expect(html).not.toContain('id="mood-reminder"');
// Low-stock is unaffected.
expect(html).toContain('id="low-stock"');
});
it("always shows low-stock unless medications is disabled", () => {
const html = render({ mood: false });
expect(html).toContain('id="low-stock"');
expect(html).not.toContain('id="mood-reminder"');
expect(html).not.toContain('id="coach-nudge"');
it("hides the low-stock card and the dose row when medications is disabled", () => {
const html = render({ medications: false });
expect(html).not.toContain('id="low-stock"');
expect(html).not.toContain('href="/medications"');
});
it("drops the channels / inbox cross-links and the Vorsorge block (D5)", () => {
// v1.18.1 — the page is lean: no section blurb, no channels/inbox
// cross-links, and no embedded Vorsorge editor (it has its own page).
it("hides the Coach nudges when the Coach is off", () => {
expect(render({ coach: false })).not.toContain('id="coach-nudge"');
});
it("drops the elsewhere card when neither of its reminders applies", () => {
const html = render({ medications: false, cycle: false });
expect(html).not.toContain('data-slot="notifications-elsewhere"');
// The checkup reminders are not a module and stay.
expect(html).toContain('id="vorsorge-reminders"');
});
it("links to the per-event matrix and to the delivery channels", () => {
const html = render({});
expect(html).not.toContain('data-slot="notifications-channels-cross-link"');
expect(html).not.toContain('data-slot="notifications-inbox-cross-link"');
// The remaining reminder-type cards (no coach nudge here anymore).
expect(html).toContain('id="mood-reminder"');
expect(html).toContain('id="low-stock"');
expect(html).not.toContain('id="coach-nudge"');
expect(html).toContain('href="/notifications"');
expect(html).toContain('href="/settings/integrations#channels"');
});
});
@@ -0,0 +1,51 @@
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { PreviewSection } from "../preview-section";
import { primeLocales } from "@/__tests__/helpers/prime-locales";
primeLocales("de");
function render(topics: boolean | undefined, locale: "en" | "de" = "en") {
const client = new QueryClient({
defaultOptions: { queries: { retry: false, refetchOnMount: false } },
});
if (topics !== undefined) {
client.setQueryData(queryKeys.previewFeatures(), { topics });
}
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>
<QueryClientProvider client={client}>
<PreviewSection />
</QueryClientProvider>
</I18nProvider>,
);
}
describe("<PreviewSection>", () => {
it("shows the Topics switch off by default, saved on change", () => {
const html = render(false);
expect(html).toContain('data-slot="settings-preview-topics-switch"');
expect(html).toMatch(
/data-slot="settings-preview-topics-switch"[^>]*aria-checked="false"|aria-checked="false"[^>]*data-slot="settings-preview-topics-switch"/,
);
expect(html).toContain("Topics (beta)");
// A switch, no Save: one save model per card.
expect(html).not.toContain('type="submit"');
});
it("reads on once the person switched it on", () => {
expect(render(true)).toMatch(
/data-slot="settings-preview-topics-switch"[^>]*aria-checked="true"|aria-checked="true"[^>]*data-slot="settings-preview-topics-switch"/,
);
});
it("says the existing pages stay as they are and nothing raw leaks", () => {
const html = render(false, "de");
expect(html).toContain("Themen (Beta)");
expect(html).not.toContain("settings.preview.");
});
});
@@ -55,7 +55,7 @@ function render(node: React.ReactNode, locale: "en" | "de" = "en") {
}
describe("<PrivacySection> — assembled Data & Privacy dashboard", () => {
it("renders the assembled sections (encryption, retention, export, delete, sessions, activity)", () => {
it("renders the assembled sections (encryption, retention, delete)", () => {
const html = render(<PrivacySection />);
// Encryption-at-rest block driven from the seeded summary.
expect(html).toContain("AES-256-GCM");
@@ -63,15 +63,14 @@ describe("<PrivacySection> — assembled Data & Privacy dashboard", () => {
expect(html).toContain("365");
// The honest backup↔deletion lag disclosure is present.
expect(html.toLowerCase()).toContain("backup");
// Links to the existing export + destructive surfaces (assembly, not rebuild).
expect(html).toContain('href="/settings/export"');
expect(html).toContain('href="/settings/gesundheitsakte"');
// Links to the destructive surface (assembly, not rebuild).
expect(html).toContain('href="/settings/advanced"');
// Embedded active-session + security-activity cards.
expect(html).toContain("settings-security-sessions-card");
// Trusted devices now lives here (and only here) — its single home is the
// Privacy dashboard, not the Security section.
expect(html).toContain("settings-trusted-devices-card");
// v1.44 — sessions, trusted devices and the activity feed live under
// Security, and the duplicate export card is gone: Export & import and
// the Health record sit beside this page in the Data group.
expect(html).not.toContain("settings-security-sessions-card");
expect(html).not.toContain("settings-trusted-devices-card");
expect(html).not.toContain('href="/settings/export"');
});
it("shows a loading placeholder for the retention block before the summary resolves", () => {
@@ -0,0 +1,256 @@
/**
* One save model per card.
*
* A settings card either saves each control the moment it changes (switches,
* selects, segmented choices) or collects a multi-field draft behind one
* explicit Save. Never both: a card with an instant switch beside a Save
* button leaves a person guessing which of their changes the button covers,
* and the channel cards (Telegram, ntfy, webhook, email) did exactly that —
* the enable switch wrote on tap while the rest of the form waited for Save.
*
* The guard splits every settings source into card units (each
* `<SettingsCard>…</SettingsCard>` block; a file without one is a single
* unit) and fails a unit that both
* - saves explicitly: a `type="submit"` control or the `common.save` label,
* - saves instantly: a Switch / NativeSelect / Select / Segmented / Checkbox
* whose change handler does more than set local draft state.
*
* Its limit, written down: the handler check is textual. It follows calls to
* functions declared in the same file two levels deep; a write hidden behind
* a callback handed in through props, or behind an imported helper whose
* name carries none of the write markers, reads as a draft field.
*/
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join, relative } from "node:path";
import { describe, expect, it } from "vitest";
const ROOT = process.cwd();
const SETTINGS_DIR = join(ROOT, "src", "components", "settings");
const EXTRA_FILES = [
"src/components/labs/labs-settings.tsx",
"src/components/illness/illness-settings.tsx",
"src/components/measurement-reminders/vorsorge-settings.tsx",
];
/**
* `<file>#<card index>` → why the unit may mix both models. Empty on purpose;
* an entry needs a reason a reviewer can check.
*/
const ALLOWLIST: Readonly<Record<string, string>> = {};
function walk(dir: string): string[] {
const out: string[] = [];
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
if (statSync(full).isDirectory()) {
if (entry === "__tests__") continue;
out.push(...walk(full));
} else if (entry.endsWith(".tsx")) {
out.push(full);
}
}
return out;
}
/** Each `<SettingsCard>` block with its nested cards counted. */
function splitCardUnits(source: string): string[] {
const open = /<SettingsCard\b/g;
const units: string[] = [];
let match: RegExpExecArray | null;
let consumedTo = 0;
while ((match = open.exec(source)) !== null) {
if (match.index < consumedTo) continue;
let depth = 0;
const token = /<SettingsCard\b|<\/SettingsCard>/g;
token.lastIndex = match.index;
let t: RegExpExecArray | null;
let end = source.length;
while ((t = token.exec(source)) !== null) {
depth += t[0] === "</SettingsCard>" ? -1 : 1;
if (depth === 0) {
end = t.index + t[0].length;
break;
}
}
units.push(source.slice(match.index, end));
consumedTo = end;
}
return units.length > 0 ? units : [source];
}
/** The JSX opening tag starting at `start`, braces and strings respected. */
function openingTag(source: string, start: number): string {
let depth = 0;
let quote: string | null = null;
for (let i = start; i < source.length; i++) {
const c = source[i];
if (quote) {
if (c === quote && source[i - 1] !== "\\") quote = null;
continue;
}
if (depth > 0 && (c === '"' || c === "'" || c === "`")) {
quote = c;
continue;
}
if (c === "{") depth++;
else if (c === "}") depth--;
else if (c === ">" && depth === 0) return source.slice(start, i + 1);
}
return source.slice(start);
}
/** The `{…}` expression of `attr=` inside a tag, or null. */
function attributeExpression(tag: string, attr: string): string | null {
const at = tag.search(new RegExp(`\\b${attr}=\\{`));
if (at < 0) return null;
const open = tag.indexOf("{", at);
let depth = 0;
for (let i = open; i < tag.length; i++) {
if (tag[i] === "{") depth++;
else if (tag[i] === "}") {
depth--;
if (depth === 0) return tag.slice(open + 1, i).trim();
}
}
return null;
}
/** A call that writes to the server (or the locale) on its own. */
const WRITE_MARKER =
/\.mutate(?:Async)?\(|\bapi(?:FetchRaw|Fetch|Get|Put|Patch|Post|Delete)\(|\bsafeFetch\(|\bpersist\w*\(/;
/** The body of a function or arrow const declared in `source`, or null. */
function localDefinition(source: string, name: string): string | null {
const decl = new RegExp(
`(?:function\\s+${name}\\s*\\(|(?:const|let)\\s+${name}\\s*=)`,
).exec(source);
if (!decl) return null;
// A braceless arrow (`const f = (v) => g(v);`) ends at its line.
const rest = source.slice(decl.index);
const arrow = /^[^\n{]*=>\s*(?!\{)([^\n]*)/.exec(rest);
if (arrow) return arrow[0];
const open = source.indexOf("{", decl.index);
if (open < 0) return null;
let depth = 0;
for (let i = open; i < source.length; i++) {
if (source[i] === "{") depth++;
else if (source[i] === "}") {
depth--;
if (depth === 0) return source.slice(decl.index, i + 1);
}
}
return null;
}
/**
* True when a change handler writes on change: the handler itself carries a
* write call, or a function it calls that is declared in the same file does
* (followed two levels deep). A handler that only touches local state, or
* calls a callback handed in through props, reads as a draft field.
*/
function writesOnChange(handler: string, source: string, depth = 0): boolean {
if (WRITE_MARKER.test(handler)) return true;
if (depth >= 2) return false;
const names = new Set<string>();
if (/^[A-Za-z_]\w*$/.test(handler)) names.add(handler);
for (const m of handler.matchAll(/\b([A-Za-z_]\w*)\s*\(/g)) {
names.add(m[1]);
}
for (const name of names) {
const body = localDefinition(source, name);
if (body && writesOnChange(body, source, depth + 1)) return true;
}
return false;
}
const CONTROL = /<(Switch|NativeSelect|Select|Segmented|Checkbox)\b/g;
const HANDLERS = ["onCheckedChange", "onChange", "onValueChange"];
function savesInstantly(unit: string, source: string = unit): boolean {
CONTROL.lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = CONTROL.exec(unit)) !== null) {
const tag = openingTag(unit, match.index);
for (const attr of HANDLERS) {
const handler = attributeExpression(tag, attr);
if (handler !== null && writesOnChange(handler, source)) return true;
}
}
return false;
}
function savesExplicitly(unit: string): boolean {
return /type="submit"/.test(unit) || /t\("common\.save"\)/.test(unit);
}
function scan() {
const files = [
...walk(SETTINGS_DIR),
...EXTRA_FILES.map((f) => join(ROOT, f)),
];
const mixed: string[] = [];
let units = 0;
for (const file of files) {
const rel = relative(ROOT, file);
const source = readFileSync(file, "utf8");
splitCardUnits(source).forEach((unit, index) => {
units++;
if (savesInstantly(unit, source) && savesExplicitly(unit)) {
const key = `${rel}#${index}`;
if (!ALLOWLIST[key]) mixed.push(key);
}
});
}
return { mixed, units, files: files.length };
}
describe("settings save model", () => {
it("classifies handlers", () => {
const source = `
function handleToggle(next) { mutation.mutate(next); }
function toggleItem(kind, on) { setDraft({ kind, on }); }
const flip = (v) => handleToggle(v);
`;
expect(writesOnChange("setChatId", source)).toBe(false);
expect(writesOnChange("(e) => setUrl(e.target.value)", source)).toBe(false);
expect(writesOnChange("(v) => toggleItem('a', v)", source)).toBe(false);
expect(
writesOnChange("(checked) => toggleEnabled.mutate(checked)", source),
).toBe(true);
expect(writesOnChange("handleToggle", source)).toBe(true);
expect(writesOnChange("(v) => flip(v)", source)).toBe(true);
});
it("refuses a card that both saves on change and behind a Save button", () => {
const mixedCard = `<SettingsCard><Switch onCheckedChange={(v) => save.mutate(v)} /><Button type="submit">{t("common.save")}</Button></SettingsCard>`;
const draftCard = `<SettingsCard><Switch onCheckedChange={setEnabled} /><Button type="submit">{t("common.save")}</Button></SettingsCard>`;
expect(
savesInstantly(splitCardUnits(mixedCard)[0]) &&
savesExplicitly(splitCardUnits(mixedCard)[0]),
).toBe(true);
expect(savesInstantly(splitCardUnits(draftCard)[0])).toBe(false);
});
it("finds no card that mixes instant and explicit saving", () => {
const { mixed, units, files } = scan();
// An empty scan would pass for the wrong reason.
expect(files).toBeGreaterThan(50);
expect(units).toBeGreaterThan(100);
expect(
mixed,
`cards mixing both save models:\n${mixed.join("\n")}`,
).toEqual([]);
});
it("keeps every allowlist entry pointing at a unit that still mixes", () => {
for (const key of Object.keys(ALLOWLIST)) {
const [file, index] = key.split("#");
const source = readFileSync(join(ROOT, file), "utf8");
const unit = splitCardUnits(source)[Number(index)];
expect(unit, key).toBeDefined();
expect(savesInstantly(unit, source) && savesExplicitly(unit), key).toBe(
true,
);
}
});
});
@@ -246,7 +246,8 @@ describe("settings sections — SSR smoke", () => {
// card is gone.
expect(html).toContain('id="mood-reminder"');
expect(html).toContain('id="low-stock"');
expect(html).not.toContain('id="coach-nudge"');
// v1.44 — the Coach nudges are back with the other reminders.
expect(html).toContain('id="coach-nudge"');
// v1.25.7 — delivery channels moved to Settings → Integrationen, so the
// channels group + panel no longer render on this surface.
expect(html).not.toContain('id="channels"');
@@ -11,6 +11,7 @@ import { join } from "node:path";
const mockPathnameRef = { value: "/settings/account" };
vi.mock("next/navigation", () => ({
usePathname: () => mockPathnameRef.value,
useRouter: () => ({ replace: () => {}, push: () => {} }),
}));
// v1.18.0 — the shell module-gates per-submodule nav entries off
@@ -169,6 +170,8 @@ describe("SETTINGS_SECTION_SLUGS", () => {
// v1.23 — "Data & Privacy" assembles the export / deletion / retention /
// encryption / session / activity pieces into one pane, after `advanced`.
"privacy",
// v1.44 — per-user preview opt-ins, in the System group before About.
"preview",
"about",
]);
});
@@ -193,23 +196,59 @@ describe("SETTINGS_SECTION_SLUGS", () => {
});
describe("<SettingsShell>", () => {
it("keeps meaningful non-interactive desktop groups while the mobile strip uses quiet separators", () => {
it("renders the nine groups as non-interactive sidebar headers, in order", () => {
const html = renderShell({ active: "account" });
for (const label of [
const labels = [
"Account",
"Your data",
"Display",
"Tracking",
"Arrangement",
"Notifications",
"Health profile",
"AI",
"Connectivity",
"Connections",
"Data",
"System",
]) {
expect(html).toContain(`>${label}</li>`);
];
let cursor = -1;
for (const label of labels) {
const at = html.indexOf(`>${label}</li>`);
expect(at, label).toBeGreaterThan(cursor);
cursor = at;
}
// Seven groups produce six quiet separators in the mobile strip.
expect(
html.match(/aria-hidden="true"/g)?.length ?? 0,
).toBeGreaterThanOrEqual(6);
});
it("puts every section in the group the plan names", () => {
const groupOf = Object.fromEntries(
SETTINGS_SECTIONS.map((section) => [section.slug, section.group]),
);
expect(groupOf).toEqual({
account: "account",
security: "account",
access: "account",
modules: "tracking",
layout: "arrangement",
notifications: "notifications",
score: "healthProfile",
thresholds: "healthProfile",
anamnesis: "healthProfile",
environment: "healthProfile",
ai: "ai",
coach: "ai",
integrations: "connections",
sources: "connections",
api: "connections",
mcp: "connections",
export: "data",
gesundheitsakte: "data",
privacy: "data",
advanced: "data",
preview: "system",
about: "system",
});
// Every routable section has a nav entry, and nothing else does.
expect([...SETTINGS_SECTIONS.map((s) => s.slug)].sort()).toEqual(
[...SETTINGS_SECTION_SLUGS].sort(),
);
});
it("starts the sticky desktop sidebar on the heading row", () => {
@@ -230,19 +269,14 @@ describe("<SettingsShell>", () => {
expect(source).not.toMatch(/<aside[\s\S]*?md:row-start-2/);
});
it("renders every navigable section link — once for the mobile strip and once for the desktop sidebar", () => {
it("renders every navigable section link in the sidebar", () => {
const html = renderShell({ active: "account" });
// Every slug — including `about`, which returned to the shell nav
// as the last entry after living dropdown-only since v1.4.33 IW7 —
// renders in both layouts.
// The phone's section sheet mounts its list only when opened, so the
// server markup carries each link once, in the desktop sidebar.
const navigableSlugs = SETTINGS_SECTIONS.map((section) => section.slug);
for (const slug of navigableSlugs) {
const matches = html.match(new RegExp(`href="/settings/${slug}"`, "g"));
// Two renders — mobile strip + desktop sidebar — guarantee the link
// exists in both layouts. Tablet/desktop hide the strip with `md:`,
// but the markup is always present so it's keyboard-discoverable
// before media queries resolve.
expect(matches?.length ?? 0).toBe(2);
expect(matches?.length ?? 0).toBe(1);
}
});
@@ -259,9 +293,9 @@ describe("<SettingsShell>", () => {
it('marks the active section with aria-current="page" (and only that one)', () => {
const html = renderShell({ active: "notifications" });
// Both layouts emit the active link, so two `aria-current="page"`.
// The sidebar marks the active link; the phone sheet is closed.
const occurrences = html.match(/aria-current="page"/g);
expect(occurrences?.length ?? 0).toBe(2);
expect(occurrences?.length ?? 0).toBe(1);
// And the active link is on the notifications href. Attribute order
// in React SSR is alphabetic, so `aria-current` precedes `href` —
@@ -269,28 +303,38 @@ describe("<SettingsShell>", () => {
// future React renderer changes.
const activeLinkRegex =
/<a\b[^>]*\baria-current="page"[^>]*\bhref="\/settings\/notifications"|<a\b[^>]*\bhref="\/settings\/notifications"[^>]*\baria-current="page"/g;
expect(html.match(activeLinkRegex)?.length ?? 0).toBe(2);
expect(html.match(activeLinkRegex)?.length ?? 0).toBe(1);
});
it("derives the active slug from the current pathname when `active` is omitted", () => {
const html = renderShell({ pathname: "/settings/api" });
const activeLinkRegex =
/<a\b[^>]*\baria-current="page"[^>]*\bhref="\/settings\/api"|<a\b[^>]*\bhref="\/settings\/api"[^>]*\baria-current="page"/g;
expect(html.match(activeLinkRegex)?.length ?? 0).toBe(2);
expect(html.match(activeLinkRegex)?.length ?? 0).toBe(1);
});
it("highlights the Layout hub when on a Layout child editor (v1.17.1 F-2)", () => {
// On `/settings/dashboard` (a Layout child reached through the hub) the
// Layout nav entry must read active even though Dashboard has no nav
// entry of its own. Both layouts emit the active link → two matches.
for (const child of ["/settings/dashboard", "/settings/insights"]) {
it("highlights the Arrangement hub on a legacy child path and on its sub-pages", () => {
// `/settings/dashboard` (a redirected legacy child) and the sub-pages
// under `/settings/layout/<id>` both read as the Arrangement entry.
for (const child of [
"/settings/dashboard",
"/settings/insights",
"/settings/layout/labs",
]) {
const html = renderShell({ pathname: child });
const layoutActive =
/<a\b[^>]*\baria-current="page"[^>]*\bhref="\/settings\/layout"|<a\b[^>]*\bhref="\/settings\/layout"[^>]*\baria-current="page"/g;
expect(html.match(layoutActive)?.length ?? 0).toBe(2);
expect(html.match(layoutActive)?.length ?? 0).toBe(1);
}
});
it("highlights Modules on a module's own settings page", () => {
const html = renderShell({ pathname: "/settings/modules/labs" });
const modulesActive =
/<a\b[^>]*\baria-current="page"[^>]*\bhref="\/settings\/modules"|<a\b[^>]*\bhref="\/settings\/modules"[^>]*\baria-current="page"/g;
expect(html.match(modulesActive)?.length ?? 0).toBe(1);
});
// v1.37.19 — the Layout children left `SETTINGS_SECTION_SLUGS`, so no
// page can pass one as the `active` override anymore; the pathname-based
// case above is the remaining (defensive) mapping.
@@ -424,7 +468,7 @@ describe("<SettingsShell>", () => {
const html = renderShell({ pathname: "/settings/totally-bogus" });
const activeLinkRegex =
/<a\b[^>]*\baria-current="page"[^>]*\bhref="\/settings\/account"|<a\b[^>]*\bhref="\/settings\/account"[^>]*\baria-current="page"/g;
expect(html.match(activeLinkRegex)?.length ?? 0).toBe(2);
expect(html.match(activeLinkRegex)?.length ?? 0).toBe(1);
});
it("resolves every section title via the i18n provider — English", () => {
@@ -440,7 +484,8 @@ describe("<SettingsShell>", () => {
// "Layout": it arranges and picks what each module shows and holds no
// theme, so "Appearance" promised a control it does not have.
expect(html).toContain('href="/settings/layout"');
expect(html).toContain(">Layout</a>");
// v1.44 — the hub holds only order and view, and says so.
expect(html).toContain(">Arrangement</a>");
// v1.25.3 — Channels folded into Notifications, so it is no longer a
// left-side entry; Sources keeps its own.
expect(html).not.toContain('href="/settings/channels"');
@@ -484,7 +529,7 @@ describe("<SettingsShell>", () => {
// (route stays `/settings/layout`). v1.42 — "Layout" in German too;
// "Darstellung" now names the account page's language-and-units card.
expect(html).toContain('href="/settings/layout"');
expect(html).toContain(">Layout</a>");
expect(html).toContain(">Anordnung</a>");
// v1.25.3 — Channels ("Kanäle") folded into Notifications; Sources keeps
// its own left-side entry.
expect(html).not.toContain('href="/settings/channels"');
@@ -512,17 +557,14 @@ describe("<SettingsShell>", () => {
expect(html).not.toContain("settings.sections.");
});
it("mobile section strip uses `no-scrollbar` so the swipe area doesn't paint a horizontal scrollbar", () => {
// The 10-section strip is wider than 393 CSS px, so without
// `no-scrollbar` Chromium/WebKit paints an always-on horizontal
// scrollbar at the top of every settings page. The class is
// defined in `globals.css` and combines `scrollbar-width: none`
// (Firefox) with `::-webkit-scrollbar { display: none }` (everyone
// else) — scroll behaviour is preserved.
const html = renderShell({ active: "account" });
const nav = html.match(/<nav\b[^>]*md:hidden[^>]*>/);
it("gives the phone one button naming the section, not a strip of every section", () => {
const html = renderShell({ active: "security" });
const nav = html.match(/<nav\b[^>]*md:hidden[^>]*>[\s\S]*?<\/nav>/);
expect(nav).not.toBeNull();
expect(nav![0]).toMatch(/\bno-scrollbar\b/);
expect(nav![0]).toMatch(/\boverflow-x-auto\b/);
expect(nav![0]).toContain('data-slot="settings-mobile-nav-trigger"');
expect(nav![0]).toContain("Security");
expect(nav![0]).toContain("Account");
expect(nav![0]).not.toContain("overflow-x-auto");
expect(nav![0]).not.toContain('href="/settings/');
});
});
@@ -26,10 +26,14 @@ function read(rel: string): string {
return readFileSync(join(process.cwd(), rel), "utf8");
}
/** The body of every `<AlertDialogAction …>…</AlertDialogAction>` block. */
/**
* Every confirm the source spells: an `<AlertDialogAction …>…</AlertDialogAction>`
* block, or a `<ConfirmButton … />` whose `confirmLabel` is the action label.
*/
function actions(source: string): string[] {
return [
...source.matchAll(/<AlertDialogAction\b[\s\S]*?<\/AlertDialogAction>/g),
...source.matchAll(/<ConfirmButton\b[\s\S]*?\/>/g),
].map((m) => m[0]);
}
@@ -41,7 +45,9 @@ describe("token revoke labels", () => {
);
expect(revokes.length).toBeGreaterThan(0);
for (const action of revokes) {
expect(action).toContain('t("settings.tokenRevokeAction")');
expect(action).toMatch(
/(confirmLabel=\{|>\s*\{)t\("settings\.tokenRevokeAction"\)/,
);
}
});
@@ -44,10 +44,15 @@ describe("Settings → Account display card", () => {
"GlucoseUnitSelect",
"TimeFormatSelect",
"DateFormatSelect",
// v1.44 — the timezone saves on change too, in the display card.
"TimezoneSelect",
"TimezonePicker",
]) {
expect(form).not.toContain(control);
}
// The timezone saves with the form, so it stays inside it.
expect(form).toContain("TimezonePicker");
const card = src.slice(
src.indexOf("export function DisplayPreferencesCard"),
);
expect(card).toContain("<TimezoneSelect");
});
});
@@ -18,9 +18,9 @@ import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
* browser actually IS in Berlin, leave it alone — the picker
* stays on Berlin and the next save is a no-op.
* - If the stored value is the default AND the browser reports a
* non-Berlin zone, pre-fill the picker with the detected zone.
* The form's existing submit handler persists the change on the
* next save; no toast, no banner, no opt-in.
* non-Berlin zone, that zone is the suggestion. Since v1.44 the
* timezone saves on change in the Display card, which offers the
* suggestion as a one-tap action under the select.
*
* The bootstrap deliberately runs inline during render (the strict
* `react-hooks/set-state-in-effect` rule outlaws setState in an
@@ -28,17 +28,12 @@ import { locales, localeLabels, type Locale } from "@/lib/i18n/config";
import { useTranslations } from "@/lib/i18n/context";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { TimezonePicker } from "@/components/settings/timezone-picker";
import { storeTimezone } from "@/lib/timezone-mirror";
import { TimezoneSelect } from "@/components/settings/timezone-select";
import { TimeFormatSelect } from "@/components/settings/time-format-select";
import { DateFormatSelect } from "@/components/settings/date-format-select";
import { UnitPreferenceSelect } from "@/components/settings/unit-preference-select";
import { GlucoseUnitSelect } from "@/components/settings/glucose-unit-select";
import {
detectBrowserTimezone,
DEFAULT_TIMEZONE,
userDayKey,
} from "@/lib/tz/format";
import { detectBrowserTimezone, userDayKey } from "@/lib/tz/format";
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
import {
recentProofErrorMessage,
@@ -52,7 +47,6 @@ import {
import { FieldError } from "@/components/forms/field-error";
import {
clearRejectedField,
resolveInitialTimezone,
statusText,
type StatusMessage,
} from "./account-section-utils";
@@ -95,7 +89,6 @@ export function AccountSection() {
const [height, setHeight] = useState<HeightDraft>(EMPTY_HEIGHT_DRAFT);
const [dateOfBirth, setDateOfBirth] = useState("");
const [gender, setGender] = useState<string>("");
const [timezone, setTimezone] = useState<string>(DEFAULT_TIMEZONE);
// The name the app addresses the account by; set in the setup flow, and
// until now not editable anywhere after it.
const [displayName, setDisplayName] = useState("");
@@ -109,7 +102,6 @@ export function AccountSection() {
height: EMPTY_HEIGHT_DRAFT,
dateOfBirth: "",
gender: "",
timezone: DEFAULT_TIMEZONE,
fullName: "",
insurerName: "",
insuranceNumber: "",
@@ -160,7 +152,6 @@ export function AccountSection() {
? dateOnlyKey(new Date(user.dateOfBirth))
: "",
gender: user.gender ?? "",
timezone: resolveInitialTimezone(user.timezone, detectBrowserTimezone()),
fullName: user.fullName ?? "",
insurerName: user.insurerName ?? "",
insuranceNumber: user.insuranceNumber ?? "",
@@ -171,7 +162,6 @@ export function AccountSection() {
setHeight(seed.height);
setDateOfBirth(seed.dateOfBirth);
setGender(seed.gender);
setTimezone(seed.timezone);
setFullName(seed.fullName);
setInsurerName(seed.insurerName);
setInsuranceNumber(seed.insuranceNumber);
@@ -203,7 +193,6 @@ export function AccountSection() {
height,
dateOfBirth,
gender,
timezone,
fullName,
insurerName,
insuranceNumber,
@@ -268,55 +257,36 @@ export function AccountSection() {
height,
dateOfBirth,
gender,
timezone,
fullName: fullName.trim(),
insurerName: insurerName.trim(),
insuranceNumber: insuranceNumber.trim(),
};
// The timezone is owned by a dedicated route (v1.4.25 W7) so the
// resolver cache can be invalidated without piping the flag
// through the bigger profile patch path. Run the two PUTs in
// parallel — they're independent.
const [profileRes, tzRes] = await Promise.all([
apiFetchRaw("/api/auth/profile", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email: savedProfile.email || null,
// An emptied field clears the name; the greeting falls back to
// the username, as it does for an account that never set one.
displayName: savedProfile.displayName || null,
...(emailChanged && emailPassword
? { currentPassword: emailPassword }
: {}),
heightCm: heightAdapter.toCanonicalCm(savedProfile.height),
dateOfBirth: savedProfile.dateOfBirth || null,
gender: savedProfile.gender || null,
fullName: savedProfile.fullName || null,
insurerName: savedProfile.insurerName || null,
insuranceNumber: savedProfile.insuranceNumber || null,
}),
// v1.44 — the timezone left this form: it saves on change in the
// Display card (`TimezoneSelect`), like the other display preferences.
const profileRes = await apiFetchRaw("/api/auth/profile", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email: savedProfile.email || null,
// An emptied field clears the name; the greeting falls back to
// the username, as it does for an account that never set one.
displayName: savedProfile.displayName || null,
...(emailChanged && emailPassword
? { currentPassword: emailPassword }
: {}),
heightCm: heightAdapter.toCanonicalCm(savedProfile.height),
dateOfBirth: savedProfile.dateOfBirth || null,
gender: savedProfile.gender || null,
fullName: savedProfile.fullName || null,
insurerName: savedProfile.insurerName || null,
insuranceNumber: savedProfile.insuranceNumber || null,
}),
user && timezone && timezone !== user.timezone
? apiFetchRaw("/api/auth/me/timezone", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ timezone }),
})
: Promise.resolve({ ok: true } as Response),
]);
});
setEmailPassword("");
if (profileRes.ok && tzRes.ok) {
// Instant same-tab flip of every rendered timestamp (issue #490):
// mirror the accepted zone before the `/me` refetch confirms it —
// the same synchronous-update pattern the time/date-format selects
// use. `refetch()` → `fetchMe` re-writes the mirror authoritatively.
if (user && timezone && timezone !== user.timezone) {
storeTimezone(timezone);
}
if (profileRes.ok) {
const profileJson = (await profileRes.json().catch(() => null)) as {
data?: { rejectedFields?: RejectedProfileField[] };
} | null;
@@ -351,22 +321,6 @@ export function AccountSection() {
setProfileSeed(savedProfile);
await refetch();
}
} else if (!tzRes.ok) {
// The dedicated tz endpoint owns the IANA validation error
// text. Surface its message verbatim so the user sees
// "Not a valid IANA timezone." instead of the generic save
// failure copy.
try {
const json = (await (tzRes as Response).json()) as { error?: string };
setSaveMsg(
json.error
? { text: json.error }
: { key: "settings.timezoneInvalid" },
);
} catch {
setSaveMsg({ key: "settings.timezoneInvalid" });
}
setSaveMsgType("error");
} else {
// The server's top-level `error` string is intentionally generic
// (never a raw validator message); resolve the specific, localized
@@ -584,10 +538,9 @@ export function AccountSection() {
</div>
</div>
{/* Date of birth + timezone share one paired grid row. The
timezone stays on this card because it saves with the form
(its own PUT runs beside the profile PUT); the display
preferences that save on change live in their own card below. */}
{/* Date of birth shares the grid of the fields above. The timezone
moved to the Display card below in v1.44: it saves on change
there, like the other display preferences. */}
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="dob">{t("settings.dateOfBirth")}</Label>
@@ -609,7 +562,6 @@ export function AccountSection() {
</p>
<FieldError id="dob-error" message={fieldErrors.dateOfBirth} />
</div>
<TimezonePicker value={timezone} onChange={setTimezone} />
</div>
{/* v1.7.0 — optional patient-identity fields surfaced on the
@@ -817,6 +769,7 @@ export function DisplayPreferencesCard({
<GlucoseUnitSelect isAuthenticated={isAuthenticated} />
<TimeFormatSelect isAuthenticated={isAuthenticated} />
<DateFormatSelect isAuthenticated={isAuthenticated} />
<TimezoneSelect isAuthenticated={isAuthenticated} />
</div>
</SettingsCard>
);
+56 -114
View File
@@ -4,17 +4,7 @@ import { useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { Loader2, Trash2 } from "lucide-react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { ConfirmDialog } from "@/components/ui/confirm-button";
import { Button } from "@/components/ui/button";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { SettingsCard } from "@/components/settings/settings-card";
@@ -137,62 +127,38 @@ function DataResetCard() {
</p>
)}
<SettingsCardActions>
<AlertDialog
<Button
type="button"
onClick={() => setConfirmOpen(true)}
variant="destructive"
size="sm"
disabled={deleting}
className="min-h-11 w-full shrink-0 sm:min-h-9 sm:w-auto"
>
{deleting ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<Trash2 className="h-3.5 w-3.5" />
)}
{t("settings.dangerZone")}
</Button>
<ConfirmDialog
open={confirmOpen}
onOpenChange={(open) => {
// 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);
}}
>
<AlertDialogTrigger asChild>
<Button
variant="destructive"
size="sm"
disabled={deleting}
className="min-h-11 w-full shrink-0 sm:min-h-9 sm:w-auto"
>
{deleting ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<Trash2 className="h-3.5 w-3.5" />
)}
{t("settings.dangerZone")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("settings.dangerZoneConfirm")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("settings.dangerZoneConfirmDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleting}>
{t("common.cancel")}
</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={(e) => {
e.preventDefault();
void handleDeleteAllData();
}}
disabled={deleting}
aria-busy={deleting || undefined}
data-slot="settings-data-reset-confirm"
>
{deleting && (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("settings.finalDelete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
title={t("settings.dangerZoneConfirm")}
body={t("settings.dangerZoneConfirmDescription")}
confirmLabel={t("settings.finalDelete")}
onConfirm={() => void handleDeleteAllData()}
pending={deleting}
slot="settings-data-reset"
holdOpen
destructive
/>
</SettingsCardActions>
</SettingsCard>
);
@@ -291,63 +257,39 @@ function AccountDeleteCard() {
</p>
)}
<SettingsCardActions>
<AlertDialog
<Button
type="button"
onClick={() => 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 ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<Trash2 className="h-3.5 w-3.5" />
)}
{t("settings.deleteAccountCta")}
</Button>
<ConfirmDialog
open={confirmOpen}
onOpenChange={(open) => {
// 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);
}}
>
<AlertDialogTrigger asChild>
<Button
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 ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<Trash2 className="h-3.5 w-3.5" />
)}
{t("settings.deleteAccountCta")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("settings.deleteAccountConfirmTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("settings.deleteAccountConfirmDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleting}>
{t("common.cancel")}
</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={(e) => {
e.preventDefault();
void handleDeleteAccount();
}}
disabled={deleting}
aria-busy={deleting || undefined}
data-slot="settings-account-delete-confirm"
>
{deleting && (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("settings.deleteAccountFinal")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
title={t("settings.deleteAccountConfirmTitle")}
body={t("settings.deleteAccountConfirmDescription")}
confirmLabel={t("settings.deleteAccountFinal")}
onConfirm={() => void handleDeleteAccount()}
pending={deleting}
slot="settings-account-delete"
holdOpen
destructive
/>
</SettingsCardActions>
</SettingsCard>
);
@@ -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 `<section>` 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
* (`<LayoutCoachFab>`, `<LayoutCoachMount>`, the inline `<CoachLaunchButton>`
* 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 `<MoodReminderCard>`: 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 `<MoodReminderCard>` pattern so the Switch
// reacts instantly to the user's tap.
const [optimistic, setOptimistic] = useState<boolean | null>(null);
const [msg, setMsg] = useState<string | null>(null);
const [msgType, setMsgType] = useState<"success" | "error" | null>(null);
// v1.4.48 — auto-clear the inline `<p role="status">` 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<ReturnType<typeof setTimeout> | 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 (
<SettingsCard
as="section"
aria-labelledby="settings-coach-activate-title"
data-testid="settings-disable-coach-card"
>
<SettingsCardHeader
anchor="coach-activate"
icon={MessageCircleHeart}
title={t("settings.coach.activate.title")}
titleId="settings-coach-activate-title"
description={t("settings.coach.activate.description")}
status={
<Switch
data-testid="settings-disable-coach-switch"
checked={activated}
onCheckedChange={handleToggle}
disabled={!isAuthenticated || mutation.isPending}
aria-label={t("settings.coach.activate.toggleAria")}
/>
}
/>
{msg && (
<p
role="status"
aria-live="polite"
className={
msgType === "error"
? "text-destructive mt-3 text-xs"
: "text-muted-foreground mt-3 text-xs"
}
>
{msg}
</p>
)}
</SettingsCard>
);
}
@@ -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({
</p>
)}
<SettingsCardActions>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button size="sm" variant="outline" className="min-h-11 sm:min-h-9">
<RotateCcw className="h-4 w-4" />
{t("settings.ai.providerChain.resetDefaults")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("settings.ai.providerChain.resetConfirmTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("settings.ai.providerChain.resetConfirmBody")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction onClick={resetToDefaults}>
{t("settings.ai.providerChain.resetDefaults")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<ConfirmButton
label={t("settings.ai.providerChain.resetDefaults")}
title={t("settings.ai.providerChain.resetConfirmTitle")}
body={t("settings.ai.providerChain.resetConfirmBody")}
confirmLabel={t("settings.ai.providerChain.resetDefaults")}
onConfirm={() => void resetToDefaults()}
size="sm"
className="min-h-11 sm:min-h-9"
icon={<RotateCcw className="h-4 w-4" />}
destructive
/>
<Button
size="sm"
className="min-h-11 sm:min-h-9"
@@ -41,6 +41,7 @@ import { EmergencyProfileManager } from "@/components/records/emergency-profile-
import { FamilyHistoryManager } from "@/components/records/family-history-manager";
import { AiProfileInclusionManager } from "@/components/records/ai-profile-inclusion-manager";
import { LifeCircumstancesManager } from "@/components/records/life-circumstances-manager";
import { ScreeningHintsCard } from "@/components/records/screening-hints-card";
export function AnamnesisSection() {
const { t } = useTranslations();
@@ -112,6 +113,11 @@ export function AnamnesisSection() {
<LifeCircumstancesManager />
</SettingsCard>
{/* 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). */}
<ScreeningHintsCard />
<SettingsCard>
<SettingsCardHeader
anchor="allergies"
+25 -77
View File
@@ -17,19 +17,9 @@ import {
import { toast } from "sonner";
import { showErrorToast } from "@/lib/errors/error-toast";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { ConfirmButton } from "@/components/ui/confirm-button";
import { EmptyState } from "@/components/ui/empty-state";
import { Input } from "@/components/ui/input";
import { QueryErrorCard } from "@/components/ui/query-error-card";
@@ -591,39 +581,19 @@ function ApiTokensCard() {
: t("settings.tokenNeverUsed")}
</td>
<td className="px-3 py-2 text-right">
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-foreground h-9 w-9"
aria-label={t("settings.tokenRevokeAction")}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("settings.tokenRevoke")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("settings.tokenRevokeDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{t("common.cancel")}
</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={() => handleRevoke(tok.id)}
>
{t("settings.tokenRevokeAction")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<ConfirmButton
label=""
ariaLabel={t("settings.tokenRevokeAction")}
title={t("settings.tokenRevoke")}
body={t("settings.tokenRevokeDescription")}
confirmLabel={t("settings.tokenRevokeAction")}
onConfirm={() => handleRevoke(tok.id)}
size="icon"
variant="ghost"
className="text-muted-foreground hover:text-foreground h-9 w-9"
icon={<Trash2 className="h-3.5 w-3.5" />}
destructive
/>
</td>
</tr>
);
@@ -690,39 +660,17 @@ function ApiTokensCard() {
? formatDateTime(tok.lastUsedAt)
: t("settings.tokenNeverUsed")}
</p>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="outline"
size="sm"
className="min-h-11 w-full"
>
<Trash2 className="h-3.5 w-3.5" />
{t("settings.tokenRevokeAction")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("settings.tokenRevoke")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("settings.tokenRevokeDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{t("common.cancel")}
</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={() => handleRevoke(tok.id)}
>
{t("settings.tokenRevokeAction")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<ConfirmButton
label={t("settings.tokenRevokeAction")}
title={t("settings.tokenRevoke")}
body={t("settings.tokenRevokeDescription")}
confirmLabel={t("settings.tokenRevokeAction")}
onConfirm={() => handleRevoke(tok.id)}
size="sm"
className="min-h-11 w-full"
icon={<Trash2 className="h-3.5 w-3.5" />}
destructive
/>
</li>
);
})}
+35 -11
View File
@@ -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)
);
}
@@ -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({
</p>
<SettingsCardActions>
{facts.length > 0 ? (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
type="button"
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
data-testid="settings-coach-memory-forget-all"
disabled={!isAuthenticated || forgetAll.isPending}
>
{forgetAll.isPending ? (
<Loader2
className="size-4 animate-spin motion-reduce:animate-none"
aria-hidden
/>
) : (
<Trash2 className="size-4" aria-hidden />
)}
{t("settings.ai.coachMemory.forgetAll")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("settings.ai.coachMemory.forgetAllConfirmTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("settings.ai.coachMemory.forgetAllConfirmBody")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{t("settings.ai.coachMemory.cancel")}
</AlertDialogCancel>
<AlertDialogAction
data-testid="settings-coach-memory-forget-all-confirm"
disabled={forgetAll.isPending}
aria-busy={forgetAll.isPending || undefined}
onClick={() => forgetAll.mutate()}
>
{forgetAll.isPending && (
<Loader2 className="mr-1 size-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("settings.ai.coachMemory.forgetAllConfirmAction")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<ConfirmButton
label={t("settings.ai.coachMemory.forgetAll")}
title={t("settings.ai.coachMemory.forgetAllConfirmTitle")}
body={t("settings.ai.coachMemory.forgetAllConfirmBody")}
confirmLabel={t("settings.ai.coachMemory.forgetAllConfirmAction")}
onConfirm={() => forgetAll.mutate()}
pending={forgetAll.isPending}
disabled={!isAuthenticated}
size="sm"
className="min-h-11 sm:min-h-9"
icon={<Trash2 className="size-4" aria-hidden />}
testId="settings-coach-memory-forget-all"
destructive
/>
) : null}
</SettingsCardActions>
</SettingsCard>
+48 -14
View File
@@ -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 @@
* `<SettingsSectionFrame>` 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 (
<div className="space-y-6">
<DisableCoachCard isAuthenticated={authed} />
{/* Before hydration the state reads on, the default, so the server
render and the first client paint agree. */}
<ModuleStateCard
moduleKey="coach"
enabled={!mounted || moduleSwitchState("coach")}
icon={MessageCircleHeart}
title={t("settings.coach.state.title")}
description={t("settings.coach.state.description")}
anchor="coach-activate"
slot="settings-coach-state-card"
/>
{coachEnabled && <CoachPrefsSection isAuthenticated={authed} />}
{/* 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 && (
<div id="coach-nudge" className="scroll-mt-28">
<CoachNudgeCard isAuthenticated={isAuthenticated} />
</div>
<SettingsCard data-slot="settings-coach-nudge-link">
<SettingsCardHeader
icon={BellRing}
title={t("notifications.coachNudge.title")}
description={t("settings.coach.nudgesMoved")}
/>
<SettingsCardActions align="start">
<Button asChild variant="outline" size="sm" className="min-h-11">
<Link href="/settings/notifications#coach-nudge">
{t("settings.coach.openNudges")}
<ChevronRight className="size-4" aria-hidden="true" />
</Link>
</Button>
</SettingsCardActions>
</SettingsCard>
)}
<CoachMemorySection isAuthenticated={authed} />
@@ -765,6 +765,7 @@ export function DashboardLayoutSection({ id }: { id: string }) {
confirmLabel={t("dashboard.layoutResetConfirm")}
pending={resetMutation.isPending}
onConfirm={() => resetMutation.mutate()}
destructive
/>
{dirty && (
<>
+8 -49
View File
@@ -1,8 +1,11 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
import { showErrorToast } from "@/lib/errors/error-toast";
import { Label } from "@/components/ui/label";
import { NativeSelect } from "@/components/ui/native-select";
import { useAuth } from "@/hooks/use-auth";
@@ -39,30 +42,6 @@ export function DateFormatSelect({
const [optimistic, setOptimistic] = useState<DateFormatPreference | null>(
null,
);
const [msg, setMsg] = useState<string | null>(null);
const [msgType, setMsgType] = useState<"success" | "error" | null>(null);
const clearTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
return () => {
if (clearTimerRef.current !== null) {
clearTimeout(clearTimerRef.current);
clearTimerRef.current = null;
}
};
}, []);
function scheduleClear() {
if (clearTimerRef.current !== null) {
clearTimeout(clearTimerRef.current);
}
clearTimerRef.current = setTimeout(() => {
clearTimerRef.current = null;
setMsg(null);
setMsgType(null);
}, 3000);
}
const value: DateFormatPreference = optimistic ?? user?.dateFormat ?? "AUTO";
const mutation = useMutation({
@@ -84,31 +63,19 @@ export function DateFormatSelect({
// Repaint every formatter consumer right away; the /me refetch below
// re-asserts the same value once it settles.
storeDateFormat(next);
setMsg(t("settings.dateFormat.saved"));
setMsgType("success");
toastWrittenOutcome("success", t("settings.dateFormat.saved"));
queryClient.invalidateQueries({ queryKey: queryKeys.authMe() });
setOptimistic(null);
scheduleClear();
},
onError: (err) => {
setOptimistic(null);
setMsg(
err instanceof Error ? err.message : t("settings.dateFormat.saveError"),
);
setMsgType("error");
scheduleClear();
showErrorToast(t("settings.dateFormat.saveError"), { cause: err });
},
});
function handleSelect(next: DateFormatPreference) {
if (next === value || mutation.isPending || !isAuthenticated) return;
setOptimistic(next);
setMsg(null);
setMsgType(null);
if (clearTimerRef.current !== null) {
clearTimeout(clearTimerRef.current);
clearTimerRef.current = null;
}
mutation.mutate(next);
}
@@ -128,16 +95,8 @@ export function DateFormatSelect({
</option>
))}
</NativeSelect>
<p
role="status"
aria-live="polite"
className={
msgType === "error"
? "text-destructive text-xs"
: "text-muted-foreground text-xs"
}
>
{msg ?? t("settings.dateFormat.description")}
<p className="text-muted-foreground text-xs">
{t("settings.dateFormat.description")}
</p>
</div>
);
+23 -60
View File
@@ -13,11 +13,8 @@ import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { TestConnectionButton } from "@/components/settings/test-connection-button";
import { isEmailFormDirty } from "@/components/settings/channel-form-dirty";
import {
optimisticallySetChannelEnabled,
persistChannelEnabled,
rollbackChannelEnabled,
} from "@/components/settings/notification-channel-toggle";
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
import { showErrorToast } from "@/lib/errors/error-toast";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
@@ -33,10 +30,9 @@ export function EmailCard({ isAuthenticated }: { isAuthenticated: boolean }) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const [recipient, setRecipient] = useState("");
const [saveMsg, setSaveMsg] = useState<string | null>(null);
const [saveMsgType, setSaveMsgType] = useState<"success" | "error" | null>(
null,
);
// The enable switch is a draft field: it saves with the form's one Save.
const [enabled, setEnabled] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const { data: settings } = useQuery({
queryKey: queryKeys.settingsEmail(),
@@ -47,16 +43,19 @@ export function EmailCard({ isAuthenticated }: { isAuthenticated: boolean }) {
});
// React-recommended sync-from-server pattern (no setState-in-effect).
const settingsKey = settings ? settings.recipient : null;
const settingsKey = settings
? `${settings.recipient}|${settings.enabled}`
: null;
const [seededKey, setSeededKey] = useState<string | null>(null);
if (settingsKey !== null && settingsKey !== seededKey) {
setSeededKey(settingsKey);
setRecipient(settings!.recipient);
setEnabled(settings!.enabled);
}
const dirty = isEmailFormDirty({ recipient }, settings);
const dirty = isEmailFormDirty({ recipient, enabled }, settings);
const save = useMutation({
mutationFn: async (enabled: boolean) => {
mutationFn: async () => {
const res = await apiFetchRaw("/api/settings/email", {
method: "PUT",
headers: { "Content-Type": "application/json" },
@@ -69,45 +68,12 @@ export function EmailCard({ isAuthenticated }: { isAuthenticated: boolean }) {
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.settingsEmail() });
setSaveMsg(t("settings.saved"));
setSaveMsgType("success");
setSaveError(null);
toastWrittenOutcome("success", t("settings.saved"));
},
onError: (err: Error) => {
setSaveMsg(err.message);
setSaveMsgType("error");
},
});
const toggleEnabled = useMutation<
void,
Error,
boolean,
EmailSettings | undefined
>({
mutationFn: (enabled) =>
persistChannelEnabled("/api/settings/email", enabled, t("common.error")),
onMutate: async (enabled) => {
const queryKey = queryKeys.settingsEmail();
await queryClient.cancelQueries({ queryKey });
setSaveMsg(null);
setSaveMsgType(null);
return optimisticallySetChannelEnabled<EmailSettings>(
queryClient,
queryKey,
enabled,
);
},
onSuccess: () => {
setSaveMsg(t("settings.saved"));
setSaveMsgType("success");
},
onError: (err, _enabled, previous) => {
rollbackChannelEnabled(queryClient, queryKeys.settingsEmail(), previous);
setSaveMsg(err.message);
setSaveMsgType("error");
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.settingsEmail() });
setSaveError(err.message);
showErrorToast(err.message, { cause: err });
},
});
@@ -128,16 +94,16 @@ export function EmailCard({ isAuthenticated }: { isAuthenticated: boolean }) {
<Label htmlFor="email-toggle">{t("settings.emailEnable")}</Label>
<Switch
id="email-toggle"
checked={settings?.enabled ?? false}
onCheckedChange={(checked) => toggleEnabled.mutate(checked)}
disabled={save.isPending || toggleEnabled.isPending}
checked={enabled}
onCheckedChange={setEnabled}
disabled={save.isPending}
/>
</div>
<form
onSubmit={(e) => {
e.preventDefault();
save.mutate(settings?.enabled ?? false);
save.mutate();
}}
className="space-y-4"
>
@@ -156,12 +122,9 @@ export function EmailCard({ isAuthenticated }: { isAuthenticated: boolean }) {
/>
</div>
{saveMsg && (saveMsgType === "error" || !dirty) && (
<p
role="alert"
className={`text-sm ${saveMsgType === "success" ? "text-success" : "text-destructive"}`}
>
{saveMsg}
{saveError && (
<p role="alert" className="text-destructive text-sm">
{saveError}
</p>
)}
@@ -173,7 +136,7 @@ export function EmailCard({ isAuthenticated }: { isAuthenticated: boolean }) {
/>
<Button
type="submit"
disabled={save.isPending || toggleEnabled.isPending}
disabled={save.isPending}
className="min-h-11"
>
{save.isPending && (
+10 -51
View File
@@ -1,8 +1,11 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
import { showErrorToast } from "@/lib/errors/error-toast";
import { Label } from "@/components/ui/label";
import { NativeSelect } from "@/components/ui/native-select";
import { useAuth } from "@/hooks/use-auth";
@@ -42,30 +45,6 @@ export function GlucoseUnitSelect({
const { user } = useAuth();
const [optimistic, setOptimistic] = useState<GlucoseUnit | null>(null);
const [msg, setMsg] = useState<string | null>(null);
const [msgType, setMsgType] = useState<"success" | "error" | null>(null);
const clearTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
return () => {
if (clearTimerRef.current !== null) {
clearTimeout(clearTimerRef.current);
clearTimerRef.current = null;
}
};
}, []);
function scheduleClear() {
if (clearTimerRef.current !== null) {
clearTimeout(clearTimerRef.current);
}
clearTimerRef.current = setTimeout(() => {
clearTimerRef.current = null;
setMsg(null);
setMsgType(null);
}, 3000);
}
const value: GlucoseUnit =
optimistic ?? resolveGlucoseUnit(user?.glucoseUnit);
@@ -85,36 +64,24 @@ export function GlucoseUnitSelect({
return next;
},
onSuccess: () => {
setMsg(t("settings.dashboard.glucoseUnit.saved"));
setMsgType("success");
toastWrittenOutcome("success", t("settings.dashboard.glucoseUnit.saved"));
queryClient.invalidateQueries({ queryKey: queryKeys.authMe() });
queryClient.invalidateQueries({
queryKey: queryKeys.userGlucoseUnit(),
});
setOptimistic(null);
scheduleClear();
},
onError: (err) => {
setOptimistic(null);
setMsg(
err instanceof Error
? err.message
: t("settings.dashboard.glucoseUnit.saveError"),
);
setMsgType("error");
scheduleClear();
showErrorToast(t("settings.dashboard.glucoseUnit.saveError"), {
cause: err,
});
},
});
function handleSelect(next: GlucoseUnit) {
if (next === value || mutation.isPending || !isAuthenticated) return;
setOptimistic(next);
setMsg(null);
setMsgType(null);
if (clearTimerRef.current !== null) {
clearTimeout(clearTimerRef.current);
clearTimerRef.current = null;
}
mutation.mutate(next);
}
@@ -134,16 +101,8 @@ export function GlucoseUnitSelect({
<option value="mg/dL">mg/dL</option>
<option value="mmol/L">mmol/L</option>
</NativeSelect>
<p
role="status"
aria-live="polite"
className={
msgType === "error"
? "text-destructive text-xs"
: "text-muted-foreground text-xs"
}
>
{msg ?? t("settings.dashboard.glucoseUnit.description")}
<p className="text-muted-foreground text-xs">
{t("settings.dashboard.glucoseUnit.description")}
</p>
</div>
);
@@ -74,6 +74,7 @@ export function InjectionSitesCard({
return (
<SettingsCard>
<SettingsCardHeader
anchor="injection-sites"
icon={Syringe}
title={t("settings.globalExcludedInjectionSitesLabel")}
description={t("settings.globalExcludedInjectionSitesHint")}
+5 -7
View File
@@ -5,7 +5,6 @@ import { surfaceModule } from "@/lib/modules/surface";
import { DashboardSection } from "./dashboard-section";
import { InsightsSection } from "./insights-section";
import { MedicationsSection } from "./medications-section";
import { MoodSection } from "./mood-section";
import { LabsSection } from "./labs-section";
import { IllnessSection } from "./illness-section";
import { DocumentsSection } from "./documents-section";
@@ -30,6 +29,11 @@ import { VorsorgeSection } from "./vorsorge-section";
* usable on the server. It holds references to the `"use client"` section
* components, which a server component renders as ordinary client boundaries.
*
* v1.44 — the hub holds order and view only ("Arrangement"). The mood tag
* management, the labs biomarker catalog and text recognition, and the
* injection sites moved to the module pages under Settings > Modules
* (`module-settings-pages.tsx`); `/settings/layout/mood` redirects there.
*
* Each module section keeps its module-gate: a disabled module's row / subpage
* does not render, read from the resolved `useAuth().user.modules` map (the
* same map the nav, the Modules hub, and the Insights pills gate off). The gate
@@ -76,12 +80,6 @@ export const LAYOUT_GROUPS: ReadonlyArray<LayoutGroup> = [
descriptionKey: "settings.sections.layout.medications.description",
Body: MedicationsSection,
},
{
id: "mood",
titleKey: "settings.sections.layout.mood.title",
descriptionKey: "settings.sections.layout.mood.description",
Body: MoodSection,
},
{
id: "labs",
titleKey: "settings.sections.layout.labs.title",
+55 -130
View File
@@ -17,22 +17,12 @@
* least-privilege read scope and nothing more.
*/
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Check, Copy, Key, Link2, Loader2, Plug, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { showErrorToast } from "@/lib/errors/error-toast";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { ConfirmButton } from "@/components/ui/confirm-button";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/ui/empty-state";
@@ -42,15 +32,12 @@ import { Skeleton } from "@/components/ui/skeleton";
import { Switch } from "@/components/ui/switch";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { ModuleStateCard } from "@/components/settings/module-state-card";
import { useAuth } from "@/hooks/use-auth";
import { formatDate, formatDateTime } from "@/lib/format";
import { useTranslations } from "@/lib/i18n/context";
import {
aiInputDependentKeys,
invalidateKeys,
queryKeys,
} from "@/lib/query-keys";
import { apiFetchRaw, apiGet, apiPatch } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
import {
recentProofErrorMessage,
throwIfReproofRequired,
@@ -169,39 +156,17 @@ function McpConnectionsCard() {
? formatDateTime(conn.lastUsedAt)
: t("settings.mcp.connectionNeverUsed")}
</p>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="outline"
size="sm"
className="min-h-11 w-full"
>
<Trash2 className="h-3.5 w-3.5" />
{t("settings.mcp.connectionRevoke")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("settings.mcp.connectionRevoke")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("settings.mcp.connectionRevokeDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{t("common.cancel")}
</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={() => handleRevoke(conn.id)}
>
{t("settings.mcp.connectionRevoke")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<ConfirmButton
label={t("settings.mcp.connectionRevoke")}
className="min-h-11 w-full"
title={t("settings.mcp.connectionRevoke")}
body={t("settings.mcp.connectionRevokeDescription")}
confirmLabel={t("settings.mcp.connectionRevoke")}
onConfirm={() => void handleRevoke(conn.id)}
slot="mcp-connection-revoke"
icon={<Trash2 className="h-3.5 w-3.5" />}
destructive
/>
</li>
))}
</ul>
@@ -211,10 +176,16 @@ function McpConnectionsCard() {
);
}
/**
* The connector's state, with a link to the one switch in Settings > Modules.
* Turning the module off pauses the endpoint: it refuses every call, while the
* tokens and connections below stay as they are and work again once it is on.
* Nothing here or in Modules revokes a token as a side effect (#1170); a token
* goes only through its own revoke.
*/
function McpEnableCard() {
const { t } = useTranslations();
const { user } = useAuth();
const queryClient = useQueryClient();
// The opt-in module is OFF until explicitly enabled — `user.modules.mcp` is
// the resolved state (true only when the user turned it on).
@@ -222,53 +193,29 @@ function McpEnableCard() {
const endpoint =
typeof window !== "undefined" ? `${window.location.origin}/mcp` : "/mcp";
const toggle = useMutation({
mutationKey: queryKeys.modulesPrefs(),
mutationFn: async (next: boolean) =>
apiPatch("/api/auth/me/modules", { mcp: next }),
onSuccess: () => {
void invalidateKeys(queryClient, aiInputDependentKeys);
toast.success(t("settings.sections.modules.saved"));
},
onError: (failure) =>
showErrorToast(t("settings.sections.modules.error"), { cause: failure }),
});
return (
<SettingsCard>
<SettingsCardHeader
anchor="mcp-enable"
icon={Plug}
title={t("settings.mcp.enableTitle")}
description={t("settings.mcp.enableDescription")}
/>
<div className="space-y-4">
<div className="flex items-center justify-between gap-4">
<span className="text-sm font-medium">
{t("settings.mcp.enableToggleLabel")}
</span>
<Switch
checked={enabled}
disabled={toggle.isPending}
onCheckedChange={(next) => toggle.mutate(next)}
aria-label={t("settings.mcp.enableToggleLabel")}
/>
</div>
<div>
<p className="text-muted-foreground mb-1 text-xs tracking-wide uppercase">
{t("settings.mcp.endpointLabel")}
</p>
<code className="bg-muted block rounded p-2 font-mono text-xs break-all">
{endpoint}
</code>
<p className="text-muted-foreground mt-2 text-xs leading-relaxed">
{t("settings.mcp.connectDescription")}
</p>
</div>
<ModuleStateCard
moduleKey="mcp"
enabled={enabled}
icon={Plug}
title={t("settings.mcp.enableTitle")}
description={t("settings.mcp.enableDescription")}
anchor="mcp-enable"
slot="settings-mcp-state-card"
>
<p className="text-sm">{t("settings.mcp.offKeepsTokens")}</p>
<div>
<p className="text-muted-foreground mb-1 text-xs tracking-wide uppercase">
{t("settings.mcp.endpointLabel")}
</p>
<code className="bg-muted block rounded p-2 font-mono text-xs break-all">
{endpoint}
</code>
<p className="text-muted-foreground mt-2 text-xs leading-relaxed">
{t("settings.mcp.connectDescription")}
</p>
</div>
</SettingsCard>
</ModuleStateCard>
);
}
@@ -508,39 +455,17 @@ function McpTokensCard() {
? formatDateTime(tok.lastUsedAt)
: t("settings.tokenNeverUsed")}
</p>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="outline"
size="sm"
className="min-h-11 w-full"
>
<Trash2 className="h-3.5 w-3.5" />
{t("settings.tokenRevokeAction")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("settings.tokenRevoke")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("settings.tokenRevokeDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{t("common.cancel")}
</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={() => handleRevoke(tok.id)}
>
{t("settings.tokenRevokeAction")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<ConfirmButton
label={t("settings.tokenRevokeAction")}
className="min-h-11 w-full"
title={t("settings.tokenRevoke")}
body={t("settings.tokenRevokeDescription")}
confirmLabel={t("settings.tokenRevokeAction")}
onConfirm={() => void handleRevoke(tok.id)}
slot="mcp-token-revoke"
icon={<Trash2 className="h-3.5 w-3.5" />}
destructive
/>
</li>
);
})}
@@ -11,8 +11,6 @@ import { MedicationViewToggle } from "@/components/medications/medication-view-t
import { SettingsCard } from "@/components/settings/settings-card";
import { Skeleton } from "@/components/ui/skeleton";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { InjectionSitesCard } from "@/components/settings/injection-sites-card";
import { useAuth } from "@/hooks/use-auth";
import { apiGet } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import { applyMedicationOrder } from "@/lib/medications/medication-order";
@@ -24,7 +22,7 @@ import { useMedicationListLayout } from "@/lib/queries/use-medication-list-layou
* from a Layout-hub child to its own standalone nav entry and gathered the
* medication-specific preferences here. v1.18.1 (D3) made medications a
* toggleable fail-open module, so this nav entry hides when the account
* turns the module off (the medication data routes stay live). Hosts three
* turns the module off (the medication data routes stay live). Hosts two
* blocks:
* 1. The list VIEW preference — cards vs table, written optimistically
* through the same `PUT /api/medications/layout` the page header
@@ -32,8 +30,9 @@ import { useMedicationListLayout } from "@/lib/queries/use-medication-list-layou
* 2. The manual ORDER editor — two grouped lists (Aktiv / Inaktiv)
* with drag + arrow reordering, flushed by an explicit Save,
* persisted as active-ids-then-inactive-ids on the same layout row.
* 3. Injection-site exclusions — moved here from the account profile so
* every medication-specific preference lives on one screen.
*
* v1.44 — Arrangement holds order and view only; the injection-site
* exclusions moved to the medications page under Modules.
*
* The list view + order both write through the same `/api/medications/layout`
* contract the /medications page reads, so a save here repaints both views.
@@ -49,7 +48,6 @@ interface MedicationListEntry {
export function MedicationsSection() {
const { t } = useTranslations();
const { isAuthenticated } = useAuth();
const { layout, isLayoutLoading, setView } = useMedicationListLayout();
const { data: medications, isLoading } = useQuery({
@@ -120,11 +118,6 @@ export function MedicationsSection() {
<MedicationOrderEditor medications={ordered} />
)}
</SettingsCard>
{/* v1.18.0 — injection-site exclusions are a medication setting;
they moved here from the account profile so all medication-specific
preferences live in one place. */}
<InjectionSitesCard isAuthenticated={isAuthenticated} />
</div>
);
}
@@ -0,0 +1,40 @@
import type { ModuleKey } from "@/lib/modules/registry";
import { surfaceModule } from "@/lib/modules/surface";
/**
* Server-safe ids and copy keys of the module pages under Settings > Modules
* (`/settings/modules/<id>`). The route reads them in
* `generateStaticParams()`, so they live apart from the client bodies in
* `module-settings-pages.tsx`.
*/
export const MODULE_SETTINGS_PAGE_IDS = [
"medications",
"mood",
"labs",
] as const;
export type ModuleSettingsPageId = (typeof MODULE_SETTINGS_PAGE_IDS)[number];
export function isModuleSettingsPageId(
value: string,
): value is ModuleSettingsPageId {
return (MODULE_SETTINGS_PAGE_IDS as readonly string[]).includes(value);
}
/** i18n keys of a page's heading, under `settings.sections.modules.pages`. */
export function moduleSettingsPageKeys(id: ModuleSettingsPageId): {
titleKey: string;
descriptionKey: string;
} {
return {
titleKey: `settings.sections.modules.pages.${id}.title`,
descriptionKey: `settings.sections.modules.pages.${id}.description`,
};
}
/** The module a page belongs to, from the surface map. */
export function moduleSettingsPageModule(
id: ModuleSettingsPageId,
): ModuleKey | undefined {
return surfaceModule(`settings-module:${id}`);
}
@@ -0,0 +1,117 @@
"use client";
/**
* The pages a module's own settings live on: `/settings/modules/<id>`.
*
* Settings > Arrangement holds order and view only. What a module needs
* beyond that (the biomarker catalog and on-device text recognition for
* labs, the mood tags, the injection sites a medication rotation leaves out)
* lives on the module's page under Modules, reached from the module's row in
* Settings > Modules and from the wrench on the module's own page. Each page
* links to the module's arrangement page and back.
*
* Every page is gated on its module through the surface map
* (`settings-module:<id>`), fail-open like the rest of Settings.
*/
import Link from "next/link";
import { ArrowUpDown, ChevronRight, SlidersHorizontal } from "lucide-react";
import { LabsModuleSettings } from "@/components/labs/labs-settings";
import { InjectionSitesCard } from "@/components/settings/injection-sites-card";
import { MoodSection } from "@/components/settings/mood-section";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { Button } from "@/components/ui/button";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import type { ModuleSettingsPageId } from "./module-settings-page-ids";
/** A card pointing at the module's order-and-view page under Arrangement. */
function ArrangementLinkCard({ id }: { id: ModuleSettingsPageId }) {
const { t } = useTranslations();
return (
<SettingsCard data-slot="module-settings-arrangement-link">
<SettingsCardHeader
icon={ArrowUpDown}
title={t("settings.sections.modules.pages.arrangementTitle")}
description={t(
"settings.sections.modules.pages.arrangementDescription",
)}
/>
<SettingsCardActions align="start">
<Button asChild variant="outline" size="sm" className="min-h-11">
<Link href={`/settings/layout/${id}`}>
{t("settings.sections.modules.pages.openArrangement")}
<ChevronRight className="size-4" aria-hidden="true" />
</Link>
</Button>
</SettingsCardActions>
</SettingsCard>
);
}
function MedicationsModulePage() {
const { isAuthenticated } = useAuth();
return (
<div className="space-y-6">
<InjectionSitesCard isAuthenticated={isAuthenticated} />
<ArrangementLinkCard id="medications" />
</div>
);
}
function LabsModulePage() {
return (
<div className="space-y-6">
<LabsModuleSettings />
<ArrangementLinkCard id="labs" />
</div>
);
}
function MoodModulePage() {
return <MoodSection />;
}
export const MODULE_SETTINGS_PAGE_BODIES: Record<
ModuleSettingsPageId,
() => React.JSX.Element
> = {
medications: MedicationsModulePage,
mood: MoodModulePage,
labs: LabsModulePage,
};
export function ModuleSettingsPageBody({ id }: { id: ModuleSettingsPageId }) {
const Body = MODULE_SETTINGS_PAGE_BODIES[id];
return <Body />;
}
/**
* The way from a module's arrangement page to its own page under Modules,
* for the modules that have one.
*/
export function ModuleSettingsLinkCard({ id }: { id: ModuleSettingsPageId }) {
const { t } = useTranslations();
return (
<SettingsCard data-slot="arrangement-module-settings-link">
<SettingsCardHeader
icon={SlidersHorizontal}
title={t("settings.sections.modules.pages.moduleSettingsTitle")}
description={t(
"settings.sections.modules.pages.moduleSettingsDescription",
)}
/>
<SettingsCardActions align="start">
<Button asChild variant="outline" size="sm" className="min-h-11">
<Link href={`/settings/modules/${id}`}>
{t("settings.sections.modules.openModuleSettings")}
<ChevronRight className="size-4" aria-hidden="true" />
</Link>
</Button>
</SettingsCardActions>
</SettingsCard>
);
}
@@ -0,0 +1,72 @@
"use client";
/**
* A module's on/off state, shown where the module is configured, with a link
* to the one switch in Settings > Modules.
*
* The Coach and the MCP connector each had a second switch on their own page
* beside the one in Modules: two controls for one state, and a person who
* flipped one never knew the other existed. The switch stays in Modules (the
* row carries `id="module-<key>"`); the module's page says what the state is
* and where to change it.
*/
import Link from "next/link";
import { ChevronRight, type LucideIcon } from "lucide-react";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { useTranslations } from "@/lib/i18n/context";
import type { ModuleKey } from "@/lib/modules/registry";
export function ModuleStateCard({
moduleKey,
enabled,
icon,
title,
description,
anchor,
slot,
children,
}: {
moduleKey: ModuleKey;
enabled: boolean;
icon: LucideIcon;
title: string;
description: string;
anchor: string;
slot: string;
/** Body copy between the header and the link, e.g. what "off" keeps. */
children?: React.ReactNode;
}) {
const { t } = useTranslations();
return (
<SettingsCard data-slot={slot} data-enabled={enabled ? "true" : "false"}>
<SettingsCardHeader
anchor={anchor}
icon={icon}
title={title}
description={description}
status={
<Badge variant={enabled ? "secondary" : "outline"}>
{enabled
? t("settings.moduleState.on")
: t("settings.moduleState.off")}
</Badge>
}
/>
{children}
<SettingsCardActions align="start">
<Button asChild variant="outline" size="sm" className="min-h-11">
<Link href={`/settings/modules#module-${moduleKey}`}>
{t("settings.moduleState.change")}
<ChevronRight className="size-4" aria-hidden="true" />
</Link>
</Button>
</SettingsCardActions>
</SettingsCard>
);
}
@@ -92,7 +92,12 @@ export function ModuleToggleRow({
// is in flight.
const interactive = !locked && disabledReason == null;
return (
<div className="flex items-start justify-between gap-4 py-3">
// `id="module-<key>"` is the anchor other pages link to when they show a
// module's state and send the person here to change it.
<div
id={`module-${moduleKey}`}
className="flex scroll-mt-28 items-start justify-between gap-4 py-3"
>
<div className="flex min-w-0 items-start gap-3">
<Icon
className="text-muted-foreground mt-0.5 h-5 w-5 shrink-0"
+11 -1
View File
@@ -40,6 +40,7 @@ import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { MODULE_ICONS } from "@/components/settings/module-icons";
import { ModuleToggleRow } from "@/components/settings/module-toggle-row";
import { isModuleSettingsPageId } from "@/components/settings/module-settings-page-ids";
import { TimelineReadinessSheet } from "@/components/timeline/readiness-sheet.lazy";
import { useAuth } from "@/hooks/use-auth";
import { useAiCapability } from "@/hooks/use-ai-capability";
@@ -117,6 +118,10 @@ export function ModulesSection() {
// Delegated modules carry more than on/off at their canonical
// surface (Coach cadence/memory; cycle goal/predictions/lengths),
// so keep a small deep-link to it beside the live switch.
//
// v1.44 — a module with its own page under Modules (labs, mood,
// medications) links to it the same way: what the module needs
// beyond order and view lives there, not under Arrangement.
const manageLink =
delegate !== undefined && def.managedAt
? {
@@ -125,7 +130,12 @@ export function ModulesSection() {
section: t(def.managedAt.labelKey),
}),
}
: undefined;
: isModuleSettingsPageId(key)
? {
href: `/settings/modules/${key}`,
label: t("settings.sections.modules.openModuleSettings"),
}
: undefined;
return (
<ModuleToggleRow
@@ -1,44 +0,0 @@
import type { QueryClient, QueryKey } from "@tanstack/react-query";
import { apiFetchRaw } from "@/lib/api/api-fetch";
interface EnabledSettings {
enabled: boolean;
}
export async function persistChannelEnabled(
endpoint: string,
enabled: boolean,
fallbackError: string,
): Promise<void> {
const response = await apiFetchRaw(endpoint, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled }),
});
if (!response.ok) {
const json = (await response.json()) as { error?: string };
throw new Error(json.error || fallbackError);
}
}
export function optimisticallySetChannelEnabled<T extends EnabledSettings>(
queryClient: QueryClient,
queryKey: QueryKey,
enabled: boolean,
): T | undefined {
const previous = queryClient.getQueryData<T>(queryKey);
queryClient.setQueryData<T>(queryKey, (current) =>
current ? { ...current, enabled } : current,
);
return previous;
}
export function rollbackChannelEnabled<T extends EnabledSettings>(
queryClient: QueryClient,
queryKey: QueryKey,
previous: T | undefined,
): void {
queryClient.setQueryData(queryKey, previous);
}
+116 -46
View File
@@ -1,78 +1,148 @@
"use client";
import Link from "next/link";
import { Bell, Smartphone } from "lucide-react";
import { Bell, BellRing, ChevronRight, Smartphone } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
import { useMounted } from "@/hooks/use-mounted";
import { useTranslations } from "@/lib/i18n/context";
import { isSurfaceVisible } from "@/lib/modules/surface";
import { CoachNudgeCard } from "@/components/settings/coach-nudge-card";
import { LowStockCard } from "@/components/settings/low-stock-card";
import { SectionHeading } from "@/components/ui/section-heading";
import { MoodReminderCard } from "@/components/settings/mood-reminder-card";
import { NotificationContentCard } from "@/components/settings/notification-content-card";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { VorsorgeReminderSwitchesCard } from "@/components/measurement-reminders/vorsorge-settings";
import { Button } from "@/components/ui/button";
import { InlineAction } from "@/components/ui/inline-action";
/**
* `<NotificationsSection>` — Settings → Notifications ("Benachrichtigungen").
*
* v1.18.0 — the single module-gated home for reminder TYPES. Earlier the
* settings carried two near-identical entries: this Notifications screen and a
* separate "Erinnerungen" hub that only deep-linked into the canonical editors
* ("doppelt gemoppelt"). The hub is gone (its route 301-redirects here); the
* reminder TYPES now live centrally on this one screen.
* The one home for every reminder kind. A reminder whose settings belong to
* one place is set here; a reminder that is set where its subject lives (a
* medication's dose times on the medication, the cycle reminders in the
* per-event matrix) gets a row here that says where, so nothing has to be
* found by luck.
*
* Each reminder-type card is shown ONLY when its module is enabled, read from
* the resolved `useAuth().user.modules` map (the same `/auth/me` map nav and
* Insights pills gate off). Mood → `mood`, low-stock runway → `medications`
* (a toggleable fail-open module since D3, so a missing key reads as enabled
* and a stale `/me` payload never blanks a card).
* Each card is shown only while its module is on, read from the resolved
* `useAuth().user.modules` map and failing open, so a stale `/me` payload
* never blanks a card.
*
* v1.25.7 — delivery channels move to Settings → Integrationen (channels are
* delivery providers, the same family as the connected services). This screen
* keeps ONLY the reminder-TYPE content; `/settings/channels` 301-redirects to
* `/settings/integrations#channels`.
*
* v1.18.6 — the page heading + subtitle come from the shared
* `<SettingsSectionFrame>`. The proactive-Coach nudge card lives under
* Settings → Coach (it is a Coach setting, not a generic notification).
* v1.18.0 — the separate "Erinnerungen" hub is gone; `/settings/reminders`
* redirects here. v1.25.7 — the delivery channels live under Integrations
* (they are delivery providers); `/settings/channels` redirects there and this
* page links to them. v1.44 — the Coach nudges came back from Settings > Coach
* and the checkup reminder switches from the checkups arrangement page; both
* old places link here (`/settings/coach#coach-nudge` forwards).
*/
export function NotificationsSection() {
const { isAuthenticated, user } = useAuth();
const { t } = useTranslations();
// The Coach gate reads the surface map like the Coach section does, after
// hydration, so the server render and the first client paint agree.
const mounted = useMounted();
// v1.18.0 — module-gated per-type visibility. Fail OPEN (`!== false`)
// so a stale `/me` payload without the module map keeps every card visible.
const moodEnabled = user?.modules?.mood !== false;
const medsEnabled = user?.modules?.medications !== false;
const anyReminder = moodEnabled || medsEnabled;
const cycleEnabled = user?.modules?.cycle !== false;
const coachEnabled =
!mounted || isSurfaceVisible("settings:coach", user?.modules);
return (
<div className="space-y-10">
{/* Group 1 — Reminders ("what you receive"). Rendered only when at
least one reminder-type card is visible, so a fully-disabled
account never shows an empty heading. */}
{anyReminder ? (
<section className="space-y-4">
<SectionHeading
icon={Bell}
title={t("settings.sections.notifications.remindersHeading")}
/>
{/* Group 1 — Reminders ("what you receive"). The checkup reminders are
not a module and always show, so the group always has content. */}
<section className="space-y-4">
<SectionHeading
icon={Bell}
title={t("settings.sections.notifications.remindersHeading")}
/>
{/* Mood check-in reminder — only when the mood module is enabled. */}
{moodEnabled ? (
<div id="mood-reminder" className="scroll-mt-28">
<MoodReminderCard isAuthenticated={isAuthenticated} />
</div>
) : null}
{moodEnabled ? (
<div id="mood-reminder" className="scroll-mt-28">
<MoodReminderCard isAuthenticated={isAuthenticated} />
</div>
) : null}
{/* Medication low-stock runway — only when the medications module
is enabled (toggleable, fail-open, since D3). */}
{medsEnabled ? (
<div id="low-stock" className="scroll-mt-28">
<LowStockCard isAuthenticated={isAuthenticated} />
</div>
) : null}
</section>
) : null}
{medsEnabled ? (
<div id="low-stock" className="scroll-mt-28">
<LowStockCard isAuthenticated={isAuthenticated} />
</div>
) : null}
{coachEnabled ? (
<div id="coach-nudge" className="scroll-mt-28">
<CoachNudgeCard isAuthenticated={isAuthenticated} />
</div>
) : null}
<div id="vorsorge-reminders" className="scroll-mt-28">
<VorsorgeReminderSwitchesCard />
</div>
{/* Reminders set where their subject lives. One row each, saying
where; nothing here writes. */}
{medsEnabled || cycleEnabled ? (
<SettingsCard data-slot="notifications-elsewhere">
<SettingsCardHeader
anchor="other-reminders"
icon={BellRing}
title={t("settings.sections.notifications.elsewhere.title")}
description={t(
"settings.sections.notifications.elsewhere.description",
)}
/>
<ul className="divide-border divide-y">
{medsEnabled ? (
<li className="space-y-1 py-3 first:pt-0">
<p className="text-sm font-medium">
{t("settings.sections.notifications.elsewhere.doses.title")}
</p>
<p className="text-muted-foreground text-xs">
{t(
"settings.sections.notifications.elsewhere.doses.description",
)}
</p>
<InlineAction asChild tone="muted">
<Link href="/medications">
{t(
"settings.sections.notifications.elsewhere.doses.link",
)}
<ChevronRight className="size-3.5" aria-hidden="true" />
</Link>
</InlineAction>
</li>
) : null}
{cycleEnabled ? (
<li
id="cycle-reminders"
className="scroll-mt-28 space-y-1 py-3 first:pt-0"
>
<p className="text-sm font-medium">
{t("settings.sections.notifications.elsewhere.cycle.title")}
</p>
<p className="text-muted-foreground text-xs">
{t(
"settings.sections.notifications.elsewhere.cycle.description",
)}
</p>
<InlineAction asChild tone="muted">
<Link href="/notifications">
{t(
"settings.sections.notifications.elsewhere.cycle.link",
)}
<ChevronRight className="size-3.5" aria-hidden="true" />
</Link>
</InlineAction>
</li>
) : null}
</ul>
</SettingsCard>
) : null}
</section>
{/* Group 2 — How notifications look on the phone. Not module-gated:
it applies to every notification the account receives. */}
+24 -60
View File
@@ -14,11 +14,8 @@ import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { TestConnectionButton } from "@/components/settings/test-connection-button";
import { isNtfyFormDirty } from "@/components/settings/channel-form-dirty";
import {
optimisticallySetChannelEnabled,
persistChannelEnabled,
rollbackChannelEnabled,
} from "@/components/settings/notification-channel-toggle";
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
import { showErrorToast } from "@/lib/errors/error-toast";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
@@ -37,10 +34,9 @@ export function NtfyCard({ isAuthenticated }: { isAuthenticated: boolean }) {
const [serverUrl, setServerUrl] = useState("https://ntfy.sh");
const [topic, setTopic] = useState("");
const [authToken, setAuthToken] = useState("");
const [saveMsg, setSaveMsg] = useState<string | null>(null);
const [saveMsgType, setSaveMsgType] = useState<"success" | "error" | null>(
null,
);
// The enable switch is a draft field: it saves with the form's one Save.
const [enabled, setEnabled] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const { data: settings } = useQuery({
queryKey: queryKeys.settingsNtfy(),
@@ -52,18 +48,22 @@ export function NtfyCard({ isAuthenticated }: { isAuthenticated: boolean }) {
// React-recommended sync-from-server pattern (no setState-in-effect).
const settingsKey = settings
? `${settings.serverUrl}|${settings.topic}`
? `${settings.serverUrl}|${settings.topic}|${settings.enabled}`
: null;
const [seededKey, setSeededKey] = useState<string | null>(null);
if (settingsKey && settingsKey !== seededKey) {
setSeededKey(settingsKey);
setServerUrl(settings!.serverUrl);
setTopic(settings!.topic);
setEnabled(settings!.enabled);
}
const dirty = isNtfyFormDirty({ serverUrl, topic, authToken }, settings);
const dirty = isNtfyFormDirty(
{ serverUrl, topic, authToken, enabled },
settings,
);
const save = useMutation({
mutationFn: async (enabled: boolean) => {
mutationFn: async () => {
const res = await apiFetchRaw("/api/settings/ntfy", {
method: "PUT",
headers: { "Content-Type": "application/json" },
@@ -93,46 +93,13 @@ export function NtfyCard({ isAuthenticated }: { isAuthenticated: boolean }) {
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.settingsNtfy() });
setSaveMsg(t("settings.saved"));
setSaveMsgType("success");
setSaveError(null);
toastWrittenOutcome("success", t("settings.saved"));
setAuthToken("");
},
onError: (err: Error) => {
setSaveMsg(err.message);
setSaveMsgType("error");
},
});
const toggleEnabled = useMutation<
void,
Error,
boolean,
NtfySettings | undefined
>({
mutationFn: (enabled) =>
persistChannelEnabled("/api/settings/ntfy", enabled, t("common.error")),
onMutate: async (enabled) => {
const queryKey = queryKeys.settingsNtfy();
await queryClient.cancelQueries({ queryKey });
setSaveMsg(null);
setSaveMsgType(null);
return optimisticallySetChannelEnabled<NtfySettings>(
queryClient,
queryKey,
enabled,
);
},
onSuccess: () => {
setSaveMsg(t("settings.saved"));
setSaveMsgType("success");
},
onError: (err, _enabled, previous) => {
rollbackChannelEnabled(queryClient, queryKeys.settingsNtfy(), previous);
setSaveMsg(err.message);
setSaveMsgType("error");
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.settingsNtfy() });
setSaveError(err.message);
showErrorToast(err.message, { cause: err });
},
});
@@ -149,16 +116,16 @@ export function NtfyCard({ isAuthenticated }: { isAuthenticated: boolean }) {
<Label htmlFor="ntfy-toggle">{t("settings.ntfyEnable")}</Label>
<Switch
id="ntfy-toggle"
checked={settings?.enabled ?? false}
onCheckedChange={(checked) => toggleEnabled.mutate(checked)}
disabled={save.isPending || toggleEnabled.isPending}
checked={enabled}
onCheckedChange={setEnabled}
disabled={save.isPending}
/>
</div>
<form
onSubmit={(e) => {
e.preventDefault();
save.mutate(settings?.enabled ?? false);
save.mutate();
}}
className="space-y-4"
>
@@ -199,12 +166,9 @@ export function NtfyCard({ isAuthenticated }: { isAuthenticated: boolean }) {
</div>
</div>
{saveMsg && (saveMsgType === "error" || !dirty) && (
<p
role="alert"
className={`text-sm ${saveMsgType === "success" ? "text-success" : "text-destructive"}`}
>
{saveMsg}
{saveError && (
<p role="alert" className="text-destructive text-sm">
{saveError}
</p>
)}
@@ -216,7 +180,7 @@ export function NtfyCard({ isAuthenticated }: { isAuthenticated: boolean }) {
/>
<Button
type="submit"
disabled={save.isPending || toggleEnabled.isPending}
disabled={save.isPending}
className="min-h-11"
>
{save.isPending && (
+116
View File
@@ -0,0 +1,116 @@
"use client";
/**
* Settings > System > Preview features.
*
* One switch per preview feature, each off until the person turns it on, each
* saved the moment it changes (`PATCH /api/settings/preview-features`). A
* preview adds something beside what is there and never replaces a page.
*
* The `topics` flag is stored and shown here; the topic overview pages that
* read it arrive in a later release, and the card says so rather than
* promising a change the switch does not make yet.
*/
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import Link from "next/link";
import { LayoutDashboard } from "lucide-react";
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { InlineAction } from "@/components/ui/inline-action";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { Switch } from "@/components/ui/switch";
import { apiGet, apiPatch } from "@/lib/api/api-fetch";
import { showErrorToast } from "@/lib/errors/error-toast";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import {
DEFAULT_PREVIEW_FEATURES,
type PreviewFeatures,
} from "@/lib/settings/preview-features";
const DISCUSSIONS_URL = "https://github.com/MBombeck/HealthLog/discussions";
export function PreviewSection() {
const { t } = useTranslations();
const queryClient = useQueryClient();
const key = queryKeys.previewFeatures();
const query = useQuery({
queryKey: key,
queryFn: () => apiGet<PreviewFeatures>("/api/settings/preview-features"),
});
const features = query.data ?? DEFAULT_PREVIEW_FEATURES;
const toggle = useMutation({
mutationKey: key,
mutationFn: (topics: boolean) =>
apiPatch<PreviewFeatures>("/api/settings/preview-features", { topics }),
onMutate: async (topics) => {
await queryClient.cancelQueries({ queryKey: key });
const previous = queryClient.getQueryData<PreviewFeatures>(key);
queryClient.setQueryData<PreviewFeatures>(key, {
...(previous ?? DEFAULT_PREVIEW_FEATURES),
topics,
});
return { previous };
},
onSuccess: (saved, topics) => {
if (saved) queryClient.setQueryData(key, saved);
toastWrittenOutcome(
"success",
topics
? t("settings.preview.topics.savedOn")
: t("settings.preview.topics.savedOff"),
);
},
onError: (failure, _topics, context) => {
queryClient.setQueryData(key, context?.previous);
showErrorToast(t("settings.savingError"), { cause: failure });
},
});
return (
<SettingsCard data-slot="settings-preview-topics">
<SettingsCardHeader
anchor="preview-topics"
icon={LayoutDashboard}
title={t("settings.preview.topics.title")}
titleId="settings-preview-topics-title"
description={t("settings.preview.topics.description")}
status={
<Switch
data-slot="settings-preview-topics-switch"
checked={features.topics}
disabled={query.isLoading || toggle.isPending}
onCheckedChange={(next) => toggle.mutate(next)}
aria-labelledby="settings-preview-topics-title"
/>
}
/>
{query.isError ? (
<QueryErrorRow
inset
error={query.error}
slot="settings-preview-error"
message={t("settings.preview.loadError")}
onRetry={() => void query.refetch()}
/>
) : null}
<p className="text-sm">{t("settings.preview.topics.detail")}</p>
<p className="text-muted-foreground text-xs">
<InlineAction asChild tone="muted" size="xs">
<Link
href={DISCUSSIONS_URL}
target="_blank"
rel="noopener noreferrer"
>
{t("settings.preview.feedbackLink")}
</Link>
</InlineAction>
</p>
</SettingsCard>
);
}
+9 -37
View File
@@ -4,18 +4,20 @@
* v1.23 — "Data & Privacy" dashboard (P7).
*
* One coherent pane that ASSEMBLES already-shipped machinery — it does not
* rebuild any of it. Each block either embeds an existing card (active
* sessions, security activity, trusted devices) or links to the existing
* surface that owns the action (export incl. the passphrase option, account
* deletion / data reset, AI privacy mode, research mode). The retention +
* encryption facts are read from the server so the copy stays truthful, and
* the backup↔deletion lag is disclosed honestly.
* rebuild any of it. Each block states a fact or links to the surface that
* owns the action (account deletion / data reset, AI privacy mode). The
* retention + encryption facts are read from the server so the copy stays
* truthful, and the backup↔deletion lag is disclosed honestly.
*
* v1.44 — the active sessions, trusted devices and security activity moved
* to Settings > Security beside the sign-in controls, and the export card
* left: Export & import and the Health record sit next to this page in the
* Data group. The old anchors forward (`settings-anchor-forward.tsx`).
*/
import { useQuery } from "@tanstack/react-query";
import Link from "next/link";
import {
Database,
Download,
ShieldCheck,
Trash2,
Clock,
@@ -29,9 +31,6 @@ import { Skeleton } from "@/components/ui/skeleton";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { SettingsInfoTile } from "@/components/settings/_info-tile";
import { SecuritySessionsCard } from "@/components/settings/security-sessions-card";
import { SecurityActivityCard } from "@/components/settings/security-activity-card";
import { TrustedDevicesCard } from "@/components/settings/trusted-devices-card";
import { WorkoutRoutesCard } from "@/components/settings/workout-routes-card";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
@@ -181,28 +180,6 @@ export function PrivacySection() {
)}
</SettingsCard>
{/* Export */}
<SettingsCard>
<SettingsCardHeader
anchor="your-data-export"
icon={Download}
title={t("settings.privacy.export.title")}
description={t("settings.privacy.export.description")}
/>
<SettingsCardActions align="start">
<Button asChild variant="outline" size="sm">
<Link href="/settings/export">
{t("settings.privacy.export.openExport")}
</Link>
</Button>
<Button asChild variant="outline" size="sm">
<Link href="/settings/gesundheitsakte">
{t("settings.privacy.export.openHealthRecord")}
</Link>
</Button>
</SettingsCardActions>
</SettingsCard>
{/* Delete / reset — with the honest backup lag disclosure */}
<SettingsCard>
<SettingsCardHeader
@@ -241,11 +218,6 @@ export function PrivacySection() {
</Button>
</SettingsCardActions>
</SettingsCard>
{/* Active sessions + trusted devices + security activity (embedded) */}
<SecuritySessionsCard isAuthenticated={isAuthenticated} />
<TrustedDevicesCard isAuthenticated={isAuthenticated} />
<SecurityActivityCard isAuthenticated={isAuthenticated} />
</div>
);
}
+3
View File
@@ -114,6 +114,9 @@ export const SETTINGS_SECTION_SLUGS = [
// pieces into one coherent privacy pane. Sits next to `advanced` (the
// destructive-action home) and before `about`.
"privacy",
// v1.44 — "Preview features": per-user opt-in to things finished enough to
// try and not yet the default. Sits in the System group before About.
"preview",
"about",
] as const;
@@ -7,6 +7,9 @@ import { useAuth } from "@/hooks/use-auth";
import { queryKeys } from "@/lib/query-keys";
import { apiGet } from "@/lib/api/api-fetch";
import { PasswordCard } from "@/components/settings/password-card";
import { SecurityActivityCard } from "@/components/settings/security-activity-card";
import { SecuritySessionsCard } from "@/components/settings/security-sessions-card";
import { TrustedDevicesCard } from "@/components/settings/trusted-devices-card";
import { TotpCard } from "./totp-card";
import { SecurityKeysCard, type WebauthnKeyInfo } from "./security-keys-card";
import { PasskeyListSection } from "./passkey-list-section";
@@ -79,6 +82,15 @@ export function SecuritySection() {
`/api/auth/me/mfa` that never answers must not take it down with the
cards that do need the status. */}
<PasswordCard />
{/* v1.44 — who is signed in and what happened, beside the controls
that decide how one signs in. These lived on the privacy page;
`/settings/privacy#sessions` (and the other two anchors) forward
here. Outside the status conditional for the same reason as the
password: they read their own routes. */}
<SecuritySessionsCard isAuthenticated={isAuthenticated} />
<TrustedDevicesCard isAuthenticated={isAuthenticated} />
<SecurityActivityCard isAuthenticated={isAuthenticated} />
</div>
);
}
@@ -27,6 +27,7 @@ import {
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { ConfirmButton } from "@/components/ui/confirm-button";
import { Input } from "@/components/ui/input";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
@@ -311,46 +312,21 @@ export function PasskeyListSection({
>
<Pencil className="h-4 w-4" />
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-foreground min-h-11 min-w-11 sm:h-8 sm:min-h-0 sm:w-8 sm:min-w-0"
disabled={remove.isPending}
aria-label={t("settings.deletePasskey")}
>
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("settings.deletePasskey")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("settings.deletePasskeyDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{t("common.cancel")}
</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
disabled={remove.isPending}
aria-busy={remove.isPending || undefined}
onClick={() => remove.mutate(pk.id)}
>
{remove.isPending && (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("common.delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<ConfirmButton
label=""
ariaLabel={t("settings.deletePasskey")}
title={t("settings.deletePasskey")}
body={t("settings.deletePasskeyDescription")}
confirmLabel={t("common.delete")}
onConfirm={() => remove.mutate(pk.id)}
pending={remove.isPending && remove.variables === pk.id}
disabled={remove.isPending}
size="icon"
variant="ghost"
className="text-muted-foreground hover:text-foreground min-h-11 min-w-11 sm:h-8 sm:min-h-0 sm:w-8 sm:min-w-0"
icon={<Trash2 className="h-4 w-4" />}
destructive
/>
</div>
)}
</div>
@@ -13,18 +13,8 @@ import {
X,
} from "lucide-react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { ConfirmButton } from "@/components/ui/confirm-button";
import { Input } from "@/components/ui/input";
import { EmptyState } from "@/components/ui/empty-state";
import { SettingsCard } from "@/components/settings/settings-card";
@@ -308,46 +298,23 @@ export function SecurityKeysCard({
>
<Pencil className="h-4 w-4" />
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-foreground min-h-11 min-w-11 sm:h-8 sm:min-h-0 sm:w-8 sm:min-w-0"
disabled={remove.isPending}
aria-label={t("settings.security.keys.remove")}
>
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("settings.security.keys.remove")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("settings.security.keys.removeConfirm")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{t("common.cancel")}
</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
disabled={remove.isPending}
aria-busy={remove.isPending || undefined}
onClick={() => remove.mutate(key.id)}
>
{remove.isPending && (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("common.delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<ConfirmButton
label=""
ariaLabel={t("settings.security.keys.remove")}
title={t("settings.security.keys.remove")}
body={t("settings.security.keys.removeConfirm")}
confirmLabel={t("common.delete")}
onConfirm={() => remove.mutate(key.id)}
pending={
remove.isPending && remove.variables === key.id
}
disabled={remove.isPending}
size="icon"
variant="ghost"
className="text-muted-foreground hover:text-foreground min-h-11 min-w-11 sm:h-8 sm:min-h-0 sm:w-8 sm:min-w-0"
icon={<Trash2 className="h-4 w-4" />}
destructive
/>
</div>
)}
</div>
@@ -498,6 +498,7 @@ export function TotpCard({
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
data-testid="recovery-regenerate-confirm"
disabled={regenerate.isPending}
aria-busy={regenerate.isPending || undefined}
@@ -0,0 +1,70 @@
"use client";
/**
* Old deep links into cards that moved to another Settings page.
*
* A path that moved is a server redirect (`next.config.ts`), but a card that
* moved while its page stayed cannot be: the `#anchor` never reaches the
* server. Bookmarks, cross-links in older notifications and the docs point at
* `/settings/privacy#sessions` and the like, so the shell forwards a known
* anchor on a known page to the card's new home, once, on arrival.
*
* `SETTINGS_ANCHOR_FORWARDS` is also read by the legacy-routes guard, which
* proves every target lands on a page that exists.
*/
import { useEffect } from "react";
import { usePathname, useRouter } from "next/navigation";
export const SETTINGS_ANCHOR_FORWARDS: Readonly<
Record<string, Readonly<Record<string, string>>>
> = {
// v1.44 — sessions, trusted devices and the security activity joined the
// other sign-in controls under Security; the duplicate export card left.
"/settings/privacy": {
sessions: "/settings/security#sessions",
"trusted-devices": "/settings/security#trusted-devices",
"security-activity": "/settings/security#security-activity",
"your-data-export": "/settings/export",
},
// v1.44 — the proactive nudges are a reminder and live with the others.
"/settings/coach": {
"coach-nudge": "/settings/notifications#coach-nudge",
},
// v1.44 — Arrangement holds order and view only; each module's own
// settings moved to its page under Modules.
"/settings/layout/labs": {
"labs-local-ocr": "/settings/modules/labs#labs-local-ocr",
"labs-biomarkers": "/settings/modules/labs#labs-biomarkers",
},
"/settings/layout/medications": {
"injection-sites": "/settings/modules/medications#injection-sites",
},
"/settings/layout/vorsorge": {
"screening-hints": "/settings/anamnesis#screening-hints",
"vorsorge-reminders": "/settings/notifications#vorsorge-reminders",
},
};
/** Where an anchor on a page now lives, or null when it did not move. */
export function forwardedAnchorTarget(
pathname: string,
hash: string,
): string | null {
const anchor = hash.startsWith("#") ? hash.slice(1) : hash;
if (!anchor) return null;
return SETTINGS_ANCHOR_FORWARDS[pathname]?.[anchor] ?? null;
}
export function SettingsAnchorForward() {
const pathname = usePathname();
const router = useRouter();
useEffect(() => {
if (!pathname) return;
const target = forwardedAnchorTarget(pathname, window.location.hash);
if (target) router.replace(target);
}, [pathname, router]);
return null;
}
@@ -0,0 +1,131 @@
"use client";
/**
* Settings navigation below `md`: one button naming where the person is, and a
* bottom sheet listing every section under its group.
*
* The phone used to carry every section as a chip in one horizontal strip,
* more than twenty of them, so most of Settings sat off screen and the groups
* the desktop sidebar shows were reduced to thin dividers. The sheet shows the
* same groups and order as the sidebar, one 44 px row per section, and closes
* on choice. Both read the visible-section list the shell computes, so the
* module and record filters apply to both alike.
*/
import * as React from "react";
import Link from "next/link";
import { ChevronsUpDown } from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { useTranslations } from "@/lib/i18n/context";
export interface SettingsNavEntry {
slug: string;
title: string;
groupLabel: string;
icon: LucideIcon;
}
export function SettingsMobileNav({
entries,
activeSlug,
}: {
entries: readonly SettingsNavEntry[];
activeSlug: string;
}) {
const { t } = useTranslations();
const [open, setOpen] = React.useState(false);
const active = entries.find((entry) => entry.slug === activeSlug);
const ActiveIcon = active?.icon;
// Group the flat, pre-ordered list for rendering; the order of first
// appearance is the group order.
const groups: { label: string; entries: SettingsNavEntry[] }[] = [];
for (const entry of entries) {
const last = groups.at(-1);
if (last && last.label === entry.groupLabel) last.entries.push(entry);
else groups.push({ label: entry.groupLabel, entries: [entry] });
}
return (
<nav
aria-label={t("settings.shell.sectionsNav")}
className="mb-4 md:hidden"
>
<button
type="button"
data-slot="settings-mobile-nav-trigger"
aria-haspopup="dialog"
aria-expanded={open}
onClick={() => setOpen(true)}
className="border-border bg-card hover:bg-accent focus-visible:ring-ring/50 flex min-h-11 w-full items-center gap-3 rounded-lg border px-3 py-2 text-left transition-colors focus-visible:ring-2 focus-visible:outline-none"
>
{ActiveIcon ? (
<ActiveIcon
className="text-muted-foreground size-4 shrink-0"
aria-hidden="true"
/>
) : null}
<span className="min-w-0 flex-1">
{active ? (
<span className="text-muted-foreground block text-xs">
{active.groupLabel}
</span>
) : null}
<span className="block truncate text-sm font-medium">
{active?.title ?? t("settings.shell.allSections")}
</span>
</span>
<span className="text-muted-foreground shrink-0 text-xs">
{t("settings.shell.allSections")}
</span>
<ChevronsUpDown
className="text-muted-foreground size-4 shrink-0"
aria-hidden="true"
/>
</button>
<ResponsiveSheet
open={open}
onOpenChange={setOpen}
title={t("settings.shell.allSections")}
>
<div className="space-y-4 pb-2" data-slot="settings-mobile-nav-list">
{groups.map((group) => (
<section key={group.label} className="space-y-1">
<h2 className="text-muted-foreground px-1 text-xs font-medium tracking-wide uppercase">
{group.label}
</h2>
<ul className="space-y-0.5">
{group.entries.map((entry) => {
const isActive = entry.slug === activeSlug;
const Icon = entry.icon;
return (
<li key={entry.slug}>
<Link
href={`/settings/${entry.slug}`}
aria-current={isActive ? "page" : undefined}
onClick={() => setOpen(false)}
className={cn(
"flex min-h-11 items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors",
isActive
? "bg-primary/10 text-primary"
: "text-foreground hover:bg-accent",
)}
>
<Icon className="size-4 shrink-0" aria-hidden="true" />
{entry.title}
</Link>
</li>
);
})}
</ul>
</section>
))}
</div>
</ResponsiveSheet>
</nav>
);
}
@@ -11,8 +11,8 @@ import type { IntegrationCallbackUrls } from "@/lib/integrations/callback-urls";
/**
* One settings section, loaded on its own.
*
* The section route renders exactly one of twenty-one sections, but importing
* them statically put all twenty-one (and everything under them: the import
* The section route renders exactly one of the sections, but importing
* them statically put all of them (and everything under them: the import
* panel, the integration cards, the MCP and API token tables, the score
* editor) into the route's entry bundle, so opening "About" downloaded the
* whole settings tree. Each section is now its own chunk. A first load still
@@ -124,6 +124,10 @@ const LOADERS: Record<
import("@/components/settings/privacy-section").then(
(m) => m.PrivacySection,
),
preview: () =>
import("@/components/settings/preview-section").then(
(m) => m.PreviewSection,
),
};
const SECTIONS: Record<SettingsSectionSlug, ComponentType<SectionProps>> = {
@@ -158,6 +162,7 @@ const SECTIONS: Record<SettingsSectionSlug, ComponentType<SectionProps>> = {
export: dynamic(LOADERS.export, { loading: () => <SectionLoading /> }),
advanced: dynamic(LOADERS.advanced, { loading: () => <SectionLoading /> }),
privacy: dynamic(LOADERS.privacy, { loading: () => <SectionLoading /> }),
preview: dynamic(LOADERS.preview, { loading: () => <SectionLoading /> }),
};
/**
+112 -219
View File
@@ -9,9 +9,10 @@
* by the page itself.
*
* Mobile-first per `docs/ui-guidelines.md`:
* - <md: section selector renders as a horizontal scroll strip at the top so
* a thumb-tap can swap sections without leaving the viewport.
* - >=md: sticky 220px sidebar + content column with `max-w-screen-xl`.
* - <md: one button naming the current section opens a bottom sheet with
* every section under its group (`SettingsMobileNav`).
* - >=md: sticky 220px sidebar, grouped the same way, beside a content
* column with `max-w-screen-xl`.
*/
import * as React from "react";
@@ -25,6 +26,7 @@ import {
CloudSun,
Download,
FileHeart,
FlaskConical,
Gauge,
Info,
KeyRound,
@@ -56,6 +58,8 @@ import {
type SettingsSectionSlug,
} from "./section-slugs";
import { SettingsHubBackLink } from "./settings-hub-back-link";
import { SettingsMobileNav } from "./settings-mobile-nav";
import { SettingsAnchorForward } from "./settings-anchor-forward";
// Re-export so existing call-sites importing from this module keep working.
// New server-side call-sites (e.g. `generateStaticParams()`) should import
@@ -71,18 +75,24 @@ export {
};
/**
* The sidebar's 19 entries render under one
* of these seven non-interactive group headers instead of as one flat
* list. Presentation only: no slug, route, or redirect changes. Order
* below is the render order of the groups themselves.
* The nine groups every section belongs to, in render order. The sidebar
* paints a non-interactive header above the first visible entry of each, and
* the phone's section sheet uses the same headers.
*
* Account: who you are and who can get in. Tracking: what the app records.
* Arrangement: order and view only. Notifications: every reminder. Health
* profile: what describes you. AI and Connections: what talks to the outside.
* Data: export, import, privacy and deletion. System: previews and about.
*/
export type SettingsSectionGroup =
| "account"
| "yourData"
| "display"
| "tracking"
| "arrangement"
| "notifications"
| "healthProfile"
| "ai"
| "connectivity"
| "connections"
| "data"
| "system";
/** i18n key for each group's header label, under `settings.shell.groups.*`. */
@@ -91,11 +101,13 @@ export const SETTINGS_SECTION_GROUP_LABEL_KEYS: Record<
string
> = {
account: "settings.shell.groups.account",
yourData: "settings.shell.groups.yourData",
display: "settings.shell.groups.display",
tracking: "settings.shell.groups.tracking",
arrangement: "settings.shell.groups.arrangement",
notifications: "settings.shell.groups.notifications",
healthProfile: "settings.shell.groups.healthProfile",
ai: "settings.shell.groups.ai",
connectivity: "settings.shell.groups.connectivity",
connections: "settings.shell.groups.connections",
data: "settings.shell.groups.data",
system: "settings.shell.groups.system",
};
@@ -109,159 +121,84 @@ interface SettingsSection {
}
/**
* Source of truth for the section list that the settings shell renders
* in its sidebar + mobile chip-strip.
* Source of truth for the section list that the settings shell renders in
* its sidebar and in the phone's section sheet, pre-ordered by group.
*
* The list used to render in a single flat
* order with no visual structure (19 entries deep, the AI cluster and the
* programmatic-access cluster interleaved, the health-profile pair split
* across the list). It is now clustered into the seven
* `SettingsSectionGroup`s, each entry carrying its `group`; the list is
* pre-ordered by group and the shell inserts a non-interactive header
* before the first VISIBLE entry of each group. This is a presentation-only
* reorder — no slug, route, or redirect changed, and `SETTINGS_SECTION_SLUGS`
* (`section-slugs.ts`, which also carries the page-only slugs and drives
* `generateStaticParams()`) is untouched.
* `SETTINGS_SECTION_SLUGS` (`section-slugs.ts`) is the route registry and
* keeps its own order (it drives `generateStaticParams()` and the record
* landing order); this list decides only where each entry is shown. Every
* slug a person ever reached keeps its URL: a section that moved between
* groups moved here, not in the route.
*
* "About" sits at the end of the list (System group). v1.4.33 IW7 had
* folded it into the sidebar user-card dropdown only, which left
* `/settings/about` an orphaned route — reachable by URL but discoverable
* nowhere in the settings navigation. It is now both a regular shell entry
* and a user-card dropdown item ("Über HealthLog" / "About HealthLog").
* The card titles people cite stay as they are: "Export & import",
* "Integrations", "API tokens", "Shared access", "Modules".
*/
export const SETTINGS_SECTIONS: readonly SettingsSection[] = [
// ── Account ──
// ── Account ── profile and display, sign-in and sessions, who else may open
// this record.
{
slug: "account",
titleKey: "settings.sections.account.title",
icon: User,
group: "account",
},
// v1.23 — the account-security home: second factors, recovery codes,
// passkey management.
// Second factors, passkeys, password, and (since v1.44) the active
// sessions, trusted devices and security activity that used to sit on the
// privacy page.
{
slug: "security",
titleKey: "settings.sections.security.title",
icon: ShieldCheck,
group: "account",
},
// v1.36.0 — who else may open this record, and whose this account may open.
// In the Account group beside Security: both answer "who can get at my
// account and what have they done", and a person looking for one will look
// where the other is.
{
slug: "access",
titleKey: "settings.sections.access.title",
icon: UsersRound,
group: "account",
},
// v1.23 — "Data & Privacy" assembles the export / deletion / retention /
// encryption / session / activity pieces into one coherent privacy pane.
{
slug: "privacy",
titleKey: "settings.sections.privacy.title",
icon: Lock,
group: "account",
},
// ── Your data ──
// v1.18.0 — the "Was du trackst" hub: the front door for enabling /
// disabling the secondary tracking domains.
// ── Tracking ── which areas are recorded; each module's own settings
// (biomarkers, mood tags, injection sites) open from its row.
{
slug: "modules",
titleKey: "settings.sections.modules.title",
icon: Blocks,
group: "yourData",
group: "tracking",
},
{
slug: "integrations",
titleKey: "settings.sections.integrations.title",
icon: Link2,
group: "yourData",
},
// v1.18.1 — Quellen (source weighting): which connection wins when
// two report the same metric. v1.25.3 — Kanäle (delivery channels) folded
// back into Notifications as an in-page group;
// `/settings/channels` 301-redirects to `/settings/notifications#channels`.
{
slug: "sources",
titleKey: "settings.sections.sources.title",
icon: Layers,
group: "yourData",
},
// v1.18.0 — the full health record (PDF + FHIR R4 + zip package).
// v1.18.6.1 — NOT module-gated: the health record is a flagship export
// capability; gating its only entry-point on the `doctorReport` opt-out
// meant a stray toggle in the Modules hub made the whole section vanish
// with no in-context way back. The `doctorReport` module still governs
// the data layer server-side (403 `module.disabled` over Bearer).
{
slug: "gesundheitsakte",
titleKey: "settings.sections.gesundheitsakte.title",
icon: FileHeart,
group: "yourData",
},
// v1.25.7 — clinician share links fold into the Gesundheitsakte section as
// a labelled "Sharing" group; `/settings/sharing` 301-redirects there.
{
slug: "export",
titleKey: "settings.sections.export.title",
icon: Download,
group: "yourData",
},
// ── Display ──
// v1.17.1 — one nav entry replaces the four scattered Dashboard /
// Insights / Medications / Mood "arrange" entries; those routes still
// resolve, reached through this hub. v1.25.3 — renamed to "Appearance" /
// "Darstellung" and widened into the index for every module's
// view/sort/order surface (the slug stays `layout`). v1.25.11 (#148) —
// each module lives on its own subpage at `/settings/layout/<module>`;
// the old per-module routes 301-redirect there.
// ── Arrangement ── order and view, nothing else. The slug stays `layout`.
{
slug: "layout",
titleKey: "settings.sections.layout.title",
icon: Palette,
group: "display",
group: "arrangement",
},
// v1.25.7 — Vorsorge (preventive-care reminders) folded into
// "Darstellung"; `/settings/vorsorge` 301-redirects to
// `/settings/layout/vorsorge`.
{
slug: "thresholds",
titleKey: "settings.sections.thresholds.title",
icon: SlidersHorizontal,
group: "display",
},
// v1.18.0 — "Benachrichtigungen" is the single module-gated
// reminder-types home; the standalone "Erinnerungen" hub is gone.
// ── Notifications ── every reminder kind, or the link to where it lives.
{
slug: "notifications",
titleKey: "settings.sections.notifications.title",
icon: Bell,
group: "display",
group: "notifications",
},
// ── Health profile ──
// v1.35.0 — which pillars count toward the Health Score. Sits at the top
// of the health-profile cluster: it is a statement about the person's own
// health picture, not about how the app looks or what it records.
{
slug: "score",
titleKey: "settings.sections.score.title",
icon: Gauge,
group: "healthProfile",
},
// v1.25 — Anamnese (medical history): the structured-record
// home for allergies + family history. NOT module-gated (like Vorsorge) —
// these are foundational health-profile records, always available.
{
slug: "thresholds",
titleKey: "settings.sections.thresholds.title",
icon: SlidersHorizontal,
group: "healthProfile",
},
{
slug: "anamnesis",
titleKey: "settings.sections.anamnesis.title",
icon: ClipboardList,
group: "healthProfile",
},
// v1.25 — Environmental context: home location, travel overrides,
// and the weather/daylight backfill. Module-gated on the opt-in
// `environment` module (`settings:environment` in the surface map), so the
// entry only appears once the user turns it on.
// Module-gated on the opt-in `environment` module.
{
slug: "environment",
titleKey: "settings.sections.environment.title",
@@ -275,37 +212,73 @@ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [
icon: Sparkles,
group: "ai",
},
// v1.18.0 — Coach: the Coach preference cards get their own entry,
// shown only when the coach module is enabled (`settings:coach` in the
// surface map). The AI entry above keeps
// provider / model / BYOK configuration.
// Module-gated on the coach module (`settings:coach`).
{
slug: "coach",
titleKey: "settings.sections.coach.title",
icon: Bot,
group: "ai",
},
// ── Connectivity ──
// ── Connections ── data coming in (integrations, which source wins) and
// programmatic access going out (tokens, the MCP connector).
{
slug: "integrations",
titleKey: "settings.sections.integrations.title",
icon: Link2,
group: "connections",
},
{
slug: "sources",
titleKey: "settings.sections.sources.title",
icon: Layers,
group: "connections",
},
{
slug: "api",
titleKey: "settings.sections.api.title",
icon: KeyRound,
group: "connectivity",
group: "connections",
},
// v1.22.0 — remote MCP connector. Not module-gated: the card carries its
// own enable toggle (mirroring `gesundheitsakte`), so the entry-point
// stays reachable even with the opt-in module off.
// Not module-gated: the page shows the connector's state and links to the
// one switch in Modules.
{
slug: "mcp",
titleKey: "settings.sections.mcp.title",
icon: Plug,
group: "connectivity",
group: "connections",
},
// ── Data ── getting data out and in, what is stored, and deleting it.
{
slug: "export",
titleKey: "settings.sections.export.title",
icon: Download,
group: "data",
},
// Not module-gated: the health record is a flagship export, and its only
// entry point must not vanish behind the `doctorReport` opt-out.
{
slug: "gesundheitsakte",
titleKey: "settings.sections.gesundheitsakte.title",
icon: FileHeart,
group: "data",
},
{
slug: "privacy",
titleKey: "settings.sections.privacy.title",
icon: Lock,
group: "data",
},
// ── System ──
{
slug: "advanced",
titleKey: "settings.sections.advanced.title",
icon: Settings2,
group: "data",
},
// ── System ──
{
slug: "preview",
titleKey: "settings.sections.preview.title",
icon: FlaskConical,
group: "system",
},
{
@@ -453,40 +426,6 @@ export function SettingsShell({
(section) => section.slug === activeSlug,
);
// v1.4.33 — keep the active chip in view inside the horizontal
// mobile strip. On a 393 CSS px viewport the strip is wider than the
// viewport and the rightmost chips sit off-screen; a user who tapped
// one of those chips lands on the new route with the strip still
// scrolled to the leftmost chip, which reads as if the navigation is
// broken. We pin the active chip to the left edge of the strip.
//
// v1.7.0 — adjust only the strip's own horizontal scroll offset
// (via `scrollTo({ left })`) instead of `Element.scrollIntoView()`.
// `scrollIntoView` walks every scrollable ancestor and adjusts both
// axes, so on mobile it also nudged the whole document vertically on
// each route change — a dizzy auto-scroll that fired on every settings
// sub-page tap. Scrolling the strip's own `scrollLeft` confines the
// motion to its horizontal axis and never touches the page scroll
// position.
const mobileStripRef = React.useRef<HTMLElement | null>(null);
React.useEffect(() => {
if (typeof window === "undefined") return;
const strip = mobileStripRef.current;
if (!strip) return;
const active = strip.querySelector<HTMLElement>('[aria-current="page"]');
if (!active) return;
// Land the active chip at the strip's left edge, clamped to the
// scrollable range. `offsetLeft` is relative to the strip's padding
// box, which is the origin for `scrollLeft`.
const maxScroll = strip.scrollWidth - strip.clientWidth;
const target = Math.max(0, Math.min(active.offsetLeft, maxScroll));
// Position the active chip instantly. A smooth behaviour here animates
// the strip from its reset `scrollLeft: 0` to the target on every tap,
// which reads as an unwanted "scroll from the start" sweep when swapping
// sections. An instant jump keeps the chip in view without the sweep.
strip.scrollTo({ left: target, behavior: "auto" });
}, [activeSlug]);
// v1.18.6.1 — resolve the heading once. Pages pass an explicit `heading`
// (the canonical path); the slug fallback keeps the shell renderable on
// its own (and in tests that mount it without a heading) by deriving the
@@ -549,64 +488,18 @@ export function SettingsShell({
<div className="mb-4 md:hidden">{headingBlock(true)}</div>
) : null}
{/* Mobile section strip — horizontal scroll, hidden on md+.
`no-scrollbar` (defined in `globals.css`) suppresses the
painted scrollbar; the horizontal swipe + keyboard arrow
scrolling still work. Without it the 10-section strip
renders an always-on scrollbar at the top of every settings
page, which makes the page feel like every section card has
an overflow problem.
v1.4.33 — `snap-x snap-mandatory` lets a swipe-flick land
on the next chip's leading edge instead of the in-between
dead zone. The auto `scrollIntoView({inline: "center"})`
effect above stays the canonical positioner for the active
chip; snap is the polish layer when the user manually flicks
through the strip without tapping. */}
<nav
ref={mobileStripRef}
aria-label={t("settings.shell.sectionsNav")}
className="no-scrollbar relative -mx-4 mb-4 snap-x snap-mandatory overflow-x-auto px-4 md:hidden"
>
<ul className="flex min-w-max items-center gap-2">
{visibleSections.map((section, index) => {
const isActive = section.slug === activeSlug;
const Icon = section.icon;
// A quiet vertical divider marks a
// group boundary in the horizontal strip (the desktop sidebar
// carries the full text header for the same grouping).
const startsNewGroup =
index > 0 && visibleSections[index - 1].group !== section.group;
return (
<React.Fragment key={section.slug}>
{startsNewGroup ? (
<li aria-hidden="true" className="shrink-0">
<span className="bg-border block h-6 w-px" />
</li>
) : null}
<li className="snap-start">
<Link
href={`/settings/${section.slug}`}
aria-current={isActive ? "page" : undefined}
className={cn(
// v1.4.25 — chip strip is the primary mobile-settings
// navigation surface. Pad to WCAG 2.5.5 44 px so the
// chips can be tapped without zoom on a Pixel-5.
"flex min-h-11 items-center gap-2 rounded-full border px-3 py-1.5 text-sm font-medium whitespace-nowrap transition-colors",
isActive
? "border-primary/40 bg-primary/10 text-primary"
: "border-border text-foreground hover:bg-accent",
)}
>
<Icon className="h-4 w-4" aria-hidden="true" />
{t(section.titleKey)}
</Link>
</li>
</React.Fragment>
);
})}
</ul>
</nav>
<SettingsAnchorForward />
{/* Below `md`: one button naming the current section, opening the
grouped section sheet. Hidden on md+, where the sidebar shows. */}
<SettingsMobileNav
activeSlug={activeSlug}
entries={visibleSections.map((section) => ({
slug: section.slug,
title: t(section.titleKey),
groupLabel: t(SETTINGS_SECTION_GROUP_LABEL_KEYS[section.group]),
icon: section.icon,
}))}
/>
{/* v1.18.6.1 — two-row grid on desktop:
row 1 / col 2 = heading (content column only)
+16 -43
View File
@@ -26,19 +26,9 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
import { FileText, KeyRound, Link2, List, Trash2 } from "lucide-react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { ConfirmButton } from "@/components/ui/confirm-button";
import { EmptyState } from "@/components/ui/empty-state";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { Skeleton } from "@/components/ui/skeleton";
@@ -255,38 +245,21 @@ function ShareLinksCard() {
{/* §12 — a right-aligned row action, not a full-width
destructive strip stacked once per link (which reads as
an alarm wall on a list of shares). */}
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="outline"
size="sm"
className="ml-auto flex min-h-11 w-fit sm:min-h-9"
>
<Trash2 className="mr-2 h-3.5 w-3.5" />
{t("settings.sharing.revoke")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("settings.sharing.revoke")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("settings.sharing.revokeDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{t("common.cancel")}
</AlertDialogCancel>
<AlertDialogAction
onClick={() => revokeMutation.mutate(link.id)}
>
{t("settings.sharing.revoke")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<ConfirmButton
label={t("settings.sharing.revoke")}
title={t("settings.sharing.revoke")}
body={t("settings.sharing.revokeDescription")}
confirmLabel={t("settings.sharing.revoke")}
onConfirm={() => revokeMutation.mutate(link.id)}
pending={
revokeMutation.isPending &&
revokeMutation.variables === link.id
}
size="sm"
className="ml-auto flex min-h-11 w-fit sm:min-h-9"
icon={<Trash2 className="h-3.5 w-3.5" />}
destructive
/>
</li>
))}
</ul>
@@ -697,6 +697,7 @@ export function SourcesSection() {
disabled={saveMutation.isPending}
pending={resetMutation.isPending}
onConfirm={() => resetMutation.mutate()}
destructive
/>
{dirty && priority ? (
<>
Loaded 100 of 129 files, more files were not shown because too many files have changed in this diff. Show more