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}

- {formatDateRange(workout.startedAt, workout.endedAt, locale)} + {formatDateRange( + workout.startedAt, + workout.endedAt, + locale, + timeFormat, + )}

diff --git a/src/components/insights/workout-list.tsx b/src/components/insights/workout-list.tsx index b43bbfb42..3f17d9fb5 100644 --- a/src/components/insights/workout-list.tsx +++ b/src/components/insights/workout-list.tsx @@ -13,7 +13,11 @@ import { } from "lucide-react"; 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 { WorkoutListEntry } from "@/hooks/use-workouts"; /** @@ -76,7 +80,11 @@ function formatEnergy(kcal: number, locale: string): string { }).format(Math.round(kcal)); } -function formatDate(iso: string, locale: string): string { +function formatDate( + iso: string, + locale: string, + timeFormat: TimeFormatPreference, +): string { const d = new Date(iso); return new Intl.DateTimeFormat(locale, { weekday: "short", @@ -84,6 +92,7 @@ function formatDate(iso: string, locale: string): string { month: "short", hour: "2-digit", minute: "2-digit", + ...hourCycleOptions(timeFormat), }).format(d); } @@ -94,6 +103,7 @@ export interface WorkoutListProps { export function WorkoutList({ workouts, className }: WorkoutListProps) { const { t, locale } = useTranslations(); + const timeFormat = useTimeFormatPreference(); return (
    {sportName} - {formatDate(workout.startedAt, locale)} + {formatDate(workout.startedAt, locale, timeFormat)}
diff --git a/src/components/labs/__tests__/lab-biomarker-detail.test.tsx b/src/components/labs/__tests__/lab-biomarker-detail.test.tsx index 7da3247d5..bec4b97b6 100644 --- a/src/components/labs/__tests__/lab-biomarker-detail.test.tsx +++ b/src/components/labs/__tests__/lab-biomarker-detail.test.tsx @@ -136,4 +136,22 @@ describe("", () => { // skeleton geometry — proof the assessment card mounted on the page. expect(html).toContain('data-testid="insight-status-card-loading"'); }); + + it("renders the rich catalog description for a catalogued marker", () => { + // "LDL Cholesterol" resolves to the `ldl` catalog slug (case-insensitive), + // so the page shows the per-biomarker explainer, not the generic fallback. + expect(html).toContain("low-density lipoprotein"); + expect(html).not.toContain( + "A lab biomarker you track. Add readings to follow", + ); + }); + + it("offers the edit + delete controls with hover tooltips", () => { + expect(html).toContain('title="Edit"'); + expect(html).toContain('title="Delete"'); + }); + + it("drops the standalone adjust-target-range control", () => { + expect(html).not.toContain("Adjust target range"); + }); }); diff --git a/src/components/labs/lab-biomarker-detail.tsx b/src/components/labs/lab-biomarker-detail.tsx index 166586105..8f1c7ca47 100644 --- a/src/components/labs/lab-biomarker-detail.tsx +++ b/src/components/labs/lab-biomarker-detail.tsx @@ -9,32 +9,25 @@ import { useQuery, useQueryClient, } from "@tanstack/react-query"; -import { - FlaskConical, - ListOrdered, - Pencil, - Plus, - SlidersHorizontal, -} from "lucide-react"; +import { FlaskConical, ListOrdered, Pencil, Plus } from "lucide-react"; import { toast } from "sonner"; import { DeleteButton } from "@/components/data-list"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import { EmptyState } from "@/components/ui/empty-state"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; import { Skeleton } from "@/components/ui/skeleton"; import { InsightStatusCard } from "@/components/insights/insight-status-card"; import { MetricStatStrip } from "@/components/insights/metric-stat-strip"; import { useInsightBiomarkerAssessment } from "@/hooks/use-insight-status"; import { useMounted } from "@/hooks/use-mounted"; -import { apiDelete, apiGet, apiPut } from "@/lib/api/api-fetch"; +import { apiDelete, apiGet } from "@/lib/api/api-fetch"; import { summarize, type DataSummary } from "@/lib/analytics/trends"; import { BIOMARKER_CATALOG } from "@/lib/labs/biomarker-catalog"; import { useTranslations } from "@/lib/i18n/context"; -import { cn } from "@/lib/utils"; +import { fallbackMessages } from "@/lib/i18n/load-locale"; +import { resolveKey } from "@/lib/i18n/resolve-key"; import { queryKeys } from "@/lib/query-keys"; import dynamic from "next/dynamic"; @@ -89,13 +82,29 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) { const queryClient = useQueryClient(); // v1.24 — the per-marker description used to live on the labs overview rows. // It belongs on the detail page beneath the heading (mirroring the metric - // pages' explainer caption). Resolve the catalog slug from the marker name - // exactly as the overview did, then fall back to the user's own `context`. + // pages' explainer caption). Resolve the catalog slug from the marker name, + // then fall back to the user's own `context`. + // + // v1.25.3 — key the lookup on BOTH the current-locale display name and the + // English canonical name. A marker stores the catalog name as it read at + // creation time (`biomarker-form` writes `t("labs.catalog.")`), so a + // marker minted under the English default and then viewed in another locale + // never matched a map built only from the current locale's names — the rich + // description silently fell through to the generic line. The English bundle + // is always in the client chunk (`fallbackMessages`), so adding its names + // makes resolution independent of the locale the marker was created in. const slugByName = useMemo(() => { const map = new Map(); for (const seed of BIOMARKER_CATALOG) { - const norm = t(`labs.catalog.${seed.slug}`).trim().toLowerCase(); - if (norm) map.set(norm, seed.slug); + const localized = t(`labs.catalog.${seed.slug}`).trim().toLowerCase(); + if (localized) map.set(localized, seed.slug); + const canonical = resolveKey( + fallbackMessages, + `labs.catalog.${seed.slug}`, + ) + ?.trim() + .toLowerCase(); + if (canonical && !map.has(canonical)) map.set(canonical, seed.slug); } return map; }, [t]); @@ -104,12 +113,6 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) { const [addFooterEl, setAddFooterEl] = useState(null); const [editOpen, setEditOpen] = useState(false); const [editFooterEl, setEditFooterEl] = useState(null); - // v1.24 — focused "adjust target range" sheet. Edits only the reference - // bounds (the full marker editor lives behind the pencil); seeded from the - // marker each time it opens. - const [rangeOpen, setRangeOpen] = useState(false); - const [lowerInput, setLowerInput] = useState(""); - const [upperInput, setUpperInput] = useState(""); const { data: marker, @@ -217,33 +220,6 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) { onError: () => toast.error(t("labs.biomarker.deleteError")), }); - function openRange() { - setLowerInput(marker?.lowerBound != null ? String(marker.lowerBound) : ""); - setUpperInput(marker?.upperBound != null ? String(marker.upperBound) : ""); - setRangeOpen(true); - } - - const saveRange = useMutation({ - mutationFn: () => { - const lower = lowerInput.trim() === "" ? null : Number(lowerInput); - const upper = upperInput.trim() === "" ? null : Number(upperInput); - return apiPut(`/api/biomarkers/${biomarkerId}`, { - lowerBound: lower, - upperBound: upper, - }); - }, - onSuccess: () => { - toast.success(t("labs.biomarker.targetRange.savedToast")); - setRangeOpen(false); - queryClient.invalidateQueries({ - queryKey: queryKeys.biomarkerDetail(biomarkerId), - }); - queryClient.invalidateQueries({ queryKey: queryKeys.biomarkers() }); - queryClient.invalidateQueries({ queryKey: queryKeys.labResults() }); - }, - onError: () => toast.error(t("labs.biomarker.targetRange.saveError")), - }); - // v1.25.1 — the per-marker description leads the page, mirroring the metric // sub-pages' explainer caption. Resolve the catalog slug from the marker name // and fall back to the user's own `context`, then to a generic explainer so a @@ -306,45 +282,17 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) { ) : null}
- {/* v1.25.1 — "show all readings" rides the header cluster as an icon - button, mirroring the metric sub-pages' `` control. - The full reading feed lives on `/labs/[biomarkerId]/values`; the - detail page keeps the numbers-first spine. */} - {readings.length > 0 ? ( - - ) : null} - + {/* v1.25.3 — header control cluster, left → right: + Delete · Edit · Show-all-values · Add. The icon controls share one + ghost-icon treatment (the target-range editing lives inside the + full marker editor behind the pencil, so the separate range + control was dropped). */} deleteMarker.mutate()} title={t("labs.biomarker.deleteConfirmTitle")} description={t("labs.biomarker.deleteConfirmDescription")} confirmLabel={t("labs.biomarker.delete")} + triggerTitle={t("labs.biomarker.delete")} className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9" iconClassName="h-4 w-4" /> @@ -352,12 +300,34 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) { variant="ghost" size="icon" className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9" - onClick={openRange} + onClick={() => setEditOpen(true)} disabled={!marker} - aria-label={t("labs.biomarker.targetRange.title")} + aria-label={t("labs.biomarker.edit")} + title={t("labs.biomarker.edit")} > - + + {/* "Show all readings" mirrors the metric sub-pages' `` + control. The full reading feed lives on + `/labs/[biomarkerId]/values`; the detail page keeps the + numbers-first spine. */} + {readings.length > 0 ? ( + + ) : null} - -
- } - > -
-
- - setLowerInput(e.target.value)} - /> -
-
- - setUpperInput(e.target.value)} - /> -
-
- {marker?.unit ? ( -

- {t("labs.biomarker.form.rangeHint")} -

- ) : null} -

); } diff --git a/src/components/measurement-reminders/vorsorge-settings.tsx b/src/components/measurement-reminders/vorsorge-settings.tsx index 979394b31..b87a9d9ca 100644 --- a/src/components/measurement-reminders/vorsorge-settings.tsx +++ b/src/components/measurement-reminders/vorsorge-settings.tsx @@ -63,7 +63,10 @@ export function VorsorgeSettings() { // `
` blocks with `text-sm` headings. return (
- + {/* v1.25.3 — `id="vorsorge-view"` + `scroll-mt-28` so the Appearance hub + deep-link (`/settings/vorsorge#vorsorge-view`) lands on this card + below the sticky page header, mirroring the labs/illness anchors. */} + { + it("builds questions + a single review dot (itemCount + 1)", () => { + expect(buildStepList(PHQ9)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); + expect(buildStepList(GAD7)).toEqual([1, 2, 3, 4, 5, 6, 7, 8]); + expect(reviewStep(PHQ9)).toBe(10); + expect(reviewStep(GAD7)).toBe(8); + }); + + it("advances forward on a question and into review from the last question", () => { + expect(nextStep(1, PHQ9)).toBe(2); + expect(nextStep(8, PHQ9)).toBe(9); + // last question (9) → review (10) + expect(nextStep(9, PHQ9)).toBe(10); + // review stays put (submit, not advance) + expect(nextStep(10, PHQ9)).toBe(10); + }); + + it("steps back, clamped at the first question", () => { + expect(prevStep(10)).toBe(9); + expect(prevStep(2)).toBe(1); + expect(prevStep(1)).toBe(1); + }); + + it("identifies the review step", () => { + expect(isReviewStep(9, PHQ9)).toBe(false); + expect(isReviewStep(10, PHQ9)).toBe(true); + expect(isReviewStep(7, GAD7)).toBe(false); + expect(isReviewStep(8, GAD7)).toBe(true); + }); +}); + +describe("reachability ceiling (never skip ahead to an unanswered question)", () => { + it("with nothing answered, only the first question is reachable", () => { + expect(reachableUntilIndex(blank(PHQ9))).toBe(0); + }); + + it("the frontier follows the first unanswered item", () => { + const items = blank(PHQ9); + items[0] = 2; // Q1 answered → can reach Q2 (index 1) + expect(reachableUntilIndex(items)).toBe(1); + items[1] = 0; + items[2] = 3; // Q3 answered too → frontier at the first gap (index 3) + expect(reachableUntilIndex(items)).toBe(3); + }); + + it("once every item is answered the review slot (last index) is reachable", () => { + const items = blank(GAD7).map(() => 1); + // step list length is itemCount + 1; review index == itemCount + expect(reachableUntilIndex(items)).toBe(GAD7); + expect(buildStepList(GAD7).length - 1).toBe(GAD7); + }); +}); + +describe("completeness + recap", () => { + it("isComplete requires every item answered", () => { + expect(isComplete(blank(PHQ9))).toBe(false); + const partial = blank(PHQ9).map((_, i) => (i < 5 ? 1 : UNANSWERED)); + expect(isComplete(partial)).toBe(false); + expect(isComplete(blank(PHQ9).map(() => 0))).toBe(true); + }); + + it("answeredCount counts only real (0–3) answers", () => { + const items = blank(PHQ9); + items[0] = 0; + items[1] = 3; + expect(answeredCount(items)).toBe(2); + }); +}); diff --git a/src/components/mental-health/__tests__/mental-wellbeing.test.tsx b/src/components/mental-health/__tests__/mental-wellbeing.test.tsx new file mode 100644 index 000000000..64069472e --- /dev/null +++ b/src/components/mental-health/__tests__/mental-wellbeing.test.tsx @@ -0,0 +1,190 @@ +import { describe, it, expect, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +import { I18nProvider } from "@/lib/i18n/context"; +import en from "../../../../messages/en.json"; + +/** + * Mental-wellbeing surface redesign (v1.25.3). The SSR-only convention + * (`renderToStaticMarkup`, `environment: "node"`, no `@testing-library/react`) + * means clicks can't be driven here — the wizard's advance/back grammar is + * pinned in `check-in-nav.test.ts`. These tests pin the SSR-observable + * contracts: the disclaimer renders ONLY on the landing (never while testing), + * a positive item-9 surfaces the crisis card, and the history paints its chart + * shell + dated list with severity + "support shown" badges. + */ + +vi.mock("@/lib/api/api-fetch", () => ({ + apiGet: () => new Promise(() => {}), + apiPost: () => new Promise(() => {}), +})); + +import { MentalWellbeing } from "../mental-wellbeing"; +import { CheckInWizard } from "../check-in-wizard"; +import { AssessmentResult } from "../assessment-result"; +import { AssessmentHistory } from "../assessment-history"; +import type { AssessmentRow, CreateResponse } from "../types"; + +const mh = en.mentalHealth; + +function withProviders(node: React.ReactNode): string { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return renderToStaticMarkup( + + {node} + , + ); +} + +describe("disclaimer placement (§2 — never while testing)", () => { + // `renderToStaticMarkup` HTML-escapes apostrophes (don't → don't), so + // assert on an apostrophe-free, distinctive slice of the disclaimer copy. + const DISCLAIMER_FRAGMENT = "voluntary self-tests, not a diagnosis"; + + it("renders the disclaimer on the landing", () => { + const html = withProviders(); + expect(html).toContain('data-slot="mental-health-disclaimer"'); + expect(html).toContain(DISCLAIMER_FRAGMENT); + }); + + it("does NOT render the disclaimer inside the check-in wizard", () => { + const html = withProviders( + {}} + onBack={() => {}} + isPending={false} + isError={false} + />, + ); + expect(html).not.toContain('data-slot="mental-health-disclaimer"'); + expect(html).not.toContain(DISCLAIMER_FRAGMENT); + // …but the standardized instrument explanation IS shown. + expect(html).toContain(mh.instrumentDescription.phq9); + }); +}); + +describe("check-in wizard scaffolding", () => { + const html = withProviders( + {}} + onBack={() => {}} + isPending={false} + isError={false} + />, + ); + + it("reuses the medication wizard stepper", () => { + expect(html).toContain('data-slot="wizard-stepper"'); + }); + + it("opens on the first question with the Back/Next footer", () => { + expect(html).toContain(mh.items.phq9["1"]); + expect(html).toContain('data-slot="check-in-back"'); + expect(html).toContain('data-slot="check-in-next"'); + // Back is disabled on the first step. + expect(html).toMatch( + /data-slot="check-in-back"[^>]*disabled|disabled[^>]*data-slot="check-in-back"/, + ); + }); +}); + +describe("item-9 crisis surfacing on the result", () => { + function result(crisis: CreateResponse["crisis"]): CreateResponse { + return { + assessment: { + id: "a1", + instrument: "PHQ9", + locale: "en", + totalScore: 18, + severityBand: "modSevere", + item9Flagged: crisis !== null, + crisisShownAt: crisis !== null ? "2026-06-20T00:00:00.000Z" : null, + takenAt: "2026-06-20T00:00:00.000Z", + }, + actionThreshold: 10, + crisis, + }; + } + + it("renders the crisis card when item 9 is flagged", () => { + const html = withProviders( + {}} + onBack={() => {}} + />, + ); + expect(html).toContain('data-slot="mental-health-crisis-card"'); + // crisis.title carries an apostrophe (escaped by SSR) — assert a slice. + expect(html).toContain("have to face this alone"); + }); + + it("omits the crisis card when item 9 is not flagged", () => { + const html = withProviders( + {}} + onBack={() => {}} + />, + ); + expect(html).not.toContain('data-slot="mental-health-crisis-card"'); + // The result still paints. + expect(html).toContain(mh.result.title); + }); +}); + +describe("history rendering", () => { + const rows: AssessmentRow[] = [ + { + id: "h1", + instrument: "PHQ9", + locale: "en", + totalScore: 16, + severityBand: "modSevere", + item9Flagged: true, + crisisShownAt: "2026-06-20T00:00:00.000Z", + takenAt: "2026-06-20T00:00:00.000Z", + }, + { + id: "h2", + instrument: "PHQ9", + locale: "en", + totalScore: 4, + severityBand: "minimal", + item9Flagged: false, + crisisShownAt: null, + takenAt: "2026-05-20T00:00:00.000Z", + }, + ]; + + const html = withProviders(); + + it("paints the lazy chart shell + the dated list", () => { + expect(html).toContain('data-slot="skeleton"'); + expect(html).toContain('data-slot="history-list"'); + }); + + it("shows the PHQ-9 / GAD-7 toggle and a severity badge", () => { + expect(html).toContain('role="tablist"'); + expect(html).toContain(mh.band.PHQ9.modSevere); + }); + + it("marks a flagged row with the discreet support marker", () => { + expect(html).toContain('data-slot="history-flagged-marker"'); + expect(html).toContain(mh.history.flaggedBadge); + }); + + it("renders the empty state with no rows", () => { + const empty = withProviders(); + expect(empty).toContain(mh.history.empty); + expect(empty).not.toContain('data-slot="history-list"'); + }); +}); diff --git a/src/components/mental-health/assessment-history-chart.tsx b/src/components/mental-health/assessment-history-chart.tsx new file mode 100644 index 000000000..d986365b3 --- /dev/null +++ b/src/components/mental-health/assessment-history-chart.tsx @@ -0,0 +1,157 @@ +"use client"; + +/** + * v1.25.3 — per-instrument screener trend chart, in the dashboard's visual + * language (mirrors `labs/lab-biomarker-chart.tsx`). A single line of the total + * score over `takenAt`, with the instrument's severity bands painted as stacked + * MUTED `ReferenceArea`s (increasing opacity toward the top, all in the + * primary/muted family) so the reader sees "where this score sits" WITHOUT a + * danger palette — never an alarm colour (the no-alarming-colour rule the labs + * chart documents). + * + * One instrument at a time: PHQ-9 (0–27) and GAD-7 (0–21) have different ranges + * and bands, so the parent toggles which series + band set this paints. + */ +import { useMemo } from "react"; +import { + CartesianGrid, + ComposedChart, + Line, + ReferenceArea, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; + +import { RichChartTooltip, type RichTooltipRow } from "../charts/chart-tooltip"; +import { CHART_HEIGHT_PX } from "@/lib/charts/constants"; +import { prefersReducedMotion } from "@/lib/charts/reduced-motion"; +import { formatDateShort } from "@/lib/format"; +import { useTranslations } from "@/lib/i18n/context"; +import { INSTRUMENTS } from "@/lib/mental-health/instruments"; + +import type { AssessmentRow, InstrumentId } from "./types"; + +interface ChartPoint { + timestamp: number; + value: number; +} + +export function AssessmentHistoryChart({ + instrument, + rows, +}: { + instrument: InstrumentId; + /** Rows for THIS instrument, any order (sorted internally, ascending time). */ + rows: AssessmentRow[]; +}) { + const { t } = useTranslations(); + const def = INSTRUMENTS[instrument]; + + const points = useMemo(() => { + return [...rows] + .sort( + (a, b) => new Date(a.takenAt).getTime() - new Date(b.takenAt).getTime(), + ) + .map((r) => ({ + timestamp: new Date(r.takenAt).getTime(), + value: r.totalScore, + })); + }, [rows]); + + const animate = !prefersReducedMotion(); + const primary = "var(--primary)"; + + if (points.length === 0) { + return ( +

+ {t("mentalHealth.history.empty")} +

+ ); + } + + // Severity bands as stacked muted areas — opacity climbs with severity so the + // higher bands read "heavier" without ever painting red. The whole y-range is + // the instrument's 0..maxScore so the bands tile the plot. + const bandCount = def.bands.length; + + return ( + + + + {def.bands.map((band, i) => ( + + ))} + formatDateShort(new Date(ts))} + tick={{ fontSize: 11, fill: "var(--muted-foreground)" }} + stroke="var(--border)" + minTickGap={32} + /> + + { + const active = props.active ?? false; + const payload = props.payload as + ReadonlyArray<{ payload?: ChartPoint }> | undefined; + const point = payload?.[0]?.payload; + if (!active || !point) { + return ; + } + const rowsOut: RichTooltipRow[] = [ + { + name: t("mentalHealth.history.totalLabel"), + value: String(point.value), + color: primary, + }, + ]; + return ( + + ); + }} + /> + + + + ); +} diff --git a/src/components/mental-health/assessment-history.tsx b/src/components/mental-health/assessment-history.tsx new file mode 100644 index 000000000..ca3a8458e --- /dev/null +++ b/src/components/mental-health/assessment-history.tsx @@ -0,0 +1,191 @@ +"use client"; + +/** + * v1.25.3 — the history card: a per-instrument trend chart over a band-shaded + * plot, then the dated list with severity badges. Mirrors the labs detail spine + * (chart → list) and lazy-loads Recharts through `next/dynamic` so it stays off + * the surface's first-load JS, with a `ChartSkeleton` shell. + * + * PHQ-9 / GAD-7 toggle: the two instruments have different score ranges + bands, + * so the chart paints one at a time. + * + * CRISIS-SAFETY: a flagged PHQ-9 row carries a discreet "support shown" marker; + * tapping it re-surfaces the STATIC crisis-resource card derived from the row's + * stored locale (`crisisResourcesForLocale`) — it NEVER decrypts or reveals the + * item-9 answer, and surfacing it triggers no third-party alert. No new data + * leaves the safety boundary. + */ +import { useMemo, useState } from "react"; +import dynamic from "next/dynamic"; +import type { ComponentProps } from "react"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { EmptyState } from "@/components/ui/empty-state"; +import { ChartErrorBoundary } from "@/components/charts/chart-error-state"; +import { ChartSkeleton } from "@/components/charts/chart-skeleton"; +import { importWithRetry } from "@/lib/retry-import"; +import { useFormatters, useTranslations } from "@/lib/i18n/context"; +import { crisisResourcesForLocale } from "@/lib/mental-health/crisis-resources"; +import { Activity } from "lucide-react"; + +import { CrisisCard } from "./crisis-card"; +import type { AssessmentRow, CrisisSet, InstrumentId } from "./types"; + +// Defer the recharts trend chart so recharts stays off the surface's first-load +// JS (the labs detail precedent). The `` shell holds the layout. +const AssessmentHistoryChartLazy = dynamic( + () => + importWithRetry(() => import("./assessment-history-chart")).then((mod) => ({ + default: mod.AssessmentHistoryChart, + })), + { ssr: false, loading: () => }, +); +function AssessmentHistoryChart( + props: ComponentProps, +) { + return ( + + + + ); +} + +function lower(id: InstrumentId): "phq9" | "gad7" { + return id === "PHQ9" ? "phq9" : "gad7"; +} + +/** Re-derive the STATIC crisis set from a row's locale — no decryption. */ +function crisisFromRow(row: AssessmentRow): CrisisSet { + const set = crisisResourcesForLocale(row.locale); + return { + emergencyNumber: set.emergencyNumber, + resources: set.resources.map((r) => ({ + id: r.id, + contacts: [...r.contacts], + })), + }; +} + +export function AssessmentHistory({ rows }: { rows: AssessmentRow[] }) { + const { t } = useTranslations(); + const { date: formatDate } = useFormatters(); + const [tab, setTab] = useState("PHQ9"); + // Which flagged row's crisis card is currently expanded (re-surfaced). + const [openCrisisRowId, setOpenCrisisRowId] = useState(null); + + const forTab = useMemo( + () => + rows + .filter((r) => r.instrument === tab) + .sort( + (a, b) => + new Date(b.takenAt).getTime() - new Date(a.takenAt).getTime(), + ), + [rows, tab], + ); + + const isEmpty = rows.length === 0; + + return ( + + + + {t("mentalHealth.history.title")} + + + + {isEmpty ? ( + } + title={t("mentalHealth.history.title")} + description={t("mentalHealth.history.empty")} + /> + ) : ( + <> + {/* PHQ-9 / GAD-7 toggle — the two ranges + band sets can't share an + axis, so the chart + list paint one instrument at a time. */} +
+ {(["PHQ9", "GAD7"] as InstrumentId[]).map((id) => ( + + ))} +
+ + + + {forTab.length === 0 ? ( +

+ {t("mentalHealth.history.empty")} +

+ ) : ( +
    + {forTab.map((row) => { + const flagged = row.item9Flagged; + const open = openCrisisRowId === row.id; + return ( +
  • +
    + + {formatDate(row.takenAt)} + +
    + {flagged && ( + + )} + + {t( + `mentalHealth.band.${row.instrument}.${row.severityBand}`, + )} + + + {row.totalScore} + +
    +
    + {flagged && open && ( + + )} +
  • + ); + })} +
+ )} + + )} +
+
+ ); +} diff --git a/src/components/mental-health/assessment-result.tsx b/src/components/mental-health/assessment-result.tsx new file mode 100644 index 000000000..67cb1b6cb --- /dev/null +++ b/src/components/mental-health/assessment-result.tsx @@ -0,0 +1,129 @@ +"use client"; + +/** + * v1.25.3 — the contained result block: instrument header → result card → + * crisis card (on a positive PHQ-9 item 9) → required attribution → "take + * another". Content is unchanged from the v1.25 monolith; it is re-housed as + * one stack inside the page spine instead of loose top-level sections. + * + * SAFETY: the crisis set rides the POST response (`crisis`) on any non-zero + * item 9 and renders immediately; this surface never invites an AI Coach + * conversation about item content. + */ +import { useEffect, useRef } from "react"; +import { ArrowLeft } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { useTranslations } from "@/lib/i18n/context"; +import { PHQ_GAD_ATTRIBUTION } from "@/lib/mental-health/instruments"; + +import { CrisisCard } from "./crisis-card"; +import type { CreateResponse, InstrumentId } from "./types"; + +function lower(id: InstrumentId): "phq9" | "gad7" { + return id === "PHQ9" ? "phq9" : "gad7"; +} + +export function AssessmentResult({ + result, + onTakeAnother, + onBack, +}: { + result: CreateResponse; + onTakeAnother: () => void; + onBack: () => void; +}) { + const { t } = useTranslations(); + const instrument = result.assessment.instrument; + const key = lower(instrument); + + // a11y: on submit the wizard unmounts and this view mounts; move focus to the + // result heading so keyboard / screen-reader users land on the new content + // rather than being dropped to . + const headingRef = useRef(null); + useEffect(() => { + headingRef.current?.focus(); + }, []); + + return ( +
+ + +
+

+ {t(`mentalHealth.instrument.${key}`)} +

+

+ {t(`mentalHealth.instrumentDescription.${key}`)} +

+
+ +

+ {t("mentalHealth.saved")} +

+ + + + {t("mentalHealth.result.title")} + + +
+ + {t("mentalHealth.result.totalLabel")} + + + {result.assessment.totalScore} + +
+
+ + {t("mentalHealth.result.bandLabel")} + + + {t( + `mentalHealth.band.${result.assessment.instrument}.${result.assessment.severityBand}`, + )} + +
+ {result.assessment.totalScore >= result.actionThreshold && ( +

+ {t("mentalHealth.considerProfessional")} +

+ )} +
+
+ + {result.crisis && } + +

+ + {t("mentalHealth.attributionLabel")}: + {" "} + {PHQ_GAD_ATTRIBUTION} +

+ + +
+ ); +} 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 ( +
    + + + {/* In-test header — instrument title + ONE standardized explanation line. + No disclaimer here (§2); the (i) tooltip on the landing carries it. */} +
    +

    + {t(`mentalHealth.instrument.${key}`)} +

    +

    + {t(`mentalHealth.instrumentDescription.${key}`)} +

    +
    + + +
    + [ + i + 1, + String(i + 1), + ]), + [itemCount + 1, t("mentalHealth.review.title")], + ])} + onJump={(target) => setStep(target)} + onFirst={() => setStep(1)} + onLast={() => setStep(itemCount + 1)} + firstEnabled={step > 1} + lastEnabled={complete && !onReview} + firstLabel={t("mentalHealth.jumpFirst")} + lastLabel={t("mentalHealth.jumpLast")} + srLabel={srLabel} + /> +
    + + + {!onReview ? ( +
    +

    + {t("mentalHealth.progress", { + current: step, + total: itemCount, + })} +

    +

    + {t(`mentalHealth.items.${key}.${step}`)} +

    +
    + {SCALE.map((v) => ( + + ))} +
    +
    + ) : ( +
    +

    + {t("mentalHealth.review.title")} +

    +

    + {t("mentalHealth.review.recap", { + answered: answeredCount(items), + total: itemCount, + })} +

    + + {/* The optional, unscored functional-difficulty follow-up folds + into the review step rather than its own dot (§1). */} +
    + + {t("mentalHealth.functionalTitle")} + +
    + {FUNCTIONAL.map((v) => ( + + ))} +
    +
    + + {isError && ( +

    + {t("mentalHealth.error")} +

    + )} +
    + )} +
    + +
    + + {!onReview ? ( + + ) : ( + + )} +
    +
    +
    + ); +} 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")} + + + +

    {t("mentalHealth.crisis.intro")}

    +

    + {t("mentalHealth.crisis.ifDanger", { + emergency: crisis.emergencyNumber, + })} +

    +
    + + {t("mentalHealth.crisis.resourcesTitle")} + +
      + {crisis.resources.map((r) => ( +
    • + + {t(`mentalHealth.crisisResource.${r.id}.name`)} + + + {r.contacts.join(" · ")} + +
    • + ))} +
    +
    +
    +
    + ); +} diff --git a/src/components/mental-health/instrument-card.tsx b/src/components/mental-health/instrument-card.tsx new file mode 100644 index 000000000..a7664446c --- /dev/null +++ b/src/components/mental-health/instrument-card.tsx @@ -0,0 +1,71 @@ +"use client"; + +/** + * v1.25.3 — one neutral card per instrument on the landing. + * + * Mirrors the Vorsorge / medication card grammar: a header (title + sub), + * a discreet "last result" line when history exists (band badge + relative + * date), and a single bottom-pinned Start action. The surface stays NEUTRAL + * regardless of the last band — no severity tint (house rule: status reads + * through a discreet badge only). + */ +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { useFormatters, useTranslations } from "@/lib/i18n/context"; + +import type { AssessmentRow, InstrumentId } from "./types"; + +function lower(id: InstrumentId): "phq9" | "gad7" { + return id === "PHQ9" ? "phq9" : "gad7"; +} + +export function InstrumentCard({ + instrument, + last, + onStart, +}: { + instrument: InstrumentId; + /** Most-recent assessment for this instrument, or undefined when none yet. */ + last: AssessmentRow | undefined; + onStart: () => void; +}) { + const { t } = useTranslations(); + const { date: formatDate } = useFormatters(); + const key = lower(instrument); + + return ( + + +
    + + {t(`mentalHealth.instrument.${key}`)} + + + {t(`mentalHealth.instrumentSub.${key}`)} + +
    + +
    + {last ? ( + + {t("mentalHealth.lastResult")} + + {t(`mentalHealth.band.${instrument}.${last.severityBand}`)} + + {formatDate(last.takenAt)} + + ) : ( + {t("mentalHealth.noResultYet")} + )} +
    + +
    + +
    +
    +
    + ); +} diff --git a/src/components/mental-health/mental-wellbeing.tsx b/src/components/mental-health/mental-wellbeing.tsx index 0cd7b89da..14978714b 100644 --- a/src/components/mental-health/mental-wellbeing.tsx +++ b/src/components/mental-health/mental-wellbeing.tsx @@ -12,70 +12,41 @@ * anxiety screen never invites an AI conversation about item content. On a * positive PHQ-9 item-9 the server returns a calm, locale-aware crisis-resource * set which is rendered immediately below the result. + * + * v1.25.3 — this is now the ORCHESTRATOR + landing only; the 387-line monolith + * split into focused components (`instrument-card`, `check-in-wizard`, + * `assessment-result`, `assessment-history` + its lazy chart, `crisis-card`). + * The landing follows the card grammar of Vorsorge / Medications: instrument + * cards (last-result line + Start) + a history card. The disclaimer renders + * ONLY here (a muted caption) and behind the InfoHint — never while testing. */ -import { useEffect, useMemo, useRef, useState } from "react"; +import { useMemo, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { useTranslations, useFormatters } from "@/lib/i18n/context"; +import { useTranslations } from "@/lib/i18n/context"; import { apiGet, apiPost } from "@/lib/api/api-fetch"; import { queryKeys } from "@/lib/query-keys"; -import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { InfoHint } from "@/components/ui/info-hint"; -import { - INSTRUMENTS, - PHQ_GAD_ATTRIBUTION, - type InstrumentId, -} from "@/lib/mental-health/instruments"; -type Phase = "choose" | "form" | "result"; - -interface AssessmentRow { - id: string; - instrument: InstrumentId; - totalScore: number; - severityBand: string; - item9Flagged: boolean; - takenAt: string; -} - -interface CrisisSet { - emergencyNumber: string; - resources: { id: string; contacts: string[] }[]; -} - -interface CreateResponse { - assessment: AssessmentRow; - actionThreshold: number; - crisis: CrisisSet | null; -} - -const SCALE = [0, 1, 2, 3] as const; -const FUNCTIONAL = [0, 1, 2, 3] as const; +import { AssessmentHistory } from "./assessment-history"; +import { AssessmentResult } from "./assessment-result"; +import { CheckInWizard } from "./check-in-wizard"; +import { InstrumentCard } from "./instrument-card"; +import type { + AssessmentRow, + CreateResponse, + InstrumentId, + Phase, +} from "./types"; export function MentalWellbeing() { const { t, locale } = useTranslations(); - const { date: formatDate } = useFormatters(); const queryClient = useQueryClient(); const [phase, setPhase] = useState("choose"); const [instrument, setInstrument] = useState("PHQ9"); - const [items, setItems] = useState([]); - const [functional, setFunctional] = useState(null); const [result, setResult] = useState(null); - // a11y: on submit the form (incl. the submit button) unmounts and `phase` - // flips to "result". Move focus to the result heading so keyboard / screen- - // reader users are not dropped to `` and land on the new content. - const resultHeadingRef = useRef(null); - useEffect(() => { - if (phase === "result") { - resultHeadingRef.current?.focus(); - } - }, [phase]); - - const lower = (id: InstrumentId) => (id === "PHQ9" ? "phq9" : "gad7"); - const { data: history } = useQuery({ queryKey: queryKeys.mentalHealthAssessments(), queryFn: () => @@ -96,18 +67,29 @@ export function MentalWellbeing() { }, }); + // Most-recent assessment per instrument for the landing's "last result" line. + // The history GET returns rows newest-first, so the first match wins. + const lastByInstrument = useMemo(() => { + const map = new Map(); + for (const row of history?.assessments ?? []) { + if (!map.has(row.instrument)) map.set(row.instrument, row); + } + return map; + }, [history]); + function begin(id: InstrumentId) { setInstrument(id); - setItems(Array(INSTRUMENTS[id].itemCount).fill(-1)); - setFunctional(null); setResult(null); + mutation.reset(); setPhase("form"); } - const complete = items.length > 0 && items.every((v) => v >= 0); + function backToLanding() { + mutation.reset(); + setPhase("choose"); + } - function submit() { - if (!complete) return; + function submit(items: number[], functional: number | null) { mutation.mutate({ instrument, items, @@ -116,271 +98,65 @@ export function MentalWellbeing() { }); } - const itemCount = INSTRUMENTS[instrument].itemCount; - const itemIndexes = useMemo( - () => Array.from({ length: itemCount }, (_, i) => i), - [itemCount], - ); - return (
    -
    -
    -

    - {t("mentalHealth.pageTitle")} -

    - -
    -

    - {t("mentalHealth.pageDescription")} -

    -
    - {phase === "choose" && ( -
    -

    - {t("mentalHealth.choosePrompt")} -

    - {(["PHQ9", "GAD7"] as InstrumentId[]).map((id) => ( - - -
    - - {t(`mentalHealth.instrument.${lower(id)}`)} - - - {t(`mentalHealth.instrumentSub.${lower(id)}`)} - -
    - -
    -
    - ))} -
    + <> +
    +
    +

    + {t("mentalHealth.pageTitle")} +

    + +
    +

    + {t("mentalHealth.pageDescription")} +

    + {/* The ONLY place the disclaimer renders as body text (§2). */} +

    + {t("mentalHealth.landing.disclaimer")} +

    +
    + +
    +

    {t("mentalHealth.choosePrompt")}

    +
      + {(["PHQ9", "GAD7"] as InstrumentId[]).map((id) => ( +
    • + begin(id)} + /> +
    • + ))} +
    +
    + + + )} {phase === "form" && ( -
    -
    - - - {t(`mentalHealth.instrument.${lower(instrument)}`)} - -
    -

    {t("mentalHealth.stem")}

    - -
      - {itemIndexes.map((i) => ( -
    1. - - {i + 1}.{" "} - {t(`mentalHealth.items.${lower(instrument)}.${i + 1}`)} - -
      - {SCALE.map((v) => ( - - ))} -
      -
    2. - ))} -
    - -
    - {t("mentalHealth.functionalTitle")} -
    - {FUNCTIONAL.map((v) => ( - - ))} -
    -
    - - {mutation.isError && ( -

    - {t("mentalHealth.error")} -

    - )} - -
    + )} {phase === "result" && result && ( -
    -

    - {t("mentalHealth.saved")} -

    - - - - {t("mentalHealth.result.title")} - - - -
    - - {t("mentalHealth.result.totalLabel")} - - - {result.assessment.totalScore} - -
    -
    - - {t("mentalHealth.result.bandLabel")} - - - {t( - `mentalHealth.band.${result.assessment.instrument}.${result.assessment.severityBand}`, - )} - -
    - {result.assessment.totalScore >= result.actionThreshold && ( -

    - {t("mentalHealth.considerProfessional")} -

    - )} -
    -
    - - {result.crisis && ( - - - - {t("mentalHealth.crisis.title")} - - - -

    {t("mentalHealth.crisis.intro")}

    -

    - {t("mentalHealth.crisis.ifDanger", { - emergency: result.crisis.emergencyNumber, - })} -

    -
    - - {t("mentalHealth.crisis.resourcesTitle")} - -
      - {result.crisis.resources.map((r) => ( -
    • - - {t(`mentalHealth.crisisResource.${r.id}.name`)} - - - {r.contacts.join(" · ")} - -
    • - ))} -
    -
    -
    -
    - )} - -

    - - {t("mentalHealth.attributionLabel")}: - {" "} - {PHQ_GAD_ATTRIBUTION} -

    - - -
    + )} - -
    -

    - {t("mentalHealth.history.title")} -

    - {!history || history.assessments.length === 0 ? ( -

    - {t("mentalHealth.history.empty")} -

    - ) : ( -
      - {history.assessments.map((row) => ( -
    • - - {t(`mentalHealth.instrument.${lower(row.instrument)}`)} - - - {t(`mentalHealth.band.${row.instrument}.${row.severityBand}`)}{" "} - · {formatDate(row.takenAt)} - -
    • - ))} -
    - )} -
    ); } diff --git a/src/components/mental-health/types.ts b/src/components/mental-health/types.ts new file mode 100644 index 000000000..7b66ca1ff --- /dev/null +++ b/src/components/mental-health/types.ts @@ -0,0 +1,37 @@ +/** + * v1.25.3 — shared types for the opt-in mental-health screener surface. + * + * The 387-line monolith split into a folder of focused components; these are + * the seams they plug into. The wire shapes mirror the assessment route's + * `shapeRow` / create response (`src/app/api/mental-health/assessments/route.ts`) + * — totals + bands + flags only, NEVER item content (the item answers stay + * encrypted at rest and never ride this surface). + */ +import type { InstrumentId } from "@/lib/mental-health/instruments"; + +export type { InstrumentId }; + +export type Phase = "choose" | "form" | "result"; + +export interface AssessmentRow { + id: string; + instrument: InstrumentId; + /** Locale of the validated wording presented — re-derives crisis resources. */ + locale: string; + totalScore: number; + severityBand: string; + item9Flagged: boolean; + crisisShownAt: string | null; + takenAt: string; +} + +export interface CrisisSet { + emergencyNumber: string; + resources: { id: string; contacts: string[] }[]; +} + +export interface CreateResponse { + assessment: AssessmentRow; + actionThreshold: number; + crisis: CrisisSet | null; +} diff --git a/src/components/settings/__tests__/layout-section.test.tsx b/src/components/settings/__tests__/layout-section.test.tsx index 12e9be175..79669842b 100644 --- a/src/components/settings/__tests__/layout-section.test.tsx +++ b/src/components/settings/__tests__/layout-section.test.tsx @@ -1,9 +1,14 @@ /** - * v1.17.1 (F-2) — the "Layout & Personalization" hub. + * v1.17.1 (F-2) — the "Appearance" hub (slug stays `layout`). * - * The hub is the single front door for the four personalization editors. - * It must link to each of them with consistent framing so the concept - * reads as one home rather than four scattered settings sections. + * The hub is the single front door for every view/arrangement surface. It must + * link to each with consistent framing so "how my app looks" reads as one home + * rather than scattered settings sections. + * + * v1.25.3 — the hub widened from 2 links (dashboard + insights) to 6: it now + * also deep-links to the view/sort/order cards of medications, labs, the + * illness journal, and checkups. Those cards stay on their own module pages; + * the hub only indexes them via anchor deep-links. */ import { describe, expect, it } from "vitest"; import { renderToStaticMarkup } from "react-dom/server"; @@ -20,26 +25,34 @@ function render(locale: "en" | "de" = "en") { } describe("", () => { - it("links to the arrangement editors it still hosts", () => { + it("links to every view surface it indexes (anchor deep-links)", () => { const html = render(); - for (const href of ["/settings/dashboard", "/settings/insights"]) { + for (const href of [ + "/settings/dashboard", + "/settings/insights", + "/settings/medications#medications-view", + "/settings/labs#labs-view", + "/settings/illness#illness-view", + "/settings/vorsorge#vorsorge-view", + ]) { expect(html).toContain(`href="${href}"`); } - // v1.18.0 (S5) — Medications (Medikamente) and Mood (Stimmung) - // graduated to their own nav entries and are no longer linked here. - expect(html).not.toContain('href="/settings/medications"'); - expect(html).not.toContain('href="/settings/mood"'); }); - it("renders the hub heading and a card per editor", () => { + it("renders one card per indexed surface (6 links)", () => { const html = render(); + // Pin the link count: every hub card is an ``. + const links = html.match(/href="\/settings\//g) ?? []; + expect(links.length).toBe(6); expect(html).toContain("Dashboard"); expect(html).toContain("Insights"); }); it("resolves its copy in German too", () => { const html = render("de"); - expect(html).toContain("Layout"); + // The hub body is the link list; the German module labels resolve. + expect(html).toContain("Medikamente"); + expect(html).toContain("Labor"); expect(html).not.toContain("settings.sections.layout."); }); }); diff --git a/src/components/settings/__tests__/modules-section.test.tsx b/src/components/settings/__tests__/modules-section.test.tsx index dbd2822da..db37c4881 100644 --- a/src/components/settings/__tests__/modules-section.test.tsx +++ b/src/components/settings/__tests__/modules-section.test.tsx @@ -20,7 +20,11 @@ import { renderToStaticMarkup } from "react-dom/server"; import { I18nProvider } from "@/lib/i18n/context"; import type { AuthUser } from "@/hooks/use-auth"; import { queryKeys } from "@/lib/query-keys"; -import { MODULE_KEYS, moduleDelegatesTo } from "@/lib/modules/registry"; +import { + MODULE_KEYS, + isCodeDisabledModule, + moduleDelegatesTo, +} from "@/lib/modules/registry"; // Capture the latest useMutation config + a shared mutate spy so we can // invoke `mutationFn` / `onSuccess` by hand (the SSR pass can't fire a @@ -112,7 +116,13 @@ describe("", () => { // only renders the toggleable modules. const html = render(); for (const key of MODULE_KEYS) { - if (moduleDelegatesTo(key) !== undefined) { + if (isCodeDisabledModule(key)) { + // v1.25.3 — a module switched off in code (pending a rebuild) drops + // out of the hub entirely: no Switch, no deep-link, no row. + expect(html, `code-disabled has no switch ${key}`).not.toContain( + `id="module-toggle-${key}"`, + ); + } else if (moduleDelegatesTo(key) !== undefined) { // Delegated modules (cycle/coach) are owned by their real control // elsewhere — they render as a read-only "manage in X" deep-link, // never a live Switch that would write an inert disabled-allowlist diff --git a/src/components/settings/__tests__/sections.test.tsx b/src/components/settings/__tests__/sections.test.tsx index 5744b4ef7..183ef2bf7 100644 --- a/src/components/settings/__tests__/sections.test.tsx +++ b/src/components/settings/__tests__/sections.test.tsx @@ -188,19 +188,25 @@ describe("settings sections — SSR smoke", () => { expect(html).toContain("Withings"); }); - it(" renders the single reminder-types home", () => { + it(" renders the reminders + delivery-channels groups", () => { const html = renderFramed("notifications", ); // v1.18.6 (W9) — the visible heading comes from the frame; the proactive // Coach nudge moved to Settings → Coach, so it no longer renders here. expect(html).toContain("settings-section-notifications-title"); - expect(html).not.toContain("Notification channels"); expect(html).not.toContain('data-slot="notifications-channels-cross-link"'); expect(html).not.toContain('data-slot="notifications-inbox-cross-link"'); // The mood card fails OPEN (mock user carries no module map), so both - // remaining reminder-type cards render. The coach-nudge card is gone. + // reminder-type cards render under the Reminders group. The coach-nudge + // card is gone. expect(html).toContain('id="mood-reminder"'); expect(html).toContain('id="low-stock"'); expect(html).not.toContain('id="coach-nudge"'); + // v1.25.3 — the delivery-channels group is now part of this surface: the + // `#channels` anchor and the embedded channels panel both render. + expect(html).toContain('id="channels"'); + expect(html).toContain('data-slot="notification-channels-panel"'); + expect(html).toContain("Reminders"); + expect(html).toContain("Delivery channels"); // No raw i18n key leaks past the provider. expect(html).not.toContain("settings.sections.notifications."); }); diff --git a/src/components/settings/__tests__/settings-shell.test.tsx b/src/components/settings/__tests__/settings-shell.test.tsx index 004d13aed..e2142a1ec 100644 --- a/src/components/settings/__tests__/settings-shell.test.tsx +++ b/src/components/settings/__tests__/settings-shell.test.tsx @@ -91,6 +91,9 @@ describe("SETTINGS_SECTION_SLUGS", () => { // sits right after `ai`. // v1.18.1 (D4) — `channels` + `sources` split out of the Integrations // sub-tabs into their own left-side entries, right after `integrations`. + // v1.25.3 — `channels` folded back into Notifications as an in-page group; + // `/settings/channels` 301-redirects to `/settings/notifications#channels`. + // `sources` keeps its own entry. // v1.18.7 — `labs`, `illness`, `vorsorge` move into the shell as // first-class sections, right after `mood` (the three were standalone // `ModuleSettingsFrame` pages). @@ -102,7 +105,6 @@ describe("SETTINGS_SECTION_SLUGS", () => { "security", "modules", "integrations", - "channels", "sources", "notifications", "layout", @@ -293,14 +295,15 @@ describe("", () => { // (single-line; the longer "Notification channels" wrapped). The // `/notifications` inbox now shares the "Notifications" label. expect(html).toContain("Notifications"); - // v1.17.1 (F-2) — the dashboard + insights arrangement editors are - // reached through one hub entry (route stays `/settings/layout`). - // v1.19.1 (S3) — the nav label now reads "Dashboard" to match where - // it leads; the route is unchanged. + // v1.17.1 (F-2) — the view/arrangement editors are reached through one hub + // entry (route stays `/settings/layout`). v1.25.3 — the hub is renamed + // "Appearance" (DE "Darstellung") and indexes every module's view surface; + // the route is unchanged. expect(html).toContain('href="/settings/layout"'); - expect(html).toContain(">Dashboard"); - // v1.18.1 (D4) — Channels + Sources are their own left-side entries. - expect(html).toContain('href="/settings/channels"'); + expect(html).toContain(">Appearance"); + // v1.25.3 — Channels folded into Notifications, so it is no longer a + // left-side entry; Sources keeps its own. + expect(html).not.toContain('href="/settings/channels"'); expect(html).toContain('href="/settings/sources"'); // v1.18.0 (S5) — Medications (Medikamente) and Mood (Stimmung, gated; // fail-open mock shows both) are their own nav entries now. @@ -334,15 +337,14 @@ describe("", () => { // compound "Benachrichtigungs-Kanäle" wrapped). The `/notifications` // inbox now shares the "Benachrichtigungen" label. expect(html).toContain("Benachrichtigungen"); - // v1.17.1 (F-2) — the arrangement editors are reached through one hub - // entry (route stays `/settings/layout`). v1.19.1 (S3) — the nav label - // reads "Dashboard" in every locale to match where it leads. + // v1.17.1 (F-2) — the view editors are reached through one hub entry + // (route stays `/settings/layout`). v1.25.3 — the German nav label reads + // "Darstellung" (Appearance) to match the renamed hub. expect(html).toContain('href="/settings/layout"'); - expect(html).toContain(">Dashboard"); - // v1.18.1 (D4) — Channels ("Kanäle") + Sources ("Quellen-Priorität") - // are their own left-side entries. - expect(html).toContain('href="/settings/channels"'); - expect(html).toContain("Kanäle"); + expect(html).toContain(">Darstellung"); + // v1.25.3 — Channels ("Kanäle") folded into Notifications; Sources keeps + // its own left-side entry. + expect(html).not.toContain('href="/settings/channels"'); // v1.18.0 (S5) — Medications + Mood are their own nav entries now. expect(html).toContain('href="/settings/medications"'); expect(html).toContain('href="/settings/mood"'); diff --git a/src/components/settings/ai/response-timeout-card.tsx b/src/components/settings/ai/response-timeout-card.tsx index c87f64e2d..590d50c26 100644 --- a/src/components/settings/ai/response-timeout-card.tsx +++ b/src/components/settings/ai/response-timeout-card.tsx @@ -7,7 +7,8 @@ * call (`CompletionParams.timeoutMs`). Surfaced here mainly for local / * self-hosted backends: an MLX/exo server can take >60 s on the first request * while it loads the model, which the legacy 60 s default timed out before the - * first token landed. Empty = the generous built-in default (~180 s). + * first token landed. Empty = the built-in comprehensive-briefing default + * (~120 s, `AI_BUDGETS.comprehensive.timeoutMs`). * ──────────────────────────────────────────────────────────────── */ import { useState } from "react"; diff --git a/src/components/settings/channels-section.tsx b/src/components/settings/channels-section.tsx deleted file mode 100644 index ebceea87e..000000000 --- a/src/components/settings/channels-section.tsx +++ /dev/null @@ -1,20 +0,0 @@ -"use client"; - -/** - * `` — Settings → Kanäle (notification delivery channels). - * - * v1.18.1 (D4) — split out of the Integrations sub-tabs into its own left-side - * entry. A channel (Telegram / ntfy / Web Push / Webhook / Email) is a - * delivery provider — the same family as a connected device — and now reads as - * a first-class settings home rather than a tab hidden behind Integrationen. - * - * The content is the existing ``. v1.18.6 (W9) — - * the visible heading + subtitle now come from the shared - * `` in the route; this wrapper is the panel only. - */ - -import { NotificationChannelsPanel } from "@/components/settings/integrations/notification-channels-panel"; - -export function ChannelsSection() { - return ; -} diff --git a/src/components/settings/layout-section.tsx b/src/components/settings/layout-section.tsx index 7605fcdc4..1eb01308c 100644 --- a/src/components/settings/layout-section.tsx +++ b/src/components/settings/layout-section.tsx @@ -2,8 +2,12 @@ import Link from "next/link"; import { + CalendarCheck, ChevronRight, + FlaskConical, LayoutDashboard, + Pill, + Thermometer, TrendingUp, type LucideIcon, } from "lucide-react"; @@ -12,17 +16,23 @@ import { useTranslations } from "@/lib/i18n/context"; import { SettingsCard } from "./settings-card"; /** - * v1.17.1 (F-2) — the one "Layout & Personalization" home. + * v1.17.1 (F-2) — the "Appearance" home (slug stays `layout`). * - * Before this section, "how my app is laid out" was scattered across - * several unrelated settings sections with no shared framing. This - * section is the single front door for the arrangement editors: it - * presents them as one consistent set of cards and links to each editor, - * which keep their own routes and mutation flows intact. + * "How my app looks and is arranged" was scattered across several unrelated + * settings sections with no shared framing. This section is the single front + * door: it presents the view/arrangement surfaces as one consistent set of + * cards and links to each editor, which keep their own routes, their own + * cards, and their mutation flows intact — the hub indexes them, it does not + * own them. * - * v1.18.0 (S5) — Medications (Medikamente) and Mood (Stimmung) graduated - * to their own top-level nav entries, so the hub now hosts only the - * dashboard + insights arrangement editors. + * v1.25.3 — the hub widens from the dashboard + insights arrangement editors + * to the full set of view surfaces: it now also deep-links to the view/sort/ + * order cards of medications, labs, the illness journal, and checkups. Each + * of those modules keeps its view cards on its own settings page; the links + * land on the existing card anchors (`#medications-view`, `#labs-view`, + * `#illness-view`, `#vorsorge-view`). Data/behaviour settings (biomarker + * catalog, local OCR, injection-site exclusions, per-reminder toggles, the + * mood tag/group catalog) stay on their module pages and are NOT indexed here. */ interface LayoutLink { href: string; @@ -44,10 +54,34 @@ const LAYOUT_LINKS: ReadonlyArray = [ titleKey: "settings.sections.layout.insights.title", descriptionKey: "settings.sections.layout.insights.description", }, - // v1.18.0 (S5) — Medications (Medikamente) and Mood (Stimmung) graduated - // to their own top-level nav entries, so they are no longer linked from - // the Layout hub. Only the dashboard + insights arrangement editors - // remain here. + // v1.25.3 — view/sort/order surfaces of the tracking modules. Each link + // deep-links to the module's existing view card anchor; the cards stay on + // their own settings page. The module routes resolve regardless of the + // module's enabled state, so the link never dead-ends. + { + href: "/settings/medications#medications-view", + icon: Pill, + titleKey: "settings.sections.layout.medications.title", + descriptionKey: "settings.sections.layout.medications.description", + }, + { + href: "/settings/labs#labs-view", + icon: FlaskConical, + titleKey: "settings.sections.layout.labs.title", + descriptionKey: "settings.sections.layout.labs.description", + }, + { + href: "/settings/illness#illness-view", + icon: Thermometer, + titleKey: "settings.sections.layout.illness.title", + descriptionKey: "settings.sections.layout.illness.description", + }, + { + href: "/settings/vorsorge#vorsorge-view", + icon: CalendarCheck, + titleKey: "settings.sections.layout.vorsorge.title", + descriptionKey: "settings.sections.layout.vorsorge.description", + }, ]; export function LayoutSection() { diff --git a/src/components/settings/modules-section.tsx b/src/components/settings/modules-section.tsx index e67da7e3f..d38419b5b 100644 --- a/src/components/settings/modules-section.tsx +++ b/src/components/settings/modules-section.tsx @@ -55,6 +55,7 @@ import { apiPatch } from "@/lib/api/api-fetch"; import { queryKeys } from "@/lib/query-keys"; import { MODULE_REGISTRY, + isCodeDisabledModule, moduleDelegatesTo, type ModuleKey, } from "@/lib/modules/registry"; @@ -130,48 +131,52 @@ export function ModulesSection() { {t("settings.sections.modules.toggleable.description")}

    - {(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 ? ( +
    +
    +

    + {t("settings.sections.notifications.remindersHeading")} +

    +

    + {t("settings.sections.notifications.remindersDescription")} +

    +
    + + {/* 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. */} +
    +
    +

    + {t("settings.sections.notifications.channelsHeading")} +

    +

    + {t("settings.sections.notifications.channelsDescription")} +

    - ) : null} + +
    ); } diff --git a/src/components/settings/section-placeholder.tsx b/src/components/settings/section-placeholder.tsx index 8b6b05c6b..ccac49cd3 100644 --- a/src/components/settings/section-placeholder.tsx +++ b/src/components/settings/section-placeholder.tsx @@ -29,7 +29,6 @@ import { Link2, Lock, Pill, - Radio, Settings2, Share2, ShieldCheck, @@ -51,7 +50,6 @@ const SLUG_ICON: Record = { security: ShieldCheck, modules: Blocks, integrations: Link2, - channels: Radio, sources: Layers, notifications: Bell, layout: LayoutDashboard, diff --git a/src/components/settings/section-slugs.ts b/src/components/settings/section-slugs.ts index 8483253ef..576964410 100644 --- a/src/components/settings/section-slugs.ts +++ b/src/components/settings/section-slugs.ts @@ -57,9 +57,10 @@ export const SETTINGS_SECTION_SLUGS = [ // single front door for enabling/disabling secondary tracking domains. "modules", "integrations", - // v1.18.1 (D4) — Kanäle (delivery channels) + Quellen (source weighting) - // split out of the Integrations tabs into their own left-side entries. - "channels", + // v1.18.1 (D4) — Quellen (source weighting) split out of the Integrations + // tabs into its own left-side entry. v1.25.3 — Kanäle (delivery channels) + // folded back into Notifications as an in-page group; `/settings/channels` + // 301-redirects to `/settings/notifications#channels` (next.config.ts). "sources", "notifications", "layout", diff --git a/src/components/settings/security-activity-card.tsx b/src/components/settings/security-activity-card.tsx index bc3450dbb..6b5959325 100644 --- a/src/components/settings/security-activity-card.tsx +++ b/src/components/settings/security-activity-card.tsx @@ -13,7 +13,7 @@ import { useQuery } from "@tanstack/react-query"; import { ChevronDown, Loader2, ShieldCheck } from "lucide-react"; 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 } from "@/lib/api/api-fetch"; import { cn } from "@/lib/utils"; @@ -54,6 +54,7 @@ export function SecurityActivityCard({ isAuthenticated: boolean; }) { const { t } = useTranslations(); + const fmt = useFormatters(); // Collapsed by default — the feed opens only on demand, keeping the security // surface skimmable. UI-only state; nothing is persisted across reloads. const [open, setOpen] = useState(false); @@ -138,7 +139,7 @@ export function SecurityActivityCard({

    - {new Date(e.createdAt).toLocaleString()} + {fmt.dateTime(e.createdAt)}

    ))} 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. */