diff --git a/CHANGELOG.md b/CHANGELOG.md
index 72cd6e4d4..033305bc0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,31 @@
## [Unreleased]
+## [1.25.3] — 2026-06-29 — Mental wellbeing, labs depth, and fixes
+
+A patch release: a clearer mental-wellbeing check-in, more useful lab pages, two visible bug fixes, and a couple of settings tidy-ups. No schema change.
+
+### Added
+
+- Each biomarker now reads with a short explanation of what it measures and why it matters, in every language — not just a generic line.
+
+### Changed
+
+- **Mental wellbeing** is reworked: the PHQ-9 / GAD-7 check-in is now a calm step-by-step flow (one question at a time, with progress) instead of a single long form; the screen reads as cards with a clear title and a short explanation, the voluntary-self-test note stays on the overview rather than interrupting the test, and past results show as a proper trend with severity context. Crisis-support always surfaces for an at-risk answer.
+- **Labs** biomarker pages: the redundant "adjust target range" control is gone (it already lives in Edit); the controls now read Delete · Edit · Show all values · Add value, with tooltips, and the "show all values" control matches the others.
+- **Settings**: delivery channels now live inside Notifications (one place for "what you receive" and "where it lands"); the former "Dashboard" settings area is renamed **Appearance** and gathers links to the view settings of the dashboard, insights, medications, labs, illness journal, and checkups.
+- The daily briefing now scales its generation budget to your configured AI response timeout, so raising the timeout for a slower model actually takes effect instead of being capped — and when a refresh genuinely fails, the message points at the right lever (raise the timeout / re-check the provider) instead of a generic error.
+
+### Fixed
+
+- **The 24-hour time preference is honored everywhere again** — several screens (sleep timeline, workout times, sign-in activity and sessions) were showing AM/PM even with 24-hour selected. Time now follows your preference across the app.
+- The readiness / recovery insight tile no longer renders broken — the method note was overlapping the score and contributing factors; the card now reads top to bottom with the factors visible, on every screen size.
+- The weekly and monthly review now use the same top-right "ask the Coach" control as the other insights, and the Coach conversations list spans the normal content width instead of a narrow column.
+
+### Removed
+
+- The Documents feature is temporarily switched off pending a deeper rebuild; the existing data and code are untouched.
+
## [1.25.2] — 2026-06-29 — Flat settings navigation
### Changed
diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml
index eb8ad6004..737849280 100644
--- a/docs/api/openapi.yaml
+++ b/docs/api/openapi.yaml
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
- version: 1.25.2
+ version: 1.25.3
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
@@ -10290,8 +10290,6 @@ components:
type: boolean
mcp:
type: boolean
- inboundDocuments:
- type: boolean
mentalHealth:
type: boolean
additionalProperties: false
@@ -16947,8 +16945,8 @@ components:
minimum: -9007199254740991
maximum: 9007199254740991
- type: "null"
- description: v1.22 (#89) — per-user response timeout for AI generation, in seconds (10–600). Null = the generous
- built-in default (~180 s). Mainly for slow local/self-hosted backends.
+ description: v1.22 (#89) — per-user response timeout for AI generation, in seconds (10–600). Null = the built-in
+ comprehensive-briefing default (~120 s). Mainly for slow local/self-hosted backends.
ocrEnabled:
type: boolean
description: v1.22 (#90) — whether a dedicated document-scan (Lab-OCR) provider is active. When false the OCR path uses
diff --git a/messages/de.json b/messages/de.json
index c97fb115e..a1bba3e2e 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -2066,7 +2066,10 @@
"refreshFailedHint": "Briefing konnte nicht aktualisiert werden — es wird das letzte angezeigt.",
"retryAction": "Erneut versuchen",
"failedTitle": "Briefing konnte nicht erstellt werden",
- "failedDescription": "Der KI-Anbieter hat das Zeitlimit überschritten oder einen Fehler zurückgegeben. Versuche es erneut oder erhöhe in den KI-Einstellungen das Antwort-Zeitlimit für ein langsames lokales Modell."
+ "failedDescription": "Der KI-Anbieter hat das Zeitlimit überschritten oder einen Fehler zurückgegeben. Versuche es erneut oder erhöhe in den KI-Einstellungen das Antwort-Zeitlimit für ein langsames lokales Modell.",
+ "failedDescriptionTimeout": "Die KI-Anfrage hat das Zeitlimit überschritten, bevor das Briefing fertig war. Erhöhe für ein langsames lokales Modell in den Einstellungen das Antwort-Zeitlimit und versuche es dann erneut.",
+ "failedDescriptionProvider": "Der KI-Anbieter hat die Anfrage abgelehnt. Prüfe Anbieter und API-Schlüssel in den KI-Einstellungen und versuche es erneut.",
+ "failedDescriptionRateLimit": "Der KI-Anbieter drosselt derzeit die Anfragen. Versuche es in Kürze erneut."
},
"trendsRow": {
"title": "Trends",
@@ -4033,15 +4036,17 @@
},
"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",
- "subtitle": "Wähle, welche Erinnerungen du erhältst."
+ "description": "Alle Erinnerungen an einem Ort — was wann erinnert wird und wohin es zugestellt wird.",
+ "subtitle": "Wähle, welche Erinnerungen du erhältst und wohin sie zugestellt werden.",
+ "remindersHeading": "Erinnerungen",
+ "remindersDescription": "Was du erhältst.",
+ "channelsHeading": "Zustellkanäle",
+ "channelsDescription": "Wohin Erinnerungen und Hinweise zugestellt werden."
},
"layout": {
- "title": "Dashboard",
- "description": "Lege fest, wie Dashboard und Insights aufgebaut und angeordnet sind.",
- "backToHub": "Zurück zu Layout & Personalisierung",
+ "title": "Darstellung",
+ "description": "Lege fest, wie Dashboard, Auswertungen und deine Tracking-Module dargestellt und angeordnet sind.",
+ "backToHub": "Zurück zu Darstellung",
"dashboard": {
"title": "Dashboard",
"description": "Kachel-Layout und Reihenfolge."
@@ -4050,7 +4055,23 @@
"title": "Insights",
"description": "Reihenfolge der Übersicht und der Navigations-Pills."
},
- "subtitle": "Passe an, wie Dashboard und Insights angeordnet sind."
+ "subtitle": "Lege fest, wie Dashboard, Auswertungen und Tracking-Module dargestellt werden.",
+ "medications": {
+ "title": "Medikamente",
+ "description": "Listenansicht und manuelle Reihenfolge."
+ },
+ "labs": {
+ "title": "Labor",
+ "description": "Ansicht, Sortierung und manuelle Reihenfolge."
+ },
+ "illness": {
+ "title": "Krankheitstagebuch",
+ "description": "Listenansicht und Reihenfolge der Episoden."
+ },
+ "vorsorge": {
+ "title": "Vorsorge",
+ "description": "Listenansicht und Reihenfolge der Erinnerungen."
+ }
},
"dashboard": {
"title": "Dashboard",
@@ -4308,11 +4329,6 @@
"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.",
- "subtitle": "Wohin Erinnerungen und Hinweise zugestellt werden."
- },
"security": {
"title": "Sicherheit",
"subtitle": "Zwei-Faktor-Authentifizierung, Wiederherstellungscodes und Passkeys."
@@ -4671,7 +4687,7 @@
"responseTimeoutHeading": "Antwort-Timeout",
"responseTimeoutBody": "Wie lange auf die Antwort der KI gewartet wird, bevor abgebrochen wird. Erhöhe den Wert für ein lokales oder selbst gehostetes Modell, das beim ersten Aufruf Zeit zum Laden braucht.",
"responseTimeoutLabel": "Antwort-Timeout (Sekunden)",
- "responseTimeoutPlaceholder": "Standard (~180)",
+ "responseTimeoutPlaceholder": "Standard (~120)",
"responseTimeoutInvalid": "Gib eine Zahl zwischen 10 und 600 Sekunden ein.",
"ocrHeading": "Dokumentenscan (Laborberichte)",
"ocrBody": "Verwende einen eigenen Anbieter zum Auslesen hochgeladener Laborberichte. Ist die Option aus, nutzt der Scan deinen Hauptanbieter.",
@@ -7129,14 +7145,7 @@
"unhiddenToast": "Biomarker wiederhergestellt",
"hideError": "Biomarker konnte nicht aktualisiert werden",
"allHidden": "Alle Biomarker sind ausgeblendet.",
- "hiddenSection": "Ausgeblendet",
- "targetRange": {
- "title": "Zielbereich anpassen",
- "description": "Lege die untere und obere Referenz für diesen Marker fest. Lass ein Feld leer für eine offene Grenze.",
- "save": "Bereich speichern",
- "savedToast": "Zielbereich aktualisiert",
- "saveError": "Zielbereich konnte nicht aktualisiert werden"
- }
+ "hiddenSection": "Ausgeblendet"
},
"catalog": {
"total-cholesterol": "Gesamtcholesterin",
@@ -7569,7 +7578,6 @@
"choosePrompt": "Check-in auswählen",
"start": "Starten",
"back": "Zurück",
- "stem": "Wie oft fühltest du dich im Verlauf der letzten 2 Wochen durch die folgenden Beschwerden beeinträchtigt?",
"submit": "Ergebnis ansehen",
"submitting": "Wird gespeichert…",
"saved": "Gespeichert",
@@ -7619,7 +7627,14 @@
},
"history": {
"title": "Dein Verlauf",
- "empty": "Noch keine Check-ins. Dein Verlauf erscheint hier, sobald du einen abgeschlossen hast."
+ "empty": "Noch keine Check-ins. Dein Verlauf erscheint hier, sobald du einen abgeschlossen hast.",
+ "chartTitle": "Punkteverlauf",
+ "flaggedBadge": "Hilfe angezeigt",
+ "totalLabel": "Punkte",
+ "tab": {
+ "phq9": "PHQ-9",
+ "gad7": "GAD-7"
+ }
},
"crisis": {
"title": "Du musst das nicht allein durchstehen",
@@ -7671,6 +7686,25 @@
"crisisTextLine": {
"name": "Crisis Text Line"
}
+ },
+ "instrumentDescription": {
+ "phq9": "Depressions-Screening mit 9 Fragen. Beziehe dich auf die letzten zwei Wochen.",
+ "gad7": "Angst-Screening mit 7 Fragen. Beziehe dich auf die letzten zwei Wochen."
+ },
+ "progress": "{current} / {total}",
+ "questionAria": "Frage {current} von {total}",
+ "next": "Weiter",
+ "lastResult": "Letztes Ergebnis",
+ "noResultYet": "Noch kein Check-in",
+ "backToList": "Übersicht",
+ "jumpFirst": "Zur ersten Frage springen",
+ "jumpLast": "Zur letzten Frage springen",
+ "review": {
+ "title": "Überblick",
+ "recap": "Du hast {answered} von {total} Fragen beantwortet."
+ },
+ "landing": {
+ "disclaimer": "Dies sind freiwillige Selbsttests, keine Diagnose. Sie unterstützen das Gespräch mit einer Fachperson, ersetzen es aber nicht."
}
},
"records": {
diff --git a/messages/en.json b/messages/en.json
index 3f2844f28..d5e7f3509 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -2066,7 +2066,10 @@
"refreshFailedHint": "Couldn't refresh the briefing — showing the last one.",
"retryAction": "Retry",
"failedTitle": "Couldn't generate the briefing",
- "failedDescription": "The AI provider timed out or returned an error. Try again, or raise the response timeout in AI settings for a slow local model."
+ "failedDescription": "The AI provider timed out or returned an error. Try again, or raise the response timeout in AI settings for a slow local model.",
+ "failedDescriptionTimeout": "The AI request ran out of time before the briefing was ready. For a slow local model, raise the AI response timeout in Settings, then try again.",
+ "failedDescriptionProvider": "The AI provider rejected the request. Re-check the provider and API key in AI settings, then try again.",
+ "failedDescriptionRateLimit": "The AI provider is rate-limiting requests right now. Try again in a little while."
},
"trendsRow": {
"title": "Trends",
@@ -4033,15 +4036,17 @@
},
"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",
- "subtitle": "Choose which reminders you receive."
+ "description": "Every reminder in one place — what gets reminded and when, and where it's delivered.",
+ "subtitle": "Choose which reminders you receive and where they're delivered.",
+ "remindersHeading": "Reminders",
+ "remindersDescription": "What you receive.",
+ "channelsHeading": "Delivery channels",
+ "channelsDescription": "Where reminders and alerts are delivered."
},
"layout": {
- "title": "Dashboard",
- "description": "Arrange how the dashboard and insights are laid out.",
- "backToHub": "Back to Layout & Personalization",
+ "title": "Appearance",
+ "description": "Choose how the dashboard, insights, and your tracking modules look and are arranged.",
+ "backToHub": "Back to Appearance",
"dashboard": {
"title": "Dashboard",
"description": "Tile layout and order."
@@ -4050,7 +4055,23 @@
"title": "Insights",
"description": "Overview sections and navigation-pill order."
},
- "subtitle": "Personalize how your dashboard and insights are arranged."
+ "subtitle": "Personalize how your dashboard, insights, and tracking modules look.",
+ "medications": {
+ "title": "Medications",
+ "description": "List view and manual order."
+ },
+ "labs": {
+ "title": "Labs",
+ "description": "View, sort, and manual order."
+ },
+ "illness": {
+ "title": "Illness journal",
+ "description": "List view and episode order."
+ },
+ "vorsorge": {
+ "title": "Checkups",
+ "description": "List view and reminder order."
+ }
},
"dashboard": {
"title": "Dashboard",
@@ -4308,11 +4329,6 @@
"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.",
- "subtitle": "Where reminders and alerts are delivered."
- },
"security": {
"title": "Security",
"subtitle": "Two-factor authentication, recovery codes, and passkeys."
@@ -4671,7 +4687,7 @@
"responseTimeoutHeading": "Response timeout",
"responseTimeoutBody": "How long to wait for the AI to respond before giving up. Raise it for a local or self-hosted model that needs time to load on the first request.",
"responseTimeoutLabel": "Response timeout (seconds)",
- "responseTimeoutPlaceholder": "Default (~180)",
+ "responseTimeoutPlaceholder": "Default (~120)",
"responseTimeoutInvalid": "Enter a number between 10 and 600 seconds.",
"ocrHeading": "Document scanning (lab reports)",
"ocrBody": "Use a dedicated provider for reading uploaded lab reports. When off, scanning uses your main provider.",
@@ -7129,14 +7145,7 @@
"unhiddenToast": "Biomarker restored",
"hideError": "Could not update the biomarker",
"allHidden": "Every biomarker is hidden.",
- "hiddenSection": "Hidden",
- "targetRange": {
- "title": "Adjust target range",
- "description": "Set the reference low and high for this marker. Leave a field empty for an open-ended bound.",
- "save": "Save range",
- "savedToast": "Target range updated",
- "saveError": "Could not update the target range"
- }
+ "hiddenSection": "Hidden"
},
"catalog": {
"total-cholesterol": "Total cholesterol",
@@ -7569,7 +7578,6 @@
"choosePrompt": "Choose a check-in",
"start": "Start",
"back": "Back",
- "stem": "Over the last 2 weeks, how often have you been bothered by the following problems?",
"submit": "See my result",
"submitting": "Saving…",
"saved": "Saved",
@@ -7619,7 +7627,14 @@
},
"history": {
"title": "Your history",
- "empty": "No check-ins yet. Your trend will appear here once you complete one."
+ "empty": "No check-ins yet. Your trend will appear here once you complete one.",
+ "chartTitle": "Score trend",
+ "flaggedBadge": "Support shown",
+ "totalLabel": "Score",
+ "tab": {
+ "phq9": "PHQ-9",
+ "gad7": "GAD-7"
+ }
},
"crisis": {
"title": "You don't have to face this alone",
@@ -7671,6 +7686,25 @@
"crisisTextLine": {
"name": "Crisis Text Line"
}
+ },
+ "instrumentDescription": {
+ "phq9": "9-question depression screen. Answer for the last two weeks.",
+ "gad7": "7-question anxiety screen. Answer for the last two weeks."
+ },
+ "progress": "{current} / {total}",
+ "questionAria": "Question {current} of {total}",
+ "next": "Next",
+ "lastResult": "Last result",
+ "noResultYet": "No check-in yet",
+ "backToList": "Overview",
+ "jumpFirst": "Jump to first question",
+ "jumpLast": "Jump to last question",
+ "review": {
+ "title": "Review",
+ "recap": "You answered {answered} of {total} questions."
+ },
+ "landing": {
+ "disclaimer": "These are voluntary self-tests, not a diagnosis. They support a conversation with a professional; they don't replace one."
}
},
"records": {
diff --git a/messages/es.json b/messages/es.json
index 98aee4b3d..2c5779350 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -2066,7 +2066,10 @@
"refreshFailedHint": "No se pudo actualizar el briefing: se muestra el anterior.",
"retryAction": "Reintentar",
"failedTitle": "No se pudo generar el briefing",
- "failedDescription": "El proveedor de IA agotó el tiempo de espera o devolvió un error. Inténtalo de nuevo o aumenta el tiempo de espera de respuesta en los ajustes de IA para un modelo local lento."
+ "failedDescription": "El proveedor de IA agotó el tiempo de espera o devolvió un error. Inténtalo de nuevo o aumenta el tiempo de espera de respuesta en los ajustes de IA para un modelo local lento.",
+ "failedDescriptionTimeout": "La solicitud de IA agotó el tiempo de espera antes de que el resumen estuviera listo. Para un modelo local lento, aumenta el tiempo de espera de respuesta en Ajustes y vuelve a intentarlo.",
+ "failedDescriptionProvider": "El proveedor de IA rechazó la solicitud. Revisa el proveedor y la clave de API en los ajustes de IA y vuelve a intentarlo.",
+ "failedDescriptionRateLimit": "El proveedor de IA está limitando las solicitudes en este momento. Vuelve a intentarlo en un rato."
},
"trendsRow": {
"title": "Tendencias",
@@ -4033,15 +4036,17 @@
},
"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",
- "subtitle": "Elige qué recordatorios recibes."
+ "description": "Todos los recordatorios en un solo lugar: qué se recuerda y cuándo, y dónde se entrega.",
+ "subtitle": "Elige qué recordatorios recibes y dónde se entregan.",
+ "remindersHeading": "Recordatorios",
+ "remindersDescription": "Lo que recibes.",
+ "channelsHeading": "Canales de entrega",
+ "channelsDescription": "Dónde se entregan los recordatorios y avisos."
},
"layout": {
- "title": "Panel",
- "description": "Organiza cómo están dispuestos el panel y los análisis.",
- "backToHub": "Volver a Diseño y personalización",
+ "title": "Apariencia",
+ "description": "Elige cómo se ven y se organizan el panel, los análisis y tus módulos de seguimiento.",
+ "backToHub": "Volver a Apariencia",
"dashboard": {
"title": "Panel",
"description": "Diseño y orden de los mosaicos."
@@ -4050,7 +4055,23 @@
"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."
+ "subtitle": "Personaliza cómo se ven tu panel, los análisis y los módulos de seguimiento.",
+ "medications": {
+ "title": "Medicamentos",
+ "description": "Vista de lista y orden manual."
+ },
+ "labs": {
+ "title": "Laboratorio",
+ "description": "Vista, orden y orden manual."
+ },
+ "illness": {
+ "title": "Diario de enfermedades",
+ "description": "Vista de lista y orden de episodios."
+ },
+ "vorsorge": {
+ "title": "Revisiones",
+ "description": "Vista de lista y orden de recordatorios."
+ }
},
"dashboard": {
"title": "Panel",
@@ -4308,11 +4329,6 @@
"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.",
- "subtitle": "Dónde se entregan los recordatorios y avisos."
- },
"security": {
"title": "Seguridad",
"subtitle": "Autenticación de dos factores, códigos de recuperación y claves de acceso."
@@ -4671,7 +4687,7 @@
"responseTimeoutHeading": "Tiempo de espera de la respuesta",
"responseTimeoutBody": "Cuánto esperar a que responda la IA antes de cancelar. Aumenta el valor para un modelo local o autoalojado que necesita tiempo para cargarse en la primera solicitud.",
"responseTimeoutLabel": "Tiempo de espera de la respuesta (segundos)",
- "responseTimeoutPlaceholder": "Predeterminado (~180)",
+ "responseTimeoutPlaceholder": "Predeterminado (~120)",
"responseTimeoutInvalid": "Introduce un número entre 10 y 600 segundos.",
"ocrHeading": "Escaneo de documentos (informes de laboratorio)",
"ocrBody": "Usa un proveedor dedicado para leer los informes de laboratorio subidos. Si está desactivado, el escaneo usa tu proveedor principal.",
@@ -7129,14 +7145,7 @@
"unhiddenToast": "Biomarcador restaurado",
"hideError": "No se pudo actualizar el biomarcador",
"allHidden": "Todos los biomarcadores están ocultos.",
- "hiddenSection": "Ocultos",
- "targetRange": {
- "title": "Ajustar el rango objetivo",
- "description": "Define la referencia mínima y máxima de este marcador. Deja un campo vacío para un límite abierto.",
- "save": "Guardar rango",
- "savedToast": "Rango objetivo actualizado",
- "saveError": "No se pudo actualizar el rango objetivo"
- }
+ "hiddenSection": "Ocultos"
},
"catalog": {
"total-cholesterol": "Colesterol total",
@@ -7569,7 +7578,6 @@
"choosePrompt": "Elige una revisión",
"start": "Empezar",
"back": "Atrás",
- "stem": "Durante las últimas 2 semanas, ¿con qué frecuencia te han molestado los siguientes problemas?",
"submit": "Ver mi resultado",
"submitting": "Guardando…",
"saved": "Guardado",
@@ -7619,7 +7627,14 @@
},
"history": {
"title": "Tu historial",
- "empty": "Aún no hay revisiones. Tu tendencia aparecerá aquí cuando completes una."
+ "empty": "Aún no hay revisiones. Tu tendencia aparecerá aquí cuando completes una.",
+ "chartTitle": "Evolución de la puntuación",
+ "flaggedBadge": "Apoyo mostrado",
+ "totalLabel": "Puntuación",
+ "tab": {
+ "phq9": "PHQ-9",
+ "gad7": "GAD-7"
+ }
},
"crisis": {
"title": "No tienes que afrontar esto a solas",
@@ -7671,6 +7686,25 @@
"crisisTextLine": {
"name": "Crisis Text Line"
}
+ },
+ "instrumentDescription": {
+ "phq9": "Cribado de depresión de 9 preguntas. Responde pensando en las últimas dos semanas.",
+ "gad7": "Cribado de ansiedad de 7 preguntas. Responde pensando en las últimas dos semanas."
+ },
+ "progress": "{current} / {total}",
+ "questionAria": "Pregunta {current} de {total}",
+ "next": "Siguiente",
+ "lastResult": "Último resultado",
+ "noResultYet": "Aún sin check-in",
+ "backToList": "Vista general",
+ "jumpFirst": "Ir a la primera pregunta",
+ "jumpLast": "Ir a la última pregunta",
+ "review": {
+ "title": "Resumen",
+ "recap": "Has respondido {answered} de {total} preguntas."
+ },
+ "landing": {
+ "disclaimer": "Son autoevaluaciones voluntarias, no un diagnóstico. Sirven de apoyo para hablar con un profesional, no lo sustituyen."
}
},
"records": {
diff --git a/messages/fr.json b/messages/fr.json
index 80c0be5fe..66b5b8e3f 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -2066,7 +2066,10 @@
"refreshFailedHint": "Impossible d'actualiser le briefing — affichage du précédent.",
"retryAction": "Réessayer",
"failedTitle": "Impossible de générer le briefing",
- "failedDescription": "Le fournisseur d'IA a dépassé le délai ou renvoyé une erreur. Réessayez ou augmentez le délai de réponse dans les paramètres d'IA pour un modèle local lent."
+ "failedDescription": "Le fournisseur d'IA a dépassé le délai ou renvoyé une erreur. Réessayez ou augmentez le délai de réponse dans les paramètres d'IA pour un modèle local lent.",
+ "failedDescriptionTimeout": "La requête IA a dépassé le délai avant que le briefing soit prêt. Pour un modèle local lent, augmentez le délai de réponse dans les Paramètres, puis réessayez.",
+ "failedDescriptionProvider": "Le fournisseur d'IA a rejeté la requête. Vérifiez le fournisseur et la clé API dans les paramètres d'IA, puis réessayez.",
+ "failedDescriptionRateLimit": "Le fournisseur d'IA limite les requêtes pour le moment. Réessayez dans un instant."
},
"trendsRow": {
"title": "Tendances",
@@ -4033,15 +4036,17 @@
},
"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",
- "subtitle": "Choisissez les rappels que vous recevez."
+ "description": "Tous les rappels au même endroit — quoi et quand, et où c'est envoyé.",
+ "subtitle": "Choisissez les rappels que vous recevez et où ils sont envoyés.",
+ "remindersHeading": "Rappels",
+ "remindersDescription": "Ce que vous recevez.",
+ "channelsHeading": "Canaux de diffusion",
+ "channelsDescription": "Où sont envoyés les rappels et alertes."
},
"layout": {
- "title": "Tableau de bord",
- "description": "Organisez la disposition du tableau de bord et des analyses.",
- "backToHub": "Retour à Disposition et personnalisation",
+ "title": "Apparence",
+ "description": "Choisissez l'apparence et l'agencement du tableau de bord, des analyses et de vos modules de suivi.",
+ "backToHub": "Retour à Apparence",
"dashboard": {
"title": "Tableau de bord",
"description": "Disposition et ordre des tuiles."
@@ -4050,7 +4055,23 @@
"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."
+ "subtitle": "Personnalisez l'apparence du tableau de bord, des analyses et des modules de suivi.",
+ "medications": {
+ "title": "Médicaments",
+ "description": "Vue en liste et ordre manuel."
+ },
+ "labs": {
+ "title": "Analyses de labo",
+ "description": "Vue, tri et ordre manuel."
+ },
+ "illness": {
+ "title": "Journal des maladies",
+ "description": "Vue en liste et ordre des épisodes."
+ },
+ "vorsorge": {
+ "title": "Examens",
+ "description": "Vue en liste et ordre des rappels."
+ }
},
"dashboard": {
"title": "Tableau de bord",
@@ -4308,11 +4329,6 @@
"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.",
- "subtitle": "Où sont envoyés les rappels et alertes."
- },
"security": {
"title": "Sécurité",
"subtitle": "Authentification à deux facteurs, codes de récupération et clés d'accès."
@@ -4671,7 +4687,7 @@
"responseTimeoutHeading": "Délai de réponse",
"responseTimeoutBody": "Durée d'attente de la réponse de l'IA avant d'abandonner. Augmentez-la pour un modèle local ou auto-hébergé qui a besoin de temps pour se charger à la première requête.",
"responseTimeoutLabel": "Délai de réponse (secondes)",
- "responseTimeoutPlaceholder": "Par défaut (~180)",
+ "responseTimeoutPlaceholder": "Par défaut (~120)",
"responseTimeoutInvalid": "Saisissez un nombre entre 10 et 600 secondes.",
"ocrHeading": "Numérisation de documents (comptes rendus de laboratoire)",
"ocrBody": "Utilisez un fournisseur dédié pour lire les comptes rendus de laboratoire importés. Désactivé, la numérisation utilise votre fournisseur principal.",
@@ -7129,14 +7145,7 @@
"unhiddenToast": "Biomarqueur restauré",
"hideError": "Impossible de mettre à jour le biomarqueur",
"allHidden": "Tous les biomarqueurs sont masqués.",
- "hiddenSection": "Masqués",
- "targetRange": {
- "title": "Ajuster la plage cible",
- "description": "Définis la référence basse et haute de ce marqueur. Laisse un champ vide pour une limite ouverte.",
- "save": "Enregistrer la plage",
- "savedToast": "Plage cible mise à jour",
- "saveError": "Impossible de mettre à jour la plage cible"
- }
+ "hiddenSection": "Masqués"
},
"catalog": {
"total-cholesterol": "Cholestérol total",
@@ -7569,7 +7578,6 @@
"choosePrompt": "Choisis un bilan",
"start": "Commencer",
"back": "Retour",
- "stem": "Au cours des 2 dernières semaines, à quelle fréquence as-tu été gêné(e) par les problèmes suivants ?",
"submit": "Voir mon résultat",
"submitting": "Enregistrement…",
"saved": "Enregistré",
@@ -7619,7 +7627,14 @@
},
"history": {
"title": "Ton historique",
- "empty": "Aucun bilan pour le moment. Ta tendance apparaîtra ici dès que tu en auras terminé un."
+ "empty": "Aucun bilan pour le moment. Ta tendance apparaîtra ici dès que tu en auras terminé un.",
+ "chartTitle": "Évolution du score",
+ "flaggedBadge": "Aide affichée",
+ "totalLabel": "Score",
+ "tab": {
+ "phq9": "PHQ-9",
+ "gad7": "GAD-7"
+ }
},
"crisis": {
"title": "Tu n'as pas à affronter cela seul(e)",
@@ -7671,6 +7686,25 @@
"crisisTextLine": {
"name": "Crisis Text Line"
}
+ },
+ "instrumentDescription": {
+ "phq9": "Dépistage de la dépression en 9 questions. Répondez en pensant aux deux dernières semaines.",
+ "gad7": "Dépistage de l'anxiété en 7 questions. Répondez en pensant aux deux dernières semaines."
+ },
+ "progress": "{current} / {total}",
+ "questionAria": "Question {current} sur {total}",
+ "next": "Suivant",
+ "lastResult": "Dernier résultat",
+ "noResultYet": "Pas encore de check-in",
+ "backToList": "Vue d'ensemble",
+ "jumpFirst": "Aller à la première question",
+ "jumpLast": "Aller à la dernière question",
+ "review": {
+ "title": "Récapitulatif",
+ "recap": "Vous avez répondu à {answered} questions sur {total}."
+ },
+ "landing": {
+ "disclaimer": "Ce sont des auto-évaluations volontaires, pas un diagnostic. Elles aident à préparer un échange avec un professionnel, sans le remplacer."
}
},
"records": {
diff --git a/messages/it.json b/messages/it.json
index 03a3fa8ed..14c32c857 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -2066,7 +2066,10 @@
"refreshFailedHint": "Impossibile aggiornare il briefing: viene mostrato l'ultimo.",
"retryAction": "Riprova",
"failedTitle": "Impossibile generare il briefing",
- "failedDescription": "Il provider IA ha superato il tempo limite o ha restituito un errore. Riprova oppure aumenta il timeout di risposta nelle impostazioni IA per un modello locale lento."
+ "failedDescription": "Il provider IA ha superato il tempo limite o ha restituito un errore. Riprova oppure aumenta il timeout di risposta nelle impostazioni IA per un modello locale lento.",
+ "failedDescriptionTimeout": "La richiesta IA ha superato il tempo limite prima che il briefing fosse pronto. Per un modello locale lento, aumenta il timeout di risposta nelle Impostazioni e riprova.",
+ "failedDescriptionProvider": "Il provider IA ha rifiutato la richiesta. Controlla il provider e la chiave API nelle impostazioni IA e riprova.",
+ "failedDescriptionRateLimit": "Il provider IA sta limitando le richieste in questo momento. Riprova tra poco."
},
"trendsRow": {
"title": "Tendenze",
@@ -4033,15 +4036,17 @@
},
"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",
- "subtitle": "Scegli quali promemoria ricevere."
+ "description": "Tutti i promemoria in un unico posto — cosa e quando, e dove vengono recapitati.",
+ "subtitle": "Scegli quali promemoria ricevere e dove vengono recapitati.",
+ "remindersHeading": "Promemoria",
+ "remindersDescription": "Ciò che ricevi.",
+ "channelsHeading": "Canali di recapito",
+ "channelsDescription": "Dove vengono recapitati promemoria e avvisi."
},
"layout": {
- "title": "Cruscotto",
- "description": "Organizza la disposizione del cruscotto e degli approfondimenti.",
- "backToHub": "Torna a Layout e personalizzazione",
+ "title": "Aspetto",
+ "description": "Scegli l'aspetto e la disposizione di dashboard, approfondimenti e dei tuoi moduli di monitoraggio.",
+ "backToHub": "Torna ad Aspetto",
"dashboard": {
"title": "Cruscotto",
"description": "Layout e ordine dei riquadri."
@@ -4050,7 +4055,23 @@
"title": "Approfondimenti",
"description": "Ordine della panoramica e delle schede di navigazione."
},
- "subtitle": "Personalizza la disposizione di dashboard e insights."
+ "subtitle": "Personalizza l'aspetto di dashboard, approfondimenti e moduli di monitoraggio.",
+ "medications": {
+ "title": "Farmaci",
+ "description": "Vista elenco e ordine manuale."
+ },
+ "labs": {
+ "title": "Analisi di laboratorio",
+ "description": "Vista, ordinamento e ordine manuale."
+ },
+ "illness": {
+ "title": "Diario delle malattie",
+ "description": "Vista elenco e ordine degli episodi."
+ },
+ "vorsorge": {
+ "title": "Controlli",
+ "description": "Vista elenco e ordine dei promemoria."
+ }
},
"dashboard": {
"title": "Cruscotto",
@@ -4308,11 +4329,6 @@
"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.",
- "subtitle": "Dove vengono recapitati promemoria e avvisi."
- },
"security": {
"title": "Sicurezza",
"subtitle": "Autenticazione a due fattori, codici di recupero e passkey."
@@ -4671,7 +4687,7 @@
"responseTimeoutHeading": "Timeout della risposta",
"responseTimeoutBody": "Quanto attendere la risposta dell'IA prima di annullare. Aumentalo per un modello locale o self-hosted che ha bisogno di tempo per caricarsi alla prima richiesta.",
"responseTimeoutLabel": "Timeout della risposta (secondi)",
- "responseTimeoutPlaceholder": "Predefinito (~180)",
+ "responseTimeoutPlaceholder": "Predefinito (~120)",
"responseTimeoutInvalid": "Inserisci un numero tra 10 e 600 secondi.",
"ocrHeading": "Scansione documenti (referti di laboratorio)",
"ocrBody": "Usa un provider dedicato per leggere i referti di laboratorio caricati. Se disattivato, la scansione usa il tuo provider principale.",
@@ -7129,14 +7145,7 @@
"unhiddenToast": "Biomarcatore ripristinato",
"hideError": "Impossibile aggiornare il biomarcatore",
"allHidden": "Tutti i biomarcatori sono nascosti.",
- "hiddenSection": "Nascosti",
- "targetRange": {
- "title": "Regola l'intervallo obiettivo",
- "description": "Imposta il riferimento minimo e massimo per questo marcatore. Lascia un campo vuoto per un limite aperto.",
- "save": "Salva intervallo",
- "savedToast": "Intervallo obiettivo aggiornato",
- "saveError": "Impossibile aggiornare l'intervallo obiettivo"
- }
+ "hiddenSection": "Nascosti"
},
"catalog": {
"total-cholesterol": "Colesterolo totale",
@@ -7569,7 +7578,6 @@
"choosePrompt": "Scegli un check-in",
"start": "Inizia",
"back": "Indietro",
- "stem": "Nelle ultime 2 settimane, con quale frequenza ti hanno disturbato i seguenti problemi?",
"submit": "Vedi il mio risultato",
"submitting": "Salvataggio…",
"saved": "Salvato",
@@ -7619,7 +7627,14 @@
},
"history": {
"title": "La tua cronologia",
- "empty": "Ancora nessun check-in. Il tuo andamento apparirà qui dopo che ne avrai completato uno."
+ "empty": "Ancora nessun check-in. Il tuo andamento apparirà qui dopo che ne avrai completato uno.",
+ "chartTitle": "Andamento del punteggio",
+ "flaggedBadge": "Supporto mostrato",
+ "totalLabel": "Punteggio",
+ "tab": {
+ "phq9": "PHQ-9",
+ "gad7": "GAD-7"
+ }
},
"crisis": {
"title": "Non devi affrontare tutto questo da solo/a",
@@ -7671,6 +7686,25 @@
"crisisTextLine": {
"name": "Crisis Text Line"
}
+ },
+ "instrumentDescription": {
+ "phq9": "Screening della depressione in 9 domande. Rispondi pensando alle ultime due settimane.",
+ "gad7": "Screening dell'ansia in 7 domande. Rispondi pensando alle ultime due settimane."
+ },
+ "progress": "{current} / {total}",
+ "questionAria": "Domanda {current} di {total}",
+ "next": "Avanti",
+ "lastResult": "Ultimo risultato",
+ "noResultYet": "Ancora nessun check-in",
+ "backToList": "Panoramica",
+ "jumpFirst": "Vai alla prima domanda",
+ "jumpLast": "Vai all'ultima domanda",
+ "review": {
+ "title": "Riepilogo",
+ "recap": "Hai risposto a {answered} domande su {total}."
+ },
+ "landing": {
+ "disclaimer": "Sono autovalutazioni volontarie, non una diagnosi. Sostengono il confronto con un professionista, non lo sostituiscono."
}
},
"records": {
diff --git a/messages/pl.json b/messages/pl.json
index 4b2ba446c..ff87fd7d6 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -2066,7 +2066,10 @@
"refreshFailedHint": "Nie udało się odświeżyć briefingu — pokazujemy poprzedni.",
"retryAction": "Spróbuj ponownie",
"failedTitle": "Nie udało się wygenerować briefingu",
- "failedDescription": "Dostawca AI przekroczył limit czasu lub zwrócił błąd. Spróbuj ponownie albo zwiększ limit czasu odpowiedzi w ustawieniach AI dla wolnego modelu lokalnego."
+ "failedDescription": "Dostawca AI przekroczył limit czasu lub zwrócił błąd. Spróbuj ponownie albo zwiększ limit czasu odpowiedzi w ustawieniach AI dla wolnego modelu lokalnego.",
+ "failedDescriptionTimeout": "Żądanie AI przekroczyło limit czasu, zanim briefing był gotowy. W przypadku wolnego modelu lokalnego zwiększ limit czasu odpowiedzi w Ustawieniach i spróbuj ponownie.",
+ "failedDescriptionProvider": "Dostawca AI odrzucił żądanie. Sprawdź dostawcę i klucz API w ustawieniach AI, a następnie spróbuj ponownie.",
+ "failedDescriptionRateLimit": "Dostawca AI ogranicza teraz liczbę żądań. Spróbuj ponownie za chwilę."
},
"trendsRow": {
"title": "Trendy",
@@ -4033,15 +4036,17 @@
},
"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",
- "subtitle": "Wybierz, które przypomnienia otrzymujesz."
+ "description": "Wszystkie przypomnienia w jednym miejscu — co i kiedy oraz gdzie są dostarczane.",
+ "subtitle": "Wybierz, które przypomnienia otrzymujesz i gdzie są dostarczane.",
+ "remindersHeading": "Przypomnienia",
+ "remindersDescription": "Co otrzymujesz.",
+ "channelsHeading": "Kanały dostarczania",
+ "channelsDescription": "Gdzie dostarczane są przypomnienia i powiadomienia."
},
"layout": {
- "title": "Pulpit",
- "description": "Ustal, jak rozłożone są pulpit i analizy.",
- "backToHub": "Powrót do Układ i personalizacja",
+ "title": "Wygląd",
+ "description": "Ustal, jak wyglądają i są rozłożone pulpit, analizy i Twoje moduły śledzenia.",
+ "backToHub": "Powrót do Wygląd",
"dashboard": {
"title": "Pulpit",
"description": "Układ i kolejność kafelków."
@@ -4050,7 +4055,23 @@
"title": "Analizy",
"description": "Kolejność przeglądu i pigułek nawigacyjnych."
},
- "subtitle": "Dostosuj układ pulpitu i analiz."
+ "subtitle": "Dostosuj wygląd pulpitu, analiz i modułów śledzenia.",
+ "medications": {
+ "title": "Leki",
+ "description": "Widok listy i ręczna kolejność."
+ },
+ "labs": {
+ "title": "Laboratorium",
+ "description": "Widok, sortowanie i ręczna kolejność."
+ },
+ "illness": {
+ "title": "Dziennik chorób",
+ "description": "Widok listy i kolejność epizodów."
+ },
+ "vorsorge": {
+ "title": "Badania kontrolne",
+ "description": "Widok listy i kolejność przypomnień."
+ }
},
"dashboard": {
"title": "Pulpit",
@@ -4308,11 +4329,6 @@
"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.",
- "subtitle": "Gdzie dostarczane są przypomnienia i powiadomienia."
- },
"security": {
"title": "Bezpieczeństwo",
"subtitle": "Uwierzytelnianie dwuskładnikowe, kody odzyskiwania i klucze dostępu."
@@ -4671,7 +4687,7 @@
"responseTimeoutHeading": "Limit czasu odpowiedzi",
"responseTimeoutBody": "Jak długo czekać na odpowiedź AI, zanim nastąpi przerwanie. Zwiększ wartość dla lokalnego lub samodzielnie hostowanego modelu, który potrzebuje czasu na załadowanie przy pierwszym żądaniu.",
"responseTimeoutLabel": "Limit czasu odpowiedzi (sekundy)",
- "responseTimeoutPlaceholder": "Domyślnie (~180)",
+ "responseTimeoutPlaceholder": "Domyślnie (~120)",
"responseTimeoutInvalid": "Podaj liczbę od 10 do 600 sekund.",
"ocrHeading": "Skanowanie dokumentów (wyniki badań)",
"ocrBody": "Użyj dedykowanego dostawcy do odczytu przesłanych wyników badań. Gdy wyłączone, skanowanie używa Twojego głównego dostawcy.",
@@ -7129,14 +7145,7 @@
"unhiddenToast": "Biomarker przywrócony",
"hideError": "Nie udało się zaktualizować biomarkera",
"allHidden": "Wszystkie biomarkery są ukryte.",
- "hiddenSection": "Ukryte",
- "targetRange": {
- "title": "Dostosuj zakres docelowy",
- "description": "Ustaw dolną i górną wartość referencyjną dla tego markera. Pozostaw pole puste, aby granica była otwarta.",
- "save": "Zapisz zakres",
- "savedToast": "Zaktualizowano zakres docelowy",
- "saveError": "Nie udało się zaktualizować zakresu docelowego"
- }
+ "hiddenSection": "Ukryte"
},
"catalog": {
"total-cholesterol": "Cholesterol całkowity",
@@ -7569,7 +7578,6 @@
"choosePrompt": "Wybierz sprawdzenie",
"start": "Rozpocznij",
"back": "Wstecz",
- "stem": "Jak często w ciągu ostatnich 2 tygodni dokuczały Ci następujące problemy?",
"submit": "Zobacz mój wynik",
"submitting": "Zapisywanie…",
"saved": "Zapisano",
@@ -7619,7 +7627,14 @@
},
"history": {
"title": "Twoja historia",
- "empty": "Brak sprawdzeń. Twój trend pojawi się tutaj po ukończeniu pierwszego."
+ "empty": "Brak sprawdzeń. Twój trend pojawi się tutaj po ukończeniu pierwszego.",
+ "chartTitle": "Przebieg wyniku",
+ "flaggedBadge": "Pokazano wsparcie",
+ "totalLabel": "Wynik",
+ "tab": {
+ "phq9": "PHQ-9",
+ "gad7": "GAD-7"
+ }
},
"crisis": {
"title": "Nie musisz mierzyć się z tym sam(a)",
@@ -7671,6 +7686,25 @@
"crisisTextLine": {
"name": "Crisis Text Line"
}
+ },
+ "instrumentDescription": {
+ "phq9": "Przesiew w kierunku depresji, 9 pytań. Odpowiadaj, myśląc o ostatnich dwóch tygodniach.",
+ "gad7": "Przesiew w kierunku lęku, 7 pytań. Odpowiadaj, myśląc o ostatnich dwóch tygodniach."
+ },
+ "progress": "{current} / {total}",
+ "questionAria": "Pytanie {current} z {total}",
+ "next": "Dalej",
+ "lastResult": "Ostatni wynik",
+ "noResultYet": "Brak check-inów",
+ "backToList": "Przegląd",
+ "jumpFirst": "Przejdź do pierwszego pytania",
+ "jumpLast": "Przejdź do ostatniego pytania",
+ "review": {
+ "title": "Podsumowanie",
+ "recap": "Odpowiedziano na {answered} z {total} pytań."
+ },
+ "landing": {
+ "disclaimer": "To dobrowolne testy przesiewowe, a nie diagnoza. Wspierają rozmowę ze specjalistą, ale jej nie zastępują."
}
},
"records": {
diff --git a/next.config.ts b/next.config.ts
index 2c6e0c4ed..219619fea 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -134,6 +134,17 @@ const nextConfig: NextConfig = {
destination: "/settings/notifications",
permanent: true,
},
+ // v1.25.3 — the standalone delivery-channels page folded into
+ // Notifications as an in-page group. The channels content now lives
+ // under the `#channels` anchor on the Notifications surface, so the old
+ // URL 301-redirects there (mirroring the `/settings/reminders` precedent
+ // above). The per-channel anchors (`#telegram`, `#ntfy`, …) inside the
+ // panel keep resolving.
+ {
+ source: "/settings/channels",
+ destination: "/settings/notifications#channels",
+ permanent: true,
+ },
// v1.22.0 — the preventive-care surface moved from the German slug
// `/vorsorge` to the English `/checkups` (the feature reads "Checkups"
// in every non-German locale now). 301-redirect so bookmarks, the PWA's
diff --git a/package.json b/package.json
index 78bb0421f..22e4dead2 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "healthlog",
- "version": "1.25.2",
+ "version": "1.25.3",
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
"license": "PolyForm-Noncommercial-1.0.0",
"homepage": "https://healthlog.dev",
diff --git a/src/app/api/insights/generate/route.ts b/src/app/api/insights/generate/route.ts
index 5dcf2081c..9a16bad10 100644
--- a/src/app/api/insights/generate/route.ts
+++ b/src/app/api/insights/generate/route.ts
@@ -319,8 +319,14 @@ export const GET = apiHandler(async (request: NextRequest) => {
// briefing is held rather than freshly refreshed — and, when there is no
// last good text, that the empty state should read "couldn't generate" with
// a retry rather than the generic "no briefing yet".
- const generationFailed =
- (await readBriefingFailure({ userId, since: cachedAt })) !== null;
+ const briefingFailure = await readBriefingFailure({
+ userId,
+ since: cachedAt,
+ });
+ const generationFailed = briefingFailure !== null;
+ // v1.25.3 — the failure class lets the empty state point its hint at the
+ // right lever (raise the response timeout vs re-check the provider).
+ const generationFailureClass = briefingFailure?.failureClass ?? null;
// Read-only: never block on the provider. Warm out of band only when the
// cached briefing is stale / missing AND a provider is configured (a
@@ -368,6 +374,9 @@ export const GET = apiHandler(async (request: NextRequest) => {
hasProvider,
// v1.25 — true when the last generation attempt failed (held text).
generationFailed,
+ // v1.25.3 — coarse failure class for the empty-state hint (null when
+ // the last attempt succeeded).
+ generationFailureClass,
});
} catch {
// Invalid cache row — fall through to the empty payload below. The
@@ -390,6 +399,8 @@ export const GET = apiHandler(async (request: NextRequest) => {
// v1.25 — no last-good text AND the last attempt failed: the UI shows a
// "couldn't generate" empty state with a retry instead of the generic one.
generationFailed,
+ // v1.25.3 — coarse failure class for the empty-state hint.
+ generationFailureClass,
});
});
@@ -760,6 +771,16 @@ export const POST = apiHandler((request: NextRequest) =>
// path, so the last good briefing stays intact). The read path pairs the
// preserved text with a discreet "couldn't refresh" hint, or shows a
// retry empty state when there is no last good text.
+ // v1.25.3 — carry the upstream status so the read-path hint can tell an
+ // auth / rate-limit failure apart from a plain timeout. For an
+ // all-providers-failed error the first hop's status is the most
+ // representative; a single provider error exposes its own `httpStatus`.
+ const failureHttpStatus =
+ e instanceof AllProvidersFailedError
+ ? (e.attempts[0]?.httpStatus ?? null)
+ : typeof (e as { httpStatus?: number }).httpStatus === "number"
+ ? (e as { httpStatus?: number }).httpStatus
+ : null;
void recordBriefingFailure({
userId,
reason:
@@ -767,6 +788,7 @@ export const POST = apiHandler((request: NextRequest) =>
? "all-providers-failed"
: "provider-error",
locale,
+ httpStatus: failureHttpStatus,
});
if (e instanceof AllProvidersFailedError) {
annotate({
diff --git a/src/app/coach/conversations/page.tsx b/src/app/coach/conversations/page.tsx
index 3d68a8fbf..dfa955015 100644
--- a/src/app/coach/conversations/page.tsx
+++ b/src/app/coach/conversations/page.tsx
@@ -120,7 +120,7 @@ function CoachConversationsBody() {
}
return (
-
+
- {MODULE_KEYS.map((key) => {
- const def = MODULE_REGISTRY[key];
- // Default-available until the read resolves; an explicit `false`
- // from the operator layer is the only thing that turns it off.
- const available = availability?.[key] ?? true;
- return (
-
- mutation.mutate({ [key]: checked } as Partial)
- }
- disabled={mutation.isPending}
- />
- );
- })}
+ {MODULE_KEYS
+ // A module switched off in code (pending a rebuild) is hard-off
+ // server-wide; drop its row so the operator toggle can't mislead.
+ .filter((key) => !isCodeDisabledModule(key))
+ .map((key) => {
+ const def = MODULE_REGISTRY[key];
+ // Default-available until the read resolves; an explicit `false`
+ // from the operator layer is the only thing that turns it off.
+ const available = availability?.[key] ?? true;
+ return (
+
+ mutation.mutate({
+ [key]: checked,
+ } as Partial)
+ }
+ disabled={mutation.isPending}
+ />
+ );
+ })}
);
diff --git a/src/components/data-list/delete-button.tsx b/src/components/data-list/delete-button.tsx
index 47ee7d5dd..c963e0026 100644
--- a/src/components/data-list/delete-button.tsx
+++ b/src/components/data-list/delete-button.tsx
@@ -32,6 +32,7 @@ export function DeleteButton({
description,
cancelLabel,
confirmLabel,
+ triggerTitle,
className = "",
iconClassName = "h-3.5 w-3.5",
}: {
@@ -40,6 +41,8 @@ export function DeleteButton({
description: string;
cancelLabel?: string;
confirmLabel?: string;
+ /** Native hover tooltip on the trigger; defaults to the accessible name. */
+ triggerTitle?: string;
className?: string;
iconClassName?: string;
}) {
@@ -52,6 +55,7 @@ export function DeleteButton({
size="icon-lg"
className={`text-destructive ${className}`}
aria-label={confirmLabel ?? t("common.delete")}
+ title={triggerTitle ?? confirmLabel ?? t("common.delete")}
>
diff --git a/src/components/insights/daily-briefing.tsx b/src/components/insights/daily-briefing.tsx
index 311b761de..309da0fc7 100644
--- a/src/components/insights/daily-briefing.tsx
+++ b/src/components/insights/daily-briefing.tsx
@@ -36,6 +36,7 @@ import type {
DailyBriefingKeyFinding,
DailyBriefingSignal,
} from "@/lib/ai/schema";
+import type { BriefingFailureClass } from "@/lib/insights/briefing-failure-marker";
/**
* v1.4.27 MB7 / CF-68 — per-metric routing target for the briefing
@@ -127,6 +128,13 @@ interface DailyBriefingProps {
* generic empty state for a "couldn't generate" one whose CTA retries.
*/
generationFailed?: boolean;
+ /**
+ * v1.25.3 — coarse class of the most recent failure. When the empty state is
+ * shown ("couldn't generate"), it lets the hint point at the right lever:
+ * `timeout` → raise the AI response timeout, `auth` → re-check the provider.
+ * Absent / null → the generic failed-description holds.
+ */
+ generationFailureClass?: BriefingFailureClass | null;
/**
* Optional slot for a meta control mounted in the card header — the
* comparison toggle migrates here from the hero in commit 5.
@@ -358,6 +366,7 @@ export function DailyBriefing({
noProvider = false,
noProviderStale = false,
generationFailed = false,
+ generationFailureClass = null,
metaSlot,
memory = null,
}: DailyBriefingProps) {
@@ -365,6 +374,20 @@ export function DailyBriefing({
const fmt = useFormatters();
const { user } = useAuth();
+ // v1.25.3 — when there is no last good text and the last attempt failed,
+ // point the hint at the lever that matches the failure class. A timeout (the
+ // dominant failure on a slow self-hosted model) → raise the AI response
+ // timeout; an auth / config rejection → re-check the provider in Settings.
+ // Anything else keeps the calmer generic line. Non-alarming by design.
+ const failedDescriptionKey =
+ generationFailureClass === "timeout"
+ ? "insights.dailyBriefing.failedDescriptionTimeout"
+ : generationFailureClass === "auth"
+ ? "insights.dailyBriefing.failedDescriptionProvider"
+ : generationFailureClass === "rate-limit"
+ ? "insights.dailyBriefing.failedDescriptionRateLimit"
+ : "insights.dailyBriefing.failedDescription";
+
return (
// v1.13.1 — heading-above-card pattern. The "Tagesbriefing" title
// moves OUT of the card into an `
` above it (mirroring the
@@ -588,7 +611,7 @@ export function DailyBriefing({
}
description={
generationFailed
- ? t("insights.dailyBriefing.failedDescription")
+ ? t(failedDescriptionKey)
: t("insights.dailyBriefing.emptyDescription")
}
action={
diff --git a/src/components/insights/derived/__tests__/score-anatomy-view.test.tsx b/src/components/insights/derived/__tests__/score-anatomy-view.test.tsx
index cd193728f..2923a6172 100644
--- a/src/components/insights/derived/__tests__/score-anatomy-view.test.tsx
+++ b/src/components/insights/derived/__tests__/score-anatomy-view.test.tsx
@@ -56,9 +56,15 @@ describe("", () => {
expect(html).toContain('data-contributor="respiratory"');
expect(html).toContain('data-present="false"');
expect(html).toContain('data-slot="provenance-explainer-method"');
+ expect(html).toContain("A blend of deviation components.");
// v1.15.12 F4 — the "keine klinische Bewertung" footer line is removed;
// the non-clinical framing now lives in the inline provenance method.
expect(html).not.toContain('data-slot="score-anatomy-disclaimer"');
+ // Regression guard: `ProvenanceExplainer` renders its full method body
+ // inline (the v1.22 refactor dropped the ⓘ glyph), so it must sit in
+ // normal flow — never pulled out via `absolute top-3 right-3`, which
+ // painted the whole paragraph over the title, ring and contributor rows.
+ expect(html).not.toContain("absolute top-3 right-3");
});
it("tints the card + leans the ring hue when a hue is passed (F1)", () => {
diff --git a/src/components/insights/derived/score-anatomy-view.tsx b/src/components/insights/derived/score-anatomy-view.tsx
index 8bac63efb..bbc2b1dc5 100644
--- a/src/components/insights/derived/score-anatomy-view.tsx
+++ b/src/components/insights/derived/score-anatomy-view.tsx
@@ -126,24 +126,12 @@ export function ScoreAnatomyView({
: undefined
}
className={cn(
- "relative flex flex-col gap-5 rounded-xl border p-5",
+ "flex flex-col gap-5 rounded-xl border p-5",
hue ? "wellness-detail-card" : "border-border bg-card",
className,
)}
aria-label={typeof title === "string" ? title : undefined}
>
- {/* v1.15.12 F5 — the ⓘ explainer is pinned TOP-right at heading height
- (it used to trail the card at bottom-right). Absolute so it never
- shifts the centred hero title; the trigger keeps its 44px touch
- target + popover/sheet behaviour. */}
-
-
-
-
{/* Hero: title + ring + caption */}
)}
- {/* v1.15.12 F4 — the "Richtwert — keine klinische Bewertung" footer line
- is gone; the method + cited standard live behind the provenance
- explainer (now top-right, F5), which carries the non-clinical framing
- where it belongs. The composition (contributor rows above) stays. */}
+ {/* The plain-language method caption closes the card, in normal flow.
+ `ProvenanceExplainer` (v1.22) is no longer a top-right ⓘ glyph — it
+ renders its full method body inline as a muted caption, so it must
+ sit in the document flow at the foot of the surface. The earlier
+ `absolute top-right` wrapper yanked the whole paragraph out of flow
+ and painted it over the centred title, ring and contributor rows on
+ every breakpoint; rendering it here keeps the spine readable. */}
+
);
}
diff --git a/src/components/insights/period-narrative-card.tsx b/src/components/insights/period-narrative-card.tsx
index 222401b94..ed5681a2f 100644
--- a/src/components/insights/period-narrative-card.tsx
+++ b/src/components/insights/period-narrative-card.tsx
@@ -11,7 +11,7 @@ import { useAuth } from "@/hooks/use-auth";
import { cn } from "@/lib/utils";
import { Skeleton } from "@/components/ui/skeleton";
import { SectionHeading } from "@/components/insights/section-heading";
-import { AskCoachAction } from "@/components/insights/ask-coach-action";
+import { AskCoachIconButton } from "@/components/insights/ask-coach-action";
import { ProseBlocks } from "@/components/insights/prose-blocks";
import { apiGet } from "@/lib/api/api-fetch";
@@ -142,29 +142,45 @@ export function PeriodNarrativeCard({
data-period={period}
className={SHELL}
>
- {/* Week / month toggle. Calm segmented control, no chart. */}
-
- {(["week", "month"] as const).map((p) => {
- const active = period === p;
- return (
-
- );
- })}
+ {/* Header row: week / month toggle on the left, the icon-only Coach
+ hand-off pinned top-right. Matches the assessment cards, which carry
+ the same single `` in their header's right slot
+ (no text label, tooltip + accessible name). The summary spans the
+ whole picture for the window, so no scope: the default snapshot
+ reads best. */}
+
+ {/* Calm segmented control, no chart. */}
+
+ {(["week", "month"] as const).map((p) => {
+ const active = period === p;
+ return (
+
+ );
+ })}
+
+
{/* v1.22 (W6) — real paragraphs via the shared ProseBlocks helper
@@ -184,19 +200,6 @@ export function PeriodNarrativeCard({
user?.timezone,
)}
-
- {/* v1.21.0 (C4 H2) — hand the period summary to the Coach. It spans
- the whole picture for the window, so no scope: the default
- snapshot reads best. */}
-
-
-
);
diff --git a/src/components/insights/sleep-hypnogram.tsx b/src/components/insights/sleep-hypnogram.tsx
index 7e6797140..6f56369b2 100644
--- a/src/components/insights/sleep-hypnogram.tsx
+++ b/src/components/insights/sleep-hypnogram.tsx
@@ -11,7 +11,8 @@ import {
} from "recharts";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
-import { useTranslations } from "@/lib/i18n/context";
+import { useTranslations, useTimeFormatPreference } from "@/lib/i18n/context";
+import { hourCycleOptions } from "@/lib/format-locale";
import { STAGE_COLORS } from "./sleep-stage-stacked-bar";
/**
@@ -112,6 +113,7 @@ function formatMinutes(total: number, locale: string): string {
export function SleepHypnogram({ session }: SleepHypnogramProps) {
const { t, locale } = useTranslations();
+ const timeFormat = useTimeFormatPreference();
const stageLabels: Record = {
DEEP: t("insights.sleep.stages.deep"),
@@ -192,8 +194,9 @@ export function SleepHypnogram({ session }: SleepHypnogramProps) {
new Intl.DateTimeFormat(locale === "de" ? "de-DE" : "en-US", {
hour: "2-digit",
minute: "2-digit",
+ ...hourCycleOptions(timeFormat),
}),
- [locale],
+ [locale, timeFormat],
);
// Per-stage breakdown with percentage of the night's total tracked time.
diff --git a/src/components/insights/use-insights-advisor.ts b/src/components/insights/use-insights-advisor.ts
index d271742df..83f0eaadc 100644
--- a/src/components/insights/use-insights-advisor.ts
+++ b/src/components/insights/use-insights-advisor.ts
@@ -16,6 +16,7 @@ import type {
} from "@/lib/ai/schema";
import { queryKeys } from "@/lib/query-keys";
import { apiFetchRaw } from "@/lib/api/api-fetch";
+import type { BriefingFailureClass } from "@/lib/insights/briefing-failure-marker";
/**
* v1.4.16 phase D reconcile (CRITICAL C1 + C2) — shared TanStack Query
@@ -80,6 +81,13 @@ export interface InsightAdvisorPayload {
* Absent on a pre-field cached payload → treated as "not failed" (no hint).
*/
generationFailed?: boolean;
+ /**
+ * v1.25.3 — coarse class of the most recent failure (`timeout`, `auth`,
+ * `rate-limit`, `provider`, `format`, `unknown`), so the empty state can
+ * point its hint at the right lever. Null / absent → no specific hint, the
+ * generic failed-description holds.
+ */
+ generationFailureClass?: BriefingFailureClass | null;
}
/**
@@ -99,8 +107,7 @@ const ADVISOR_TIMEOUT_MS = 8_000;
* discarded a slow-but-successful generation — the abort returned a null
* payload, the mutation only invalidated (re-reading the OLD cache), yet
* the server had often already written the fresh briefing. Give the force
- * branch the same 45 s budget the out-of-band warm job uses
- * (`COMPREHENSIVE_WARM_TIMEOUT_MS`) so a slow success is kept, not
+ * branch a generous 45 s client budget so a slow success is kept, not
* dropped. The READ path stays fast at 8 s — only the explicit user tap
* may wait. Per `.planning/v1.15.18-daily-briefing-audit.md` Fix 1b.
*/
@@ -291,6 +298,12 @@ export interface UseInsightsAdvisorResult {
* when there is no last good text. Defaults false (no hint) when absent.
*/
generationFailed: boolean;
+ /**
+ * v1.25.3 — coarse class of the most recent failure, so the empty state can
+ * point its hint at the right lever. Null when the last attempt succeeded or
+ * the payload predates the field.
+ */
+ generationFailureClass: BriefingFailureClass | null;
}
/**
@@ -379,5 +392,7 @@ export function useInsightsAdvisorQuery(
// Absent (pre-field cached payload or unsettled query) → not failed, so a
// transient unknown never flashes a false "couldn't refresh" hint.
generationFailed: query.data?.payload?.generationFailed ?? false,
+ // Absent → no specific lever hint; the generic failed-description holds.
+ generationFailureClass: query.data?.payload?.generationFailureClass ?? null,
};
}
diff --git a/src/components/insights/workout-detail.tsx b/src/components/insights/workout-detail.tsx
index 2ac0f7cb5..4087e4bd3 100644
--- a/src/components/insights/workout-detail.tsx
+++ b/src/components/insights/workout-detail.tsx
@@ -17,7 +17,11 @@ import {
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { cn } from "@/lib/utils";
-import { useTranslations } from "@/lib/i18n/context";
+import { useTranslations, useTimeFormatPreference } from "@/lib/i18n/context";
+import {
+ hourCycleOptions,
+ type TimeFormatPreference,
+} from "@/lib/format-locale";
import type { WorkoutDetailPayload } from "@/hooks/use-workouts";
/**
@@ -103,6 +107,7 @@ function formatDateRange(
startedAt: string,
endedAt: string,
locale: string,
+ timeFormat: TimeFormatPreference,
): string {
const start = new Date(startedAt);
const end = new Date(endedAt);
@@ -115,6 +120,7 @@ function formatDateRange(
const timeFmt = new Intl.DateTimeFormat(locale, {
hour: "2-digit",
minute: "2-digit",
+ ...hourCycleOptions(timeFormat),
});
return `${dateFmt.format(start)} · ${timeFmt.format(start)} – ${timeFmt.format(end)}`;
}
@@ -139,6 +145,7 @@ function renderSportIconBadge(sportType: string) {
export function WorkoutDetailHeader({ workout }: WorkoutDetailHeaderProps) {
const { t, locale } = useTranslations();
+ const timeFormat = useTimeFormatPreference();
const sportLabelKey = `insights.workouts.sport.${workout.sportType}`;
const sportLabel = t(sportLabelKey);
const sportName =
@@ -158,7 +165,12 @@ export function WorkoutDetailHeader({ workout }: WorkoutDetailHeaderProps) {
{sportName}
+
+
+ {t("mentalHealth.result.takeAnother")}
+
+
+ );
+}
diff --git a/src/components/mental-health/check-in-nav.ts b/src/components/mental-health/check-in-nav.ts
new file mode 100644
index 000000000..125d04a6b
--- /dev/null
+++ b/src/components/mental-health/check-in-nav.ts
@@ -0,0 +1,71 @@
+/**
+ * v1.25.3 — pure step grammar for the one-question-at-a-time check-in.
+ *
+ * The PHQ-9 / GAD-7 screeners are LINEAR (no mode/cadence branching like the
+ * medication wizard), so the step list is a fixed `[1..itemCount, review]` and
+ * the reachability ceiling is just "the question you are currently working on"
+ * — you can always step backward to change an answer, never skip ahead onto an
+ * unanswered question. Keeping the navigation here (mirroring the medication
+ * wizard's `wizard-payload.ts`) makes the advance/back/reachability contract
+ * unit-testable without driving a DOM.
+ *
+ * Step numbers are 1-based and run `1..itemCount` for the questions; the review
+ * step is `itemCount + 1`. The optional functional-difficulty control folds
+ * into the review step (it is unscored), so the dot count stays at
+ * `itemCount + 1` rather than gaining its own dot.
+ *
+ * An unanswered item carries the sentinel `-1`.
+ */
+
+export const UNANSWERED = -1;
+
+/** The 1-based review step number for an instrument of `itemCount` items. */
+export function reviewStep(itemCount: number): number {
+ return itemCount + 1;
+}
+
+/** Ordered raw step numbers fed straight to `WizardStepper`: questions + review. */
+export function buildStepList(itemCount: number): number[] {
+ return Array.from({ length: itemCount + 1 }, (_, i) => i + 1);
+}
+
+/** Index (into the step list) of the highest forward-reachable slot. */
+export function reachableUntilIndex(items: readonly number[]): number {
+ const firstUnanswered = items.findIndex((v) => v < 0);
+ // All answered → the review slot (last index) is reachable.
+ if (firstUnanswered === -1) return items.length;
+ // Otherwise the frontier is the question currently being answered; its index
+ // in the step list equals its 0-based item index.
+ return firstUnanswered;
+}
+
+/** Every item answered (0–3) — gates the review step + submit. */
+export function isComplete(items: readonly number[]): boolean {
+ return items.length > 0 && items.every((v) => v >= 0);
+}
+
+/** How many items carry a real answer (for the review recap). */
+export function answeredCount(items: readonly number[]): number {
+ return items.filter((v) => v >= 0).length;
+}
+
+/**
+ * The next step after `step`. A question advances to the next question, the
+ * last question advances to the review step, and the review step stays put
+ * (submit, not advance).
+ */
+export function nextStep(step: number, itemCount: number): number {
+ const review = reviewStep(itemCount);
+ if (step >= review) return review;
+ return step + 1;
+}
+
+/** The previous step, clamped at the first question. */
+export function prevStep(step: number): number {
+ return step > 1 ? step - 1 : 1;
+}
+
+/** Whether `step` is the review step for this instrument. */
+export function isReviewStep(step: number, itemCount: number): boolean {
+ return step >= reviewStep(itemCount);
+}
diff --git a/src/components/mental-health/check-in-wizard.tsx b/src/components/mental-health/check-in-wizard.tsx
new file mode 100644
index 000000000..8d337b859
--- /dev/null
+++ b/src/components/mental-health/check-in-wizard.tsx
@@ -0,0 +1,281 @@
+"use client";
+
+/**
+ * v1.25.3 — one-question-at-a-time check-in.
+ *
+ * Replaces the all-questions-at-once `` wall with a linear stepper that
+ * reuses the medication wizard's presentational `WizardStepper` (the dot row +
+ * "Question 3 of 9" caption) and its Back/Next footer grammar — rendered INLINE
+ * on the page (not a modal), because here the page *is* the surface. The step
+ * navigation lives in `check-in-nav.ts` (pure, linear: backward always allowed,
+ * never skip ahead to an unanswered question).
+ *
+ * Header: instrument title + ONE standardized explanation line, NO disclaimer
+ * (§2 — the "voluntary self-test, not a diagnosis" disclaimer renders only on
+ * the landing + behind the InfoHint, never while taking the test). The optional
+ * functional-difficulty control folds into the review step.
+ */
+import { useEffect, useRef, useState } from "react";
+import { ArrowLeft } from "lucide-react";
+
+import { Button } from "@/components/ui/button";
+import { Card, CardContent } from "@/components/ui/card";
+import { WizardStepper } from "@/components/medications/wizard/wizard-stepper";
+import { useTranslations } from "@/lib/i18n/context";
+import { cn } from "@/lib/utils";
+import { INSTRUMENTS } from "@/lib/mental-health/instruments";
+
+import {
+ UNANSWERED,
+ answeredCount,
+ buildStepList,
+ isComplete,
+ isReviewStep,
+ nextStep,
+ prevStep,
+ reachableUntilIndex,
+} from "./check-in-nav";
+import type { InstrumentId } from "./types";
+
+const SCALE = [0, 1, 2, 3] as const;
+const FUNCTIONAL = [0, 1, 2, 3] as const;
+
+function lower(id: InstrumentId): "phq9" | "gad7" {
+ return id === "PHQ9" ? "phq9" : "gad7";
+}
+
+export function CheckInWizard({
+ instrument,
+ onSubmit,
+ onBack,
+ isPending,
+ isError,
+}: {
+ instrument: InstrumentId;
+ /** Submit the completed check-in. `functional` is null when unanswered. */
+ onSubmit: (items: number[], functional: number | null) => void;
+ /** Return to the landing (abandons the in-progress check-in). */
+ onBack: () => void;
+ isPending: boolean;
+ isError: boolean;
+}) {
+ const { t } = useTranslations();
+ const key = lower(instrument);
+ const itemCount = INSTRUMENTS[instrument].itemCount;
+
+ const [step, setStep] = useState(1);
+ const [items, setItems] = useState(() =>
+ Array(itemCount).fill(UNANSWERED),
+ );
+ const [functional, setFunctional] = useState(null);
+
+ const stepList = buildStepList(itemCount);
+ const onReview = isReviewStep(step, itemCount);
+ const questionIndex = step - 1; // 0-based item index for a question step
+ const complete = isComplete(items);
+ const reachableUntil = reachableUntilIndex(items);
+
+ // a11y: announce each step by moving focus to its heading (the question text
+ // / the review title). Mirrors the medication dialog's per-step title focus
+ // so a keyboard / screen-reader user hears the new question after an
+ // auto-advance, not silence.
+ const headingRef = useRef(null);
+ useEffect(() => {
+ headingRef.current?.focus({ preventScroll: false });
+ }, [step]);
+
+ function selectAnswer(value: number) {
+ setItems((prev) => {
+ const next = [...prev];
+ next[questionIndex] = value;
+ return next;
+ });
+ // Forward-on-select: advance to the next question, or onto the review step
+ // from the last question. The footer Next is the redundant keyboard path.
+ setStep((s) => nextStep(s, itemCount));
+ }
+
+ const srLabel = onReview
+ ? t("mentalHealth.review.title")
+ : t("mentalHealth.questionAria", { current: step, total: itemCount });
+
+ return (
+
+
+
+ {t("mentalHealth.backToList")}
+
+
+ {/* In-test header — instrument title + ONE standardized explanation line.
+ No disclaimer here (§2); the (i) tooltip on the landing carries it. */}
+
+
+
+
+ );
+}
diff --git a/src/components/mental-health/crisis-card.tsx b/src/components/mental-health/crisis-card.tsx
new file mode 100644
index 000000000..d10aaee09
--- /dev/null
+++ b/src/components/mental-health/crisis-card.tsx
@@ -0,0 +1,61 @@
+"use client";
+
+/**
+ * v1.25.3 — the calm, non-alarmist crisis-resource card.
+ *
+ * Shared between the fresh-result view (the POST response carries the crisis
+ * set on any non-zero PHQ-9 item 9) and the history list, where tapping a
+ * flagged row re-surfaces this card from the STATIC, locale-derived resource
+ * config (`crisisResourcesForLocale`) — it NEVER decrypts or reveals the
+ * item-9 answer, and showing it never triggers any third-party alert.
+ *
+ * Tone follows the v1.25.1 decision: a calm `border-destructive/40` border,
+ * never an amber/red fill; the `role="alert"` + `aria-live` carry the urgency.
+ */
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { useTranslations } from "@/lib/i18n/context";
+
+import type { CrisisSet } from "./types";
+
+export function CrisisCard({ crisis }: { crisis: CrisisSet }) {
+ const { t } = useTranslations();
+ return (
+
+
+
+ {t("mentalHealth.crisis.title")}
+
+
+
+
- {(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 (
- toggle.mutate({ key, enabled: next })}
- />
- );
- })}
+ {(Object.keys(MODULE_REGISTRY) as ModuleKey[])
+ // Modules switched off in code (pending a rebuild) carry no live
+ // toggle — drop the row entirely so a user can't turn one on.
+ .filter((key) => !isCodeDisabledModule(key))
+ .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 (
+ toggle.mutate({ key, enabled: next })}
+ />
+ );
+ })}
);
diff --git a/src/components/settings/notifications-section.tsx b/src/components/settings/notifications-section.tsx
index edeeb8df3..28bb7fb1a 100644
--- a/src/components/settings/notifications-section.tsx
+++ b/src/components/settings/notifications-section.tsx
@@ -1,8 +1,10 @@
"use client";
import { useAuth } from "@/hooks/use-auth";
+import { useTranslations } from "@/lib/i18n/context";
import { LowStockCard } from "@/components/settings/low-stock-card";
import { MoodReminderCard } from "@/components/settings/mood-reminder-card";
+import { NotificationChannelsPanel } from "@/components/settings/integrations/notification-channels-panel";
/**
* `` — Settings → Notifications ("Benachrichtigungen").
@@ -19,41 +21,77 @@ import { MoodReminderCard } from "@/components/settings/mood-reminder-card";
* (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.25.3 — the standalone "Channels" entry folds back in here. The page is
+ * now one Notifications surface with two sequential, labelled groups, ordered
+ * the way a user reasons about it: **Reminders** ("what you receive") first,
+ * then **Delivery channels** ("where it's delivered") = the existing
+ * ``. This is two distinct-but-related concepts
+ * sequenced under one roof, not one concept split top/bottom — the
+ * channels group carries `id="channels"` so `/settings/channels` can
+ * 301-redirect to `/settings/notifications#channels`, and the per-channel
+ * anchors (`#telegram`, `#ntfy`, …) inside the panel keep working.
*
- * v1.18.6 (W9) — the visible heading + subtitle now come from the shared
- * ``. 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.
+ * v1.18.6 (W9) — the page heading + subtitle come from the shared
+ * ``. The proactive-Coach nudge card lives under
+ * Settings → Coach (it is a Coach setting, not a generic notification).
*/
export function NotificationsSection() {
const { isAuthenticated, user } = useAuth();
+ const { t } = useTranslations();
// 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 medsEnabled = user?.modules?.medications !== false;
+ const anyReminder = moodEnabled || medsEnabled;
return (
-
- {/* Mood check-in reminder — only when the mood module is enabled. */}
- {moodEnabled ? (
-
-
-
+
+ {/* Group 1 — Reminders ("what you receive"). Rendered only when at
+ least one reminder-type card is visible, so a fully-disabled
+ account never shows an empty heading. */}
+ {anyReminder ? (
+
+
+
+ {/* Mood check-in reminder — only when the mood module is enabled. */}
+ {moodEnabled ? (
+
+
+
+ ) : null}
+
+ {/* Medication low-stock runway — only when the medications module
+ is enabled (toggleable, fail-open, since D3). */}
+ {medsEnabled ? (
+
+
+
+ ) : null}
+
) : null}
- {/* Medication low-stock runway — only when the medications module is
- enabled (toggleable, fail-open, since D3). */}
- {medsEnabled ? (
-
-
+ {/* Group 2 — Delivery channels ("where it's delivered"). The existing
+ channels panel, unchanged. `id="channels"` anchors the group for the
+ `/settings/channels` redirect. */}
+
+
))}
diff --git a/src/components/settings/security-sessions-card.tsx b/src/components/settings/security-sessions-card.tsx
index 24d969551..d02b41f81 100644
--- a/src/components/settings/security-sessions-card.tsx
+++ b/src/components/settings/security-sessions-card.tsx
@@ -15,7 +15,7 @@ import { ChevronDown, Loader2, MonitorSmartphone } from "lucide-react";
import { Button } from "@/components/ui/button";
import { SettingsCard } from "@/components/settings/settings-card";
-import { useTranslations } from "@/lib/i18n/context";
+import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiGet, apiDelete } from "@/lib/api/api-fetch";
import { cn } from "@/lib/utils";
@@ -36,6 +36,7 @@ export function SecuritySessionsCard({
isAuthenticated: boolean;
}) {
const { t } = useTranslations();
+ const fmt = useFormatters();
const queryClient = useQueryClient();
const [status, setStatus] = useState(null);
// Collapsed by default — the list opens only when the user asks for it, so
@@ -158,7 +159,7 @@ export function SecuritySessionsCard({
{s.lastActiveAt && (
{t("settings.security.lastActive", {
- time: new Date(s.lastActiveAt).toLocaleString(),
+ time: fmt.dateTime(s.lastActiveAt),
})}
)}
diff --git a/src/components/settings/settings-shell.tsx b/src/components/settings/settings-shell.tsx
index 37bc06cfc..cd20feaa5 100644
--- a/src/components/settings/settings-shell.tsx
+++ b/src/components/settings/settings-shell.tsx
@@ -30,12 +30,11 @@ import {
Info,
KeyRound,
Plug,
- LayoutDashboard,
Layers,
Link2,
Lock,
+ Palette,
Pill,
- Radio,
Settings2,
Share2,
ShieldCheck,
@@ -123,13 +122,9 @@ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [
titleKey: "settings.sections.integrations.title",
icon: Link2,
},
- // v1.18.1 (D4) — Kanäle (delivery channels) split out of the Integrations
- // sub-tabs into its own entry. A channel is a delivery provider.
- {
- slug: "channels",
- titleKey: "settings.sections.channels.title",
- icon: Radio,
- },
+ // v1.25.3 — the standalone Kanäle (delivery channels) entry folded back
+ // into Notifications as a labelled in-page group; `/settings/channels`
+ // 301-redirects to `/settings/notifications#channels`.
// v1.18.1 (D4) — Quellen (source weighting) split out into its own entry:
// which connection wins when two report the same metric.
{
@@ -145,15 +140,17 @@ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [
titleKey: "settings.sections.notifications.title",
icon: Bell,
},
- // v1.17.1 (F-2) — one "Layout & Personalization" nav entry replaces the
- // four scattered Dashboard / Insights / Medications / Mood "arrange"
- // entries. Those routes still resolve (deep links, page-header cogs, and
- // the hub's own links all work); they are simply reached through the
- // Layout hub now, so "how my app is laid out" reads as one home.
+ // v1.17.1 (F-2) — one nav entry replaces the four scattered Dashboard /
+ // Insights / Medications / Mood "arrange" entries. Those routes still
+ // resolve (deep links, page-header cogs, and the hub's own links all work);
+ // they are reached through this hub now, so "how my app looks" reads as one
+ // home. v1.25.3 — renamed to "Appearance"/"Darstellung" and widened into the
+ // index for every module's view/sort/order surface. The slug stays `layout`
+ // so every route + deep link is untouched.
{
slug: "layout",
titleKey: "settings.sections.layout.title",
- icon: LayoutDashboard,
+ icon: Palette,
},
// v1.18.0 (S5) — Medikamente: medication-specific settings (list view +
// order + injection sites). Was a hidden child of the Layout hub.
diff --git a/src/components/ui/date-time-field.tsx b/src/components/ui/date-time-field.tsx
index 09ab7fc22..d0b86c19a 100644
--- a/src/components/ui/date-time-field.tsx
+++ b/src/components/ui/date-time-field.tsx
@@ -10,6 +10,7 @@ import {
useTimeFormatPreference,
} from "@/lib/i18n/context";
import { formatDate, resolveDateLocale } from "@/lib/date-format";
+import { hourCycleOptions } from "@/lib/format-locale";
/**
* Dependency-free datetime input that DISPLAYS the value in the user's
@@ -221,20 +222,6 @@ function formatLocalDateTime(
return `${datePart} ${timePart}`;
}
-/** Mirror of `format-locale.ts` `hourCycleOptions`, kept local to the field. */
-function hourCycleOptions(
- timeFormat: ReturnType,
-): Intl.DateTimeFormatOptions {
- switch (timeFormat) {
- case "H12":
- return { hour12: true };
- case "H24":
- return { hourCycle: "h23" };
- default:
- return {};
- }
-}
-
/** Split a local `yyyy-MM-ddTHH:mm` string into its calendar parts. */
function parseLocalDateTime(value: string): {
date: string;
diff --git a/src/lib/__tests__/format-locale.test.ts b/src/lib/__tests__/format-locale.test.ts
index e8f727b0c..fef7c9a44 100644
--- a/src/lib/__tests__/format-locale.test.ts
+++ b/src/lib/__tests__/format-locale.test.ts
@@ -1,5 +1,6 @@
import { describe, it, expect } from "vitest";
import {
+ hourCycleOptions,
makeFormatters,
parseLocaleFromAcceptLanguage,
resolveIntlLocale,
@@ -137,3 +138,49 @@ describe("makeFormatters", () => {
expect(tokyo.date(lateUtc)).toMatch(/11/);
});
});
+
+// v1.25.3 — the time-format preference must reach every hour/minute renderer,
+// not only the `makeFormatters().time` path. Some surfaces build their own
+// `Intl.DateTimeFormat` because they render in the browser timezone (chart
+// axes) or combine weekday + time in one label (workout list/detail). They now
+// spread `hourCycleOptions(preference)` into the options. These tests pin both
+// the helper contract and the exact call-site pattern, so a future renderer
+// that drops the spread (re-introducing AM/PM under H24) fails here.
+describe("hourCycleOptions", () => {
+ it("AUTO contributes nothing (locale default applies)", () => {
+ expect(hourCycleOptions("AUTO")).toEqual({});
+ });
+
+ it("H12 forces a 12-hour clock", () => {
+ expect(hourCycleOptions("H12")).toEqual({ hour12: true });
+ });
+
+ it("H24 forces the 24-hour h23 cycle", () => {
+ expect(hourCycleOptions("H24")).toEqual({ hourCycle: "h23" });
+ });
+
+ // The shared call-site shape used by sleep-hypnogram, workout-list and
+ // workout-detail: an `en-US` formatter (AM/PM by default) with hour+minute.
+ const sample = new Date("2026-04-18T14:30:00Z"); // 16:30 Europe/Berlin (CEST)
+ const buildLabel = (preference: Parameters[0]) =>
+ new Intl.DateTimeFormat("en-US", {
+ timeZone: "Europe/Berlin",
+ hour: "2-digit",
+ minute: "2-digit",
+ ...hourCycleOptions(preference),
+ }).format(sample);
+
+ it("an en-US hour/minute formatter drops AM/PM under H24", () => {
+ const label = buildLabel("H24");
+ expect(label).not.toMatch(/[AP]M/i);
+ expect(label).toBe("16:30");
+ });
+
+ it("an en-US hour/minute formatter keeps AM/PM under H12", () => {
+ expect(buildLabel("H12")).toMatch(/PM/);
+ });
+
+ it("an en-US hour/minute formatter follows the locale (AM/PM) under AUTO", () => {
+ expect(buildLabel("AUTO")).toMatch(/PM/);
+ });
+});
diff --git a/src/lib/format-locale.ts b/src/lib/format-locale.ts
index cbe3f1ffe..5ddf52d65 100644
--- a/src/lib/format-locale.ts
+++ b/src/lib/format-locale.ts
@@ -61,8 +61,17 @@ export type DateFormatPreference = "AUTO" | "DMY" | "MDY" | "YMD";
/**
* Intl options for the requested hour cycle. AUTO contributes nothing so
* `Intl.DateTimeFormat` falls back to the locale default.
+ *
+ * Exported so call sites that build their own `Intl.DateTimeFormat` (chart
+ * axes, list rows that deliberately render in the browser timezone rather
+ * than the profile zone) can still honour the user's H12 / H24 preference by
+ * spreading these options. Pair it with `useTimeFormatPreference()` on the
+ * client. Any new hour/minute renderer must route through this rather than
+ * re-deciding the cycle locally — that is the regression this single source
+ * prevents (an `en`-locale or AUTO user got AM/PM even with H24 selected
+ * wherever a formatter omitted the cycle).
*/
-function hourCycleOptions(
+export function hourCycleOptions(
timeFormat: TimeFormatPreference,
): Intl.DateTimeFormatOptions {
switch (timeFormat) {
diff --git a/src/lib/i18n/__tests__/relative-time-updated.test.ts b/src/lib/i18n/__tests__/relative-time-updated.test.ts
index 9603730ec..5198411d6 100644
--- a/src/lib/i18n/__tests__/relative-time-updated.test.ts
+++ b/src/lib/i18n/__tests__/relative-time-updated.test.ts
@@ -5,6 +5,7 @@
import { describe, expect, it } from "vitest";
import { formatUpdatedLabel } from "@/lib/i18n/relative-time";
+import { makeFormatters } from "@/lib/format-locale";
const t = (key: string, params?: Record): string => {
if (key === "insights.updatedTodayAt")
@@ -54,4 +55,38 @@ describe("formatUpdatedLabel", () => {
"",
);
});
+
+ // v1.25.3 — the briefing freshness label (and the per-metric / narrative
+ // cards) feed `formatUpdatedLabel` the preference-aware `fmt.time`. With H24
+ // selected the "today" caption must never carry AM/PM, even for an en user
+ // whose locale default is 12-hour.
+ it("renders today's time without AM/PM when fed an H24 formatter", () => {
+ const fmt = makeFormatters("en", "UTC", "H24");
+ const now = new Date();
+ const out = formatUpdatedLabel(
+ now.toISOString(),
+ t,
+ fmt.dateShort,
+ fmt.time,
+ "UTC",
+ );
+ expect(out.startsWith("Updated today, ")).toBe(true);
+ expect(out).not.toMatch(/[AP]M/i);
+ });
+
+ it("renders today's time with AM/PM when fed an H12 formatter", () => {
+ const fmt = makeFormatters("en", "UTC", "H12");
+ // A fixed afternoon instant so the assertion is deterministic regardless
+ // of when the suite runs; bucketed as "today" via the UTC day boundary.
+ const today = new Date();
+ today.setUTCHours(14, 30, 0, 0);
+ const out = formatUpdatedLabel(
+ today.toISOString(),
+ t,
+ fmt.dateShort,
+ fmt.time,
+ "UTC",
+ );
+ expect(out).toMatch(/PM/);
+ });
});
diff --git a/src/lib/insights/__tests__/briefing-failure-marker.test.ts b/src/lib/insights/__tests__/briefing-failure-marker.test.ts
index e50ff58d3..3ea4e5ad0 100644
--- a/src/lib/insights/__tests__/briefing-failure-marker.test.ts
+++ b/src/lib/insights/__tests__/briefing-failure-marker.test.ts
@@ -21,6 +21,7 @@ vi.mock("@/lib/db", () => ({
import {
readBriefingFailure,
recordBriefingFailure,
+ classifyBriefingFailure,
BRIEFING_FAILURE_ACTION,
} from "../briefing-failure-marker";
@@ -45,6 +46,34 @@ describe("readBriefingFailure", () => {
);
});
+ it("derives a failure class from the recorded reason + httpStatus (v1.25.3)", async () => {
+ findFirst.mockResolvedValue({
+ createdAt: new Date("2026-06-28T10:00:00Z"),
+ details: JSON.stringify({ reason: "provider-error", httpStatus: 401 }),
+ });
+
+ const result = await readBriefingFailure({
+ userId: "u1",
+ since: new Date("2026-06-28T09:00:00Z"),
+ });
+
+ expect(result?.failureClass).toBe("auth");
+ });
+
+ it("classes a status-less failure as a timeout (the slow-backend case)", async () => {
+ findFirst.mockResolvedValue({
+ createdAt: new Date("2026-06-28T10:00:00Z"),
+ details: JSON.stringify({ reason: "provider-error" }),
+ });
+
+ const result = await readBriefingFailure({
+ userId: "u1",
+ since: new Date("2026-06-28T09:00:00Z"),
+ });
+
+ expect(result?.failureClass).toBe("timeout");
+ });
+
it("treats a marker older than the last success as superseded", async () => {
findFirst.mockResolvedValue({
createdAt: new Date("2026-06-28T08:00:00Z"),
@@ -89,4 +118,45 @@ describe("recordBriefingFailure", () => {
recordBriefingFailure({ userId: "u1", reason: "timeout" }),
).resolves.toBeUndefined();
});
+
+ it("persists the upstream httpStatus when one is supplied (v1.25.3)", async () => {
+ create.mockResolvedValue({});
+ await recordBriefingFailure({
+ userId: "u1",
+ reason: "provider-error",
+ httpStatus: 429,
+ });
+ const details = create.mock.calls[0][0].data.details as string;
+ expect(JSON.parse(details).httpStatus).toBe(429);
+ });
+});
+
+describe("classifyBriefingFailure", () => {
+ it("maps invalid-json to format regardless of status", () => {
+ expect(classifyBriefingFailure({ reason: "invalid-json" })).toBe("format");
+ });
+
+ it("maps 429 to rate-limit, other 4xx to auth, 5xx to provider", () => {
+ expect(
+ classifyBriefingFailure({ reason: "provider-error", httpStatus: 429 }),
+ ).toBe("rate-limit");
+ expect(
+ classifyBriefingFailure({ reason: "provider-error", httpStatus: 403 }),
+ ).toBe("auth");
+ expect(
+ classifyBriefingFailure({ reason: "provider-error", httpStatus: 503 }),
+ ).toBe("provider");
+ });
+
+ it("maps a missing / sentinel-zero status to timeout", () => {
+ expect(classifyBriefingFailure({ reason: "provider-error" })).toBe(
+ "timeout",
+ );
+ expect(
+ classifyBriefingFailure({
+ reason: "all-providers-failed",
+ httpStatus: 0,
+ }),
+ ).toBe("timeout");
+ });
});
diff --git a/src/lib/insights/briefing-failure-marker.ts b/src/lib/insights/briefing-failure-marker.ts
index ad11c863d..71abc304a 100644
--- a/src/lib/insights/briefing-failure-marker.ts
+++ b/src/lib/insights/briefing-failure-marker.ts
@@ -19,6 +19,46 @@ import { prisma } from "@/lib/db";
*/
export const BRIEFING_FAILURE_ACTION = "insights.briefing-failure";
+/**
+ * Coarse class of a briefing-generation failure, used by the read path to
+ * point the empty-state hint at the right lever:
+ * - `timeout` — the generation ran out of time (no upstream HTTP status,
+ * or a transport/abort error). Lever: raise the AI response
+ * timeout for a slow local model.
+ * - `auth` — the provider rejected the request (4xx that is not 429).
+ * Lever: re-check the provider / API key in Settings.
+ * - `rate-limit` — the provider returned 429.
+ * - `provider` — a 5xx / generic upstream failure.
+ * - `format` — the model returned unparseable output (invalid JSON).
+ * - `unknown` — anything that does not classify.
+ */
+export type BriefingFailureClass =
+ "timeout" | "auth" | "rate-limit" | "provider" | "format" | "unknown";
+
+/**
+ * Classify a failure from its recorded reason and (optional) upstream HTTP
+ * status. A reason of `invalid-json` is always a format miss; a 401/403 (or any
+ * other non-429 4xx) is an auth / configuration problem; 429 is a rate limit; a
+ * 5xx is a generic provider failure. With no status at all — the timeout /
+ * transport / abort path, which is the dominant briefing failure on a slow
+ * self-hosted backend — the most useful lever is the response-timeout, so it
+ * classifies as `timeout`.
+ */
+export function classifyBriefingFailure(args: {
+ reason: string;
+ httpStatus?: number | null;
+}): BriefingFailureClass {
+ if (args.reason === "invalid-json") return "format";
+ const status = args.httpStatus;
+ if (typeof status === "number" && status > 0) {
+ if (status === 429) return "rate-limit";
+ if (status >= 400 && status < 500) return "auth";
+ if (status >= 500) return "provider";
+ }
+ // No / sentinel-zero upstream status: a transport-level timeout or abort.
+ return "timeout";
+}
+
/** UTC YYYY-MM-DD calendar-day key for the marker payload. */
function dateKey(at: Date = new Date()): string {
return at.toISOString().slice(0, 10);
@@ -32,6 +72,12 @@ export async function recordBriefingFailure(args: {
userId: string;
reason: string;
locale?: string;
+ /**
+ * v1.25.3 — upstream HTTP status when the failure carried one, so the read
+ * path can tell an auth / rate-limit / provider failure apart from a plain
+ * timeout and point the hint at the right lever.
+ */
+ httpStatus?: number | null;
}): Promise {
try {
await prisma.auditLog.create({
@@ -41,6 +87,7 @@ export async function recordBriefingFailure(args: {
details: JSON.stringify({
dateKey: dateKey(),
reason: args.reason,
+ httpStatus: args.httpStatus ?? null,
locale: args.locale ?? null,
triedAt: new Date().toISOString(),
}),
@@ -54,6 +101,8 @@ export async function recordBriefingFailure(args: {
export interface BriefingFailureState {
triedAt: string;
reason: string;
+ /** v1.25.3 — coarse class for the empty-state hint (see classifyBriefingFailure). */
+ failureClass: BriefingFailureClass;
}
/**
@@ -77,13 +126,22 @@ export async function readBriefingFailure(args: {
if (args.since && latest.createdAt <= args.since) return null;
let reason = "unknown";
+ let httpStatus: number | null = null;
if (latest.details) {
try {
- const parsed = JSON.parse(latest.details) as { reason?: unknown };
+ const parsed = JSON.parse(latest.details) as {
+ reason?: unknown;
+ httpStatus?: unknown;
+ };
if (typeof parsed.reason === "string") reason = parsed.reason;
+ if (typeof parsed.httpStatus === "number") httpStatus = parsed.httpStatus;
} catch {
// Malformed marker payload — still report the failure, generic reason.
}
}
- return { triedAt: latest.createdAt.toISOString(), reason };
+ return {
+ triedAt: latest.createdAt.toISOString(),
+ reason,
+ failureClass: classifyBriefingFailure({ reason, httpStatus }),
+ };
}
diff --git a/src/lib/insights/comprehensive-generate.ts b/src/lib/insights/comprehensive-generate.ts
index 5d754e993..be4ab8ca4 100644
--- a/src/lib/insights/comprehensive-generate.ts
+++ b/src/lib/insights/comprehensive-generate.ts
@@ -1049,7 +1049,12 @@ export async function generateComprehensiveInsight(
// discreet "couldn't refresh" hint alongside the preserved last-good
// briefing. No cache row is written here, so `insightsCachedText` (the
// last good payload) stays intact and the surface never blanks.
- void recordBriefingFailure({ userId, reason, locale });
+ void recordBriefingFailure({
+ userId,
+ reason,
+ locale,
+ httpStatus: typeof err.httpStatus === "number" ? err.httpStatus : null,
+ });
return { status: "failed", reason };
}
diff --git a/src/lib/jobs/__tests__/insight-pregenerate.test.ts b/src/lib/jobs/__tests__/insight-pregenerate.test.ts
index 1dc34196b..5dea6830d 100644
--- a/src/lib/jobs/__tests__/insight-pregenerate.test.ts
+++ b/src/lib/jobs/__tests__/insight-pregenerate.test.ts
@@ -82,13 +82,21 @@ import {
runInsightPregenerate,
forceWarmUser,
findPregenerateCandidates,
+ comprehensiveWarmBudgetMs,
PREGENERATE_STALE_MS,
FORCE_WARM_DAILY_LIMIT,
INSIGHT_PREGENERATE_QUEUE,
INSIGHT_PREGENERATE_CRON,
} from "../insight-pregenerate";
+import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
-function makePrisma(users: Array<{ id: string; locale: string | null }>) {
+function makePrisma(
+ users: Array<{
+ id: string;
+ locale: string | null;
+ aiResponseTimeoutSeconds?: number | null;
+ }>,
+) {
const findMany = vi.fn().mockResolvedValue(users);
// forceWarmUser reads `insightsCachedAt` / `insightsWarmFailedAt` at job
// start and maintains the failure marker; default: never warmed, never
@@ -296,8 +304,9 @@ describe("runInsightPregenerate — outcome tally", () => {
observedSignal = opts.signal;
setTimeout(
() => resolve({ status: "generated", providerType: "x" }),
- // v1.21.5 — above COMPREHENSIVE_WARM_TIMEOUT_MS (130 s) so the
- // bounded budget fires before this "slow" generation settles.
+ // v1.25.3 — above the default warm budget (~150 s = the 120 s
+ // comprehensive provider budget + headroom) so the bounded budget
+ // fires before this "slow" generation settles.
200_000,
);
}),
@@ -314,7 +323,7 @@ describe("runInsightPregenerate — outcome tally", () => {
statusGenerators,
warmGenericMetrics,
});
- await vi.advanceTimersByTimeAsync(140_000);
+ await vi.advanceTimersByTimeAsync(160_000);
const result = await promise;
// The timeout fired, aborted the still-running generation (so its
@@ -789,8 +798,9 @@ describe("forceWarmUser — on-demand single-user warm (v1.8.7.1)", () => {
// advance past it so the warm continues.
setTimeout(
() => resolve({ status: "generated", providerType: "x" }),
- // v1.21.5 — above COMPREHENSIVE_WARM_TIMEOUT_MS (130 s) so the
- // bounded budget fires before this "slow" generation settles.
+ // v1.25.3 — above the default warm budget (~150 s = the 120 s
+ // comprehensive provider budget + headroom) so the bounded budget
+ // fires before this "slow" generation settles.
200_000,
);
}),
@@ -808,7 +818,7 @@ describe("forceWarmUser — on-demand single-user warm (v1.8.7.1)", () => {
warmGenericMetrics,
});
// Advance past the comprehensive budget so withTimeout fires.
- await vi.advanceTimersByTimeAsync(140_000);
+ await vi.advanceTimersByTimeAsync(160_000);
const result = await promise;
for (const g of statusGenerators) {
@@ -906,8 +916,9 @@ describe("forceWarmUser — on-demand single-user warm (v1.8.7.1)", () => {
observedSignal = opts.signal;
setTimeout(
() => resolve({ status: "generated", providerType: "x" }),
- // v1.21.5 — above COMPREHENSIVE_WARM_TIMEOUT_MS (130 s) so the
- // bounded budget fires before this "slow" generation settles.
+ // v1.25.3 — above the default warm budget (~150 s = the 120 s
+ // comprehensive provider budget + headroom) so the bounded budget
+ // fires before this "slow" generation settles.
200_000,
);
}),
@@ -924,7 +935,7 @@ describe("forceWarmUser — on-demand single-user warm (v1.8.7.1)", () => {
statusGenerators,
warmGenericMetrics,
});
- await vi.advanceTimersByTimeAsync(140_000);
+ await vi.advanceTimersByTimeAsync(160_000);
const result = await promise;
// The timeout fired and aborted the still-running generation.
@@ -975,3 +986,77 @@ describe("queue registration", () => {
expect(INSIGHT_PREGENERATE_CRON).toMatch(/^\d+ \d+ \* \* \*$/);
});
});
+
+describe("comprehensiveWarmBudgetMs — warm budget scales with the response timeout (v1.25.3)", () => {
+ const HEADROOM_MS = 30_000;
+ const DEFAULT_MS = AI_BUDGETS.comprehensive.timeoutMs;
+
+ it("falls back to the comprehensive surface budget + headroom for an unset value", () => {
+ expect(comprehensiveWarmBudgetMs(null)).toBe(DEFAULT_MS + HEADROOM_MS);
+ expect(comprehensiveWarmBudgetMs(undefined)).toBe(DEFAULT_MS + HEADROOM_MS);
+ // A non-positive stored value is treated as unset.
+ expect(comprehensiveWarmBudgetMs(0)).toBe(DEFAULT_MS + HEADROOM_MS);
+ });
+
+ it("scales the budget with a raised setting and stays above the old fixed 130 s cap", () => {
+ // The regression: any account whose response timeout was >= ~130 s had its
+ // comprehensive warm clipped by the old flat 130 s cap, never landing.
+ const budget = comprehensiveWarmBudgetMs(300);
+ expect(budget).toBe(300_000 + HEADROOM_MS);
+ expect(budget).toBeGreaterThan(130_000);
+ });
+
+ it("clamps to an absolute ceiling that still sits above the 600 s write-time maximum", () => {
+ // The 600 s max a user can configure is honoured (not clipped below it);
+ // the ceiling adds the headroom so a legitimate 600 s setting fits.
+ expect(comprehensiveWarmBudgetMs(600)).toBe(600_000 + HEADROOM_MS);
+ // A value beyond the write-time bound can never reach here, but the clamp
+ // is the structural backstop against a wedged call regardless.
+ expect(comprehensiveWarmBudgetMs(100_000)).toBe(600_000 + HEADROOM_MS);
+ });
+});
+
+describe("runInsightPregenerate — warm budget honours the response timeout (v1.25.3)", () => {
+ it("does NOT clip a slow comprehensive for an account whose response timeout is raised past the old cap", async () => {
+ const { prisma } = makePrisma([
+ { id: "u1", locale: "de", aiResponseTimeoutSeconds: 300 },
+ ]);
+ let observedSignal: AbortSignal | undefined;
+ const generate = vi.fn().mockImplementation(
+ (_userId: string, opts: { signal?: AbortSignal }) =>
+ new Promise((resolve) => {
+ observedSignal = opts.signal;
+ // 200 s: above the OLD fixed 130 s cap (which would have aborted it)
+ // but well within the 330 s budget a 300 s setting now yields.
+ setTimeout(
+ () => resolve({ status: "generated", providerType: "x" }),
+ 200_000,
+ );
+ }),
+ );
+ const statusGenerators = Array.from({ length: 7 }, () =>
+ vi.fn().mockResolvedValue({ hasProvider: true, cached: false }),
+ );
+ const warmGenericMetrics = vi.fn().mockResolvedValue(0);
+
+ vi.useFakeTimers();
+ try {
+ const promise = runInsightPregenerate(prisma as never, {
+ generate,
+ statusGenerators,
+ warmGenericMetrics,
+ });
+ // Advance past the slow generation but below the 330 s warm budget.
+ await vi.advanceTimersByTimeAsync(210_000);
+ const result = await promise;
+
+ // The generation landed instead of being clipped: counted as generated,
+ // not failed, and the abort never fired.
+ expect(result.generated).toBe(1);
+ expect(result.failed).toBe(0);
+ expect(observedSignal?.aborted).toBe(false);
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+});
diff --git a/src/lib/jobs/insight-pregenerate.ts b/src/lib/jobs/insight-pregenerate.ts
index 0c8af42ea..080707a2c 100644
--- a/src/lib/jobs/insight-pregenerate.ts
+++ b/src/lib/jobs/insight-pregenerate.ts
@@ -67,6 +67,8 @@ import {
type MetricStatusMetricId,
} from "@/lib/insights/metric-status-registry";
import { withTimeout } from "@/lib/insights/with-timeout";
+import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
+import { resolveEffectiveTimeoutMs } from "@/lib/ai/effective-timeout";
import {
INSIGHT_PREGENERATE_QUEUE,
enqueueForceWarm,
@@ -164,15 +166,72 @@ const WARM_PASS_CONCURRENCY = 3;
* the provider's own abort governs a genuine completion while this stays the
* backstop against a wedged call. The per-user budget gate + the WARM_PASS
* concurrency cap still bound total nightly cost.
+ *
+ * v1.25.3 — the cap is no longer a fixed constant. The per-user
+ * `User.aiResponseTimeoutSeconds` setting (bounded 10–600 s at write-time) is
+ * the operator's lever for slow self-hosted / local backends, and the AI
+ * settings UI tells users to RAISE it for a model that loads slowly. The old
+ * fixed 130 s cap sat below the upper end of that range, so any account whose
+ * response timeout was ≥ ~130 s had every comprehensive warm clipped by the
+ * warm bound — not the provider — before the generation could land, leaving the
+ * briefing on its hard empty state. The warm budget now derives from the
+ * resolved effective per-request timeout plus a fixed headroom for feature
+ * extraction / prompt assembly / IO, so raising the response timeout actually
+ * extends the warm/cron budget. A sane absolute ceiling (the 600 s write-time
+ * maximum plus the same headroom) keeps a single wedged call from pinning the
+ * worker forever, while staying ABOVE the largest value a user can configure.
*/
-const COMPREHENSIVE_WARM_TIMEOUT_MS = 130_000;
+
+/**
+ * Fixed slack added on top of the resolved upstream provider timeout when
+ * sizing the warm/cron outer abort: bounded-feature extraction, prompt
+ * assembly, and the cache write all run inside the warm wrapper but outside the
+ * single provider round-trip the response-timeout governs.
+ */
+const COMPREHENSIVE_WARM_HEADROOM_MS = 30_000;
+
+/**
+ * Largest value `User.aiResponseTimeoutSeconds` accepts (see the 10–600 s
+ * write-time bound in `POST /api/user/ai-provider`), in ms. The warm ceiling is
+ * derived from it so the ceiling is always ABOVE the maximum a user can set.
+ */
+const AI_RESPONSE_TIMEOUT_MAX_MS = 600_000;
+
+/**
+ * Absolute backstop on the warm outer abort. A misconfigured 600 s response
+ * timeout still cannot pin the worker beyond this; the ceiling sits above the
+ * write-time maximum (by exactly the headroom) so a legitimate 600 s setting is
+ * honoured rather than silently clipped.
+ */
+const COMPREHENSIVE_WARM_CEILING_MS =
+ AI_RESPONSE_TIMEOUT_MAX_MS + COMPREHENSIVE_WARM_HEADROOM_MS;
+
+/**
+ * Outer-abort budget (ms) for one comprehensive warm, derived from the user's
+ * resolved effective upstream timeout. `effectiveTimeoutMs + headroom`, clamped
+ * to the absolute ceiling. A null/unset setting falls back to the comprehensive
+ * surface budget (`AI_BUDGETS.comprehensive.timeoutMs`), so a default account
+ * gets `120 s + headroom` rather than the old flat 130 s.
+ */
+export function comprehensiveWarmBudgetMs(
+ aiResponseTimeoutSeconds: number | null | undefined,
+): number {
+ const effectiveTimeoutMs = resolveEffectiveTimeoutMs(
+ aiResponseTimeoutSeconds,
+ AI_BUDGETS.comprehensive.timeoutMs,
+ );
+ return Math.min(
+ effectiveTimeoutMs + COMPREHENSIVE_WARM_HEADROOM_MS,
+ COMPREHENSIVE_WARM_CEILING_MS,
+ );
+}
/** Per-user result of one forced full warm. */
export interface ForceWarmResult {
/**
* Outcome of the comprehensive-insight generation. `"timeout"` is
* distinct from `"failed"`: the comprehensive step exceeded its own
- * bounded budget (see `COMPREHENSIVE_WARM_TIMEOUT_MS`) and was
+ * bounded budget (see `comprehensiveWarmBudgetMs`) and was
* abandoned so the per-status + generic-metric passes could still run.
*
* v1.16.8 — three pre-flight outcomes that never reach the generator:
@@ -384,6 +443,12 @@ async function warmGenericMetricCaches(
interface PregenerateCandidate {
id: string;
locale: string | null;
+ /**
+ * v1.25.3 — per-user response-timeout (seconds), feeds the warm outer
+ * abort so a raised setting is not clipped by a fixed cap. Null = the
+ * comprehensive surface default.
+ */
+ aiResponseTimeoutSeconds: number | null;
}
/**
@@ -410,7 +475,9 @@ export async function findPregenerateCandidates(
// never-generated users sort ahead of merely-stale ones.
orderBy: { insightsCachedAt: "asc" },
take: cap,
- select: { id: true, locale: true },
+ // v1.25.3 — carry the per-user response-timeout so the warm outer abort
+ // scales with the operator's setting instead of a fixed cap.
+ select: { id: true, locale: true, aiResponseTimeoutSeconds: true },
});
}
@@ -531,6 +598,12 @@ export async function runInsightPregenerate(
// late resolve would still write a cache row + timestamp after the
// loop moved on (the same race the forced single-user warm closes).
const controller = new AbortController();
+ // v1.25.3 — size the outer abort from the candidate's resolved
+ // response-timeout so a raised setting extends the warm budget
+ // instead of being clipped by a fixed cap.
+ const warmBudgetMs = comprehensiveWarmBudgetMs(
+ candidate.aiResponseTimeoutSeconds,
+ );
const bounded = await withTimeout(
() =>
generate(candidate.id, {
@@ -538,7 +611,7 @@ export async function runInsightPregenerate(
force: true,
signal: controller.signal,
}),
- COMPREHENSIVE_WARM_TIMEOUT_MS,
+ warmBudgetMs,
null,
() => controller.abort(),
);
@@ -657,7 +730,7 @@ export async function runInsightPregenerate(
* actually reads warms lazily through the read-path enqueue.
*
* The three sections are decoupled: the comprehensive step runs under its
- * own bounded budget (`COMPREHENSIVE_WARM_TIMEOUT_MS`) and its
+ * own bounded budget (`comprehensiveWarmBudgetMs`) and its
* failure / timeout / skip is non-fatal — the per-status (7) and
* generic-metric (~30) warm passes ALWAYS run afterwards. The earlier
* design short-circuited both passes on a non-`generated`/`cached`
@@ -760,11 +833,17 @@ export async function forceWarmUser(
let freshness: {
insightsCachedAt: Date | null;
insightsWarmFailedAt: Date | null;
+ // v1.25.3 — feeds the warm outer abort (see comprehensiveWarmBudgetMs).
+ aiResponseTimeoutSeconds: number | null;
} | null = null;
try {
freshness = await prisma.user.findUnique({
where: { id: userId },
- select: { insightsCachedAt: true, insightsWarmFailedAt: true },
+ select: {
+ insightsCachedAt: true,
+ insightsWarmFailedAt: true,
+ aiResponseTimeoutSeconds: true,
+ },
});
} catch {
// Best-effort pre-flight — on a read failure fall through to the
@@ -807,6 +886,12 @@ export async function forceWarmUser(
});
} else {
const controller = new AbortController();
+ // v1.25.3 — size the outer abort from the user's resolved
+ // response-timeout (read with the freshness gate above) so a raised
+ // setting extends the warm budget instead of being clipped.
+ const warmBudgetMs = comprehensiveWarmBudgetMs(
+ freshness?.aiResponseTimeoutSeconds,
+ );
const comprehensive = await withTimeout(
() =>
generate(userId, {
@@ -814,7 +899,7 @@ export async function forceWarmUser(
force: true,
signal: controller.signal,
}),
- COMPREHENSIVE_WARM_TIMEOUT_MS,
+ warmBudgetMs,
null,
() => controller.abort(),
);
diff --git a/src/lib/modules/__tests__/gate.test.ts b/src/lib/modules/__tests__/gate.test.ts
index 730bf8b87..8785629eb 100644
--- a/src/lib/modules/__tests__/gate.test.ts
+++ b/src/lib/modules/__tests__/gate.test.ts
@@ -20,6 +20,7 @@ import {
} from "../operator-availability";
import {
isCoreDomain,
+ isCodeDisabledModule,
isModuleKey,
isOptInModule,
moduleDelegatesTo,
@@ -373,14 +374,22 @@ describe("registry — opt-in marker", () => {
});
});
-describe("resolveModuleEnabled — inboundDocuments module (opt-in, default-OFF)", () => {
+describe("resolveModuleEnabled — inboundDocuments module (disabled in code)", () => {
+ // v1.25.3 — `inboundDocuments` is switched off in code pending a rebuild
+ // (`CODE_DISABLED_MODULE_KEYS`). It is hard-off ahead of both the operator
+ // and the per-user layer: no stored preference or availability blob can
+ // re-surface it. The registry entry + `optIn` marker stay intact.
+ it("is flagged as code-disabled in the registry", () => {
+ expect(isCodeDisabledModule("inboundDocuments")).toBe(true);
+ });
+
it("is OFF when no preference is recorded", () => {
expect(
resolveModuleEnabled("inboundDocuments", inputs(), false, ALL_AVAILABLE),
).toBe(false);
});
- it("turns ON only on an explicit true (opt-in)", () => {
+ it("stays OFF even when the user opted in (the in-code switch wins)", () => {
expect(
resolveModuleEnabled(
"inboundDocuments",
@@ -388,16 +397,16 @@ describe("resolveModuleEnabled — inboundDocuments module (opt-in, default-OFF)
false,
ALL_AVAILABLE,
),
- ).toBe(true);
+ ).toBe(false);
});
- it("operator-off short-circuits even when the user opted in", () => {
+ it("stays OFF when the user opted in and the operator left it available", () => {
expect(
resolveModuleEnabled(
"inboundDocuments",
inputs({ modulePreferences: { inboundDocuments: true } }),
false,
- operator({ inboundDocuments: false }),
+ operator({ inboundDocuments: true }),
),
).toBe(false);
});
diff --git a/src/lib/modules/gate.ts b/src/lib/modules/gate.ts
index ff32f383a..61bb9bf04 100644
--- a/src/lib/modules/gate.ts
+++ b/src/lib/modules/gate.ts
@@ -55,6 +55,7 @@ import {
} from "./operator-availability";
import {
MODULE_KEYS,
+ isCodeDisabledModule,
isOptInModule,
moduleDelegatesTo,
type ModuleKey,
@@ -126,6 +127,14 @@ export function resolveModuleEnabled(
assistantCoach: boolean,
operatorAvailability: OperatorModuleAvailability,
): boolean {
+ // LAYER 0 — switched off in code pending a rebuild. Hard-off for every
+ // account, ahead of both the operator and the per-user layer, so no
+ // stored preference or availability blob can re-surface it. See
+ // `CODE_DISABLED_MODULE_KEYS` in ./registry.
+ if (isCodeDisabledModule(key)) {
+ return false;
+ }
+
// LAYER 1 — operator server-wide availability. An operator-disabled
// module is off for everyone regardless of personal preference.
if (operatorAvailability[key] === false) {
diff --git a/src/lib/modules/registry.ts b/src/lib/modules/registry.ts
index 2af5a9636..97d422b38 100644
--- a/src/lib/modules/registry.ts
+++ b/src/lib/modules/registry.ts
@@ -327,6 +327,37 @@ export function isModuleKey(key: string): key is ModuleKey {
return MODULE_KEY_SET.has(key);
}
+/**
+ * Modules switched OFF in code, pending a holistic rebuild.
+ *
+ * A key listed here is hard-off for every account regardless of the
+ * per-user opt-in or the operator-availability layer: the gate short-
+ * circuits it to disabled (`resolveModuleEnabled`), the Modules hub and the
+ * operator-availability panel drop its row, and the per-user PATCH surface
+ * refuses to write it. The whole implementation (routes, components, schema,
+ * migration) stays intact — re-enabling the module is a one-line removal
+ * from this list once the rebuild lands.
+ *
+ * v1.25.3 — `inboundDocuments` is parked here: the inbound-documents surface
+ * is withdrawn from this release and returns later with more depth. Its
+ * registry entry, `optIn` marker, routes, view, and migration 0222 are all
+ * preserved; only the user-reachable switches are removed.
+ */
+export const CODE_DISABLED_MODULE_KEYS = ["inboundDocuments"] as const;
+
+const CODE_DISABLED_MODULE_SET: ReadonlySet = new Set(
+ CODE_DISABLED_MODULE_KEYS,
+);
+
+/**
+ * True for a module intentionally switched off in code pending a rebuild
+ * (see `CODE_DISABLED_MODULE_KEYS`). Such a module is hard-off everywhere:
+ * the gate never reports it enabled and the user-facing toggles drop it.
+ */
+export function isCodeDisabledModule(key: ModuleKey): boolean {
+ return CODE_DISABLED_MODULE_SET.has(key);
+}
+
/** The two delegated keys, resolved by their existing source of truth. */
export function moduleDelegatesTo(
key: ModuleKey,
diff --git a/src/lib/openapi/routes/profile.ts b/src/lib/openapi/routes/profile.ts
index d987f1fc7..e6f43ee11 100644
--- a/src/lib/openapi/routes/profile.ts
+++ b/src/lib/openapi/routes/profile.ts
@@ -339,7 +339,7 @@ const aiProviderResponse = z
.int()
.nullable()
.describe(
- "v1.22 (#89) — per-user response timeout for AI generation, in seconds (10–600). Null = the generous built-in default (~180 s). Mainly for slow local/self-hosted backends.",
+ "v1.22 (#89) — per-user response timeout for AI generation, in seconds (10–600). Null = the built-in comprehensive-briefing default (~120 s). Mainly for slow local/self-hosted backends.",
),
ocrEnabled: z
.boolean()
diff --git a/src/lib/validations/modules.ts b/src/lib/validations/modules.ts
index 05c041870..7b9189e29 100644
--- a/src/lib/validations/modules.ts
+++ b/src/lib/validations/modules.ts
@@ -25,14 +25,21 @@
*/
import { z } from "zod/v4";
-import { MODULE_KEYS, moduleDelegatesTo } from "@/lib/modules/registry";
+import {
+ MODULE_KEYS,
+ isCodeDisabledModule,
+ moduleDelegatesTo,
+} from "@/lib/modules/registry";
/**
* The directly-owned (non-delegated) toggleable keys — the only keys this
- * PATCH surface may write into `modulePreferencesJson`.
+ * PATCH surface may write into `modulePreferencesJson`. A module switched
+ * off in code (pending a rebuild) is also excluded, so a client trying to
+ * opt into it gets a clean 422 rather than persisting an inert `true` the
+ * gate would ignore anyway.
*/
const WRITABLE_MODULE_KEYS = MODULE_KEYS.filter(
- (k) => moduleDelegatesTo(k) === undefined,
+ (k) => moduleDelegatesTo(k) === undefined && !isCodeDisabledModule(k),
);
/** Build `{ ?: boolean }` from the directly-owned key list. */