feat(settings): standardize settings and admin section framing

Introduce a shared SettingsSectionFrame (the settings twin of the admin
renderer's SectionFrame) that emits the canonical visible heading +
subheading, and route all 19 settings sections through it. Every settings
page now shows a real h1 + subtitle in the reference Übersicht style
instead of an invisible sr-only title; the section bodies drop their own
headings and off-style intro paragraphs (the explanatory copy folds into
the frame subtitle).

- Route the admin About page through SectionFrame so it carries the same
  visible heading as every other admin section.
- Rename the AI settings section from KI-Auswertung to KI-Anbieter across
  six locales: the page configures the provider and key, not an analysis.
- Add a heading-block spacer to the settings and admin sidebars so the
  first menu item lines up with the top of the first card.
- Remove the always-on core-domains card from Module: a tracking area that
  can't be turned off doesn't need listing.
- Move the proactive-coach control out of Benachrichtigungen into Coach.
- Move admin module availability out of the Coach section into its own
  page: it gates every module, not just the coach.
- Give the Layout Insights editor a proper Insights heading.

Adds settings.sections.<slug>.subtitle and the new admin keys across all
six locales; removes the now-unused module core-domain strings.
This commit is contained in:
Marc-André Bombeck committed 2026-06-18 21:28:33 +02:00
1 parent ae3091df9e
commit 33d7d103da
42 files changed
+822 -802

No files matched your search

+47 -40
View File
@@ -3778,18 +3778,21 @@
"sections": {
"account": {
"title": "Konto",
"description": "Profil, Passwort, Passkeys und deine Onboarding-Tour."
"description": "Profil, Passwort, Passkeys und deine Onboarding-Tour.",
"subtitle": "Dein Profil, deine Identität und deine Anmeldedaten."
},
"integrations": {
"title": "Integrationen",
"description": "Verbundene Dienste, Zustellkanäle und die Quellen-Priorität an einem Ort."
"description": "Verbundene Dienste, Zustellkanäle und die Quellen-Priorität an einem Ort.",
"subtitle": "Datenquellen und Geräte verbinden und verwalten."
},
"notifications": {
"title": "Benachrichtigungen",
"description": "Alle Erinnerungen an einem Ort — was wann erinnert wird. Die Zustellkanäle liegen unter Integrationen.",
"channelsHint": "Telegram, ntfy, Web-Push, Webhook und E-Mail richtest du jetzt unter",
"channelsHintLink": "Integrationen → Kanäle",
"inboxLink": "Posteingang öffnen"
"inboxLink": "Posteingang öffnen",
"subtitle": "Wähle, welche Erinnerungen du erhältst."
},
"layout": {
"title": "Layout",
@@ -3802,15 +3805,18 @@
"insights": {
"title": "Insights",
"description": "Reihenfolge der Übersicht und der Navigations-Pills."
}
},
"subtitle": "Passe an, wie Dashboard und Insights angeordnet sind."
},
"dashboard": {
"title": "Dashboard",
"description": "Kachel-Layout und Reihenfolge."
"description": "Kachel-Layout und Reihenfolge.",
"subtitle": "Ordne die Kacheln auf deinem Dashboard an."
},
"thresholds": {
"title": "Zielwerte",
"description": "Pro Metrik dein eigener Zielbereich für gesunde Werte."
"description": "Pro Metrik dein eigener Zielbereich für gesunde Werte.",
"subtitle": "Lege deine persönlichen Zielbereiche je Messgröße fest."
},
"sources": {
"title": "Quellen-Priorität",
@@ -3869,19 +3875,23 @@
"scale": "Waage",
"other": "Sonstige",
"unknown": "Unbekannt"
}
},
"subtitle": "Lege fest, welche Quelle gewinnt, wenn zwei dieselbe Messgröße liefern."
},
"ai": {
"title": "KI-Auswertungen",
"description": "Provider, Modell und API-Key für die Auswertungen — dazu dein „Über mich“-Kontext."
"title": "KI-Anbieter",
"description": "Provider, Modell und API-Key für die Auswertungen — dazu dein „Über mich“-Kontext.",
"subtitle": "Wähle deinen KI-Anbieter und hinterlege deinen eigenen Schlüssel."
},
"api": {
"title": "API & Tokens",
"description": "Bearer-Tokens für eigene Skripte und Apps — erfasse Messwerte oder Einnahmen von überall."
"description": "Bearer-Tokens für eigene Skripte und Apps — erfasse Messwerte oder Einnahmen von überall.",
"subtitle": "API-Endpunkte und Zugriffstoken verwalten."
},
"advanced": {
"title": "Erweitert",
"description": "Opt-in-Schalter für den Forschungsmodus sowie der Notausgang zum unwiderruflichen Löschen aller Gesundheitsdaten."
"description": "Opt-in-Schalter für den Forschungsmodus sowie der Notausgang zum unwiderruflichen Löschen aller Gesundheitsdaten.",
"subtitle": "Research-Modus, Daten zurücksetzen und Konto löschen."
},
"export": {
"title": "Export & Import",
@@ -3982,27 +3992,33 @@
"resultSummary": "{inserted} neu importiert und {updated} aktualisiert, {skipped} übersprungen.",
"rowError": "Zeile {line}: {reason}"
}
}
},
"subtitle": "Exportiere deine Daten oder importiere aus einer Sicherung."
},
"about": {
"title": "Über diese App",
"description": "Version, Lizenz, Links."
"description": "Version, Lizenz, Links.",
"subtitle": "Version, Lizenz und Projektlinks."
},
"sharing": {
"title": "Freigabe",
"description": "Erstelle einen schreibgeschützten Link, um deine Gesundheitsakte mit einer Ärztin oder einem Arzt zu teilen."
"description": "Erstelle einen schreibgeschützten Link, um deine Gesundheitsakte mit einer Ärztin oder einem Arzt zu teilen.",
"subtitle": "Teile schreibgeschützten Zugriff auf deine Daten."
},
"insights": {
"title": "Insights",
"description": "Reihenfolge der Übersicht und der Navigations-Pills."
"description": "Reihenfolge der Übersicht und der Navigations-Pills.",
"subtitle": "Ordne die Übersichts-Bereiche und die Reihenfolge der Pills an."
},
"medications": {
"title": "Medikamente",
"description": "Listenansicht, Reihenfolge und Injektionsstellen deiner Medikamente."
"description": "Listenansicht, Reihenfolge und Injektionsstellen deiner Medikamente.",
"subtitle": "Verwalte Ansicht und Reihenfolge deiner Medikamentenliste."
},
"mood": {
"title": "Stimmungs-Tags",
"description": "Gruppen, Tags und archivierte Tags der Stimmungs-Auswahl."
"description": "Gruppen, Tags und archivierte Tags der Stimmungs-Auswahl.",
"subtitle": "Organisiere deine Stimmungs-Tags, Gruppen und Auswahlreihenfolge."
},
"modules": {
"title": "Module",
@@ -4012,26 +4028,24 @@
"title": "Was du trackst",
"description": "Optionale Bereiche. Deaktivierte Module verschwinden aus Navigation, Kacheln und Auswertungen — deine Daten bleiben erhalten."
},
"core": {
"title": "Immer aktiv",
"description": "Die Grundlagen von HealthLog. Diese Bereiche lassen sich nicht abschalten.",
"note": "Kernbereich — immer aktiv"
},
"description": "Lege fest, was HealthLog für dich erfasst. Schalte einzelne Bereiche ein oder aus — die Navigation, Kacheln und Auswertungen passen sich sofort an.",
"manageIn": "In {section} verwalten",
"operatorDisabled": "Serverweit deaktiviert"
"operatorDisabled": "Serverweit deaktiviert",
"subtitle": "Lege fest, welche Tracking-Bereiche HealthLog überall anzeigt."
},
"gesundheitsakte": {
"title": "Gesundheitsakte",
"description": "Vollständige Gesundheitsakte als PDF, FHIR R4 oder kombiniertes ZIP-Paket exportieren."
"description": "Vollständige Gesundheitsakte als PDF, FHIR R4 oder kombiniertes ZIP-Paket exportieren.",
"subtitle": "Exportiere eine vollständige, arzttaugliche Gesundheitsakte."
},
"coach": {
"title": "Coach",
"description": "Coach aktivieren, Ton und Umfang, gespeicherte Erinnerungen."
"description": "Coach aktivieren, Ton und Umfang, gespeicherte Erinnerungen.",
"subtitle": "Stelle den Coach, seine proaktiven Hinweise und sein Gedächtnis ein."
},
"channels": {
"title": "Kanäle",
"description": "Zustellung über Telegram, ntfy, Web-Push, Webhook und E-Mail."
"description": "Zustellung über Telegram, ntfy, Web-Push, Webhook und E-Mail.",
"subtitle": "Wohin Erinnerungen und Hinweise zugestellt werden."
}
},
"profile": "Profil",
@@ -5291,7 +5305,12 @@
"subtitle": "Destruktive systemweite Operationen."
},
"about": {
"title": "Über"
"title": "Über",
"subtitle": "Version, Lizenz und Update-Status."
},
"module-availability": {
"title": "Modulverfügbarkeit",
"subtitle": "Module für jedes Konto auf diesem Server aktivieren oder deaktivieren."
}
},
"carrier": "Anbieter",
@@ -6588,18 +6607,6 @@
"label": "Arztbericht",
"description": "PDF- und FHIR-Export für deine Praxis."
},
"weight": {
"label": "Gewicht",
"description": "Gewicht und Körperzusammensetzung."
},
"bloodPressure": {
"label": "Blutdruck",
"description": "Systolisch und diastolisch."
},
"pulse": {
"label": "Puls",
"description": "Ruhepuls und Herzfrequenz."
},
"medications": {
"label": "Medikamente",
"description": "Pläne, Einnahmen und Compliance."
+47 -40
View File
@@ -3778,18 +3778,21 @@
"sections": {
"account": {
"title": "Account",
"description": "Profile, password, passkeys, and your onboarding tour."
"description": "Profile, password, passkeys, and your onboarding tour.",
"subtitle": "Your profile, identity, and sign-in details."
},
"integrations": {
"title": "Integrations",
"description": "Connected services, delivery channels, and source priority in one place."
"description": "Connected services, delivery channels, and source priority in one place.",
"subtitle": "Connect and manage your data sources and devices."
},
"notifications": {
"title": "Notifications",
"description": "Every reminder in one place — what gets reminded and when. The delivery channels live under Integrations.",
"channelsHint": "Telegram, ntfy, Web Push, Webhook, and Email are now set up under",
"channelsHintLink": "Integrations → Channels",
"inboxLink": "Open the inbox"
"inboxLink": "Open the inbox",
"subtitle": "Choose which reminders you receive."
},
"layout": {
"title": "Layout",
@@ -3802,15 +3805,18 @@
"insights": {
"title": "Insights",
"description": "Overview sections and navigation-pill order."
}
},
"subtitle": "Personalize how your dashboard and insights are arranged."
},
"dashboard": {
"title": "Dashboard",
"description": "Tile layout and order."
"description": "Tile layout and order.",
"subtitle": "Arrange the tiles on your dashboard."
},
"thresholds": {
"title": "Targets",
"description": "Per metric: your own target range for healthy values."
"description": "Per metric: your own target range for healthy values.",
"subtitle": "Set your personal target ranges per metric."
},
"sources": {
"title": "Source priority",
@@ -3869,19 +3875,23 @@
"scale": "Scale",
"other": "Other",
"unknown": "Unknown"
}
},
"subtitle": "Decide which source wins when two report the same metric."
},
"ai": {
"title": "AI Insights",
"description": "Provider, model, and API key for the insights — plus your “about me” context."
"title": "AI provider",
"description": "Provider, model, and API key for the insights — plus your “about me” context.",
"subtitle": "Choose your AI provider and bring your own key."
},
"api": {
"title": "API & Tokens",
"description": "Bearer tokens for your own scripts and apps — log measurements or medication intake from anywhere."
"description": "Bearer tokens for your own scripts and apps — log measurements or medication intake from anywhere.",
"subtitle": "Manage API endpoints and access tokens."
},
"advanced": {
"title": "Advanced",
"description": "Opt-in Research Mode toggle plus the account-wide danger zone for wiping every health record."
"description": "Opt-in Research Mode toggle plus the account-wide danger zone for wiping every health record.",
"subtitle": "Research mode, data reset, and account deletion."
},
"export": {
"title": "Export & Import",
@@ -3982,27 +3992,33 @@
"resultSummary": "Imported {inserted} new and updated {updated}, {skipped} skipped.",
"rowError": "Line {line}: {reason}"
}
}
},
"subtitle": "Export your data or import from a backup."
},
"about": {
"title": "About this app",
"description": "Version, license, links."
"description": "Version, license, links.",
"subtitle": "Version, license, and project links."
},
"sharing": {
"title": "Sharing",
"description": "Create a read-only link to share your health record with a clinician."
"description": "Create a read-only link to share your health record with a clinician.",
"subtitle": "Share read-only access to your data."
},
"insights": {
"title": "Insights",
"description": "Overview and navigation-pill order."
"description": "Overview and navigation-pill order.",
"subtitle": "Arrange your overview sections and pill order."
},
"medications": {
"title": "Medications",
"description": "List view, order, and injection sites for your medications."
"description": "List view, order, and injection sites for your medications.",
"subtitle": "Manage your medication list view and order."
},
"mood": {
"title": "Mood tags",
"description": "Groups, tags, and archived tags for the mood picker."
"description": "Groups, tags, and archived tags for the mood picker.",
"subtitle": "Organize your mood tags, groups, and picker order."
},
"modules": {
"title": "Modules",
@@ -4012,26 +4028,24 @@
"title": "What you track",
"description": "Optional areas. Disabled modules drop out of the navigation, tiles, and insights — your data stays untouched."
},
"core": {
"title": "Always on",
"description": "HealthLog's foundations. These areas can't be turned off.",
"note": "Core area — always on"
},
"description": "Choose what HealthLog tracks for you. Turn individual areas on or off — the navigation, tiles, and insights adapt right away.",
"manageIn": "Manage in {section}",
"operatorDisabled": "Disabled server-wide"
"operatorDisabled": "Disabled server-wide",
"subtitle": "Choose which tracking areas HealthLog shows across the app."
},
"gesundheitsakte": {
"title": "Health record",
"description": "Export your full health record as PDF, FHIR R4, or a combined ZIP package."
"description": "Export your full health record as PDF, FHIR R4, or a combined ZIP package.",
"subtitle": "Export a complete, clinician-ready health record."
},
"coach": {
"title": "Coach",
"description": "Enable the Coach, tone and scope, stored memories."
"description": "Enable the Coach, tone and scope, stored memories.",
"subtitle": "Tune the Coach, its proactive nudges, and its memory."
},
"channels": {
"title": "Channels",
"description": "Telegram, ntfy, Web Push, Webhook, and Email delivery."
"description": "Telegram, ntfy, Web Push, Webhook, and Email delivery.",
"subtitle": "Where reminders and alerts are delivered."
}
},
"profile": "Profile",
@@ -5291,7 +5305,12 @@
"subtitle": "Destructive system-wide operations."
},
"about": {
"title": "About"
"title": "About",
"subtitle": "Version, license, and update status."
},
"module-availability": {
"title": "Module availability",
"subtitle": "Turn modules on or off for every account on this server."
}
},
"carrier": "Carrier",
@@ -6588,18 +6607,6 @@
"label": "Doctor report",
"description": "PDF and FHIR export for your practice."
},
"weight": {
"label": "Weight",
"description": "Weight and body composition."
},
"bloodPressure": {
"label": "Blood pressure",
"description": "Systolic and diastolic."
},
"pulse": {
"label": "Pulse",
"description": "Resting pulse and heart rate."
},
"medications": {
"label": "Medications",
"description": "Schedules, intakes, and compliance."
+47 -40
View File
@@ -3778,18 +3778,21 @@
"sections": {
"account": {
"title": "Cuenta",
"description": "Perfil, contraseña, claves de acceso y tu recorrido de incorporación."
"description": "Perfil, contraseña, claves de acceso y tu recorrido de incorporación.",
"subtitle": "Tu perfil, identidad y datos de acceso."
},
"integrations": {
"title": "Integraciones",
"description": "Servicios conectados, canales de entrega y prioridad de fuentes en un solo lugar."
"description": "Servicios conectados, canales de entrega y prioridad de fuentes en un solo lugar.",
"subtitle": "Conecta y gestiona tus fuentes de datos y dispositivos."
},
"notifications": {
"title": "Notificaciones",
"description": "Todos los recordatorios en un solo lugar: qué se recuerda y cuándo. Los canales de entrega están en Integraciones.",
"channelsHint": "Telegram, ntfy, Web Push, Webhook y correo se configuran ahora en",
"channelsHintLink": "Integraciones → Canales",
"inboxLink": "Abrir la bandeja de entrada"
"inboxLink": "Abrir la bandeja de entrada",
"subtitle": "Elige qué recordatorios recibes."
},
"layout": {
"title": "Diseño",
@@ -3802,15 +3805,18 @@
"insights": {
"title": "Análisis",
"description": "Orden de la vista general y de las pestañas de navegación."
}
},
"subtitle": "Personaliza cómo se organizan tu panel e insights."
},
"dashboard": {
"title": "Panel",
"description": "Diseño y orden de los mosaicos."
"description": "Diseño y orden de los mosaicos.",
"subtitle": "Organiza los mosaicos de tu panel."
},
"thresholds": {
"title": "Objetivos",
"description": "Por métrica: tu propio rango objetivo de valores saludables."
"description": "Por métrica: tu propio rango objetivo de valores saludables.",
"subtitle": "Define tus rangos objetivo personales por métrica."
},
"sources": {
"title": "Prioridad de fuentes",
@@ -3869,19 +3875,23 @@
"scale": "Báscula",
"other": "Otro",
"unknown": "Desconocido"
}
},
"subtitle": "Decide qué fuente prevalece cuando dos informan la misma métrica."
},
"ai": {
"title": "Análisis con IA",
"description": "Proveedor, modelo y clave API para los análisis, además de tu contexto «sobre mí»."
"title": "Proveedor de IA",
"description": "Proveedor, modelo y clave API para los análisis, además de tu contexto «sobre mí».",
"subtitle": "Elige tu proveedor de IA y usa tu propia clave."
},
"api": {
"title": "API & Tokens",
"description": "Tokens Bearer para tus propios scripts y apps — registra mediciones o tomas de medicación desde cualquier lugar."
"description": "Tokens Bearer para tus propios scripts y apps — registra mediciones o tomas de medicación desde cualquier lugar.",
"subtitle": "Gestiona los endpoints de la API y los tokens de acceso."
},
"advanced": {
"title": "Avanzado",
"description": "Activador opt-in del modo Investigación y zona de peligro para borrar irrevocablemente todos los registros de salud."
"description": "Activador opt-in del modo Investigación y zona de peligro para borrar irrevocablemente todos los registros de salud.",
"subtitle": "Modo investigación, restablecer datos y eliminar cuenta."
},
"export": {
"title": "Exportar e importar",
@@ -3982,27 +3992,33 @@
"resultSummary": "{inserted} nuevas importadas y {updated} actualizadas, {skipped} omitidas.",
"rowError": "Línea {line}: {reason}"
}
}
},
"subtitle": "Exporta tus datos o importa desde una copia de seguridad."
},
"about": {
"title": "Acerca de esta app",
"description": "Versión, licencia, enlaces."
"description": "Versión, licencia, enlaces.",
"subtitle": "Versión, licencia y enlaces del proyecto."
},
"sharing": {
"title": "Compartir",
"description": "Crea un enlace de solo lectura para compartir tu historial de salud con un profesional médico."
"description": "Crea un enlace de solo lectura para compartir tu historial de salud con un profesional médico.",
"subtitle": "Comparte acceso de solo lectura a tus datos."
},
"insights": {
"title": "Análisis",
"description": "Orden de la vista general y de las pestañas de navegación."
"description": "Orden de la vista general y de las pestañas de navegación.",
"subtitle": "Organiza las secciones de tu resumen y el orden de las píldoras."
},
"medications": {
"title": "Medicamentos",
"description": "Vista de lista, orden y zonas de inyección de tus medicamentos."
"description": "Vista de lista, orden y zonas de inyección de tus medicamentos.",
"subtitle": "Gestiona la vista y el orden de tu lista de medicamentos."
},
"mood": {
"title": "Etiquetas de ánimo",
"description": "Grupos, etiquetas y etiquetas archivadas del selector de ánimo."
"description": "Grupos, etiquetas y etiquetas archivadas del selector de ánimo.",
"subtitle": "Organiza tus etiquetas de ánimo, grupos y orden del selector."
},
"modules": {
"title": "Módulos",
@@ -4012,26 +4028,24 @@
"title": "Qué registras",
"description": "Áreas opcionales. Los módulos desactivados desaparecen de la navegación, los paneles y los análisis; tus datos se conservan."
},
"core": {
"title": "Siempre activos",
"description": "Los pilares de HealthLog. Estas áreas no se pueden desactivar.",
"note": "Área esencial — siempre activa"
},
"description": "Elige qué registra HealthLog por ti. Activa o desactiva áreas concretas: la navegación, los paneles y los análisis se adaptan al instante.",
"manageIn": "Gestionar en {section}",
"operatorDisabled": "Desactivado en todo el servidor"
"operatorDisabled": "Desactivado en todo el servidor",
"subtitle": "Elige qué áreas de seguimiento muestra HealthLog en la app."
},
"gesundheitsakte": {
"title": "Historial médico",
"description": "Exporta tu historial médico completo como PDF, FHIR R4 o un paquete ZIP combinado."
"description": "Exporta tu historial médico completo como PDF, FHIR R4 o un paquete ZIP combinado.",
"subtitle": "Exporta un historial de salud completo, listo para el médico."
},
"coach": {
"title": "Coach",
"description": "Activa el Coach, tono y alcance, recuerdos guardados."
"description": "Activa el Coach, tono y alcance, recuerdos guardados.",
"subtitle": "Ajusta el Coach, sus avisos proactivos y su memoria."
},
"channels": {
"title": "Canales",
"description": "Envío por Telegram, ntfy, Web Push, Webhook y correo electrónico."
"description": "Envío por Telegram, ntfy, Web Push, Webhook y correo electrónico.",
"subtitle": "Dónde se entregan los recordatorios y avisos."
}
},
"profile": "Perfil",
@@ -5291,7 +5305,12 @@
"subtitle": "Operaciones destructivas en todo el sistema."
},
"about": {
"title": "Acerca de"
"title": "Acerca de",
"subtitle": "Versión, licencia y estado de actualizaciones."
},
"module-availability": {
"title": "Disponibilidad de módulos",
"subtitle": "Activa o desactiva módulos para todas las cuentas de este servidor."
}
},
"carrier": "Operador",
@@ -6588,18 +6607,6 @@
"label": "Informe médico",
"description": "Exportación PDF y FHIR para tu consulta."
},
"weight": {
"label": "Peso",
"description": "Peso y composición corporal."
},
"bloodPressure": {
"label": "Presión arterial",
"description": "Sistólica y diastólica."
},
"pulse": {
"label": "Pulso",
"description": "Pulso en reposo y frecuencia cardiaca."
},
"medications": {
"label": "Medicamentos",
"description": "Pautas, tomas y cumplimiento."
+47 -40
View File
@@ -3778,18 +3778,21 @@
"sections": {
"account": {
"title": "Compte",
"description": "Profil, mot de passe, clés d’accès et votre visite guidée."
"description": "Profil, mot de passe, clés d’accès et votre visite guidée.",
"subtitle": "Votre profil, votre identité et vos identifiants."
},
"integrations": {
"title": "Intégrations",
"description": "Services connectés, canaux de diffusion et priorité des sources au même endroit."
"description": "Services connectés, canaux de diffusion et priorité des sources au même endroit.",
"subtitle": "Connectez et gérez vos sources de données et appareils."
},
"notifications": {
"title": "Notifications",
"description": "Tous les rappels au même endroit — quoi et quand. Les canaux de diffusion se trouvent dans Intégrations.",
"channelsHint": "Telegram, ntfy, Web Push, Webhook et e-mail se configurent désormais dans",
"channelsHintLink": "Intégrations → Canaux",
"inboxLink": "Ouvrir la boîte de réception"
"inboxLink": "Ouvrir la boîte de réception",
"subtitle": "Choisissez les rappels que vous recevez."
},
"layout": {
"title": "Disposition",
@@ -3802,15 +3805,18 @@
"insights": {
"title": "Analyses",
"description": "Ordre de la vue d'ensemble et des onglets de navigation."
}
},
"subtitle": "Personnalisez l'agencement de votre tableau de bord et de vos insights."
},
"dashboard": {
"title": "Tableau de bord",
"description": "Disposition et ordre des tuiles."
"description": "Disposition et ordre des tuiles.",
"subtitle": "Organisez les tuiles de votre tableau de bord."
},
"thresholds": {
"title": "Objectifs",
"description": "Par métrique : votre propre plage cible pour des valeurs saines."
"description": "Par métrique : votre propre plage cible pour des valeurs saines.",
"subtitle": "Définissez vos plages cibles personnelles par mesure."
},
"sources": {
"title": "Priorité des sources",
@@ -3869,19 +3875,23 @@
"scale": "Balance",
"other": "Autre",
"unknown": "Inconnu"
}
},
"subtitle": "Choisissez la source prioritaire en cas de doublon de mesure."
},
"ai": {
"title": "Analyses IA",
"description": "Fournisseur, modèle et clé API pour les analyses, ainsi que votre contexte « à propos de moi »."
"title": "Fournisseur d'IA",
"description": "Fournisseur, modèle et clé API pour les analyses, ainsi que votre contexte « à propos de moi ».",
"subtitle": "Choisissez votre fournisseur d'IA et utilisez votre propre clé."
},
"api": {
"title": "API & Tokens",
"description": "Jetons Bearer pour vos propres scripts et applis — enregistrez des mesures ou des prises de médicaments depuis n’importe où."
"description": "Jetons Bearer pour vos propres scripts et applis — enregistrez des mesures ou des prises de médicaments depuis n’importe où.",
"subtitle": "Gérez les points d'accès API et les jetons d'accès."
},
"advanced": {
"title": "Avancé",
"description": "Bouton opt-in du mode Recherche et zone dangereuse pour effacer irrévocablement toutes les données de santé."
"description": "Bouton opt-in du mode Recherche et zone dangereuse pour effacer irrévocablement toutes les données de santé.",
"subtitle": "Mode recherche, réinitialisation des données et suppression du compte."
},
"export": {
"title": "Export et import",
@@ -3982,27 +3992,33 @@
"resultSummary": "{inserted} nouvelles importées et {updated} mises à jour, {skipped} ignorées.",
"rowError": "Ligne {line} : {reason}"
}
}
},
"subtitle": "Exportez vos données ou importez depuis une sauvegarde."
},
"about": {
"title": "À propos de cette app",
"description": "Version, licence, liens."
"description": "Version, licence, liens.",
"subtitle": "Version, licence et liens du projet."
},
"sharing": {
"title": "Partage",
"description": "Créez un lien en lecture seule pour partager votre dossier de santé avec un professionnel de santé."
"description": "Créez un lien en lecture seule pour partager votre dossier de santé avec un professionnel de santé.",
"subtitle": "Partagez un accès en lecture seule à vos données."
},
"insights": {
"title": "Analyses",
"description": "Ordre de la vue d'ensemble et des onglets de navigation."
"description": "Ordre de la vue d'ensemble et des onglets de navigation.",
"subtitle": "Organisez les sections d'aperçu et l'ordre des pastilles."
},
"medications": {
"title": "Médicaments",
"description": "Vue liste, ordre et sites d’injection de vos médicaments."
"description": "Vue liste, ordre et sites d’injection de vos médicaments.",
"subtitle": "Gérez l'affichage et l'ordre de votre liste de médicaments."
},
"mood": {
"title": "Étiquettes d’humeur",
"description": "Groupes, étiquettes et étiquettes archivées du sélecteur d’humeur."
"description": "Groupes, étiquettes et étiquettes archivées du sélecteur d’humeur.",
"subtitle": "Organisez vos tags d'humeur, groupes et ordre du sélecteur."
},
"modules": {
"title": "Modules",
@@ -4012,26 +4028,24 @@
"title": "Ce que vous suivez",
"description": "Domaines optionnels. Les modules désactivés disparaissent de la navigation, des tuiles et des analyses ; vos données sont conservées."
},
"core": {
"title": "Toujours actifs",
"description": "Les fondations de HealthLog. Ces domaines ne peuvent pas être désactivés.",
"note": "Domaine essentiel — toujours actif"
},
"description": "Choisissez ce que HealthLog suit pour vous. Activez ou désactivez chaque domaine : la navigation, les tuiles et les analyses s'adaptent aussitôt.",
"manageIn": "Gérer dans {section}",
"operatorDisabled": "Désactivé sur tout le serveur"
"operatorDisabled": "Désactivé sur tout le serveur",
"subtitle": "Choisissez les domaines de suivi affichés par HealthLog."
},
"gesundheitsakte": {
"title": "Dossier médical",
"description": "Exportez votre dossier médical complet au format PDF, FHIR R4 ou en paquet ZIP combiné."
"description": "Exportez votre dossier médical complet au format PDF, FHIR R4 ou en paquet ZIP combiné.",
"subtitle": "Exportez un dossier de santé complet, prêt pour le médecin."
},
"coach": {
"title": "Coach",
"description": "Activez le Coach, le ton et la portée, les souvenirs enregistrés."
"description": "Activez le Coach, le ton et la portée, les souvenirs enregistrés.",
"subtitle": "Réglez le Coach, ses suggestions proactives et sa mémoire."
},
"channels": {
"title": "Canaux",
"description": "Distribution via Telegram, ntfy, Web Push, Webhook et e-mail."
"description": "Distribution via Telegram, ntfy, Web Push, Webhook et e-mail.",
"subtitle": "Où sont envoyés les rappels et alertes."
}
},
"profile": "Profil",
@@ -5291,7 +5305,12 @@
"subtitle": "Opérations destructrices à l'échelle du système."
},
"about": {
"title": "À propos"
"title": "À propos",
"subtitle": "Version, licence et état des mises à jour."
},
"module-availability": {
"title": "Disponibilité des modules",
"subtitle": "Activez ou désactivez les modules pour tous les comptes de ce serveur."
}
},
"carrier": "Opérateur",
@@ -6588,18 +6607,6 @@
"label": "Rapport médical",
"description": "Export PDF et FHIR pour votre cabinet."
},
"weight": {
"label": "Poids",
"description": "Poids et composition corporelle."
},
"bloodPressure": {
"label": "Tension artérielle",
"description": "Systolique et diastolique."
},
"pulse": {
"label": "Pouls",
"description": "Pouls au repos et fréquence cardiaque."
},
"medications": {
"label": "Médicaments",
"description": "Schémas, prises et observance."
+47 -40
View File
@@ -3778,18 +3778,21 @@
"sections": {
"account": {
"title": "Account",
"description": "Profilo, password, passkey e il tour di benvenuto."
"description": "Profilo, password, passkey e il tour di benvenuto.",
"subtitle": "Il tuo profilo, la tua identità e i dati di accesso."
},
"integrations": {
"title": "Integrazioni",
"description": "Servizi collegati, canali di consegna e priorità delle fonti in un unico posto."
"description": "Servizi collegati, canali di consegna e priorità delle fonti in un unico posto.",
"subtitle": "Collega e gestisci le tue fonti di dati e i dispositivi."
},
"notifications": {
"title": "Notifiche",
"description": "Tutti i promemoria in un unico posto — cosa e quando. I canali di recapito si trovano in Integrazioni.",
"channelsHint": "Telegram, ntfy, Web Push, Webhook ed e-mail ora si configurano in",
"channelsHintLink": "Integrazioni → Canali",
"inboxLink": "Apri la posta in arrivo"
"inboxLink": "Apri la posta in arrivo",
"subtitle": "Scegli quali promemoria ricevere."
},
"layout": {
"title": "Layout",
@@ -3802,15 +3805,18 @@
"insights": {
"title": "Approfondimenti",
"description": "Ordine della panoramica e delle schede di navigazione."
}
},
"subtitle": "Personalizza la disposizione di dashboard e insights."
},
"dashboard": {
"title": "Cruscotto",
"description": "Layout e ordine dei riquadri."
"description": "Layout e ordine dei riquadri.",
"subtitle": "Disponi i riquadri della tua dashboard."
},
"thresholds": {
"title": "Obiettivi",
"description": "Per metrica: il tuo intervallo target per valori sani."
"description": "Per metrica: il tuo intervallo target per valori sani.",
"subtitle": "Imposta i tuoi intervalli target personali per metrica."
},
"sources": {
"title": "Priorità delle fonti",
@@ -3869,19 +3875,23 @@
"scale": "Bilancia",
"other": "Altro",
"unknown": "Sconosciuto"
}
},
"subtitle": "Decidi quale fonte prevale quando due riportano la stessa metrica."
},
"ai": {
"title": "Analisi IA",
"description": "Provider, modello e chiave API per gli approfondimenti, oltre al tuo contesto «su di me»."
"title": "Provider IA",
"description": "Provider, modello e chiave API per gli approfondimenti, oltre al tuo contesto «su di me».",
"subtitle": "Scegli il tuo provider IA e usa la tua chiave."
},
"api": {
"title": "API & Tokens",
"description": "Token Bearer per i tuoi script e le tue app — registra misurazioni o assunzioni di farmaci da ovunque."
"description": "Token Bearer per i tuoi script e le tue app — registra misurazioni o assunzioni di farmaci da ovunque.",
"subtitle": "Gestisci gli endpoint API e i token di accesso."
},
"advanced": {
"title": "Avanzate",
"description": "Interruttore opt-in della modalità Ricerca e zona pericolosa per cancellare in modo irrevocabile tutti i dati di salute."
"description": "Interruttore opt-in della modalità Ricerca e zona pericolosa per cancellare in modo irrevocabile tutti i dati di salute.",
"subtitle": "Modalità ricerca, ripristino dati ed eliminazione account."
},
"export": {
"title": "Esporta e importa",
@@ -3982,27 +3992,33 @@
"resultSummary": "{inserted} nuove importate e {updated} aggiornate, {skipped} saltate.",
"rowError": "Riga {line}: {reason}"
}
}
},
"subtitle": "Esporta i tuoi dati o importa da un backup."
},
"about": {
"title": "Informazioni su questa app",
"description": "Versione, licenza, link."
"description": "Versione, licenza, link.",
"subtitle": "Versione, licenza e link al progetto."
},
"sharing": {
"title": "Condivisione",
"description": "Crea un link di sola lettura per condividere la tua cartella clinica con un medico."
"description": "Crea un link di sola lettura per condividere la tua cartella clinica con un medico.",
"subtitle": "Condividi l'accesso in sola lettura ai tuoi dati."
},
"insights": {
"title": "Approfondimenti",
"description": "Ordine della panoramica e delle schede di navigazione."
"description": "Ordine della panoramica e delle schede di navigazione.",
"subtitle": "Disponi le sezioni della panoramica e l'ordine dei pill."
},
"medications": {
"title": "Farmaci",
"description": "Vista elenco, ordine e siti di iniezione dei tuoi farmaci."
"description": "Vista elenco, ordine e siti di iniezione dei tuoi farmaci.",
"subtitle": "Gestisci vista e ordine del tuo elenco di farmaci."
},
"mood": {
"title": "Tag dell’umore",
"description": "Gruppi, tag e tag archiviati del selettore dell’umore."
"description": "Gruppi, tag e tag archiviati del selettore dell’umore.",
"subtitle": "Organizza i tag dell'umore, i gruppi e l'ordine del selettore."
},
"modules": {
"title": "Moduli",
@@ -4012,26 +4028,24 @@
"title": "Cosa monitori",
"description": "Ambiti opzionali. I moduli disattivati spariscono da navigazione, riquadri e analisi; i tuoi dati restano intatti."
},
"core": {
"title": "Sempre attivi",
"description": "Le fondamenta di HealthLog. Questi ambiti non possono essere disattivati.",
"note": "Ambito di base — sempre attivo"
},
"description": "Scegli cosa registra HealthLog per te. Attiva o disattiva i singoli ambiti: navigazione, riquadri e analisi si adattano subito.",
"manageIn": "Gestisci in {section}",
"operatorDisabled": "Disattivato a livello di server"
"operatorDisabled": "Disattivato a livello di server",
"subtitle": "Scegli quali aree di monitoraggio HealthLog mostra nell'app."
},
"gesundheitsakte": {
"title": "Cartella clinica",
"description": "Esporta la tua cartella clinica completa in PDF, FHIR R4 o in un pacchetto ZIP combinato."
"description": "Esporta la tua cartella clinica completa in PDF, FHIR R4 o in un pacchetto ZIP combinato.",
"subtitle": "Esporta una cartella clinica completa, pronta per il medico."
},
"coach": {
"title": "Coach",
"description": "Attiva il Coach, tono e ambito, ricordi salvati."
"description": "Attiva il Coach, tono e ambito, ricordi salvati.",
"subtitle": "Regola il Coach, i suoi suggerimenti proattivi e la sua memoria."
},
"channels": {
"title": "Canali",
"description": "Recapito tramite Telegram, ntfy, Web Push, Webhook ed e-mail."
"description": "Recapito tramite Telegram, ntfy, Web Push, Webhook ed e-mail.",
"subtitle": "Dove vengono recapitati promemoria e avvisi."
}
},
"profile": "Profilo",
@@ -5291,7 +5305,12 @@
"subtitle": "Operazioni distruttive a livello di sistema."
},
"about": {
"title": "Informazioni"
"title": "Informazioni",
"subtitle": "Versione, licenza e stato degli aggiornamenti."
},
"module-availability": {
"title": "Disponibilità dei moduli",
"subtitle": "Attiva o disattiva i moduli per tutti gli account su questo server."
}
},
"carrier": "Operatore",
@@ -6588,18 +6607,6 @@
"label": "Referto medico",
"description": "Esportazione PDF e FHIR per il tuo studio."
},
"weight": {
"label": "Peso",
"description": "Peso e composizione corporea."
},
"bloodPressure": {
"label": "Pressione",
"description": "Sistolica e diastolica."
},
"pulse": {
"label": "Battito",
"description": "Battito a riposo e frequenza cardiaca."
},
"medications": {
"label": "Farmaci",
"description": "Schemi, assunzioni e aderenza."
+47 -40
View File
@@ -3778,18 +3778,21 @@
"sections": {
"account": {
"title": "Konto",
"description": "Profil, hasło, klucze dostępu i przewodnik wprowadzający."
"description": "Profil, hasło, klucze dostępu i przewodnik wprowadzający.",
"subtitle": "Twój profil, tożsamość i dane logowania."
},
"integrations": {
"title": "Integracje",
"description": "Połączone usługi, kanały dostarczania i priorytet źródeł w jednym miejscu."
"description": "Połączone usługi, kanały dostarczania i priorytet źródeł w jednym miejscu.",
"subtitle": "Połącz i zarządzaj źródłami danych oraz urządzeniami."
},
"notifications": {
"title": "Powiadomienia",
"description": "Wszystkie przypomnienia w jednym miejscu — co i kiedy. Kanały dostarczania znajdują się w Integracjach.",
"channelsHint": "Telegram, ntfy, Web Push, Webhook i e-mail konfigurujesz teraz w",
"channelsHintLink": "Integracje → Kanały",
"inboxLink": "Otwórz skrzynkę odbiorczą"
"inboxLink": "Otwórz skrzynkę odbiorczą",
"subtitle": "Wybierz, które przypomnienia otrzymujesz."
},
"layout": {
"title": "Układ",
@@ -3802,15 +3805,18 @@
"insights": {
"title": "Analizy",
"description": "Kolejność przeglądu i pigułek nawigacyjnych."
}
},
"subtitle": "Dostosuj układ pulpitu i analiz."
},
"dashboard": {
"title": "Pulpit",
"description": "Układ i kolejność kafelków."
"description": "Układ i kolejność kafelków.",
"subtitle": "Rozmieść kafelki na pulpicie."
},
"thresholds": {
"title": "Cele",
"description": "Dla każdej metryki: własny zakres docelowy zdrowych wartości."
"description": "Dla każdej metryki: własny zakres docelowy zdrowych wartości.",
"subtitle": "Ustaw osobiste zakresy docelowe dla każdej metryki."
},
"sources": {
"title": "Priorytet źródeł",
@@ -3869,19 +3875,23 @@
"scale": "Waga",
"other": "Inne",
"unknown": "Nieznane"
}
},
"subtitle": "Zdecyduj, które źródło wygrywa, gdy dwa podają tę samą metrykę."
},
"ai": {
"title": "Analizy AI",
"description": "Dostawca, model i klucz API do analiz, a także Twój kontekst „o mnie”."
"title": "Dostawca AI",
"description": "Dostawca, model i klucz API do analiz, a także Twój kontekst „o mnie”.",
"subtitle": "Wybierz dostawcę AI i użyj własnego klucza."
},
"api": {
"title": "API & Tokens",
"description": "Tokeny Bearer dla własnych skryptów i aplikacji — zapisuj pomiary lub przyjęcia leków z dowolnego miejsca."
"description": "Tokeny Bearer dla własnych skryptów i aplikacji — zapisuj pomiary lub przyjęcia leków z dowolnego miejsca.",
"subtitle": "Zarządzaj punktami końcowymi API i tokenami dostępu."
},
"advanced": {
"title": "Zaawansowane",
"description": "Opt-in przełącznika trybu Badawczego oraz strefa zagrożenia umożliwiająca nieodwracalne wymazanie wszystkich danych zdrowotnych."
"description": "Opt-in przełącznika trybu Badawczego oraz strefa zagrożenia umożliwiająca nieodwracalne wymazanie wszystkich danych zdrowotnych.",
"subtitle": "Tryb badawczy, reset danych i usunięcie konta."
},
"export": {
"title": "Eksport i import",
@@ -3982,27 +3992,33 @@
"resultSummary": "Zaimportowano {inserted} nowych i zaktualizowano {updated}, pominięto {skipped}.",
"rowError": "Wiersz {line}: {reason}"
}
}
},
"subtitle": "Wyeksportuj dane lub zaimportuj z kopii zapasowej."
},
"about": {
"title": "O tej aplikacji",
"description": "Wersja, licencja, linki."
"description": "Wersja, licencja, linki.",
"subtitle": "Wersja, licencja i linki do projektu."
},
"sharing": {
"title": "Udostępnianie",
"description": "Utwórz link tylko do odczytu, aby udostępnić swoją dokumentację zdrowotną lekarzowi."
"description": "Utwórz link tylko do odczytu, aby udostępnić swoją dokumentację zdrowotną lekarzowi.",
"subtitle": "Udostępnij dostęp tylko do odczytu do swoich danych."
},
"insights": {
"title": "Analizy",
"description": "Kolejność przeglądu i pigułek nawigacyjnych."
"description": "Kolejność przeglądu i pigułek nawigacyjnych.",
"subtitle": "Rozmieść sekcje przeglądu i kolejność etykiet."
},
"medications": {
"title": "Leki",
"description": "Widok listy, kolejność i miejsca wstrzyknięć Twoich leków."
"description": "Widok listy, kolejność i miejsca wstrzyknięć Twoich leków.",
"subtitle": "Zarządzaj widokiem i kolejnością listy leków."
},
"mood": {
"title": "Tagi nastroju",
"description": "Grupy, tagi i zarchiwizowane tagi w wyborze nastroju."
"description": "Grupy, tagi i zarchiwizowane tagi w wyborze nastroju.",
"subtitle": "Uporządkuj tagi nastroju, grupy i kolejność wyboru."
},
"modules": {
"title": "Moduły",
@@ -4012,26 +4028,24 @@
"title": "Co śledzisz",
"description": "Obszary opcjonalne. Wyłączone moduły znikają z nawigacji, kafelków i analiz — Twoje dane pozostają nienaruszone."
},
"core": {
"title": "Zawsze aktywne",
"description": "Podstawy HealthLog. Tych obszarów nie można wyłączyć.",
"note": "Obszar podstawowy — zawsze aktywny"
},
"description": "Wybierz, co HealthLog ma dla Ciebie śledzić. Włączaj i wyłączaj poszczególne obszary — nawigacja, kafelki i analizy dostosują się od razu.",
"manageIn": "Zarządzaj w {section}",
"operatorDisabled": "Wyłączone na całym serwerze"
"operatorDisabled": "Wyłączone na całym serwerze",
"subtitle": "Wybierz, które obszary śledzenia pokazuje HealthLog w aplikacji."
},
"gesundheitsakte": {
"title": "Dokumentacja zdrowotna",
"description": "Wyeksportuj pełną dokumentację zdrowotną jako PDF, FHIR R4 lub łączony pakiet ZIP."
"description": "Wyeksportuj pełną dokumentację zdrowotną jako PDF, FHIR R4 lub łączony pakiet ZIP.",
"subtitle": "Eksportuj kompletną dokumentację zdrowotną gotową dla lekarza."
},
"coach": {
"title": "Coach",
"description": "Włącz Coacha, ton i zakres, zapisane wspomnienia."
"description": "Włącz Coacha, ton i zakres, zapisane wspomnienia.",
"subtitle": "Dostosuj Coacha, jego proaktywne podpowiedzi i pamięć."
},
"channels": {
"title": "Kanały",
"description": "Dostarczanie przez Telegram, ntfy, Web Push, Webhook i e-mail."
"description": "Dostarczanie przez Telegram, ntfy, Web Push, Webhook i e-mail.",
"subtitle": "Gdzie dostarczane są przypomnienia i powiadomienia."
}
},
"profile": "Profil",
@@ -5291,7 +5305,12 @@
"subtitle": "Destrukcyjne operacje obejmujące cały system."
},
"about": {
"title": "O programie"
"title": "O programie",
"subtitle": "Wersja, licencja i stan aktualizacji."
},
"module-availability": {
"title": "Dostępność modułów",
"subtitle": "Włącz lub wyłącz moduły dla wszystkich kont na tym serwerze."
}
},
"carrier": "Operator",
@@ -6588,18 +6607,6 @@
"label": "Raport dla lekarza",
"description": "Eksport PDF i FHIR dla Twojej przychodni."
},
"weight": {
"label": "Waga",
"description": "Waga i skład ciała."
},
"bloodPressure": {
"label": "Ciśnienie krwi",
"description": "Skurczowe i rozkurczowe."
},
"pulse": {
"label": "Tętno",
"description": "Tętno spoczynkowe i rytm serca."
},
"medications": {
"label": "Leki",
"description": "Schematy, przyjęcia i zgodność."
+27 -7
View File
@@ -94,18 +94,32 @@ export function AdminSectionRenderer({
// v1.18.1 — the former ai-quality, assistant, and coach-feedback
// sections fold into one Coach area: surface toggles + operator
// config first, then the two feedback-quality tables.
// v1.18.6 (W9) — server-wide module availability moved out to its own
// `module-availability` section: it gates EVERY module, not just the
// coach, so it no longer belongs stacked under Coach.
return (
<SectionFrame
title={t("admin.section.coach.title")}
subtitle={t("admin.section.coach.subtitle")}
>
<AssistantSection />
<ModuleAvailabilitySection />
<AiServerKeySection />
<CoachFeedbackSection />
<AiQualitySection />
</SectionFrame>
);
case "module-availability":
// v1.18.6 (W9) — operator-side server-wide module on/off, its own
// admin section (was stacked under Coach). Reconciled name: the
// user-facing "Module" settings pick from what is available here.
return (
<SectionFrame
title={t("admin.section.module-availability.title")}
subtitle={t("admin.section.module-availability.subtitle")}
>
<ModuleAvailabilitySection />
</SectionFrame>
);
case "feedback":
return (
<SectionFrame
@@ -191,12 +205,18 @@ export function AdminSectionRenderer({
</SectionFrame>
);
case "about":
// v1.4.36 W4e — About section reused as-is from the settings
// surface. The component owns its own heading + cards layout
// so no SectionFrame wrapper.
// v1.18.6 — the About surface no longer carries the tour-replay card
// (consolidated onto Settings → Advanced), so there's nothing to hide.
return <AboutSection />;
// v1.4.36 W4e — About section reused as-is from the settings surface.
// v1.18.6 (W9) — route it through `SectionFrame` like every other admin
// section so it gets the same visible heading + subtitle (the one admin
// page that was bypassing the frame and showing no heading).
return (
<SectionFrame
title={t("admin.section.about.title")}
subtitle={t("admin.section.about.subtitle")}
>
<AboutSection />
</SectionFrame>
);
default:
slug satisfies never;
return null;
+58 -2
View File
@@ -1,5 +1,5 @@
import { notFound } from "next/navigation";
import type { JSX } from "react";
import type { JSX, ReactNode } from "react";
import { AccountSection } from "@/components/settings/account-section";
import { AboutSection } from "@/components/settings/about-section";
@@ -29,6 +29,9 @@ import {
type SettingsSectionSlug,
} from "@/components/settings/section-slugs";
import { SettingsShell } from "@/components/settings/settings-shell";
import { SettingsSectionFrame } from "@/components/settings/settings-section-frame";
import { SettingsHubBackLink } from "@/components/settings/settings-hub-back-link";
import { ModuleTourTrigger } from "@/components/onboarding/module-tour-trigger";
/**
* Dynamic settings section route. Each of the `SETTINGS_SECTION_SLUGS`
@@ -71,6 +74,50 @@ const SECTION_COMPONENTS: Record<
advanced: AdvancedSection,
};
// v1.18.6 (W9) — the two Layout-hub child editors render a "← back to hub"
// link above their heading. The shared `<SettingsSectionFrame>` exposes a
// `topSlot` for exactly this; the link lives here so the section bodies stay
// pure card content.
const HUB_CHILD_SLUGS: ReadonlySet<SettingsSectionSlug> = new Set([
"dashboard",
"insights",
]);
/** Optional content rendered above the canonical heading, per slug. */
function frameTopSlot(slug: SettingsSectionSlug): ReactNode {
if (HUB_CHILD_SLUGS.has(slug)) {
return (
<SettingsHubBackLink
href="/settings/layout"
labelKey="settings.sections.layout.backToHub"
/>
);
}
return undefined;
}
/** Optional inline content rendered at the top-right of the heading row. */
function frameHeadingAccessory(slug: SettingsSectionSlug): ReactNode {
// v1.18.6 (W9) — the module guided-tour re-entry triggers used to live in
// a `justify-end` header inside the section body; they move to the frame's
// heading row so the heading itself is the spotlight anchor.
if (slug === "gesundheitsakte") {
return (
<span data-tour-id="export-hero">
<ModuleTourTrigger stopId="export" />
</span>
);
}
if (slug === "integrations") {
return (
<span data-tour-id="integrations-hero">
<ModuleTourTrigger stopId="integrations" />
</span>
);
}
return undefined;
}
interface PageProps {
// Next.js 16 made route `params` an async Promise. We `await` it before use.
params: Promise<{ section: string }>;
@@ -91,8 +138,17 @@ export default async function SettingsSectionPage({ params }: PageProps) {
// guards the slug, but a future slug added to `SETTINGS_SECTION_SLUGS`
// without a wired component would otherwise crash silently — placeholder
// surfaces the gap visually instead.
// v1.18.6 (W9) — every section now renders inside the shared
// `<SettingsSectionFrame>`, which emits the one visible heading + subtitle
// each page must show (the section bodies no longer carry their own).
const body = SectionComponent ? (
<SectionComponent />
<SettingsSectionFrame
slug={section}
topSlot={frameTopSlot(section)}
headingAccessory={frameHeadingAccessory(section)}
>
<SectionComponent />
</SettingsSectionFrame>
) : (
<SectionPlaceholder slug={section} />
);
+12
View File
@@ -18,6 +18,7 @@ import Link from "next/link";
import { usePathname } from "next/navigation";
import {
Bell,
Blocks,
Database,
FileText,
Info,
@@ -39,6 +40,7 @@ import { cn } from "@/lib/utils";
import { scrollBehaviorForUser } from "@/lib/motion";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import { SectionNavHeadingSpacer } from "@/components/settings/section-nav-heading-spacer";
import { isAdminSectionSlug, type AdminSectionSlug } from "./section-slugs";
interface AdminSection {
@@ -82,6 +84,13 @@ export const ADMIN_SECTIONS: readonly AdminSection[] = [
titleKey: "admin.section.coach.title",
icon: Sparkles,
},
// v1.18.6 (W9) — server-wide module availability split out of Coach into
// its own entry, sitting next to it.
{
slug: "module-availability",
titleKey: "admin.section.module-availability.title",
icon: Blocks,
},
{
slug: "feedback",
titleKey: "admin.section.feedback.title",
@@ -276,6 +285,9 @@ export function AdminShell({ active, children }: AdminShellProps) {
className="hidden md:block"
>
<div className="sticky top-20">
{/* v1.18.6 (W9) — align the first nav item with the top of the
first card, matching the settings shell. */}
<SectionNavHeadingSpacer />
<ul className="space-y-1">
<li>
<Link
+8 -4
View File
@@ -16,11 +16,15 @@ export const ADMIN_SECTION_SLUGS = [
"integrations",
// v1.18.1 — the former `ai-quality`, `assistant`, and `coach-feedback`
// sections collapse into one "Coach" area. It holds the assistant
// surface toggles, the server-key + module-availability operator
// config, and both feedback-quality tables (Coach + Insights). Three
// navigation entries for one coherent coach-quality concern read as
// clutter; one card opens the full picture.
// surface toggles, the server-key operator config, and both
// feedback-quality tables (Coach + Insights). Three navigation entries
// for one coherent coach-quality concern read as clutter; one card opens
// the full picture.
"coach",
// v1.18.6 (W9) — server-wide module availability is its own section now
// (was stacked under Coach). It gates every module, not just the coach,
// so it reads better next to Coach than buried inside it.
"module-availability",
"feedback",
"reminders",
"users",
@@ -31,12 +31,17 @@ vi.mock("@/hooks/use-auth", () => ({
}));
import { I18nProvider } from "@/lib/i18n/context";
import { SettingsSectionFrame } from "../settings-section-frame";
import { CoachSection } from "../coach-section";
function render(locale: "en" | "de" = "en") {
// v1.18.6 (W9) — the visible heading lives in the shared frame the route
// wraps the section in; render through it so the heading id is present.
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>
<CoachSection />
<SettingsSectionFrame slug="coach">
<CoachSection />
</SettingsSectionFrame>
</I18nProvider>,
);
}
@@ -51,6 +56,9 @@ describe("<CoachSection> — SSR smoke", () => {
// v1.18.1 (D7) — polarity flipped to activate/default-on.
expect(html).toContain("Activate Coach");
expect(html).toContain("Show the Coach button and drawer. On by default");
// v1.18.6 (W9) — the proactive Coach nudge card moved here from
// Benachrichtigungen; its anchor renders under the enabled-coach gate.
expect(html).toContain('id="coach-nudge"');
// Raw key never leaks past i18n.
expect(html).not.toContain("settings.sections.coach.");
expect(html).not.toContain("settings.coach.activate.");
@@ -44,6 +44,7 @@ vi.mock("@/hooks/use-auth", () => ({
}));
import { I18nProvider } from "@/lib/i18n/context";
import { SettingsSectionFrame } from "../settings-section-frame";
import { ExportSection } from "../export-section";
function render(node: React.ReactElement, locale: "en" | "de" = "en") {
@@ -54,7 +55,13 @@ function render(node: React.ReactElement, locale: "en" | "de" = "en") {
describe("<ExportSection> — SSR smoke", () => {
it("renders the section heading + description", () => {
const html = render(<ExportSection />);
// v1.18.6 (W9) — the page heading comes from the shared frame the route
// wraps the section in.
const html = render(
<SettingsSectionFrame slug="export">
<ExportSection />
</SettingsSectionFrame>,
);
// v1.15.7 — the section was relabelled "Export & Import" (issue #281).
expect(html).toContain("Export &amp; Import");
// Raw key never leaks past i18n.
@@ -29,6 +29,7 @@ vi.mock("@/hooks/use-auth", () => ({
}));
import { I18nProvider } from "@/lib/i18n/context";
import { SettingsSectionFrame } from "../settings-section-frame";
import { GesundheitsakteSection } from "../gesundheitsakte-section";
function render(node: React.ReactElement, locale: "en" | "de" = "en") {
@@ -38,8 +39,14 @@ function render(node: React.ReactElement, locale: "en" | "de" = "en") {
}
describe("<GesundheitsakteSection> — SSR smoke", () => {
it("renders the section description and the health-record panel", () => {
const html = render(<GesundheitsakteSection />);
it("renders the section heading and the health-record panel", () => {
// v1.18.6 (W9) — the visible heading (with the historic id) comes from
// the shared frame the route wraps the section in.
const html = render(
<SettingsSectionFrame slug="gesundheitsakte">
<GesundheitsakteSection />
</SettingsSectionFrame>,
);
expect(html).toContain('id="settings-section-gesundheitsakte-title"');
expect(html).toContain('data-testid="health-record-export-panel"');
// Raw key never leaks past i18n.
@@ -65,12 +65,17 @@ vi.mock("@/hooks/use-auth", () => ({
}));
import { I18nProvider } from "@/lib/i18n/context";
import { SettingsSectionFrame } from "../settings-section-frame";
import { MedicationsSection } from "../medications-section";
function render(locale: "en" | "de" = "en"): string {
// v1.18.6 (W9) — the visible heading (with the historic id) comes from the
// shared frame the route wraps the section in.
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>
<MedicationsSection />
<SettingsSectionFrame slug="medications">
<MedicationsSection />
</SettingsSectionFrame>
</I18nProvider>,
);
}
@@ -20,11 +20,7 @@ import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import type { AuthUser } from "@/hooks/use-auth";
import { queryKeys } from "@/lib/query-keys";
import {
MODULE_KEYS,
CORE_DOMAIN_KEYS,
moduleDelegatesTo,
} from "@/lib/modules/registry";
import { MODULE_KEYS, moduleDelegatesTo } from "@/lib/modules/registry";
// Capture the latest useMutation config + a shared mutate spy so we can
// invoke `mutationFn` / `onSuccess` by hand (the SSR pass can't fire a
@@ -46,9 +42,9 @@ vi.mock("@tanstack/react-query", () => ({
},
}));
const authSpy = vi.fn<() => { user: AuthUser | null; isAuthenticated: boolean }>(
() => ({ user: buildUser({}), isAuthenticated: true }),
);
const authSpy = vi.fn<
() => { user: AuthUser | null; isAuthenticated: boolean }
>(() => ({ user: buildUser({}), isAuthenticated: true }));
vi.mock("@/hooks/use-auth", async () => {
const actual =
await vi.importActual<typeof import("@/hooks/use-auth")>(
@@ -109,7 +105,10 @@ afterEach(() => {
});
describe("<ModulesSection>", () => {
it("renders a Switch for every non-delegated toggleable module, a managed deep-link for delegated ones, and locked switches for core", () => {
it("renders a Switch for every non-delegated toggleable module and a managed deep-link for delegated ones", () => {
// v1.18.6 (W9) — the "Always on" core-domains card was removed (a domain
// that can't be turned off doesn't need listing), so this section now
// only renders the toggleable modules.
const html = render();
for (const key of MODULE_KEYS) {
if (moduleDelegatesTo(key) !== undefined) {
@@ -126,8 +125,16 @@ describe("<ModulesSection>", () => {
);
}
}
for (const key of CORE_DOMAIN_KEYS) {
expect(html, `core switch ${key}`).toContain(`id="module-toggle-${key}"`);
});
it("no longer renders the 'Always on' core-domains card", () => {
// v1.18.6 (W9) — the read-only core card (weight / BP / pulse) was
// removed; the core domains are always active regardless.
const html = render();
for (const key of ["weight", "bloodPressure", "pulse"]) {
expect(html, `core ${key} not rendered`).not.toContain(
`id="module-toggle-${key}"`,
);
}
});
@@ -152,18 +159,6 @@ describe("<ModulesSection>", () => {
expect(moodTag).toMatch(/data-state="checked"/);
});
it("renders the core domains locked (checked + disabled)", () => {
const html = render();
for (const key of CORE_DOMAIN_KEYS) {
const tag = html.match(
new RegExp(`<button[^>]*id="module-toggle-${key}"[^>]*>`),
)?.[0];
expect(tag, `core ${key}`).toBeDefined();
expect(tag).toMatch(/data-state="checked"/);
expect(tag).toMatch(/disabled/);
}
});
it("PATCHes only the flipped key as a disabled-allowlist entry", async () => {
render();
expect(lastMutation?.mutationFn).toBeTypeOf("function");
@@ -131,12 +131,17 @@ vi.mock("@/hooks/use-auth", () => ({
}));
import { I18nProvider } from "@/lib/i18n/context";
import { SettingsSectionFrame } from "../settings-section-frame";
import { MoodSection } from "../mood-section";
function render(locale: "en" | "de" = "en"): string {
// v1.18.6 (W9) — the visible heading (with the historic id) comes from the
// shared frame the route wraps the section in.
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>
<MoodSection />
<SettingsSectionFrame slug="mood">
<MoodSection />
</SettingsSectionFrame>
</I18nProvider>,
);
}
@@ -2,10 +2,13 @@
* v1.18.0 (S4) — module-gated per-type visibility on the consolidated
* "Benachrichtigungen" reminder-types home.
*
* The mood reminder shows only when the `mood` module is enabled; the Coach
* nudge only when `coach` is enabled. Low-stock maps to the medications CORE
* domain (always shown). The gate fails OPEN, so a `/me` payload without a
* module map keeps every card visible.
* 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 (W9) — the proactive Coach nudge card moved to Settings → Coach, so
* it no longer appears on this screen.
*/
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
@@ -47,30 +50,23 @@ function render(modules?: Record<string, boolean>) {
}
describe("<NotificationsSection> module-gated reminder types", () => {
it("shows mood + coach cards when no module map (fails open)", () => {
it("shows mood + low-stock cards when no module map (fails open); no coach nudge", () => {
const html = render(undefined);
expect(html).toContain('id="mood-reminder"');
expect(html).toContain('id="coach-nudge"');
expect(html).toContain('id="low-stock"');
// v1.18.6 (W9) — the coach nudge moved to Settings → Coach.
expect(html).not.toContain('id="coach-nudge"');
});
it("hides the mood card when the mood module is disabled", () => {
const html = render({ mood: false });
expect(html).not.toContain('id="mood-reminder"');
// Coach + low-stock are unaffected.
expect(html).toContain('id="coach-nudge"');
// Low-stock is unaffected.
expect(html).toContain('id="low-stock"');
});
it("hides the coach card when the coach module is disabled", () => {
const html = render({ coach: false });
expect(html).not.toContain('id="coach-nudge"');
expect(html).toContain('id="mood-reminder"');
expect(html).toContain('id="low-stock"');
});
it("always shows low-stock (medications is a CORE domain)", () => {
const html = render({ mood: false, coach: false });
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"');
@@ -82,9 +78,9 @@ describe("<NotificationsSection> module-gated reminder types", () => {
const html = render({});
expect(html).not.toContain('data-slot="notifications-channels-cross-link"');
expect(html).not.toContain('data-slot="notifications-inbox-cross-link"');
// The three reminder-type cards remain.
// The remaining reminder-type cards (no coach nudge here anymore).
expect(html).toContain('id="mood-reminder"');
expect(html).toContain('id="low-stock"');
expect(html).toContain('id="coach-nudge"');
expect(html).not.toContain('id="coach-nudge"');
});
});
@@ -2,11 +2,11 @@
* v1.4.16 phase B6 — settings i18n parity.
*
* Every slug declared in `SETTINGS_SECTION_SLUGS` MUST have a
* `settings.sections.<slug>.title` AND a `settings.sections.<slug>.description`
* resolved in both English and German. The audit doc
* (`docs/audit/v1416-settings-audit.md`) calls this out as the canonical
* shape — the sidebar, the page `<h1>`, and the page subtitle all read
* from these two keys, so a missing key paints a raw key in production.
* `settings.sections.<slug>.title` AND a `settings.sections.<slug>.subtitle`
* resolved in both English and German. The sidebar reads `.title`; the shared
* `<SettingsSectionFrame>` reads `.title` + `.subtitle` for the visible page
* heading + subheading (v1.18.6 W9), so a missing key paints a raw key in
* production.
*/
import { describe, expect, it } from "vitest";
@@ -46,43 +46,39 @@ describe("settings sections — i18n parity (B6)", () => {
expect(deValue?.length ?? 0).toBeGreaterThan(0);
});
it(`every locale resolves settings.sections.${slug}.description`, () => {
it(`every locale resolves settings.sections.${slug}.subtitle`, () => {
const enValue = lookup(en as Bag, [
"settings",
"sections",
slug,
"description",
"subtitle",
]);
const deValue = lookup(de as Bag, [
"settings",
"sections",
slug,
"description",
"subtitle",
]);
expect(enValue, `EN missing description for ${slug}`).toBeTypeOf(
"string",
);
expect(enValue, `EN missing subtitle for ${slug}`).toBeTypeOf("string");
expect(enValue?.length ?? 0).toBeGreaterThan(0);
expect(deValue, `DE missing description for ${slug}`).toBeTypeOf(
"string",
);
expect(deValue, `DE missing subtitle for ${slug}`).toBeTypeOf("string");
expect(deValue?.length ?? 0).toBeGreaterThan(0);
});
}
it("EN and DE description for every slug are different (catches accidental copy-paste)", () => {
it("EN and DE subtitle for every slug are different (catches accidental copy-paste)", () => {
for (const slug of SETTINGS_SECTION_SLUGS) {
const enValue = lookup(en as Bag, [
"settings",
"sections",
slug,
"description",
"subtitle",
]);
const deValue = lookup(de as Bag, [
"settings",
"sections",
slug,
"description",
"subtitle",
]);
// It's legitimate for some titles to coincide between locales when
// the proper noun is the same (e.g. "Dashboard", "API & Tokens").
@@ -90,7 +86,7 @@ describe("settings sections — i18n parity (B6)", () => {
// EN value into the DE bag is a bug.
expect(
deValue,
`DE description for ${slug} is identical to EN — needs translation`,
`DE subtitle for ${slug} is identical to EN — needs translation`,
).not.toBe(enValue);
}
});
@@ -87,6 +87,8 @@ vi.mock("@/components/settings/thresholds-editor-section", () => ({
ThresholdsEditorSection: () => <div data-testid="thresholds-editor" />,
}));
import { I18nProvider } from "@/lib/i18n/context";
import { SettingsSectionFrame } from "../settings-section-frame";
import type { SettingsSectionSlug } from "../section-slugs";
import { AboutSection } from "../about-section";
import { AccountSection } from "../account-section";
import { AdvancedSection } from "../advanced-section";
@@ -108,10 +110,25 @@ function render(node: React.ReactElement, locale: "en" | "de" = "en") {
);
}
// v1.18.6 (W9) — the visible page heading + subtitle moved out of the section
// bodies into the shared `<SettingsSectionFrame>` that the route wraps every
// section in. The heading-level smoke checks render the body THROUGH the frame
// (mirroring production) so they assert what the user actually sees.
function renderFramed(
slug: SettingsSectionSlug,
node: React.ReactElement,
locale: "en" | "de" = "en",
) {
return render(
<SettingsSectionFrame slug={slug}>{node}</SettingsSectionFrame>,
locale,
);
}
describe("settings sections — SSR smoke", () => {
it("<AccountSection> renders the Account heading and Profile card", () => {
const html = render(<AccountSection />);
// Section title resolves via the i18n provider.
const html = renderFramed("account", <AccountSection />);
// Section title resolves via the frame + i18n provider.
expect(html).toContain("Account");
// Profile card heading is also painted.
expect(html).toContain("Profile");
@@ -136,7 +153,7 @@ describe("settings sections — SSR smoke", () => {
});
it("<AboutSection> renders version + license + sources/docs cards (no Updates panel)", () => {
const html = render(<AboutSection />);
const html = renderFramed("about", <AboutSection />);
expect(html).toContain("About");
// v1.4.36 W4f — the dedicated "Updates" card with its manual
// "Check now" button is gone. The 24 h auto-check still runs and
@@ -158,34 +175,33 @@ describe("settings sections — SSR smoke", () => {
expect(html).not.toContain("Replay the tour");
});
it("<AiSection> renders the AI Insights heading", () => {
const html = render(<AiSection />);
// v1.8.7.1 — section renamed to "AI Insights" so the AI nature of the
// surface is explicit (was the bare "Insights"). The section H1 carries
// the only heading.
expect(html).toContain("AI Insights");
it("<AiSection> renders the AI provider heading", () => {
const html = renderFramed("ai", <AiSection />);
// v1.18.6 (W9) — section renamed to "AI provider"; the visible heading
// now comes from the shared frame, which keeps the historic id.
expect(html).toContain("AI provider");
expect(html).toContain("settings-section-ai-title");
});
it("<IntegrationsSection> renders Withings card title", () => {
const html = render(<IntegrationsSection />);
const html = renderFramed("integrations", <IntegrationsSection />);
expect(html).toContain("Integrations");
expect(html).toContain("Withings");
});
it("<NotificationsSection> renders the single reminder-types home", () => {
const html = render(<NotificationsSection />);
// v1.18.1 (D0/D5) — the screen is lean: sr-only h1 only, no section blurb,
// no channels/inbox cross-links, and no embedded Vorsorge editor.
const html = renderFramed("notifications", <NotificationsSection />);
// v1.18.6 (W9) — the visible heading comes from the frame; the proactive
// Coach nudge moved to Settings → Coach, so it no longer renders here.
expect(html).toContain("settings-section-notifications-title");
expect(html).not.toContain("Notification channels");
expect(html).not.toContain('data-slot="notifications-channels-cross-link"');
expect(html).not.toContain('data-slot="notifications-inbox-cross-link"');
// The mood + coach cards fail OPEN (mock user carries no module map),
// so all three reminder-type cards render.
// The mood card fails OPEN (mock user carries no module map), so both
// remaining reminder-type cards render. The coach-nudge card is gone.
expect(html).toContain('id="mood-reminder"');
expect(html).toContain('id="coach-nudge"');
expect(html).toContain('id="low-stock"');
expect(html).not.toContain('id="coach-nudge"');
// No raw i18n key leaks past the provider.
expect(html).not.toContain("settings.sections.notifications.");
});
@@ -194,32 +210,31 @@ describe("settings sections — SSR smoke", () => {
// v1.4.3 split: the per-metric thresholds (Persönliche Zielwerte) moved
// out into their own settings section under `/settings/thresholds`,
// so the dashboard panel is now layout-only. Verifying the heading
// says "Dashboard" (not "Übersicht" — the v1.4.2 placeholder) and the
// layout slot still mounts.
const html = render(<DashboardSection />);
// (supplied by the frame) says "Dashboard" and the layout slot mounts.
const html = renderFramed("dashboard", <DashboardSection />);
expect(html).toContain("Dashboard");
expect(html).toContain('data-testid="dashboard-layout"');
expect(html).not.toContain('data-testid="thresholds"');
});
it("<ApiSection> renders endpoints + tokens cards", () => {
const html = render(<ApiSection />);
const html = renderFramed("api", <ApiSection />);
expect(html).toContain("API &amp; Tokens");
});
it("<AdvancedSection> renders the danger-zone card only (export moved to /settings/export)", () => {
const html = render(<AdvancedSection />);
const html = renderFramed("advanced", <AdvancedSection />);
// v1.4.16 phase B7: every export path moved out into the dedicated
// `<ExportSection>` so what remains is the irreversible delete-all
// path. The section heading + dangerZone surface are the only
// primary surfaces here now.
// path. The heading (supplied by the frame) + dangerZone surface are the
// only primary surfaces here now.
expect(html).toContain("Advanced");
expect(html).not.toContain("settings.export");
expect(html).not.toContain("doctorReport");
});
it("<AboutSection> resolves about.* keys in German", () => {
const html = render(<AboutSection />, "de");
const html = renderFramed("about", <AboutSection />, "de");
// v1.4.36 W4f — "Updates" panel + "Jetzt prüfen" button removed;
// the surviving German copy is the section title + the Sources
// & docs heading + the version line.
@@ -235,7 +250,7 @@ describe("settings sections — SSR smoke", () => {
// `<ThresholdsEditorSection>` (was `<ThresholdsSection>`).
// v1.8.7.1 — Sources split back onto its own page, so this wrapper
// renders the target-range editor only.
const html = render(<ThresholdsSection />);
const html = renderFramed("thresholds", <ThresholdsSection />);
// v1.8.7.1: section renamed to "Targets".
expect(html).toContain("Targets");
expect(html).toContain('data-testid="thresholds-editor"');
@@ -244,7 +259,7 @@ describe("settings sections — SSR smoke", () => {
});
it("<ThresholdsSection> resolves the German title", () => {
const html = render(<ThresholdsSection />, "de");
const html = renderFramed("thresholds", <ThresholdsSection />, "de");
// v1.8.7.1 — German title is "Zielwerte" (Targets only).
expect(html).toContain("Zielwerte");
expect(html).toContain('data-testid="thresholds-editor"');
@@ -319,9 +319,9 @@ describe("<SettingsShell>", () => {
expect(html).toContain('href="/settings/mood"');
// Targets keeps its German nav entry ("Zielwerte").
expect(html).toContain("Zielwerte");
// v1.8.7.1 — the AI Insights section is named "KI-Auswertungen" in
// German (the "KI" prefix makes the AI nature explicit).
expect(html).toContain("KI-Auswertungen");
// v1.18.6 (W9) — the AI section is named "KI-Anbieter" in German: the
// page is about the provider / BYOK, not an "Auswertung".
expect(html).toContain("KI-Anbieter");
// API & Tokens is identical in both locales (proper noun + ampersand)
expect(html).toContain("API &amp; Tokens");
expect(html).toContain("Erweitert");
+6 -11
View File
@@ -181,17 +181,12 @@ export function AboutSection() {
}, [version?.version]);
/* eslint-enable react-hooks/exhaustive-deps, react-hooks/set-state-in-effect */
// v1.18.6 (W9) — the visible heading + subtitle now come from the section
// frame that wraps this component: `<SettingsSectionFrame>` on the settings
// route and the admin renderer's `SectionFrame` on `/admin/about`. The body
// is the version / license / sources cards.
return (
<section
aria-labelledby="settings-section-about-title"
className="space-y-6"
>
<header>
<h1 id="settings-section-about-title" className="sr-only">
{t("settings.sections.about.title")}
</h1>
</header>
<div className="space-y-6">
{/* Identity card — version + license inline. The v1.4.2 layout
stacked the license under the version inside its own boxed Badge,
which read as a separate field even though the two values belong
@@ -321,6 +316,6 @@ export function AboutSection() {
button were collectively never the point of the surface —
the only signal users ever cared about was "is a newer
release out?", and the badge answers that with no clicks. */}
</section>
</div>
);
}
+4 -14
View File
@@ -367,20 +367,10 @@ export function AccountSection() {
if (!user) return null;
// v1.18.6 (W9) — the visible heading + subtitle now come from the shared
// `<SettingsSectionFrame>` in the route; this body is the profile cards.
return (
<section
aria-labelledby="settings-section-account-title"
className="space-y-6"
>
{/* v1.18.1 (D0) — the explanatory section blurb was dropped so every
settings section starts at the same vertical height; the sr-only
heading stays for the accessibility tree. */}
<header>
<h1 id="settings-section-account-title" className="sr-only">
{t("settings.sections.account.title")}
</h1>
</header>
<div className="space-y-6">
{/* Profile card */}
{/* Profile photo card */}
<AvatarSection />
@@ -760,7 +750,7 @@ export function AccountSection() {
</form>
</DialogContent>
</Dialog>
</section>
</div>
);
}
+4 -14
View File
@@ -51,26 +51,16 @@ import { apiFetchRaw } from "@/lib/api/api-fetch";
* dialog.
*/
export function AdvancedSection() {
const { t } = useTranslations();
// v1.18.6 (W9) — the visible heading + subtitle now come from the shared
// `<SettingsSectionFrame>` in the route; this body is the advanced cards.
return (
<section
aria-labelledby="settings-section-advanced-title"
className="space-y-6"
>
{/* v1.18.1 (D0) — section blurb dropped for consistent top alignment. */}
<header>
<h1 id="settings-section-advanced-title" className="sr-only">
{t("settings.sections.advanced.title")}
</h1>
</header>
<div className="space-y-6">
{/* v1.18.1 (D1) — "Tour neu starten" relocated here from Account. */}
<TourReplayCard />
<ResearchModeCard />
<DataResetCard />
<AccountDeleteCard />
</section>
</div>
);
}
+4 -17
View File
@@ -21,10 +21,8 @@
import { AiInsightsCard } from "@/components/settings/ai/ai-insights-card";
import { useAuth } from "@/hooks/use-auth";
import { useMounted } from "@/hooks/use-mounted";
import { useTranslations } from "@/lib/i18n/context";
export function AiSection() {
const { t } = useTranslations();
const { isAuthenticated } = useAuth();
// v1.16.6 — the auth query can resolve before this boundary
// hydrates; every `disabled={!isAuthenticated}` binding in the child
@@ -35,19 +33,8 @@ export function AiSection() {
const mounted = useMounted();
const authed = mounted && isAuthenticated;
return (
<section aria-labelledby="settings-section-ai-title" className="space-y-6">
{/* v1.18.1 (D0) — section blurb dropped for consistent top alignment. */}
<header>
<h1 id="settings-section-ai-title" className="sr-only">
{t("settings.sections.ai.title")}
</h1>
</header>
<AiInsightsCard isAuthenticated={authed} />
{/* v1.18.1 (D8) — the "About me" context moved to Settings → Account
(under Profil, before Zyklus-Tracking). */}
</section>
);
// v1.18.6 (W9) — the visible heading + subtitle now come from the shared
// `<SettingsSectionFrame>` in the route; this body is the single provider
// card. (The "About me" context moved to Settings → Account in v1.18.1 D8.)
return <AiInsightsCard isAuthenticated={authed} />;
}
+4 -10
View File
@@ -37,19 +37,13 @@ interface ApiTokenInfo {
}
export function ApiSection() {
const { t } = useTranslations();
// v1.18.6 (W9) — the visible heading + subtitle now come from the shared
// `<SettingsSectionFrame>` in the route; this body is the API cards.
return (
<section aria-labelledby="settings-section-api-title" className="space-y-6">
<header>
<h1 id="settings-section-api-title" className="sr-only">
{t("settings.sections.api.title")}
</h1>
</header>
<div className="space-y-6">
<ApiEndpointsCard />
<ApiTokensCard />
</section>
</div>
);
}
+4 -21
View File
@@ -8,30 +8,13 @@
* delivery provider — the same family as a connected device — and now reads as
* a first-class settings home rather than a tab hidden behind Integrationen.
*
* The content is the existing `<NotificationChannelsPanel>`; this wrapper only
* owns the section frame (sr-only heading scaffold matching every other
* settings section).
* The content is the existing `<NotificationChannelsPanel>`. v1.18.6 (W9) —
* the visible heading + subtitle now come from the shared
* `<SettingsSectionFrame>` in the route; this wrapper is the panel only.
*/
import { NotificationChannelsPanel } from "@/components/settings/integrations/notification-channels-panel";
import { useTranslations } from "@/lib/i18n/context";
export function ChannelsSection() {
const { t } = useTranslations();
return (
<section
aria-labelledby="settings-section-channels-title"
className="space-y-6"
>
{/* v1.18.1 (D0) — section blurb dropped for consistent top alignment. */}
<header>
<h1 id="settings-section-channels-title" className="sr-only">
{t("settings.sections.channels.title")}
</h1>
</header>
<NotificationChannelsPanel />
</section>
);
return <NotificationChannelsPanel />;
}
+21 -17
View File
@@ -10,25 +10,31 @@
* 1. Disable Coach — the per-user opt-out (kept first so a user who
* wants the surface gone finds it immediately).
* 2. Coach preferences — tone, verbosity, data scope (`useCoachPrefs`).
* 3. Coach memory — durable-fact review + forget controls.
* 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).
* 4. Coach memory — durable-fact review + forget controls.
*
* The nav entry is module-gated on `coach`. The preference + memory cards
* keep their own content gate on `!user.disableCoach` (mirrored from the
* The nav entry is module-gated on `coach`. The preference + nudge + memory
* cards keep their own content gate on `!user.disableCoach` (mirrored from the
* old AI section): hiding the Coach hides its tuning + memory controls too.
*
* The "about me" context lives in the AI section, not here — the daily
* briefing reads it too, so it is not a Coach-only setting.
*
* v1.18.6 (W9) — the visible heading + subtitle now come from the shared
* `<SettingsSectionFrame>` in the route; this body is the Coach cards only.
*/
import { DisableCoachCard } from "@/components/settings/ai/disable-coach-card";
import { CoachMemorySection } from "@/components/settings/coach-memory-section";
import { CoachNudgeCard } from "@/components/settings/coach-nudge-card";
import { CoachPrefsSection } from "@/components/settings/coach-prefs-section";
import { useAuth } from "@/hooks/use-auth";
import { useMounted } from "@/hooks/use-mounted";
import { useTranslations } from "@/lib/i18n/context";
export function CoachSection() {
const { t } = useTranslations();
const { isAuthenticated, user } = useAuth();
// Mirror the AI section's hydration-safe gating: the auth query can
// resolve before this boundary hydrates, so gate the child props on
@@ -42,22 +48,20 @@ export function CoachSection() {
const coachEnabled = !mounted || !user?.disableCoach;
return (
<section
aria-labelledby="settings-section-coach-title"
className="space-y-6"
>
{/* v1.18.1 (D0) — section blurb dropped for consistent top alignment. */}
<header>
<h1 id="settings-section-coach-title" className="sr-only">
{t("settings.sections.coach.title")}
</h1>
</header>
<div className="space-y-6">
<DisableCoachCard isAuthenticated={authed} />
{coachEnabled && <CoachPrefsSection isAuthenticated={authed} />}
{/* Proactive Coach nudge — moved here from Benachrichtigungen (v1.18.6).
Keeps the `#coach-nudge` anchor so existing deep links resolve. */}
{coachEnabled && (
<div id="coach-nudge" className="scroll-mt-28">
<CoachNudgeCard isAuthenticated={isAuthenticated} />
</div>
)}
{coachEnabled && <CoachMemorySection isAuthenticated={authed} />}
</section>
</div>
);
}
+6 -23
View File
@@ -1,29 +1,12 @@
"use client";
import { DashboardLayoutSection } from "@/components/settings/dashboard-layout-section";
import { SettingsHubBackLink } from "@/components/settings/settings-hub-back-link";
import { useTranslations } from "@/lib/i18n/context";
/**
* v1.18.6 (W9) — the visible heading + subtitle now come from the shared
* `<SettingsSectionFrame>`, and the "← back to hub" link rides the frame's
* `topSlot`. The body is the layout customizer only.
*/
export function DashboardSection() {
const { t } = useTranslations();
return (
<section
aria-labelledby="settings-section-dashboard-title"
className="space-y-6"
>
<header className="space-y-2">
<SettingsHubBackLink
href="/settings/layout"
labelKey="settings.sections.layout.backToHub"
/>
<h1 id="settings-section-dashboard-title" className="sr-only">
{t("settings.sections.dashboard.title")}
</h1>
{/* v1.18.1 (D0) — section blurb dropped for consistent top alignment. */}
</header>
<DashboardLayoutSection id="dashboard-layout" />
</section>
);
return <DashboardLayoutSection id="dashboard-layout" />;
}
+5 -11
View File
@@ -70,17 +70,11 @@ async function downloadFromUrl(url: string, filename: string): Promise<void> {
export function ExportSection() {
const { t } = useTranslations();
// v1.18.6 (W9) — the visible page heading + subtitle now come from the
// shared `<SettingsSectionFrame>` in the route; the inner `<h2>` keeps its
// own "other options" subsection label.
return (
<section
aria-labelledby="settings-section-export-title"
className="space-y-6"
>
<header>
<h1 id="settings-section-export-title" className="sr-only">
{t("settings.sections.export.title")}
</h1>
</header>
<div className="space-y-6">
{/* v1.18.0 (S5) — the full health-record export moved to its own
top-level "Gesundheitsakte" section. This page keeps the generic
CSV/JSON data-out paths and the import surface. */}
@@ -111,7 +105,7 @@ export function ExportSection() {
`export.zip` and the generic JSON paths. The export routes had
backends with no UI until now. */}
<ImportPanel />
</section>
</div>
);
}
@@ -18,32 +18,10 @@
*/
import { HealthRecordExportPanel } from "@/components/settings/health-record-export-panel";
import { useTranslations } from "@/lib/i18n/context";
import { ModuleTourTrigger } from "@/components/onboarding/module-tour-trigger";
export function GesundheitsakteSection() {
const { t } = useTranslations();
return (
<section
aria-labelledby="settings-section-gesundheitsakte-title"
className="space-y-6"
>
{/* v1.18.1 (D0) — section blurb dropped for consistent top alignment. */}
<header className="flex items-center justify-end">
<h1
id="settings-section-gesundheitsakte-title"
className="sr-only"
>
{t("settings.sections.gesundheitsakte.title")}
</h1>
{/* v1.18.6 — guided-tour re-entry for the doctor-report module + a
real spotlight anchor (the section h1 is sr-only). */}
<span data-tour-id="export-hero">
<ModuleTourTrigger stopId="export" />
</span>
</header>
<HealthRecordExportPanel />
</section>
);
// v1.18.6 (W9) — the visible heading + subtitle and the module tour-replay
// trigger now live in the shared `<SettingsSectionFrame>` in the route; the
// body is the health-record export panel only.
return <HealthRecordExportPanel />;
}
+6 -20
View File
@@ -2,8 +2,6 @@
import { InsightsOverviewArrangeSection } from "@/components/settings/insights-overview-arrange-section";
import { InsightsPillOrderSection } from "@/components/settings/insights-pill-order-section";
import { SettingsHubBackLink } from "@/components/settings/settings-hub-back-link";
import { useTranslations } from "@/lib/i18n/context";
/**
* v1.15.18 — the "Insights" settings section (analogous to the Dashboard
@@ -15,28 +13,16 @@ import { useTranslations } from "@/lib/i18n/context";
*
* Both write through the same `/api/insights/layout` contract the tab strip
* and the overview share, so a save here repaints both surfaces in lockstep.
*
* v1.18.6 (W9) — the visible "Insights" heading + subtitle now come from the
* shared `<SettingsSectionFrame>`, and the "← back to hub" link rides the
* frame's `topSlot`. The body is the two arrange cards only.
*/
export function InsightsSection() {
const { t } = useTranslations();
return (
<section
aria-labelledby="settings-section-insights-title"
className="space-y-6"
>
<header className="space-y-2">
<SettingsHubBackLink
href="/settings/layout"
labelKey="settings.sections.layout.backToHub"
/>
<h1 id="settings-section-insights-title" className="sr-only">
{t("settings.sections.insights.title")}
</h1>
{/* v1.18.1 (D0) — section blurb dropped for consistent top alignment. */}
</header>
<div className="space-y-6">
<InsightsOverviewArrangeSection id="insights-overview-arrange" />
<InsightsPillOrderSection id="insights-pill-order" />
</section>
</div>
);
}
@@ -16,8 +16,6 @@
*/
import { ConnectionsPanel } from "@/components/settings/integrations/connections-panel";
import { useTranslations } from "@/lib/i18n/context";
import { ModuleTourTrigger } from "@/components/onboarding/module-tour-trigger";
export {
parseOAuthOutcome,
@@ -25,27 +23,8 @@ export {
} from "@/components/settings/integrations/connections-panel";
export function IntegrationsSection() {
const { t } = useTranslations();
return (
<section
aria-labelledby="settings-section-integrations-title"
className="space-y-6"
>
{/* v1.18.1 (D0) — section blurb dropped for consistent top alignment. */}
<header className="flex items-center justify-end">
<h1 id="settings-section-integrations-title" className="sr-only">
{t("settings.sections.integrations.title")}
</h1>
{/* v1.18.6 — guided-tour re-entry for the integrations module. The
visible affordance also gives the spotlight a real anchor (the
section h1 is sr-only). */}
<span data-tour-id="integrations-hero">
<ModuleTourTrigger stopId="integrations" />
</span>
</header>
<ConnectionsPanel />
</section>
);
// v1.18.6 (W9) — the visible heading + subtitle and the module tour-replay
// trigger now live in the shared `<SettingsSectionFrame>` in the route; the
// body is the connections panel only.
return <ConnectionsPanel />;
}
+32 -41
View File
@@ -53,47 +53,38 @@ const LAYOUT_LINKS: ReadonlyArray<LayoutLink> = [
export function LayoutSection() {
const { t } = useTranslations();
// v1.18.6 (W9) — the visible heading + subtitle now come from the shared
// `<SettingsSectionFrame>` in the route; this body is the hub link list.
return (
<section
aria-labelledby="settings-section-layout-title"
className="space-y-6"
>
<header>
<h1 id="settings-section-layout-title" className="sr-only">
{t("settings.sections.layout.title")}
</h1>
</header>
<ul className="space-y-3">
{LAYOUT_LINKS.map((link) => {
const Icon = link.icon;
return (
<li key={link.href}>
<Link
href={link.href}
className={cn(
"bg-card border-border hover:bg-accent/40 group flex items-center gap-4 rounded-xl border p-4 transition-colors sm:p-5",
)}
>
<Icon
className="text-muted-foreground h-5 w-5 shrink-0"
aria-hidden="true"
/>
<div className="min-w-0 flex-1 space-y-0.5">
<p className="text-sm font-semibold">{t(link.titleKey)}</p>
<p className="text-muted-foreground text-xs">
{t(link.descriptionKey)}
</p>
</div>
<ChevronRight
className="text-muted-foreground/60 group-hover:text-foreground h-4 w-4 shrink-0 transition-colors"
aria-hidden="true"
/>
</Link>
</li>
);
})}
</ul>
</section>
<ul className="space-y-3">
{LAYOUT_LINKS.map((link) => {
const Icon = link.icon;
return (
<li key={link.href}>
<Link
href={link.href}
className={cn(
"bg-card border-border hover:bg-accent/40 group flex items-center gap-4 rounded-xl border p-4 transition-colors sm:p-5",
)}
>
<Icon
className="text-muted-foreground h-5 w-5 shrink-0"
aria-hidden="true"
/>
<div className="min-w-0 flex-1 space-y-0.5">
<p className="text-sm font-semibold">{t(link.titleKey)}</p>
<p className="text-muted-foreground text-xs">
{t(link.descriptionKey)}
</p>
</div>
<ChevronRight
className="text-muted-foreground/60 group-hover:text-foreground h-4 w-4 shrink-0 transition-colors"
aria-hidden="true"
/>
</Link>
</li>
);
})}
</ul>
);
}
@@ -73,17 +73,10 @@ export function MedicationsSection() {
),
].map((m) => ({ id: m.id, name: m.name, dose: m.dose, active: m.active }));
// v1.18.6 (W9) — the visible heading + subtitle now come from the shared
// `<SettingsSectionFrame>` in the route; this body is the medication cards.
return (
<section
aria-labelledby="settings-section-medications-title"
className="space-y-6"
>
<header>
<h1 id="settings-section-medications-title" className="sr-only">
{t("settings.sections.medications.title")}
</h1>
</header>
<div className="space-y-6">
{/* View preference — cards vs table. The shared header toggle
writes optimistically, so there is no Save button here. */}
<div
@@ -130,6 +123,6 @@ export function MedicationsSection() {
they moved here from the account profile so all medication-specific
preferences live in one place. */}
<InjectionSitesCard isAuthenticated={isAuthenticated} />
</section>
</div>
);
}
+54 -104
View File
@@ -32,12 +32,9 @@ import {
Droplet,
Dumbbell,
FileText,
HeartPulse,
Lock,
MessageCircleHeart,
Moon,
Pill,
Scale,
Smile,
Sparkles,
TestTube,
@@ -52,10 +49,8 @@ import { useTranslations } from "@/lib/i18n/context";
import { apiPatch } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
import {
CORE_DOMAIN_KEYS,
MODULE_REGISTRY,
moduleDelegatesTo,
type CoreDomainKey,
type ModuleKey,
} from "@/lib/modules/registry";
@@ -77,13 +72,6 @@ const MODULE_ICONS: Record<ModuleKey, LucideIcon> = {
doctorReport: FileText,
};
/** Neutral Lucide glyph per core (always-on) domain. */
const CORE_ICONS: Record<CoreDomainKey, LucideIcon> = {
weight: Scale,
bloodPressure: HeartPulse,
pulse: Activity,
};
export function ModulesSection() {
const { t } = useTranslations();
const { user } = useAuth();
@@ -113,103 +101,65 @@ export function ModulesSection() {
},
});
// v1.18.6 (W9) — the visible heading + subtitle now come from the shared
// `<SettingsSectionFrame>` (the former muted-`<p>` intro folded into the
// frame's standard subtitle). The "Module immer aktiv" core-domains card
// was removed: a domain that can't be turned off doesn't need to be listed.
return (
<section
aria-labelledby="settings-section-modules-title"
className="space-y-6"
>
{/* v1.18.6 — restore a short intro line so a new user understands
that this page drives the nav, tiles and assessments they see.
(Dropped in v1.18.1 D0 for top alignment; the orientation cost
outweighed the alignment.) */}
<header className="space-y-1">
<h1 id="settings-section-modules-title" className="sr-only">
{t("settings.sections.modules.title")}
</h1>
<p className="text-muted-foreground text-sm leading-relaxed">
{t("settings.sections.modules.description")}
</p>
</header>
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
<SettingsCardHeader
icon={Blocks}
title={t("settings.sections.modules.toggleable.title")}
className="mb-2"
/>
{/* v1.18.6 — restore the one-line "what this section does" hint. */}
<p className="text-muted-foreground mb-3 pl-7 text-sm leading-relaxed">
{t("settings.sections.modules.toggleable.description")}
</p>
<div className="divide-border divide-y pl-7">
{(Object.keys(MODULE_REGISTRY) as ModuleKey[]).map((key) => {
const def = MODULE_REGISTRY[key];
// Default-on: a module is enabled unless explicitly `false`.
const enabled = modules[key] !== false;
// Delegated modules (cycle/coach) are owned elsewhere — never a
// live toggle here; deep-link to their real control instead.
const delegated = moduleDelegatesTo(key) !== undefined;
// Operator turned this module off server-wide: no per-user
// toggle can re-enable it, so show a read-only note. Delegated
// rows already deep-link, so this only matters for owned keys.
const operatorDisabled =
!delegated && moduleAvailability[key] === false;
return (
<ModuleToggleRow
key={key}
moduleKey={key}
icon={MODULE_ICONS[key]}
label={t(def.labelKey)}
description={t(def.descriptionKey)}
enabled={enabled}
pending={toggle.isPending}
managedAt={
delegated && def.managedAt
? {
href: def.managedAt.href,
label: t(def.managedAt.labelKey),
}
: undefined
}
manageLinkLabel={
delegated && def.managedAt
? t("settings.sections.modules.manageIn", {
section: t(def.managedAt.labelKey),
})
: undefined
}
operatorDisabled={operatorDisabled}
operatorNote={t("settings.sections.modules.operatorDisabled")}
onToggle={(next) => toggle.mutate({ key, enabled: next })}
/>
);
})}
</div>
</div>
{/* Core domains — always on, read-only */}
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
<SettingsCardHeader
icon={Lock}
title={t("settings.sections.modules.core.title")}
description={t("settings.sections.modules.core.description")}
className="mb-2"
/>
<div className="divide-border divide-y pl-7">
{CORE_DOMAIN_KEYS.map((key) => (
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
<SettingsCardHeader
icon={Blocks}
title={t("settings.sections.modules.toggleable.title")}
className="mb-2"
/>
{/* v1.18.6 — restore the one-line "what this section does" hint. */}
<p className="text-muted-foreground mb-3 pl-7 text-sm leading-relaxed">
{t("settings.sections.modules.toggleable.description")}
</p>
<div className="divide-border divide-y pl-7">
{(Object.keys(MODULE_REGISTRY) as ModuleKey[]).map((key) => {
const def = MODULE_REGISTRY[key];
// Default-on: a module is enabled unless explicitly `false`.
const enabled = modules[key] !== false;
// Delegated modules (cycle/coach) are owned elsewhere — never a
// live toggle here; deep-link to their real control instead.
const delegated = moduleDelegatesTo(key) !== undefined;
// Operator turned this module off server-wide: no per-user
// toggle can re-enable it, so show a read-only note. Delegated
// rows already deep-link, so this only matters for owned keys.
const operatorDisabled =
!delegated && moduleAvailability[key] === false;
return (
<ModuleToggleRow
key={key}
moduleKey={key}
icon={CORE_ICONS[key]}
label={t(`modules.${key}.label`)}
description={t(`modules.${key}.description`)}
enabled
locked
lockedNote={t("settings.sections.modules.core.note")}
icon={MODULE_ICONS[key]}
label={t(def.labelKey)}
description={t(def.descriptionKey)}
enabled={enabled}
pending={toggle.isPending}
managedAt={
delegated && def.managedAt
? {
href: def.managedAt.href,
label: t(def.managedAt.labelKey),
}
: undefined
}
manageLinkLabel={
delegated && def.managedAt
? t("settings.sections.modules.manageIn", {
section: t(def.managedAt.labelKey),
})
: undefined
}
operatorDisabled={operatorDisabled}
operatorNote={t("settings.sections.modules.operatorDisabled")}
onToggle={(next) => toggle.mutate({ key, enabled: next })}
/>
))}
</div>
);
})}
</div>
</section>
</div>
);
}
+4 -11
View File
@@ -40,17 +40,10 @@ export function MoodSection() {
</div>
);
// v1.18.6 (W9) — the visible heading + subtitle now come from the shared
// `<SettingsSectionFrame>` in the route; this body is the mood-tag cards.
return (
<section
aria-labelledby="settings-section-mood-title"
className="space-y-6"
>
<header>
<h1 id="settings-section-mood-title" className="sr-only">
{t("settings.sections.mood.title")}
</h1>
</header>
<div className="space-y-6">
{/* Groups — picker group order + own groups. */}
<div
id="mood-groups"
@@ -87,6 +80,6 @@ export function MoodSection() {
<ArchivedTagsCard catalog={catalog} />
)}
</div>
</section>
</div>
);
}
@@ -1,8 +1,6 @@
"use client";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import { CoachNudgeCard } from "@/components/settings/coach-nudge-card";
import { LowStockCard } from "@/components/settings/low-stock-card";
import { MoodReminderCard } from "@/components/settings/mood-reminder-card";
@@ -17,44 +15,31 @@ import { MoodReminderCard } from "@/components/settings/mood-reminder-card";
*
* 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`, Coach nudge → `coach`, 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).
* 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).
*
* v1.18.1 (D5) — the page is intentionally lean: the section blurb, the
* channels / inbox cross-links, and the embedded Vorsorge (preventive-care)
* editor were all removed. Vorsorge has its own `/vorsorge` page, so embedding
* it here was a duplicate. Delivery CHANNELS (Telegram / ntfy / Web Push /
* Webhook / Email) live under Settings → Integrations → Channels.
*
* v1.18.6 (W9) — the visible heading + subtitle now come from the shared
* `<SettingsSectionFrame>`. The proactive-Coach nudge card moved to Settings →
* Coach (it is a Coach setting, not a generic notification), so this screen
* carries the mood + low-stock reminder cards only.
*/
export function NotificationsSection() {
const { t } = useTranslations();
const { isAuthenticated, user } = useAuth();
// v1.18.0 (S4) — 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 coachEnabled = user?.modules?.coach !== false;
const medsEnabled = user?.modules?.medications !== false;
return (
<section
aria-labelledby="settings-section-notifications-title"
className="space-y-6"
>
{/* v1.18.1 (D0/D5) — the section blurb and the channels / inbox cross-
links were dropped: the header now starts at the same height as every
other section and the page carries only the reminder-type cards. */}
<header>
<h1 id="settings-section-notifications-title" className="sr-only">
{t("settings.sections.notifications.title")}
</h1>
</header>
{/* v1.18.1 (D5) — the Vorsorge (preventive-care) block was removed; it
has its own /vorsorge page, so embedding the editor here was a
duplicate. This screen keeps the three reminder-type cards below. */}
<div className="space-y-6">
{/* Mood check-in reminder — only when the mood module is enabled. */}
{moodEnabled ? (
<div id="mood-reminder" className="scroll-mt-28">
@@ -69,13 +54,6 @@ export function NotificationsSection() {
<LowStockCard isAuthenticated={isAuthenticated} />
</div>
) : null}
{/* Proactive Coach nudge — only when the coach module is enabled. */}
{coachEnabled ? (
<div id="coach-nudge" className="scroll-mt-28">
<CoachNudgeCard isAuthenticated={isAuthenticated} />
</div>
) : null}
</section>
</div>
);
}
@@ -0,0 +1,25 @@
/**
* `<SectionNavHeadingSpacer>` — invisible spacer that reserves exactly the
* height of a section's heading block (h1 + subtitle) plus the `space-y-6`
* gap that separates the heading from the first card.
*
* v1.18.6 (W9) — Marc's symmetry ask: the left settings / admin menu's first
* item must line up with the TOP OF THE FIRST CARD, not with the page
* heading. The page heading lives in the content column above the first card,
* so the sidebar needs to start one heading-block lower. Replicating the
* exact heading markup (same `text-2xl` h1 + `text-sm` subtitle) as a hidden,
* `aria-hidden` block keeps the offset pixel-accurate across locales and
* line-wrapping — a hard-coded `pt-[…]` would drift the moment a title or
* subtitle wraps to a second line.
*
* It is only rendered on `md+` (the desktop sticky sidebar); the mobile chip
* strip has no heading-to-align-with.
*/
export function SectionNavHeadingSpacer() {
return (
<div aria-hidden="true" className="invisible mb-6 select-none">
<h1 className="text-2xl font-bold tracking-tight">&nbsp;</h1>
<p className="text-muted-foreground text-sm">&nbsp;</p>
</div>
);
}
@@ -0,0 +1,74 @@
"use client";
/**
* `<SettingsSectionFrame>` — the canonical page-frame for every Settings
* section. The settings twin of the admin renderer's `SectionFrame`
* (`src/app/admin/[section]/renderer.tsx`): it emits the one visible
* heading + subheading every page must show, in the reference style used by
* the Admin Konsole → Übersicht landing.
*
* Before this primitive every settings section hand-rolled its own
* `sr-only` `<h1>`, so no settings page showed a visible heading and the
* left-menu-to-first-card alignment drifted page to page. Routing all 19
* sections through one frame fixes both: a constant-height heading block
* above the first card, and a real visible `h1 + subtitle`.
*
* <SettingsSectionFrame slug="account">
* {cards}
* </SettingsSectionFrame>
*
* Title comes from `settings.sections.<slug>.title`; subtitle from
* `settings.sections.<slug>.subtitle`. The `<h1>` keeps the historic
* `settings-section-<slug>-title` id so the section's own `aria-labelledby`
* linkage — and the spotlight-tour anchors that reference it — stay intact.
*
* `topSlot` renders ABOVE the heading (used by the Layout-hub child
* editors for their "← back to hub" link, which must sit above the title).
* `headingAccessory` renders inline at the top-right of the heading row
* (used by Gesundheitsakte for its guided-tour re-entry trigger).
*/
import * as React from "react";
import { useTranslations } from "@/lib/i18n/context";
import type { SettingsSectionSlug } from "./section-slugs";
export interface SettingsSectionFrameProps {
slug: SettingsSectionSlug;
/** Optional content rendered above the heading (e.g. a back-to-hub link). */
topSlot?: React.ReactNode;
/** Optional content rendered inline at the top-right of the heading row
* (e.g. a guided-tour re-entry trigger). */
headingAccessory?: React.ReactNode;
children: React.ReactNode;
}
export function SettingsSectionFrame({
slug,
topSlot,
headingAccessory,
children,
}: SettingsSectionFrameProps) {
const { t } = useTranslations();
const titleId = `settings-section-${slug}-title`;
return (
<section aria-labelledby={titleId} className="space-y-6">
{topSlot ? <div>{topSlot}</div> : null}
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<h1 id={titleId} className="text-2xl font-bold tracking-tight">
{t(`settings.sections.${slug}.title`)}
</h1>
<p className="text-muted-foreground text-sm">
{t(`settings.sections.${slug}.subtitle`)}
</p>
</div>
{headingAccessory ? (
<div className="shrink-0">{headingAccessory}</div>
) : null}
</div>
{children}
</section>
);
}
+6 -2
View File
@@ -48,6 +48,7 @@ import {
isSettingsSectionSlug,
type SettingsSectionSlug,
} from "./section-slugs";
import { SectionNavHeadingSpacer } from "./section-nav-heading-spacer";
// Re-export so existing call-sites importing from this module keep working.
// New server-side call-sites (e.g. `generateStaticParams()`) should import
@@ -259,8 +260,7 @@ export function SettingsShell({ active, children }: SettingsShellProps) {
// no `moduleGate` (global / CORE) are always shown.
const modules = user?.modules;
const visibleSections = SETTINGS_SECTIONS.filter(
(section) =>
!section.moduleGate || modules?.[section.moduleGate] !== false,
(section) => !section.moduleGate || modules?.[section.moduleGate] !== false,
);
// v1.4.33 IW4 — keep the active chip in view inside the horizontal
@@ -358,6 +358,10 @@ export function SettingsShell({ active, children }: SettingsShellProps) {
className="hidden md:block"
>
<div className="sticky top-20">
{/* v1.18.6 (W9) — push the first nav item down to the top of the
first card (level with the page heading's following gap), so
the menu reads symmetric with the content column. */}
<SectionNavHeadingSpacer />
<ul className="space-y-1">
{visibleSections.map((section) => {
const isActive = section.slug === activeSlug;
+21 -25
View File
@@ -278,31 +278,13 @@ export function SourcesSection() {
return Object.keys(priority.deviceTypePriority).length;
}, [priority]);
// v1.18.6 (W9) — the visible heading + subtitle now come from the shared
// `<SettingsSectionFrame>` (the off-style muted intro folded into the
// standard subtitle). The quiet cross-link back to Integrationen — where
// the sources this ladder ranks are added / removed — stays inside the
// card, below the ladder.
return (
<section
aria-labelledby="settings-section-sources-title"
className="space-y-6"
>
{/* v1.18.1 (D0/D4) — standalone left-side entry again. The section
blurb is dropped for consistent top alignment; the cross-link back
to Integrationen (where the sources this ladder ranks are added /
removed) stays as a quiet pointer. */}
<header>
<h1 id="settings-section-sources-title" className="sr-only">
{t("settings.sections.sources.title")}
</h1>
<p className="text-muted-foreground text-xs">
{t("settings.sections.sources.integrationsHint")}{" "}
<Link
href="/settings/integrations"
className="text-primary underline underline-offset-2"
data-slot="sources-integrations-cross-link"
>
{t("settings.sections.sources.integrationsHintLink")}
</Link>
</p>
</header>
<div className="space-y-6">
<div className="bg-card border-border space-y-4 rounded-xl border p-4 sm:p-6">
<SettingsCardHeader
icon={Layers}
@@ -578,8 +560,22 @@ export function SourcesSection() {
</Button>
</div>
)}
{/* v1.18.6 (W9) — quiet pointer back to Integrationen, where the
sources this ladder ranks are connected / removed. Folded into
the card so the page top stays the standard heading + subtitle. */}
<p className="text-muted-foreground border-border border-t pt-3 text-xs">
{t("settings.sections.sources.integrationsHint")}{" "}
<Link
href="/settings/integrations"
className="text-primary underline underline-offset-2"
data-slot="sources-integrations-cross-link"
>
{t("settings.sections.sources.integrationsHintLink")}
</Link>
</p>
</div>
</section>
</div>
);
}
+4 -19
View File
@@ -1,7 +1,6 @@
"use client";
import { ThresholdsEditorSection } from "@/components/settings/thresholds-editor-section";
import { useTranslations } from "@/lib/i18n/context";
/**
* `<ThresholdsSection>` — route-level wrapper for `/settings/thresholds`
@@ -16,7 +15,8 @@ import { useTranslations } from "@/lib/i18n/context";
* flows, so each concern is self-contained on its own page.
*
* Visual structure on this page:
* 1. Section header (title + description).
* 1. Section heading + subtitle — supplied by the shared
* `<SettingsSectionFrame>` in the route (v1.18.6 W9).
* 2. `<ThresholdsEditorSection>` — per-metric range inputs. The editor
* card keeps its own affordance row (icon, reset-all button).
*
@@ -31,21 +31,6 @@ import { useTranslations } from "@/lib/i18n/context";
* the names stop clashing.
*/
export function ThresholdsSection() {
const { t } = useTranslations();
return (
<section
aria-labelledby="settings-section-thresholds-title"
className="space-y-6"
>
<header>
<h1 id="settings-section-thresholds-title" className="sr-only">
{t("settings.sections.thresholds.title")}
</h1>
</header>
{/* Per-metric threshold ranges. */}
<ThresholdsEditorSection id="thresholds" />
</section>
);
// Per-metric threshold ranges; the heading is supplied by the route frame.
return <ThresholdsEditorSection id="thresholds" />;
}