feat(settings): nine groups, module pages and one switch per state

Settings now reads as nine groups: Account, Tracking, Arrangement,
Notifications, Health profile, AI, Connections, Data and System. On a
phone one button names the current section and opens the grouped list,
replacing the strip of every section.

What moved, every old address still working:
- Sessions, trusted devices and security activity sit under Security;
  the privacy page anchors forward there.
- Notifications holds every reminder kind: the Coach nudges and the
  checkup reminder switches joined, and a card names the reminders set
  where their subject lives (dose times, cycle reminders).
- Arrangement holds order and view only. Mood tags, the labs biomarker
  catalog and on-device text recognition, and the injection sites live on
  module pages under /settings/modules/<id>, linked from each Modules row
  and from the module's own wrench. /settings/layout/mood redirects.
- The timezone saves on change in the Display card, with a one-tap offer
  of the device zone.
- Screening hints sit beside the life circumstances they come from.
- System gains Preview features with the Topics switch.

Duplicates: the Coach and MCP pages show their state and link to the one
switch in Modules (turning MCP off keeps tokens), the Coach overlay shows
the provider and model with a link to Settings > AI instead of a second
set of selects, and the privacy page drops its copy of the export links.

A guard walks every historical /settings address through the redirects
and every forwarded anchor to a page that renders.
This commit is contained in:
Marc-André Bombeck committed 2026-10-21 19:43:02 +02:00
1 parent 3ebef19ac3
commit 71dbef1742
76 files changed
+2517 -1806

No files matched your search

+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();
+85 -36
View File
@@ -3264,12 +3264,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",
@@ -5289,12 +5286,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": {
@@ -5428,11 +5430,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."
@@ -5441,7 +5457,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."
@@ -5458,10 +5474,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."
@@ -5852,7 +5864,28 @@
},
"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",
@@ -5995,6 +6028,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",
@@ -7006,21 +7043,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:",
@@ -7163,12 +7198,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.",
@@ -7184,7 +7213,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",
@@ -7203,7 +7231,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.",
@@ -7465,7 +7494,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",
@@ -8855,6 +8902,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": {
@@ -9631,8 +9682,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": {
+85 -36
View File
@@ -3264,12 +3264,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",
@@ -5289,12 +5286,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": {
@@ -5428,11 +5430,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."
@@ -5441,7 +5457,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."
@@ -5458,10 +5474,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."
@@ -5852,7 +5864,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",
@@ -5995,6 +6028,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",
@@ -7006,21 +7043,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:",
@@ -7163,12 +7198,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.",
@@ -7184,7 +7213,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",
@@ -7203,7 +7231,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.",
@@ -7465,7 +7494,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",
@@ -8855,6 +8902,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": {
@@ -9631,8 +9682,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
@@ -3264,12 +3264,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",
@@ -5289,12 +5286,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": {
@@ -5428,11 +5430,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."
@@ -5441,7 +5457,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."
@@ -5458,10 +5474,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."
@@ -5852,7 +5864,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",
@@ -5995,6 +6028,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",
@@ -7006,21 +7043,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:",
@@ -7163,12 +7198,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.",
@@ -7184,7 +7213,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",
@@ -7203,7 +7231,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.",
@@ -7465,7 +7494,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",
@@ -8855,6 +8902,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": {
@@ -9631,8 +9682,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
@@ -3264,12 +3264,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",
@@ -5289,12 +5286,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": {
@@ -5428,7 +5430,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",
@@ -5441,7 +5457,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."
@@ -5458,10 +5474,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."
@@ -5852,7 +5864,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",
@@ -5995,6 +6028,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",
@@ -7006,21 +7043,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 :",
@@ -7163,12 +7198,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.",
@@ -7184,7 +7213,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",
@@ -7203,7 +7231,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.",
@@ -7465,7 +7494,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",
@@ -8855,6 +8902,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": {
@@ -9631,8 +9682,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
@@ -3264,12 +3264,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",
@@ -5289,12 +5286,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": {
@@ -5428,11 +5430,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."
@@ -5441,7 +5457,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."
@@ -5458,10 +5474,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."
@@ -5852,7 +5864,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",
@@ -5995,6 +6028,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",
@@ -7006,21 +7043,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):",
@@ -7163,12 +7198,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.",
@@ -7184,7 +7213,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",
@@ -7203,7 +7231,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.",
@@ -7465,7 +7494,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",
@@ -8855,6 +8902,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": {
@@ -9631,8 +9682,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
@@ -3264,12 +3264,9 @@
"provider": "응답 제공자",
"modelManaged": "이 제공자의 모델은 AI 설정에서 정해요.",
"noProvider": "사용할 수 있는 제공자가 없어요",
"saving": "저장 중…",
"saveFailed": "변경 사항을 저장하지 못했어요.",
"allSettings": "모든 AI 설정",
"providerNotActive": "{provider}(으)로 전환하지 못했습니다. 작동하는 로그인이나 키가 없습니다. AI 설정에서 설정하세요.",
"operatorCodex": "ChatGPT (운영자 공유)",
"operatorManaged": "이 연결은 서버 운영자가 관리합니다. 내 공급자를 하나라도 설정하면 그 공급자가 먼저 답합니다."
"changeModel": "설정 > AI에서 변경"
},
"answer": {
"scrollToBottom": "최신 메시지로 이동",
@@ -5289,12 +5286,17 @@
"groups": {
"account": "계정",
"yourData": "내 데이터",
"display": "화면",
"healthProfile": "건강 프로필",
"ai": "AI",
"connectivity": "연결",
"system": "시스템"
}
"system": "시스템",
"tracking": "기록",
"arrangement": "배치",
"notifications": "알림",
"connections": "연결",
"data": "데이터"
},
"allSections": "모든 항목"
},
"sharedRecord": {
"managedSettings": {
@@ -5428,11 +5430,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": "타일 배치와 순서."
@@ -5441,7 +5457,7 @@
"title": "인사이트",
"description": "개요 섹션과 내비게이션 칩 순서."
},
"subtitle": "대시보드, 인사이트, 기록 모듈에 무엇을 어떤 순서로 보여줄지 정해요.",
"subtitle": "대시보드, 인사이트, 기록 목록의 순서와 보기를 정하세요.",
"medications": {
"title": "복약",
"description": "목록 보기와 수동 순서."
@@ -5458,10 +5474,6 @@
"title": "건강검진",
"description": "목록 보기와 알림 순서."
},
"mood": {
"title": "기분 태그",
"description": "기분 선택기의 그룹, 태그, 보관된 태그."
},
"documents": {
"title": "문서",
"description": "보기 방식과 월 배치."
@@ -5852,7 +5864,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": "건강 기록",
@@ -5995,6 +6028,10 @@
"access": {
"title": "공유 접근",
"subtitle": "내 건강 기록을 누가 열 수 있고, 내가 누구의 기록을 열 수 있는지예요."
},
"preview": {
"title": "미리 보기 기능",
"subtitle": "아직 완성되지 않은 기능을 써 보세요. 직접 켜기 전까지는 모두 꺼져 있습니다."
}
},
"profile": "프로필",
@@ -7006,21 +7043,19 @@
},
"integrationSetupGuide": "설정 가이드",
"coach": {
"activate": {
"title": "코치 활성화",
"description": "코치 버튼과 패널을 표시해요.",
"toggleAria": "코치 버튼과 패널 활성화",
"savedOn": "코치를 켰어요.",
"savedOff": "코치를 숨겼어요.",
"saveError": "변경 사항을 저장하지 못했어요. 다시 시도해 주세요."
},
"memory": {
"edit": "편집",
"source": {
"user": "내가 알려 줌",
"coach": "코치가 기억함"
}
}
},
"state": {
"title": "코치",
"description": "코치 버튼과 패널 표시 여부입니다. 스위치는 모듈에 있습니다."
},
"nudgesMoved": "알림에서 다른 리마인더와 함께 설정합니다.",
"openNudges": "알림에서 열기"
},
"integrationRedirectGuide": {
"intro": "직접 만든 {provider} 개발자 앱으로 연결해요. 설정에 5분쯤 걸려요:",
@@ -7163,12 +7198,6 @@
"delivery": "알림 발송 기록: {days}일 보관",
"unavailable": "지금은 보관 기간 정보를 볼 수 없어요."
},
"export": {
"title": "내 데이터 내려받기",
"description": "기록을 CSV나 JSON으로, 또는 진료용 전체 건강 기록으로 받을 수 있어요.",
"openExport": "내보내기·백업",
"openHealthRecord": "건강 기록"
},
"delete": {
"title": "삭제 또는 초기화",
"description": "계정을 완전히 없애거나, 계정은 두고 건강 기록만 지워요.",
@@ -7184,7 +7213,6 @@
"mcp": {
"enableTitle": "원격 MCP 엔드포인트",
"enableDescription": "MCP를 지원하는 어시스턴트(Claude.ai, ChatGPT)가 범위가 제한된 토큰으로 건강 데이터를 읽게 해요.",
"enableToggleLabel": "MCP 엔드포인트 켜기",
"endpointLabel": "커넥터 URL",
"connectDescription": "어시스턴트의 커스텀 커넥터 설정에 이 URL을 붙여 넣고, 아래 토큰으로 인증하거나 안내가 나오면 로그인하세요.",
"tokensTitle": "커넥터 토큰",
@@ -7203,7 +7231,8 @@
"connectionRevokeFailed": "어시스턴트 연결을 해제하지 못했어요.",
"connectionsDetail": "연결을 해지하면 갱신 권한까지 포함해 접근이 즉시 끊겨요.",
"tokensDetail": "토큰은 한 번만 보여줘요.",
"tokenNamePlaceholder": "이름 (예: Claude Desktop)"
"tokenNamePlaceholder": "이름 (예: Claude Desktop)",
"offKeepsTokens": "끄면 엔드포인트가 일시 중지됩니다. 토큰과 연결은 그대로 남고 다시 켜면 다시 작동합니다."
},
"strava": "Strava",
"stravaDescription": "Strava 계정을 연결해 활동을 운동 기록으로 동기화해요.",
@@ -7465,7 +7494,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": "관리자",
@@ -8855,6 +8902,10 @@
"detailedToast": "이제 알림에 건강 수치가 포함됩니다.",
"genericToast": "이제 알림에 건강 수치가 포함되지 않습니다.",
"saveError": "저장하지 못했어요. 나중에 다시 시도해 주세요."
},
"checkupReminders": {
"title": "검진 리마인더",
"description": "검진 리마인더를 개별로 켜거나 끕니다."
}
},
"i18n": {
@@ -9631,8 +9682,6 @@
"description": "측정값을 입력하면 알림이 완료로 표시돼요."
},
"manage": {
"title": "알림 관리",
"description": "알림을 하나씩 켜거나 끌 수 있어요.",
"empty": "아직 설정할 알림이 없어요."
},
"dashboard": {
+83 -34
View File
@@ -3264,12 +3264,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",
@@ -5289,12 +5286,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": {
@@ -5428,7 +5430,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",
@@ -5441,7 +5457,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."
@@ -5458,10 +5474,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."
@@ -5852,7 +5864,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",
@@ -5995,6 +6028,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",
@@ -7006,21 +7043,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:",
@@ -7163,12 +7198,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.",
@@ -7184,7 +7213,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",
@@ -7203,7 +7231,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.",
@@ -7465,7 +7494,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",
@@ -8855,6 +8902,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": {
@@ -9631,8 +9682,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
@@ -3264,12 +3264,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",
@@ -5289,12 +5286,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": {
@@ -5428,11 +5430,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."
@@ -5441,7 +5457,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ść."
@@ -5458,10 +5474,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."
@@ -5852,7 +5864,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",
@@ -5995,6 +6028,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",
@@ -7006,21 +7043,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:",
@@ -7163,12 +7198,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.",
@@ -7184,7 +7213,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",
@@ -7203,7 +7231,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.",
@@ -7465,7 +7494,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",
@@ -8855,6 +8902,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": {
@@ -9631,8 +9682,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,
},
{
@@ -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", () => {
@@ -162,7 +162,7 @@ const REGISTRY: DestructiveEntry[] = [
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",
@@ -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,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();
});
});
@@ -382,7 +382,7 @@ 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,
+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
@@ -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,
}));
@@ -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;
@@ -24,8 +24,6 @@ const ALLOWED: Readonly<Record<string, string>> = {
"the add-passkey re-authentication form; the delete confirm uses ConfirmButton",
"security-section/totp-card.tsx":
"the disable confirm asks for a current code and stays open until the server answers; both actions wear the destructive style",
"mcp-section.tsx":
"the connection and token revoke confirms; the MCP card is reshaped separately",
};
/** Owned by the integration cards, which follow their own card kit. */
@@ -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.");
});
@@ -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"');
});
});
@@ -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", () => {
@@ -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/');
});
});
@@ -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>
);
@@ -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>
);
}
@@ -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"
+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} />
@@ -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
+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. */}
+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 { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import { MODULE_KEYS, MODULE_REGISTRY } from "@/lib/modules/registry";
@@ -177,28 +176,6 @@ export function PrivacySection() {
)}
</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
@@ -237,11 +214,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>
);
}
@@ -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)
@@ -0,0 +1,98 @@
"use client";
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
import { resolveInitialTimezone } from "@/components/settings/account-section/account-section-utils";
import { TimezonePicker } from "@/components/settings/timezone-picker";
import { InlineAction } from "@/components/ui/inline-action";
import { useAuth } from "@/hooks/use-auth";
import { useMounted } from "@/hooks/use-mounted";
import { apiFetchRaw } from "@/lib/api/api-fetch";
import { showErrorToast } from "@/lib/errors/error-toast";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { storeTimezone } from "@/lib/timezone-mirror";
import { DEFAULT_TIMEZONE, detectBrowserTimezone } from "@/lib/tz/format";
/**
* The profile timezone as a Display preference: it saves the moment it
* changes, like the language, units and formats beside it.
*
* Until v1.44 it sat in the profile form and waited for that form's Save,
* which made it the one display preference that did not take effect on
* change. Persistence is unchanged (`PUT /api/auth/me/timezone`, the route
* that owns the IANA validation). The local mirror is written on success so
* every rendered timestamp repaints in the same tab before the `/me`
* refetch confirms it (issue #490).
*
* The select shows the stored zone: a value on screen is a value that is
* saved. The form used to pre-fill the device's zone over the untouched
* default and save it with the next profile save; that suggestion stays,
* as a one-tap offer under the select (`resolveInitialTimezone` decides
* when), instead of an unsaved value dressed as the current one.
*/
export function TimezoneSelect({
isAuthenticated,
}: {
isAuthenticated: boolean;
}) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const { user } = useAuth();
const [optimistic, setOptimistic] = useState<string | null>(null);
const value = optimistic ?? user?.timezone ?? DEFAULT_TIMEZONE;
const mutation = useMutation({
mutationFn: async (next: string) => {
const res = await apiFetchRaw("/api/auth/me/timezone", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ timezone: next }),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return next;
},
onSuccess: (next) => {
storeTimezone(next);
toastWrittenOutcome("success", t("settings.timezoneSaved"));
void queryClient.invalidateQueries({ queryKey: queryKeys.authMe() });
setOptimistic(null);
},
onError: (err) => {
setOptimistic(null);
showErrorToast(t("settings.timezoneInvalid"), { cause: err });
},
});
function handleChange(next: string) {
if (next === value || mutation.isPending || !isAuthenticated) return;
setOptimistic(next);
mutation.mutate(next);
}
// The device zone is read after hydration so both renders agree.
const mounted = useMounted();
const suggestion = mounted
? resolveInitialTimezone(user?.timezone, detectBrowserTimezone())
: value;
return (
<div className="space-y-1">
<TimezonePicker value={value} onChange={handleChange} />
{suggestion !== value && optimistic === null ? (
<InlineAction
tone="primary"
size="xs"
data-slot="timezone-use-device"
disabled={mutation.isPending || !isAuthenticated}
onClick={() => handleChange(suggestion)}
>
{t("settings.timezoneUseDevice", { zone: suggestion })}
</InlineAction>
) : null}
</div>
);
}
+4 -3
View File
@@ -9,17 +9,18 @@
* The shell supplies the page chrome (left nav, heading, subtitle) so this
* body is the preventive-care cards only. Preventive-care reminders are not
* a toggleable module, so this entry carries no `moduleGate` (always shown).
*
* v1.44 — order and view only. The reminder switches moved to Settings >
* Notifications, the screening hints to Health profile > Medical history
* beside the life circumstances they come from.
*/
import { VorsorgeSettings } from "@/components/measurement-reminders/vorsorge-settings";
import { ScreeningHintsCard } from "@/components/records/screening-hints-card";
export function VorsorgeSection() {
return (
<div className="space-y-6">
<VorsorgeSettings />
{/* v1.43 (#614) — hints from the life circumstances, with their source. */}
<ScreeningHintsCard />
</div>
);
}
@@ -7,6 +7,7 @@ import en from "../../../../messages/en.json";
import {
SETTINGS_CARDS,
SETTINGS_LAYOUT_PAGES,
SETTINGS_MODULE_PAGES,
} from "@/lib/command-palette/settings-cards";
/**
@@ -63,7 +64,11 @@ describe("SETTINGS_CARDS", () => {
);
it("every title key resolves", () => {
const missing = [...SETTINGS_CARDS, ...SETTINGS_LAYOUT_PAGES]
const missing = [
...SETTINGS_CARDS,
...SETTINGS_LAYOUT_PAGES,
...SETTINGS_MODULE_PAGES,
]
.map((c) => c.titleKey)
.filter((key) => !hasKey(key));
expect(missing).toEqual([]);
+21 -1
View File
@@ -20,7 +20,11 @@ import { MODULE_REGISTRY, type ModuleKey } from "@/lib/modules/registry";
import { isSurfaceVisible, type SurfaceModuleMap } from "@/lib/modules/surface";
import type { ShareDomain } from "@/lib/sharing/scope";
import { SETTINGS_CARDS, SETTINGS_LAYOUT_PAGES } from "./settings-cards";
import {
SETTINGS_CARDS,
SETTINGS_LAYOUT_PAGES,
SETTINGS_MODULE_PAGES,
} from "./settings-cards";
import { PALETTE_SYNONYMS } from "./synonyms";
/**
@@ -244,6 +248,22 @@ export function buildPaletteIndex(input: PaletteIndexInput): PaletteEntry[] {
});
}
}
if (sectionVisible.has("modules")) {
const modulesTitle = sectionTitle.get("modules") ?? "";
for (const page of SETTINGS_MODULE_PAGES) {
if (modules?.[page.module] === false) continue;
const id = `settings:modules/${page.slug}`;
entries.push({
id,
group: "settings",
title: t(page.titleKey),
hint: modulesTitle,
keywords: keywordsFor(id, [modulesTitle]),
icon: MODULE_ICONS[page.module],
run: { kind: "href", href: `/settings/modules/${page.slug}` },
});
}
}
}
// ── Module switches. ──
+37 -16
View File
@@ -95,11 +95,6 @@ export const SETTINGS_CARDS: ReadonlyArray<SettingsCardEntry> = [
anchor: "retention",
titleKey: "settings.privacy.retention.title",
},
{
section: "privacy",
anchor: "your-data-export",
titleKey: "settings.privacy.export.title",
},
{
section: "privacy",
anchor: "delete-data",
@@ -111,17 +106,17 @@ export const SETTINGS_CARDS: ReadonlyArray<SettingsCardEntry> = [
titleKey: "settings.privacy.posture.title",
},
{
section: "privacy",
section: "security",
anchor: "sessions",
titleKey: "settings.security.sessionsTitle",
},
{
section: "privacy",
section: "security",
anchor: "trusted-devices",
titleKey: "settings.security.trustedDevices.title",
},
{
section: "privacy",
section: "security",
anchor: "security-activity",
titleKey: "settings.security.activityTitle",
},
@@ -260,6 +255,11 @@ export const SETTINGS_CARDS: ReadonlyArray<SettingsCardEntry> = [
titleKey: "notifications.lowStock.title",
modules: ["medications"],
},
{
section: "notifications",
anchor: "vorsorge-reminders",
titleKey: "notifications.checkupReminders.title",
},
// ── Anamnesis ──
{
section: "anamnesis",
@@ -355,7 +355,7 @@ export const SETTINGS_CARDS: ReadonlyArray<SettingsCardEntry> = [
{
section: "coach",
anchor: "coach-activate",
titleKey: "settings.coach.activate.title",
titleKey: "settings.coach.state.title",
},
{
section: "coach",
@@ -363,7 +363,7 @@ export const SETTINGS_CARDS: ReadonlyArray<SettingsCardEntry> = [
titleKey: "insights.coach.settingsTitle",
},
{
section: "coach",
section: "notifications",
anchor: "coach-nudge",
titleKey: "notifications.coachNudge.title",
},
@@ -429,7 +429,7 @@ export const SETTINGS_CARDS: ReadonlyArray<SettingsCardEntry> = [
];
/**
* The Appearance hub's sub-pages, each its own entry. A module's page shows
* The Arrangement hub's sub-pages, each its own entry. A module's page shows
* only while the module is on.
*/
export const SETTINGS_LAYOUT_PAGES: ReadonlyArray<{
@@ -444,11 +444,6 @@ export const SETTINGS_LAYOUT_PAGES: ReadonlyArray<{
titleKey: "settings.sections.layout.medications.title",
module: "medications",
},
{
slug: "mood",
titleKey: "settings.sections.layout.mood.title",
module: "mood",
},
{
slug: "labs",
titleKey: "settings.sections.layout.labs.title",
@@ -466,3 +461,29 @@ export const SETTINGS_LAYOUT_PAGES: ReadonlyArray<{
},
{ slug: "vorsorge", titleKey: "settings.sections.layout.vorsorge.title" },
];
/**
* The module pages under Settings > Modules (`/settings/modules/<slug>`),
* each its own entry while its module is on.
*/
export const SETTINGS_MODULE_PAGES: ReadonlyArray<{
slug: string;
titleKey: string;
module: ModuleKey;
}> = [
{
slug: "medications",
titleKey: "settings.sections.modules.pages.medications.title",
module: "medications",
},
{
slug: "mood",
titleKey: "settings.sections.modules.pages.mood.title",
module: "mood",
},
{
slug: "labs",
titleKey: "settings.sections.modules.pages.labs.title",
module: "labs",
},
];
@@ -48,6 +48,7 @@ import { NAV_DESTINATIONS } from "@/components/layout/nav-model";
import { CAPTURE_KIND_ORDER } from "@/components/layout/capture-picker";
import { SETTINGS_SECTIONS } from "@/components/settings/settings-shell";
import { LAYOUT_GROUP_IDS } from "@/components/settings/layout-groups";
import { MODULE_SETTINGS_PAGE_IDS } from "@/components/settings/module-settings-page-ids";
import {
DASHBOARD_WIDGET_CATALOGUE_IDS,
SCORE_RING_IDS,
@@ -160,6 +161,13 @@ describe("every surface id names a surface that exists", () => {
}
});
it("settings-module: a module page under Settings > Modules", () => {
const ids = new Set<string>(MODULE_SETTINGS_PAGE_IDS);
for (const id of localIds("settings-module")) {
expect(ids.has(id), id).toBe(true);
}
});
it("day-section: a section the day view renders", () => {
const keys = new Set<string>(DAY_SECTION_KEYS);
for (const id of localIds("day-section")) {
+7 -1
View File
@@ -46,6 +46,7 @@ import type { ModuleKey } from "@/lib/modules/registry";
* - `derived`: a derived score, by its `DerivedMetricId`.
* - `settings`: a Settings section, by its slug.
* - `settings-layout`: a Settings, Layout group, by its id.
* - `settings-module`: a module's own Settings page under Modules, by its id.
* - `correlation`: a correlation-discovery channel, by its channel key.
* - `score-ring`: a dashboard hero score ring, by its `ScoreRingId`.
* - `day-section`: a section of the day view, by its `DaySectionKey`. The
@@ -66,6 +67,7 @@ export const SURFACE_KINDS = [
"derived",
"settings",
"settings-layout",
"settings-module",
"correlation",
"score-ring",
"day-section",
@@ -200,11 +202,15 @@ const STATIC_SURFACE_MODULE = {
// ── Settings, Layout groups ──
"settings-layout:medications": "medications",
"settings-layout:mood": "mood",
"settings-layout:labs": "labs",
"settings-layout:illness": "illness",
"settings-layout:documents": "inboundDocuments",
// ── Settings, a module's own page under Modules ──
"settings-module:medications": "medications",
"settings-module:mood": "mood",
"settings-module:labs": "labs",
// ── Correlation channels ──
// Rated mood factors (`FACTOR:<key>`) are mood's too; see
// `correlationChannelSurfaceId`.
@@ -98,6 +98,7 @@ export const SETTINGS_DESTINATION_INVENTORY = {
export: PERSONAL,
advanced: PERSONAL,
privacy: PERSONAL,
preview: PERSONAL,
about: PERSONAL,
} as const satisfies Record<
SettingsSectionSlug,
-7
View File
@@ -76,13 +76,6 @@ export function moduleListDefaults(list: ModuleListKey): ModuleListPrefs {
return { ...defaults, order: [...defaults.order] };
}
export function isModuleListKey(value: unknown): value is ModuleListKey {
return (
typeof value === "string" &&
(MODULE_LIST_KEYS as readonly string[]).includes(value)
);
}
export function isModuleListView(value: unknown): value is ModuleListView {
return (
typeof value === "string" &&