mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
Merge pull request #384 from MBombeck/release/v1.25.3
release: v1.25.3 — Mental wellbeing, labs depth, time-format + tile fixes
This commit is contained in:
66 files changed
+2758
-894
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+59
-25
@@ -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": {
|
||||
|
||||
+59
-25
@@ -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": {
|
||||
|
||||
+59
-25
@@ -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": {
|
||||
|
||||
+59
-25
@@ -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": {
|
||||
|
||||
+59
-25
@@ -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": {
|
||||
|
||||
+59
-25
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -120,7 +120,7 @@ function CoachConversationsBody() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex h-full min-h-0 w-full max-w-2xl flex-col gap-4 px-4 pt-6 pb-4 md:px-6">
|
||||
<div className="mx-auto flex h-full min-h-0 w-full max-w-screen-xl flex-col gap-4 px-4 pt-6 pb-4 md:px-6">
|
||||
<header className="flex flex-col gap-3">
|
||||
<h1
|
||||
data-slot="coach-conversations-heading"
|
||||
|
||||
@@ -480,6 +480,9 @@ export default function InsightsPage() {
|
||||
// generate" one. Suppressed when no provider is configured (that hint
|
||||
// owns the surface and a retry would be futile).
|
||||
generationFailed={advisor.generationFailed && advisor.hasProvider}
|
||||
// v1.25.3 — failure class points the empty-state hint at the right
|
||||
// lever (raise the response timeout vs re-check the provider).
|
||||
generationFailureClass={advisor.generationFailureClass}
|
||||
// v1.21.2 (A4) — server-resolved recall + forward-look off the dashboard
|
||||
// snapshot. Both strings are already localised; null leaves the card's
|
||||
// memory block unrendered.
|
||||
|
||||
@@ -26,9 +26,8 @@ import { NotificationsSection } from "@/components/settings/notifications-sectio
|
||||
import { PrivacySection } from "@/components/settings/privacy-section";
|
||||
import { SectionPlaceholder } from "@/components/settings/section-placeholder";
|
||||
import { VorsorgeSection } from "@/components/settings/vorsorge-section";
|
||||
// v1.18.1 (D4) — `channels` and `sources` are standalone left-side entries
|
||||
// again (split out of the Integrations sub-tabs).
|
||||
import { ChannelsSection } from "@/components/settings/channels-section";
|
||||
// v1.18.1 (D4) — `sources` is a standalone left-side entry (split out of the
|
||||
// Integrations sub-tabs). v1.25.3 — `channels` folded into Notifications.
|
||||
import { SharingSection } from "@/components/settings/sharing-section";
|
||||
import { SourcesSection } from "@/components/settings/sources-section";
|
||||
import { ThresholdsSection } from "@/components/settings/thresholds-section";
|
||||
@@ -66,7 +65,6 @@ const SECTION_COMPONENTS: Record<
|
||||
ai: AiSection,
|
||||
coach: CoachSection,
|
||||
integrations: IntegrationsSection,
|
||||
channels: ChannelsSection,
|
||||
sources: SourcesSection,
|
||||
notifications: NotificationsSection,
|
||||
layout: LayoutSection,
|
||||
|
||||
@@ -7,7 +7,11 @@ import { toast } from "sonner";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { MODULE_KEYS, MODULE_REGISTRY } from "@/lib/modules/registry";
|
||||
import {
|
||||
MODULE_KEYS,
|
||||
MODULE_REGISTRY,
|
||||
isCodeDisabledModule,
|
||||
} from "@/lib/modules/registry";
|
||||
import type { ModuleKey } from "@/lib/modules/registry";
|
||||
import { SettingsToggle } from "./_shared";
|
||||
import { apiGet, apiPatch } from "@/lib/api/api-fetch";
|
||||
@@ -90,24 +94,30 @@ export function ModuleAvailabilitySection() {
|
||||
description={t("admin.modules.description")}
|
||||
/>
|
||||
<div className="mt-4 space-y-4 pl-7">
|
||||
{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 (
|
||||
<SettingsToggle
|
||||
key={key}
|
||||
label={t(def.labelKey)}
|
||||
description={t(def.descriptionKey)}
|
||||
checked={available}
|
||||
onCheckedChange={(checked) =>
|
||||
mutation.mutate({ [key]: checked } as Partial<AvailabilityMap>)
|
||||
}
|
||||
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 (
|
||||
<SettingsToggle
|
||||
key={key}
|
||||
label={t(def.labelKey)}
|
||||
description={t(def.descriptionKey)}
|
||||
checked={available}
|
||||
onCheckedChange={(checked) =>
|
||||
mutation.mutate({
|
||||
[key]: checked,
|
||||
} as Partial<AvailabilityMap>)
|
||||
}
|
||||
disabled={mutation.isPending}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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")}
|
||||
>
|
||||
<Trash2 className={iconClassName} />
|
||||
</Button>
|
||||
|
||||
@@ -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 `<h2>` above it (mirroring the
|
||||
@@ -588,7 +611,7 @@ export function DailyBriefing({
|
||||
}
|
||||
description={
|
||||
generationFailed
|
||||
? t("insights.dailyBriefing.failedDescription")
|
||||
? t(failedDescriptionKey)
|
||||
: t("insights.dailyBriefing.emptyDescription")
|
||||
}
|
||||
action={
|
||||
|
||||
@@ -56,9 +56,15 @@ describe("<ScoreAnatomyView>", () => {
|
||||
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)", () => {
|
||||
|
||||
@@ -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. */}
|
||||
<div className="absolute top-3 right-3 z-10">
|
||||
<ProvenanceExplainer
|
||||
provenance={provenance}
|
||||
method={method}
|
||||
standard={standard}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Hero: title + ring + caption */}
|
||||
<div className="flex flex-col items-center gap-3 text-center">
|
||||
<h2
|
||||
@@ -199,10 +187,18 @@ export function ScoreAnatomyView({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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. */}
|
||||
<ProvenanceExplainer
|
||||
provenance={provenance}
|
||||
method={method}
|
||||
standard={standard}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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. */}
|
||||
<div className="flex gap-1 self-start">
|
||||
{(["week", "month"] as const).map((p) => {
|
||||
const active = period === p;
|
||||
return (
|
||||
<button
|
||||
key={p}
|
||||
type="button"
|
||||
aria-pressed={active}
|
||||
onClick={() => setPeriod(p)}
|
||||
className={cn(
|
||||
"focus-visible:ring-ring inline-flex min-h-11 items-center rounded-md px-2.5 py-1 text-xs font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none",
|
||||
active
|
||||
? "bg-muted text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{p === "week"
|
||||
? t("insights.narrativeWeek")
|
||||
: t("insights.narrativeMonth")}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{/* 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 `<AskCoachIconButton>` 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. */}
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
{/* Calm segmented control, no chart. */}
|
||||
<div className="flex gap-1">
|
||||
{(["week", "month"] as const).map((p) => {
|
||||
const active = period === p;
|
||||
return (
|
||||
<button
|
||||
key={p}
|
||||
type="button"
|
||||
aria-pressed={active}
|
||||
onClick={() => setPeriod(p)}
|
||||
className={cn(
|
||||
"focus-visible:ring-ring inline-flex min-h-11 items-center rounded-md px-2.5 py-1 text-xs font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none",
|
||||
active
|
||||
? "bg-muted text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{p === "week"
|
||||
? t("insights.narrativeWeek")
|
||||
: t("insights.narrativeMonth")}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<AskCoachIconButton
|
||||
question={t(
|
||||
period === "week"
|
||||
? "insights.coach.seed.periodWeek"
|
||||
: "insights.coach.seed.periodMonth",
|
||||
)}
|
||||
className="shrink-0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* v1.22 (W6) — real paragraphs via the shared ProseBlocks helper
|
||||
@@ -184,19 +200,6 @@ export function PeriodNarrativeCard({
|
||||
user?.timezone,
|
||||
)}
|
||||
</p>
|
||||
|
||||
{/* 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. */}
|
||||
<div className="flex justify-end">
|
||||
<AskCoachAction
|
||||
question={t(
|
||||
period === "week"
|
||||
? "insights.coach.seed.periodWeek"
|
||||
: "insights.coach.seed.periodMonth",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -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<string, string> = {
|
||||
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.
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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}
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{formatDateRange(workout.startedAt, workout.endedAt, locale)}
|
||||
{formatDateRange(
|
||||
workout.startedAt,
|
||||
workout.endedAt,
|
||||
locale,
|
||||
timeFormat,
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-end text-right">
|
||||
|
||||
@@ -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 (
|
||||
<ul
|
||||
@@ -129,7 +139,7 @@ export function WorkoutList({ workouts, className }: WorkoutListProps) {
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<span className="truncate font-medium">{sportName}</span>
|
||||
<span className="text-muted-foreground truncate text-xs">
|
||||
{formatDate(workout.startedAt, locale)}
|
||||
{formatDate(workout.startedAt, locale, timeFormat)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-0.5 text-xs">
|
||||
|
||||
@@ -136,4 +136,22 @@ describe("<LabBiomarkerDetail>", () => {
|
||||
// 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");
|
||||
});
|
||||
});
|
||||
@@ -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.<slug>")`), 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<string, string>();
|
||||
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<HTMLDivElement | null>(null);
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [editFooterEl, setEditFooterEl] = useState<HTMLDivElement | null>(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}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{/* v1.25.1 — "show all readings" rides the header cluster as an icon
|
||||
button, mirroring the metric sub-pages' `<SubPageShell>` control.
|
||||
The full reading feed lives on `/labs/[biomarkerId]/values`; the
|
||||
detail page keeps the numbers-first spine. */}
|
||||
{readings.length > 0 ? (
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
data-slot="lab-show-all-values"
|
||||
className={cn(
|
||||
"text-muted-foreground hover:text-foreground relative size-10",
|
||||
"before:absolute before:-inset-1.5 before:content-['']",
|
||||
)}
|
||||
>
|
||||
<Link
|
||||
href={`/labs/${biomarkerId}/values`}
|
||||
aria-label={t("insights.subPage.showAllValues")}
|
||||
title={t("insights.subPage.showAllValues")}
|
||||
>
|
||||
<ListOrdered className="size-4" aria-hidden="true" />
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
|
||||
onClick={() => setEditOpen(true)}
|
||||
disabled={!marker}
|
||||
aria-label={t("labs.biomarker.edit")}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
{/* 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). */}
|
||||
<DeleteButton
|
||||
onConfirm={() => 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")}
|
||||
>
|
||||
<SlidersHorizontal className="h-4 w-4" />
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
{/* "Show all readings" mirrors the metric sub-pages' `<SubPageShell>`
|
||||
control. The full reading feed lives on
|
||||
`/labs/[biomarkerId]/values`; the detail page keeps the
|
||||
numbers-first spine. */}
|
||||
{readings.length > 0 ? (
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
data-slot="lab-show-all-values"
|
||||
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
|
||||
>
|
||||
<Link
|
||||
href={`/labs/${biomarkerId}/values`}
|
||||
aria-label={t("insights.subPage.showAllValues")}
|
||||
title={t("insights.subPage.showAllValues")}
|
||||
>
|
||||
<ListOrdered className="h-4 w-4" aria-hidden="true" />
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
onClick={() => setAddOpen(true)}
|
||||
// v1.18.10 (W10) — on the narrowest phones the h1 + Edit + Delete +
|
||||
@@ -496,62 +466,6 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
|
||||
/>
|
||||
) : null}
|
||||
</ResponsiveSheet>
|
||||
|
||||
<ResponsiveSheet
|
||||
open={rangeOpen}
|
||||
onOpenChange={setRangeOpen}
|
||||
title={t("labs.biomarker.targetRange.title")}
|
||||
description={t("labs.biomarker.targetRange.description")}
|
||||
footer={
|
||||
<div className="flex w-full justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setRangeOpen(false)}
|
||||
disabled={saveRange.isPending}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => saveRange.mutate()}
|
||||
disabled={saveRange.isPending}
|
||||
>
|
||||
{t("labs.biomarker.targetRange.save")}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="biomarker-lower-bound">
|
||||
{t("labs.biomarker.form.lowerBound")}
|
||||
</Label>
|
||||
<Input
|
||||
id="biomarker-lower-bound"
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
value={lowerInput}
|
||||
onChange={(e) => setLowerInput(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="biomarker-upper-bound">
|
||||
{t("labs.biomarker.form.upperBound")}
|
||||
</Label>
|
||||
<Input
|
||||
id="biomarker-upper-bound"
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
value={upperInput}
|
||||
onChange={(e) => setUpperInput(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{marker?.unit ? (
|
||||
<p className="text-muted-foreground mt-2 text-xs">
|
||||
{t("labs.biomarker.form.rangeHint")}
|
||||
</p>
|
||||
) : null}
|
||||
</ResponsiveSheet>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -63,7 +63,10 @@ export function VorsorgeSettings() {
|
||||
// `<section>` blocks with `text-sm` headings.
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<SettingsCard>
|
||||
{/* 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. */}
|
||||
<SettingsCard id="vorsorge-view" className="scroll-mt-28">
|
||||
<SettingsCardHeader
|
||||
icon={LayoutGrid}
|
||||
title={t("moduleList.viewHeading")}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import {
|
||||
UNANSWERED,
|
||||
answeredCount,
|
||||
buildStepList,
|
||||
isComplete,
|
||||
isReviewStep,
|
||||
nextStep,
|
||||
prevStep,
|
||||
reachableUntilIndex,
|
||||
reviewStep,
|
||||
} from "../check-in-nav";
|
||||
|
||||
/**
|
||||
* The one-question-at-a-time check-in is LINEAR: the step list is
|
||||
* `[1..itemCount, review]`, backward navigation is always allowed, and the
|
||||
* forward ceiling never lets a user skip ahead onto an unanswered question.
|
||||
* These are the contracts the wizard's advance / back / reachability behaviour
|
||||
* rests on (the SSR-only test convention can't drive the clicks, so the pure
|
||||
* grammar is pinned here — the medication wizard's `wizard-payload` precedent).
|
||||
*/
|
||||
|
||||
const PHQ9 = 9;
|
||||
const GAD7 = 7;
|
||||
|
||||
function blank(count: number): number[] {
|
||||
return Array(count).fill(UNANSWERED);
|
||||
}
|
||||
|
||||
describe("check-in step grammar", () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<I18nProvider initialLocale="en">{node}</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
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(<MentalWellbeing />);
|
||||
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(
|
||||
<CheckInWizard
|
||||
instrument="PHQ9"
|
||||
onSubmit={() => {}}
|
||||
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(
|
||||
<CheckInWizard
|
||||
instrument="PHQ9"
|
||||
onSubmit={() => {}}
|
||||
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(
|
||||
<AssessmentResult
|
||||
result={result({
|
||||
emergencyNumber: "112",
|
||||
resources: [{ id: "findahelpline", contacts: ["findahelpline.com"] }],
|
||||
})}
|
||||
onTakeAnother={() => {}}
|
||||
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(
|
||||
<AssessmentResult
|
||||
result={result(null)}
|
||||
onTakeAnother={() => {}}
|
||||
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(<AssessmentHistory rows={rows} />);
|
||||
|
||||
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(<AssessmentHistory rows={[]} />);
|
||||
expect(empty).toContain(mh.history.empty);
|
||||
expect(empty).not.toContain('data-slot="history-list"');
|
||||
});
|
||||
});
|
||||
@@ -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<ChartPoint[]>(() => {
|
||||
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 (
|
||||
<p className="text-muted-foreground py-10 text-center text-sm">
|
||||
{t("mentalHealth.history.empty")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<ResponsiveContainer width="100%" height={CHART_HEIGHT_PX}>
|
||||
<ComposedChart
|
||||
data={points}
|
||||
margin={{ top: 8, right: 12, bottom: 4, left: 0 }}
|
||||
>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
stroke="var(--border)"
|
||||
vertical={false}
|
||||
/>
|
||||
{def.bands.map((band, i) => (
|
||||
<ReferenceArea
|
||||
key={band.key}
|
||||
y1={band.min}
|
||||
// Extend the top band to the instrument max so the last slice fills.
|
||||
y2={i === bandCount - 1 ? def.maxScore : band.max + 1}
|
||||
fill={primary}
|
||||
// 0.04 → ~0.16 across the bands: muted, monochrome, never alarm.
|
||||
fillOpacity={0.04 + (i / Math.max(1, bandCount - 1)) * 0.12}
|
||||
stroke="none"
|
||||
ifOverflow="extendDomain"
|
||||
/>
|
||||
))}
|
||||
<XAxis
|
||||
dataKey="timestamp"
|
||||
type="number"
|
||||
scale="time"
|
||||
domain={["dataMin", "dataMax"]}
|
||||
tickFormatter={(ts: number) => formatDateShort(new Date(ts))}
|
||||
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
|
||||
stroke="var(--border)"
|
||||
minTickGap={32}
|
||||
/>
|
||||
<YAxis
|
||||
domain={[0, def.maxScore]}
|
||||
allowDecimals={false}
|
||||
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
|
||||
stroke="var(--border)"
|
||||
width={32}
|
||||
/>
|
||||
<Tooltip
|
||||
content={(props) => {
|
||||
const active = props.active ?? false;
|
||||
const payload = props.payload as
|
||||
ReadonlyArray<{ payload?: ChartPoint }> | undefined;
|
||||
const point = payload?.[0]?.payload;
|
||||
if (!active || !point) {
|
||||
return <RichChartTooltip active={false} rows={[]} />;
|
||||
}
|
||||
const rowsOut: RichTooltipRow[] = [
|
||||
{
|
||||
name: t("mentalHealth.history.totalLabel"),
|
||||
value: String(point.value),
|
||||
color: primary,
|
||||
},
|
||||
];
|
||||
return (
|
||||
<RichChartTooltip
|
||||
active
|
||||
label={formatDateShort(new Date(point.timestamp))}
|
||||
rows={rowsOut}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="value"
|
||||
stroke={primary}
|
||||
strokeWidth={2}
|
||||
dot={{ r: 3, fill: primary }}
|
||||
activeDot={{ r: 5 }}
|
||||
isAnimationActive={animate}
|
||||
/>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
@@ -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 `<ChartSkeleton>` shell holds the layout.
|
||||
const AssessmentHistoryChartLazy = dynamic(
|
||||
() =>
|
||||
importWithRetry(() => import("./assessment-history-chart")).then((mod) => ({
|
||||
default: mod.AssessmentHistoryChart,
|
||||
})),
|
||||
{ ssr: false, loading: () => <ChartSkeleton /> },
|
||||
);
|
||||
function AssessmentHistoryChart(
|
||||
props: ComponentProps<typeof AssessmentHistoryChartLazy>,
|
||||
) {
|
||||
return (
|
||||
<ChartErrorBoundary>
|
||||
<AssessmentHistoryChartLazy {...props} />
|
||||
</ChartErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
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<InstrumentId>("PHQ9");
|
||||
// Which flagged row's crisis card is currently expanded (re-surfaced).
|
||||
const [openCrisisRowId, setOpenCrisisRowId] = useState<string | null>(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 (
|
||||
<Card data-slot="mental-health-history">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">
|
||||
{t("mentalHealth.history.title")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
{isEmpty ? (
|
||||
<EmptyState
|
||||
icon={<Activity className="size-6" />}
|
||||
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. */}
|
||||
<div
|
||||
className="flex gap-1"
|
||||
role="tablist"
|
||||
aria-label={t("mentalHealth.history.chartTitle")}
|
||||
>
|
||||
{(["PHQ9", "GAD7"] as InstrumentId[]).map((id) => (
|
||||
<Button
|
||||
key={id}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={tab === id}
|
||||
size="sm"
|
||||
variant={tab === id ? "secondary" : "ghost"}
|
||||
className="h-8 px-3 text-xs"
|
||||
onClick={() => {
|
||||
setTab(id);
|
||||
setOpenCrisisRowId(null);
|
||||
}}
|
||||
>
|
||||
{t(`mentalHealth.history.tab.${lower(id)}`)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<AssessmentHistoryChart instrument={tab} rows={forTab} />
|
||||
|
||||
{forTab.length === 0 ? (
|
||||
<p className="text-muted-foreground py-4 text-center text-sm">
|
||||
{t("mentalHealth.history.empty")}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-1.5" data-slot="history-list">
|
||||
{forTab.map((row) => {
|
||||
const flagged = row.item9Flagged;
|
||||
const open = openCrisisRowId === row.id;
|
||||
return (
|
||||
<li key={row.id} className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center justify-between gap-3 text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
{formatDate(row.takenAt)}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{flagged && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-auto p-0"
|
||||
aria-expanded={open}
|
||||
onClick={() =>
|
||||
setOpenCrisisRowId(open ? null : row.id)
|
||||
}
|
||||
data-slot="history-flagged-marker"
|
||||
>
|
||||
<Badge variant="outline">
|
||||
{t("mentalHealth.history.flaggedBadge")}
|
||||
</Badge>
|
||||
</Button>
|
||||
)}
|
||||
<Badge variant="secondary">
|
||||
{t(
|
||||
`mentalHealth.band.${row.instrument}.${row.severityBand}`,
|
||||
)}
|
||||
</Badge>
|
||||
<span className="font-medium tabular-nums">
|
||||
{row.totalScore}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{flagged && open && (
|
||||
<CrisisCard crisis={crisisFromRow(row)} />
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -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 <body>.
|
||||
const headingRef = useRef<HTMLHeadingElement | null>(null);
|
||||
useEffect(() => {
|
||||
headingRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<section
|
||||
className="flex flex-col gap-4"
|
||||
data-slot="mental-health-result"
|
||||
aria-live="polite"
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="-ml-2 w-fit"
|
||||
onClick={onBack}
|
||||
data-slot="result-back-to-landing"
|
||||
>
|
||||
<ArrowLeft className="size-4" aria-hidden="true" />
|
||||
{t("mentalHealth.backToList")}
|
||||
</Button>
|
||||
|
||||
<header className="flex flex-col gap-1">
|
||||
<h1
|
||||
ref={headingRef}
|
||||
tabIndex={-1}
|
||||
className="scroll-mt-4 text-2xl font-semibold outline-none"
|
||||
>
|
||||
{t(`mentalHealth.instrument.${key}`)}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t(`mentalHealth.instrumentDescription.${key}`)}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<p className="text-muted-foreground text-xs" role="status">
|
||||
{t("mentalHealth.saved")}
|
||||
</p>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("mentalHealth.result.title")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3 text-sm">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<span className="text-muted-foreground">
|
||||
{t("mentalHealth.result.totalLabel")}
|
||||
</span>
|
||||
<span className="text-2xl font-semibold">
|
||||
{result.assessment.totalScore}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between">
|
||||
<span className="text-muted-foreground">
|
||||
{t("mentalHealth.result.bandLabel")}
|
||||
</span>
|
||||
<span className="font-medium">
|
||||
{t(
|
||||
`mentalHealth.band.${result.assessment.instrument}.${result.assessment.severityBand}`,
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{result.assessment.totalScore >= result.actionThreshold && (
|
||||
<p className="text-muted-foreground bg-muted/40 rounded-md p-3 text-xs">
|
||||
{t("mentalHealth.considerProfessional")}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{result.crisis && <CrisisCard crisis={result.crisis} />}
|
||||
|
||||
<p className="text-muted-foreground text-[11px] leading-snug">
|
||||
<span className="font-medium">
|
||||
{t("mentalHealth.attributionLabel")}:
|
||||
</span>{" "}
|
||||
{PHQ_GAD_ATTRIBUTION}
|
||||
</p>
|
||||
|
||||
<Button variant="outline" onClick={onTakeAnother}>
|
||||
{t("mentalHealth.result.takeAnother")}
|
||||
</Button>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* v1.25.3 — one-question-at-a-time check-in.
|
||||
*
|
||||
* Replaces the all-questions-at-once `<ol>` 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<number[]>(() =>
|
||||
Array(itemCount).fill(UNANSWERED),
|
||||
);
|
||||
const [functional, setFunctional] = useState<number | null>(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<HTMLHeadingElement | null>(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 (
|
||||
<section
|
||||
className="flex flex-col gap-5"
|
||||
data-slot="mental-health-check-in"
|
||||
aria-busy={isPending || undefined}
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="-ml-2 w-fit"
|
||||
onClick={onBack}
|
||||
data-slot="check-in-back-to-landing"
|
||||
>
|
||||
<ArrowLeft className="size-4" aria-hidden="true" />
|
||||
{t("mentalHealth.backToList")}
|
||||
</Button>
|
||||
|
||||
{/* In-test header — instrument title + ONE standardized explanation line.
|
||||
No disclaimer here (§2); the (i) tooltip on the landing carries it. */}
|
||||
<header className="flex flex-col gap-1">
|
||||
<h1 className="text-2xl font-semibold">
|
||||
{t(`mentalHealth.instrument.${key}`)}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t(`mentalHealth.instrumentDescription.${key}`)}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<Card className="gap-0 p-0">
|
||||
<div className="border-border/70 space-y-1.5 border-b p-4 sm:p-6">
|
||||
<WizardStepper
|
||||
steps={stepList}
|
||||
current={step}
|
||||
reachableUntil={reachableUntil}
|
||||
labels={Object.fromEntries([
|
||||
...Array.from({ length: itemCount }, (_, i) => [
|
||||
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}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<CardContent className="flex flex-col gap-5 p-4 sm:p-6">
|
||||
{!onReview ? (
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("mentalHealth.progress", {
|
||||
current: step,
|
||||
total: itemCount,
|
||||
})}
|
||||
</p>
|
||||
<h2
|
||||
ref={headingRef}
|
||||
tabIndex={-1}
|
||||
className="scroll-mt-4 text-base font-medium outline-none"
|
||||
>
|
||||
{t(`mentalHealth.items.${key}.${step}`)}
|
||||
</h2>
|
||||
<div
|
||||
className="flex flex-col gap-2"
|
||||
role="group"
|
||||
aria-label={t(`mentalHealth.items.${key}.${step}`)}
|
||||
>
|
||||
{SCALE.map((v) => (
|
||||
<Button
|
||||
key={v}
|
||||
type="button"
|
||||
// 44px touch-target floor (WCAG 2.5.5).
|
||||
className="min-h-11 justify-start"
|
||||
variant={items[questionIndex] === v ? "default" : "outline"}
|
||||
aria-pressed={items[questionIndex] === v}
|
||||
onClick={() => selectAnswer(v)}
|
||||
>
|
||||
{t(`mentalHealth.options.${v}`)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-5">
|
||||
<h2
|
||||
ref={headingRef}
|
||||
tabIndex={-1}
|
||||
className="scroll-mt-4 text-base font-medium outline-none"
|
||||
>
|
||||
{t("mentalHealth.review.title")}
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("mentalHealth.review.recap", {
|
||||
answered: answeredCount(items),
|
||||
total: itemCount,
|
||||
})}
|
||||
</p>
|
||||
|
||||
{/* The optional, unscored functional-difficulty follow-up folds
|
||||
into the review step rather than its own dot (§1). */}
|
||||
<div className="border-border/60 flex flex-col gap-2 border-t pt-4">
|
||||
<span className="text-sm">
|
||||
{t("mentalHealth.functionalTitle")}
|
||||
</span>
|
||||
<div
|
||||
className="flex flex-col gap-2"
|
||||
role="group"
|
||||
aria-label={t("mentalHealth.functionalTitle")}
|
||||
>
|
||||
{FUNCTIONAL.map((v) => (
|
||||
<Button
|
||||
key={v}
|
||||
type="button"
|
||||
className="min-h-11 justify-start"
|
||||
variant={functional === v ? "default" : "outline"}
|
||||
aria-pressed={functional === v}
|
||||
onClick={() => setFunctional(v)}
|
||||
>
|
||||
{t(`mentalHealth.functional.${v}`)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isError && (
|
||||
<p className="text-destructive text-sm">
|
||||
{t("mentalHealth.error")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
<div className="border-border/70 flex items-center justify-between gap-2 border-t p-4 sm:p-6">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="h-11"
|
||||
onClick={() => setStep((s) => prevStep(s))}
|
||||
disabled={step === 1 || isPending}
|
||||
data-slot="check-in-back"
|
||||
>
|
||||
{t("mentalHealth.back")}
|
||||
</Button>
|
||||
{!onReview ? (
|
||||
<Button
|
||||
type="button"
|
||||
className="h-11"
|
||||
onClick={() => setStep((s) => nextStep(s, itemCount))}
|
||||
disabled={items[questionIndex] < 0}
|
||||
data-slot="check-in-next"
|
||||
>
|
||||
{t("mentalHealth.next")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
className={cn("h-11")}
|
||||
onClick={() => onSubmit(items, functional)}
|
||||
disabled={!complete || isPending}
|
||||
aria-busy={isPending || undefined}
|
||||
data-slot="check-in-submit"
|
||||
>
|
||||
{isPending
|
||||
? t("mentalHealth.submitting")
|
||||
: t("mentalHealth.submit")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<Card
|
||||
className="border-destructive/40"
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
data-slot="mental-health-crisis-card"
|
||||
>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">
|
||||
{t("mentalHealth.crisis.title")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3 text-sm">
|
||||
<p>{t("mentalHealth.crisis.intro")}</p>
|
||||
<p className="font-medium">
|
||||
{t("mentalHealth.crisis.ifDanger", {
|
||||
emergency: crisis.emergencyNumber,
|
||||
})}
|
||||
</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-muted-foreground text-xs font-medium">
|
||||
{t("mentalHealth.crisis.resourcesTitle")}
|
||||
</span>
|
||||
<ul className="flex flex-col gap-2">
|
||||
{crisis.resources.map((r) => (
|
||||
<li key={r.id} className="flex flex-col">
|
||||
<span className="font-medium">
|
||||
{t(`mentalHealth.crisisResource.${r.id}.name`)}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
{r.contacts.join(" · ")}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<Card className="h-full gap-3" data-slot="instrument-card">
|
||||
<CardContent className="flex h-full flex-col gap-4 p-4">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="font-medium">
|
||||
{t(`mentalHealth.instrument.${key}`)}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{t(`mentalHealth.instrumentSub.${key}`)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="text-muted-foreground min-h-5 text-xs">
|
||||
{last ? (
|
||||
<span className="flex flex-wrap items-center gap-1.5">
|
||||
<span>{t("mentalHealth.lastResult")}</span>
|
||||
<Badge variant="secondary">
|
||||
{t(`mentalHealth.band.${instrument}.${last.severityBand}`)}
|
||||
</Badge>
|
||||
<span>{formatDate(last.takenAt)}</span>
|
||||
</span>
|
||||
) : (
|
||||
<span>{t("mentalHealth.noResultYet")}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-auto pt-0">
|
||||
<Button type="button" className="min-h-11 w-full" onClick={onStart}>
|
||||
{t("mentalHealth.start")}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -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<Phase>("choose");
|
||||
const [instrument, setInstrument] = useState<InstrumentId>("PHQ9");
|
||||
const [items, setItems] = useState<number[]>([]);
|
||||
const [functional, setFunctional] = useState<number | null>(null);
|
||||
const [result, setResult] = useState<CreateResponse | null>(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 `<body>` and land on the new content.
|
||||
const resultHeadingRef = useRef<HTMLDivElement | null>(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<InstrumentId, AssessmentRow>();
|
||||
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 (
|
||||
<div className="mx-auto flex max-w-3xl flex-col gap-6">
|
||||
<header className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<h1 className="text-2xl font-semibold">
|
||||
{t("mentalHealth.pageTitle")}
|
||||
</h1>
|
||||
<InfoHint label={t("mentalHealth.infoTooltip")} />
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("mentalHealth.pageDescription")}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{phase === "choose" && (
|
||||
<section
|
||||
className="flex flex-col gap-3"
|
||||
aria-label={t("mentalHealth.choosePrompt")}
|
||||
>
|
||||
<h2 className="text-sm font-medium">
|
||||
{t("mentalHealth.choosePrompt")}
|
||||
</h2>
|
||||
{(["PHQ9", "GAD7"] as InstrumentId[]).map((id) => (
|
||||
<Card key={id}>
|
||||
<CardContent className="flex items-center justify-between gap-4 p-4">
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">
|
||||
{t(`mentalHealth.instrument.${lower(id)}`)}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{t(`mentalHealth.instrumentSub.${lower(id)}`)}
|
||||
</span>
|
||||
</div>
|
||||
<Button onClick={() => begin(id)}>
|
||||
{t("mentalHealth.start")}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</section>
|
||||
<>
|
||||
<header className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<h1 className="text-2xl font-semibold">
|
||||
{t("mentalHealth.pageTitle")}
|
||||
</h1>
|
||||
<InfoHint label={t("mentalHealth.infoTooltip")} />
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("mentalHealth.pageDescription")}
|
||||
</p>
|
||||
{/* The ONLY place the disclaimer renders as body text (§2). */}
|
||||
<p
|
||||
className="text-muted-foreground text-xs"
|
||||
data-slot="mental-health-disclaimer"
|
||||
>
|
||||
{t("mentalHealth.landing.disclaimer")}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section aria-label={t("mentalHealth.choosePrompt")}>
|
||||
<h2 className="sr-only">{t("mentalHealth.choosePrompt")}</h2>
|
||||
<ul className="grid list-none gap-4 p-0 sm:grid-cols-2">
|
||||
{(["PHQ9", "GAD7"] as InstrumentId[]).map((id) => (
|
||||
<li key={id} className="contents">
|
||||
<InstrumentCard
|
||||
instrument={id}
|
||||
last={lastByInstrument.get(id)}
|
||||
onStart={() => begin(id)}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<AssessmentHistory rows={history?.assessments ?? []} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{phase === "form" && (
|
||||
<section className="flex flex-col gap-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setPhase("choose")}
|
||||
>
|
||||
{t("mentalHealth.back")}
|
||||
</Button>
|
||||
<span className="text-sm font-medium">
|
||||
{t(`mentalHealth.instrument.${lower(instrument)}`)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm">{t("mentalHealth.stem")}</p>
|
||||
|
||||
<ol className="flex flex-col gap-5">
|
||||
{itemIndexes.map((i) => (
|
||||
<li key={i} className="flex flex-col gap-2">
|
||||
<span className="text-sm">
|
||||
{i + 1}.{" "}
|
||||
{t(`mentalHealth.items.${lower(instrument)}.${i + 1}`)}
|
||||
</span>
|
||||
<div
|
||||
className="flex flex-wrap gap-2"
|
||||
role="group"
|
||||
aria-label={t(
|
||||
`mentalHealth.items.${lower(instrument)}.${i + 1}`,
|
||||
)}
|
||||
>
|
||||
{SCALE.map((v) => (
|
||||
<Button
|
||||
key={v}
|
||||
type="button"
|
||||
size="sm"
|
||||
// 44px touch-target floor (WCAG 2.5.5) — these are the
|
||||
// questionnaire option controls a full PHQ-9 taps 36 times.
|
||||
className="min-h-11"
|
||||
variant={items[i] === v ? "default" : "outline"}
|
||||
aria-pressed={items[i] === v}
|
||||
onClick={() =>
|
||||
setItems((prev) => {
|
||||
const next = [...prev];
|
||||
next[i] = v;
|
||||
return next;
|
||||
})
|
||||
}
|
||||
>
|
||||
{t(`mentalHealth.options.${v}`)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
<div className="border-border/60 flex flex-col gap-2 border-t pt-4">
|
||||
<span className="text-sm">{t("mentalHealth.functionalTitle")}</span>
|
||||
<div
|
||||
className="flex flex-wrap gap-2"
|
||||
role="group"
|
||||
aria-label={t("mentalHealth.functionalTitle")}
|
||||
>
|
||||
{FUNCTIONAL.map((v) => (
|
||||
<Button
|
||||
key={v}
|
||||
type="button"
|
||||
size="sm"
|
||||
// 44px touch-target floor (WCAG 2.5.5), matching the scale.
|
||||
className="min-h-11"
|
||||
variant={functional === v ? "default" : "outline"}
|
||||
aria-pressed={functional === v}
|
||||
onClick={() => setFunctional(v)}
|
||||
>
|
||||
{t(`mentalHealth.functional.${v}`)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mutation.isError && (
|
||||
<p className="text-destructive text-sm">
|
||||
{t("mentalHealth.error")}
|
||||
</p>
|
||||
)}
|
||||
<Button onClick={submit} disabled={!complete || mutation.isPending}>
|
||||
{mutation.isPending
|
||||
? t("mentalHealth.submitting")
|
||||
: t("mentalHealth.submit")}
|
||||
</Button>
|
||||
</section>
|
||||
<CheckInWizard
|
||||
instrument={instrument}
|
||||
onSubmit={submit}
|
||||
onBack={backToLanding}
|
||||
isPending={mutation.isPending}
|
||||
isError={mutation.isError}
|
||||
/>
|
||||
)}
|
||||
|
||||
{phase === "result" && result && (
|
||||
<section className="flex flex-col gap-4">
|
||||
<p className="text-muted-foreground text-xs" role="status">
|
||||
{t("mentalHealth.saved")}
|
||||
</p>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle
|
||||
ref={resultHeadingRef}
|
||||
tabIndex={-1}
|
||||
className="scroll-mt-4 outline-none"
|
||||
>
|
||||
{t("mentalHealth.result.title")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3 text-sm">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<span className="text-muted-foreground">
|
||||
{t("mentalHealth.result.totalLabel")}
|
||||
</span>
|
||||
<span className="text-2xl font-semibold">
|
||||
{result.assessment.totalScore}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between">
|
||||
<span className="text-muted-foreground">
|
||||
{t("mentalHealth.result.bandLabel")}
|
||||
</span>
|
||||
<span className="font-medium">
|
||||
{t(
|
||||
`mentalHealth.band.${result.assessment.instrument}.${result.assessment.severityBand}`,
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{result.assessment.totalScore >= result.actionThreshold && (
|
||||
<p className="text-muted-foreground bg-muted/40 rounded-md p-3 text-xs">
|
||||
{t("mentalHealth.considerProfessional")}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{result.crisis && (
|
||||
<Card
|
||||
// v1.25.1 — calm destructive-subtle border instead of the amber
|
||||
// tint v1.25 retired; the role/aria-live carry the urgency, the
|
||||
// tone stays consistent with the rest of the app.
|
||||
className="border-destructive/40"
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">
|
||||
{t("mentalHealth.crisis.title")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3 text-sm">
|
||||
<p>{t("mentalHealth.crisis.intro")}</p>
|
||||
<p className="font-medium">
|
||||
{t("mentalHealth.crisis.ifDanger", {
|
||||
emergency: result.crisis.emergencyNumber,
|
||||
})}
|
||||
</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-muted-foreground text-xs font-medium">
|
||||
{t("mentalHealth.crisis.resourcesTitle")}
|
||||
</span>
|
||||
<ul className="flex flex-col gap-2">
|
||||
{result.crisis.resources.map((r) => (
|
||||
<li key={r.id} className="flex flex-col">
|
||||
<span className="font-medium">
|
||||
{t(`mentalHealth.crisisResource.${r.id}.name`)}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
{r.contacts.join(" · ")}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<p className="text-muted-foreground text-[11px] leading-snug">
|
||||
<span className="font-medium">
|
||||
{t("mentalHealth.attributionLabel")}:
|
||||
</span>{" "}
|
||||
{PHQ_GAD_ATTRIBUTION}
|
||||
</p>
|
||||
|
||||
<Button variant="outline" onClick={() => setPhase("choose")}>
|
||||
{t("mentalHealth.result.takeAnother")}
|
||||
</Button>
|
||||
</section>
|
||||
<AssessmentResult
|
||||
result={result}
|
||||
onTakeAnother={backToLanding}
|
||||
onBack={backToLanding}
|
||||
/>
|
||||
)}
|
||||
|
||||
<section className="border-border/60 flex flex-col gap-2 border-t pt-4">
|
||||
<h2 className="text-sm font-medium">
|
||||
{t("mentalHealth.history.title")}
|
||||
</h2>
|
||||
{!history || history.assessments.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("mentalHealth.history.empty")}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-1">
|
||||
{history.assessments.map((row) => (
|
||||
<li
|
||||
key={row.id}
|
||||
className="flex items-center justify-between text-sm"
|
||||
>
|
||||
<span>
|
||||
{t(`mentalHealth.instrument.${lower(row.instrument)}`)}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
{t(`mentalHealth.band.${row.instrument}.${row.severityBand}`)}{" "}
|
||||
· {formatDate(row.takenAt)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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("<LayoutSection>", () => {
|
||||
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 `<a href="/settings/…">`.
|
||||
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.");
|
||||
});
|
||||
});
|
||||
@@ -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("<ModulesSection>", () => {
|
||||
// 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
|
||||
|
||||
@@ -188,19 +188,25 @@ describe("settings sections — SSR smoke", () => {
|
||||
expect(html).toContain("Withings");
|
||||
});
|
||||
|
||||
it("<NotificationsSection> renders the single reminder-types home", () => {
|
||||
it("<NotificationsSection> renders the reminders + delivery-channels groups", () => {
|
||||
const html = renderFramed("notifications", <NotificationsSection />);
|
||||
// v1.18.6 (W9) — the visible heading comes from the frame; the proactive
|
||||
// Coach nudge moved to Settings → Coach, so it no longer renders here.
|
||||
expect(html).toContain("settings-section-notifications-title");
|
||||
expect(html).not.toContain("Notification channels");
|
||||
expect(html).not.toContain('data-slot="notifications-channels-cross-link"');
|
||||
expect(html).not.toContain('data-slot="notifications-inbox-cross-link"');
|
||||
// The mood 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.");
|
||||
});
|
||||
|
||||
@@ -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("<SettingsShell>", () => {
|
||||
// (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</a>");
|
||||
// v1.18.1 (D4) — Channels + Sources are their own left-side entries.
|
||||
expect(html).toContain('href="/settings/channels"');
|
||||
expect(html).toContain(">Appearance</a>");
|
||||
// v1.25.3 — Channels folded into Notifications, so it is no longer a
|
||||
// left-side entry; Sources keeps its own.
|
||||
expect(html).not.toContain('href="/settings/channels"');
|
||||
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("<SettingsShell>", () => {
|
||||
// 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</a>");
|
||||
// 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</a>");
|
||||
// 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"');
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* `<ChannelsSection>` — 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 `<NotificationChannelsPanel>`. v1.18.6 (W9) —
|
||||
* the visible heading + subtitle now come from the shared
|
||||
* `<SettingsSectionFrame>` in the route; this wrapper is the panel only.
|
||||
*/
|
||||
|
||||
import { NotificationChannelsPanel } from "@/components/settings/integrations/notification-channels-panel";
|
||||
|
||||
export function ChannelsSection() {
|
||||
return <NotificationChannelsPanel />;
|
||||
}
|
||||
@@ -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<LayoutLink> = [
|
||||
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() {
|
||||
|
||||
@@ -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")}
|
||||
</p>
|
||||
<div className="divide-border divide-y pl-7">
|
||||
{(Object.keys(MODULE_REGISTRY) as ModuleKey[]).map((key) => {
|
||||
const def = MODULE_REGISTRY[key];
|
||||
// Default-on: a module is enabled unless explicitly `false`.
|
||||
const enabled = modules[key] !== false;
|
||||
// Delegated modules (cycle/coach) are owned elsewhere — never a
|
||||
// live toggle here; deep-link to their real control instead.
|
||||
const delegated = moduleDelegatesTo(key) !== undefined;
|
||||
// Operator turned this module off server-wide: no per-user
|
||||
// toggle can re-enable it, so show a read-only note. Delegated
|
||||
// rows already deep-link, so this only matters for owned keys.
|
||||
const operatorDisabled =
|
||||
!delegated && moduleAvailability[key] === false;
|
||||
return (
|
||||
<ModuleToggleRow
|
||||
key={key}
|
||||
moduleKey={key}
|
||||
icon={MODULE_ICONS[key]}
|
||||
label={t(def.labelKey)}
|
||||
description={t(def.descriptionKey)}
|
||||
enabled={enabled}
|
||||
pending={toggle.isPending}
|
||||
managedAt={
|
||||
delegated && def.managedAt
|
||||
? {
|
||||
href: def.managedAt.href,
|
||||
label: t(def.managedAt.labelKey),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
manageLinkLabel={
|
||||
delegated && def.managedAt
|
||||
? t("settings.sections.modules.manageIn", {
|
||||
section: t(def.managedAt.labelKey),
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
operatorDisabled={operatorDisabled}
|
||||
operatorNote={t("settings.sections.modules.operatorDisabled")}
|
||||
onToggle={(next) => toggle.mutate({ key, enabled: next })}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{(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 (
|
||||
<ModuleToggleRow
|
||||
key={key}
|
||||
moduleKey={key}
|
||||
icon={MODULE_ICONS[key]}
|
||||
label={t(def.labelKey)}
|
||||
description={t(def.descriptionKey)}
|
||||
enabled={enabled}
|
||||
pending={toggle.isPending}
|
||||
managedAt={
|
||||
delegated && def.managedAt
|
||||
? {
|
||||
href: def.managedAt.href,
|
||||
label: t(def.managedAt.labelKey),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
manageLinkLabel={
|
||||
delegated && def.managedAt
|
||||
? t("settings.sections.modules.manageIn", {
|
||||
section: t(def.managedAt.labelKey),
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
operatorDisabled={operatorDisabled}
|
||||
operatorNote={t("settings.sections.modules.operatorDisabled")}
|
||||
onToggle={(next) => toggle.mutate({ key, enabled: next })}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</SettingsCard>
|
||||
);
|
||||
|
||||
@@ -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";
|
||||
|
||||
/**
|
||||
* `<NotificationsSection>` — 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
|
||||
* `<NotificationChannelsPanel>`. 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
|
||||
* `<SettingsSectionFrame>`. The proactive-Coach nudge card moved to Settings →
|
||||
* Coach (it is a Coach setting, not a generic notification), so this screen
|
||||
* carries the mood + low-stock reminder cards only.
|
||||
* v1.18.6 (W9) — the page heading + subtitle come from the shared
|
||||
* `<SettingsSectionFrame>`. The proactive-Coach nudge card lives under
|
||||
* Settings → Coach (it is a Coach setting, not a generic notification).
|
||||
*/
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
{/* Mood check-in reminder — only when the mood module is enabled. */}
|
||||
{moodEnabled ? (
|
||||
<div id="mood-reminder" className="scroll-mt-28">
|
||||
<MoodReminderCard isAuthenticated={isAuthenticated} />
|
||||
</div>
|
||||
<div className="space-y-10">
|
||||
{/* Group 1 — Reminders ("what you receive"). Rendered only when at
|
||||
least one reminder-type card is visible, so a fully-disabled
|
||||
account never shows an empty heading. */}
|
||||
{anyReminder ? (
|
||||
<section className="space-y-4">
|
||||
<div className="space-y-0.5">
|
||||
<h2 className="text-foreground text-lg font-semibold">
|
||||
{t("settings.sections.notifications.remindersHeading")}
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("settings.sections.notifications.remindersDescription")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Mood check-in reminder — only when the mood module is enabled. */}
|
||||
{moodEnabled ? (
|
||||
<div id="mood-reminder" className="scroll-mt-28">
|
||||
<MoodReminderCard isAuthenticated={isAuthenticated} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Medication low-stock runway — only when the medications module
|
||||
is enabled (toggleable, fail-open, since D3). */}
|
||||
{medsEnabled ? (
|
||||
<div id="low-stock" className="scroll-mt-28">
|
||||
<LowStockCard isAuthenticated={isAuthenticated} />
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{/* Medication low-stock runway — only when the medications module is
|
||||
enabled (toggleable, fail-open, since D3). */}
|
||||
{medsEnabled ? (
|
||||
<div id="low-stock" className="scroll-mt-28">
|
||||
<LowStockCard isAuthenticated={isAuthenticated} />
|
||||
{/* Group 2 — Delivery channels ("where it's delivered"). The existing
|
||||
channels panel, unchanged. `id="channels"` anchors the group for the
|
||||
`/settings/channels` redirect. */}
|
||||
<section id="channels" className="scroll-mt-28 space-y-4">
|
||||
<div className="space-y-0.5">
|
||||
<h2 className="text-foreground text-lg font-semibold">
|
||||
{t("settings.sections.notifications.channelsHeading")}
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("settings.sections.notifications.channelsDescription")}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
<NotificationChannelsPanel />
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -29,7 +29,6 @@ import {
|
||||
Link2,
|
||||
Lock,
|
||||
Pill,
|
||||
Radio,
|
||||
Settings2,
|
||||
Share2,
|
||||
ShieldCheck,
|
||||
@@ -51,7 +50,6 @@ const SLUG_ICON: Record<SettingsSectionSlug, LucideIcon> = {
|
||||
security: ShieldCheck,
|
||||
modules: Blocks,
|
||||
integrations: Link2,
|
||||
channels: Radio,
|
||||
sources: Layers,
|
||||
notifications: Bell,
|
||||
layout: LayoutDashboard,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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({
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-muted-foreground shrink-0 text-xs">
|
||||
{new Date(e.createdAt).toLocaleString()}
|
||||
{fmt.dateTime(e.createdAt)}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
// Collapsed by default — the list opens only when the user asks for it, so
|
||||
@@ -158,7 +159,7 @@ export function SecuritySessionsCard({
|
||||
{s.lastActiveAt && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("settings.security.lastActive", {
|
||||
time: new Date(s.lastActiveAt).toLocaleString(),
|
||||
time: fmt.dateTime(s.lastActiveAt),
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<typeof useTimeFormatPreference>,
|
||||
): 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;
|
||||
|
||||
@@ -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<typeof hourCycleOptions>[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/);
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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, string | number>): 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/);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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<void> {
|
||||
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 }),
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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(),
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<string> = 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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 `{ <writableKey>?: boolean }` from the directly-owned key list. */
|
||||
|
||||
Reference in new issue
Block a user