mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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:
1 parent
3ebef19ac3
commit
71dbef1742
76 files changed
+2517
-1806
No files matched your search
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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))(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")}
|
||||
>
|
||||
|
||||
@@ -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")}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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 +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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -40,6 +40,7 @@ import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { MODULE_ICONS } from "@/components/settings/module-icons";
|
||||
import { ModuleToggleRow } from "@/components/settings/module-toggle-row";
|
||||
import { isModuleSettingsPageId } from "@/components/settings/module-settings-page-ids";
|
||||
import { TimelineReadinessSheet } from "@/components/timeline/readiness-sheet.lazy";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useAiCapability } from "@/hooks/use-ai-capability";
|
||||
@@ -117,6 +118,10 @@ export function ModulesSection() {
|
||||
// Delegated modules carry more than on/off at their canonical
|
||||
// surface (Coach cadence/memory; cycle goal/predictions/lengths),
|
||||
// so keep a small deep-link to it beside the live switch.
|
||||
//
|
||||
// v1.44 — a module with its own page under Modules (labs, mood,
|
||||
// medications) links to it the same way: what the module needs
|
||||
// beyond order and view lives there, not under Arrangement.
|
||||
const manageLink =
|
||||
delegate !== undefined && def.managedAt
|
||||
? {
|
||||
@@ -125,7 +130,12 @@ export function ModulesSection() {
|
||||
section: t(def.managedAt.labelKey),
|
||||
}),
|
||||
}
|
||||
: undefined;
|
||||
: isModuleSettingsPageId(key)
|
||||
? {
|
||||
href: `/settings/modules/${key}`,
|
||||
label: t("settings.sections.modules.openModuleSettings"),
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<ModuleToggleRow
|
||||
|
||||
@@ -1,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. */}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 /> }),
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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([]);
|
||||
|
||||
@@ -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. ──
|
||||
|
||||
@@ -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")) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" &&
|
||||
|
||||
Reference in new issue
Block a user