feat(mental-health): rebuild the check-in page around four instrument cards

The landing slims to intro + instrument cards: the combined history card
leaves the main page entirely. Each card body now opens a per-instrument
detail sheet (the med-/Vorsorge card interaction) carrying the last
score + band, the score-over-time trend chart (restored from the
pre-v1.27.6 chart, lazy Recharts, bands surfacing discreetly in the
tooltip), the dated result history with the crisis re-surface, the Start
action, and the instrument's required attribution line — the Verlauf is
deliberately opt-in behind a click, never pushed onto the page someone
arrives at to take stock.

The wizard drives from the registry: option values in source order (the
WHO-5 leads with 5 = all of the time, the SCI with 4), shared vs
per-item anchor groups, and recall-stem captions above the WHO-5 / SCI
item fragments. WHO-5 items and anchors ship VERBATIM from the official
WHO publication in all six locales; the SCI is validated in English, so
every non-English locale presents the English items plus a localized
'validated in English' note (a self-made translation would invalidate
the instrument — the leak-guard allowlist documents this as intended).
Result view: direction-aware follow-up hints (WHO-5 <= 50 points gently
to the PHQ-9 check-in; SCI <= 16 carries the paper's neutral band
wording; PHQ-9 / GAD-7 keep their >= 10 hint). The intro states each
recall window honestly (two weeks vs past month). The Vorsorge screening
group offers all four instruments. Chrome localized in de/en/es/fr/it/pl.
This commit is contained in:
Marc-André Bombeck committed 2026-07-05 20:34:01 +02:00
1 parent 03d4496d3a
commit 8f1485fdcb
16 files changed
+1445 -268

No files matched your search

+106 -12
View File
@@ -599,7 +599,9 @@
"typeWaistCircumference": "Taillenumfang",
"typeWaistToHeight": "Taille-Größe-Verhältnis",
"unitScore": "Pkt.",
"unitRatio": "Verhältnis"
"unitRatio": "Verhältnis",
"typeWho5Score": "WHO-5-Wert",
"typeSciScore": "SCI-Wert"
},
"mood": {
"title": "Stimmung",
@@ -7639,8 +7641,8 @@
},
"mentalHealth": {
"pageTitle": "Check-in zum seelischen Wohlbefinden",
"pageDescription": "Zwei validierte, freiwillige Selbsttests — PHQ-9 (Stimmung) und GAD-7 (Sorgen) — ergänzend zu deinem Stimmungstagebuch. Aussagekräftig ist der Verlauf über die Zeit, nicht ein einzelner Wert.",
"pageRhythmHint": "Beide Tests blicken auf die letzten zwei Wochen zurück — ein Check-in alle zwei bis vier Wochen ist deshalb ein guter Rhythmus.",
"pageDescription": "Vier validierte, freiwillige Selbsttests — PHQ-9 (Stimmung), GAD-7 (Sorgen), WHO-5 (Wohlbefinden) und SCI (Schlaf) — ergänzend zu deinem Stimmungstagebuch. Aussagekräftig ist der Verlauf über die Zeit, nicht ein einzelner Wert.",
"pageRhythmHint": "PHQ-9, GAD-7 und der Wohlbefindens-Check blicken auf die letzten zwei Wochen zurück, der Schlaf-Check auf den letzten Monat — ein Check-in alle zwei bis vier Wochen ist ein guter Rhythmus.",
"attributionLabel": "Quelle",
"choosePrompt": "Check-in auswählen",
"start": "Starten",
@@ -7649,14 +7651,17 @@
"submitting": "Wird gespeichert…",
"saved": "Gespeichert",
"error": "Etwas ist schiefgelaufen. Bitte versuche es erneut.",
"considerProfessional": "Dein Wert liegt in einem Bereich, in dem ein Gespräch mit einer Fachperson helfen kann. Das ist ein behutsamer Hinweis, keine Diagnose.",
"instrument": {
"phq9": "PHQ-9 · Stimmung",
"gad7": "GAD-7 · Sorgen"
"gad7": "GAD-7 · Sorgen",
"who5": "WHO-5 · Wohlbefinden",
"sci": "SCI · Schlaf"
},
"instrumentSub": {
"phq9": "Depressions-Screening, 9 Fragen",
"gad7": "Angst-Screening, 7 Fragen"
"gad7": "Angst-Screening, 7 Fragen",
"who5": "Wohlbefindens-Check, 5 Fragen",
"sci": "Schlaf-Check, 8 Fragen"
},
"options": {
"0": "Überhaupt nicht",
@@ -7691,16 +7696,23 @@
"mild": "Leicht",
"moderate": "Mittelgradig",
"severe": "Schwer"
},
"WHO5": {
"low": "Niedrigeres Wohlbefinden",
"good": "Gutes Wohlbefinden"
},
"SCI": {
"belowThreshold": "Möglicher Insomnie-Bereich",
"aboveThreshold": "Kein Insomnie-Hinweis"
}
},
"history": {
"title": "Dein Verlauf",
"empty": "Noch keine Check-ins. Dein Verlauf erscheint hier, sobald du einen abgeschlossen hast.",
"flaggedBadge": "Hilfe angezeigt",
"tab": {
"phq9": "PHQ-9",
"gad7": "GAD-7"
}
"chartTitle": "Punkteverlauf",
"totalLabel": "Punkte",
"bandLabel": "Einstufung"
},
"crisis": {
"title": "Du musst das nicht allein durchstehen",
@@ -7728,6 +7740,23 @@
"5": "Rastlosigkeit, sodass Stillsitzen schwerfällt",
"6": "Schnelle Verärgerung oder Gereiztheit",
"7": "Gefühl der Angst, als würde etwas Schlimmes passieren"
},
"who5": {
"1": "… war ich froh und guter Laune",
"2": "… habe ich mich ruhig und entspannt gefühlt",
"3": "… habe ich mich energisch und aktiv gefühlt",
"4": "… habe ich mich beim Aufwachen frisch und ausgeruht gefühlt",
"5": "… war mein Alltag voller Dinge, die mich interessieren"
},
"sci": {
"1": "… how long does it take you to fall asleep?",
"2": "… if you then wake up during the night … how long are you awake for in total? (add all the wakenings up)",
"3": "… how many nights a week do you have a problem with your sleep?",
"4": "… how would you rate your sleep quality?",
"5": "… affected your mood, energy, or relationships?",
"6": "… affected your concentration, productivity, or ability to stay awake?",
"7": "… troubled you in general?",
"8": "… how long have you had a problem with your sleep?"
}
},
"crisisResource": {
@@ -7755,14 +7784,79 @@
},
"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."
"gad7": "Angst-Screening mit 7 Fragen. Beziehe dich auf die letzten zwei Wochen.",
"who5": "Wohlbefindens-Check mit 5 Fragen. Beziehe dich auf die letzten zwei Wochen; höhere Werte bedeuten besseres Wohlbefinden.",
"sci": "Schlaf-Check mit 8 Fragen. Die meisten Fragen beziehen sich auf den letzten Monat; höhere Werte bedeuten besseren Schlaf."
},
"progress": "{current} / {total}",
"next": "Weiter",
"lastResult": "Letzter Test",
"lastScore": "Letztes Ergebnis",
"noResultYet": "Noch kein Check-in",
"backToList": "Übersicht"
"backToList": "Übersicht",
"followUpHint": {
"phq9": "Dein Wert liegt in einem Bereich, in dem ein Gespräch mit einer Fachperson helfen kann. Das ist ein behutsamer Hinweis, keine Diagnose.",
"gad7": "Dein Wert liegt in einem Bereich, in dem ein Gespräch mit einer Fachperson helfen kann. Das ist ein behutsamer Hinweis, keine Diagnose.",
"who5": "Ein Wohlbefindens-Wert von 50 oder darunter kann einen genaueren Blick wert sein. Wenn du magst, schaut der PHQ-9-Check-in auf dieser Seite genauer auf die Stimmung. Das ist ein behutsamer Hinweis, keine Diagnose.",
"sci": "In der Studie zu diesem Fragebogen waren Werte von 16 oder darunter mit wahrscheinlicher Insomnie verbunden. Wenn schlechter Schlaf dich weiter belastet, kann ein Gespräch mit einer Fachperson helfen. Das ist keine Diagnose."
},
"validatedInEnglishNote": "Dieser Fragebogen ist in englischer Sprache validiert; die Fragen erscheinen im englischen Original.",
"who5Options": {
"5": "Die ganze Zeit",
"4": "Meistens",
"3": "Etwas mehr als die Hälfte der Zeit",
"2": "Etwas weniger als die Hälfte der Zeit",
"1": "Ab und zu",
"0": "Zu keinem Zeitpunkt"
},
"sciOptions": {
"duration": {
"4": "0–15 min",
"3": "16–30 min",
"2": "31–45 min",
"1": "46–60 min",
"0": "≥ 61 min"
},
"nights": {
"4": "0–1",
"3": "2",
"2": "3",
"1": "4",
"0": "5–7"
},
"quality": {
"4": "Very good",
"3": "Good",
"2": "Average",
"1": "Poor",
"0": "Very poor"
},
"impact": {
"4": "Not at all",
"3": "A little",
"2": "Somewhat",
"1": "Much",
"0": "Very much"
},
"problemDuration": {
"4": "I don't have a problem / < 1 month",
"3": "1–2 months",
"2": "3–6 months",
"1": "7–12 months",
"0": "> 1 year"
}
},
"stems": {
"who5": {
"period": "In den letzten zwei Wochen …"
},
"sci": {
"night": "Thinking about a typical night in the last month …",
"impact": "Thinking about the past month, to what extent has poor sleep …",
"finally": "Finally …"
}
},
"openDetail": "Details anzeigen"
},
"records": {
"disclaimer": "Das sind deine eigenen Angaben, keine ärztliche Diagnose. HealthLog stellt keine Diagnose, behandelt nicht und deutet nichts.",
+106 -12
View File
@@ -599,7 +599,9 @@
"typeWaistCircumference": "Waist circumference",
"typeWaistToHeight": "Waist-to-height ratio",
"unitScore": "pts",
"unitRatio": "ratio"
"unitRatio": "ratio",
"typeWho5Score": "WHO-5 score",
"typeSciScore": "SCI score"
},
"mood": {
"title": "Mood",
@@ -7639,8 +7641,8 @@
},
"mentalHealth": {
"pageTitle": "Mental wellbeing check-in",
"pageDescription": "Two validated, opt-in self-assessments — PHQ-9 (mood) and GAD-7 (worry) — that sit beside your mood tracking. The value is the trend over time, not any single number.",
"pageRhythmHint": "Both tests look back over the last two weeks, so a check-in every two to four weeks is a comfortable rhythm.",
"pageDescription": "Validated, opt-in self-checks — PHQ-9 (mood), GAD-7 (worry), WHO-5 (well-being) and SCI (sleep) — that sit beside your mood tracking. The value is the trend over time, not any single number.",
"pageRhythmHint": "PHQ-9, GAD-7 and the well-being check look back over the last two weeks; the sleep check asks about the past month. A check-in every two to four weeks is a comfortable rhythm.",
"attributionLabel": "Attribution",
"choosePrompt": "Choose a check-in",
"start": "Start",
@@ -7649,14 +7651,17 @@
"submitting": "Saving…",
"saved": "Saved",
"error": "Something went wrong. Please try again.",
"considerProfessional": "Your score is in a range where talking to a health professional may help. This is a gentle suggestion, not a diagnosis.",
"instrument": {
"phq9": "PHQ-9 · mood",
"gad7": "GAD-7 · worry"
"gad7": "GAD-7 · worry",
"who5": "WHO-5 · well-being",
"sci": "SCI · sleep"
},
"instrumentSub": {
"phq9": "Depression screen, 9 questions",
"gad7": "Anxiety screen, 7 questions"
"gad7": "Anxiety screen, 7 questions",
"who5": "Well-being check, 5 questions",
"sci": "Sleep check, 8 questions"
},
"options": {
"0": "Not at all",
@@ -7691,16 +7696,23 @@
"mild": "Mild",
"moderate": "Moderate",
"severe": "Severe"
},
"WHO5": {
"low": "Lower well-being",
"good": "Good well-being"
},
"SCI": {
"belowThreshold": "Possible insomnia range",
"aboveThreshold": "No insomnia indication"
}
},
"history": {
"title": "Your history",
"empty": "No check-ins yet. Your trend will appear here once you complete one.",
"flaggedBadge": "Support shown",
"tab": {
"phq9": "PHQ-9",
"gad7": "GAD-7"
}
"chartTitle": "Score trend",
"totalLabel": "Score",
"bandLabel": "Category"
},
"crisis": {
"title": "You don't have to face this alone",
@@ -7728,6 +7740,23 @@
"5": "Being so restless that it is hard to sit still",
"6": "Becoming easily annoyed or irritable",
"7": "Feeling afraid as if something awful might happen"
},
"who5": {
"1": "I have felt cheerful and in good spirits",
"2": "I have felt calm and relaxed",
"3": "I have felt active and vigorous",
"4": "I woke up feeling fresh and rested",
"5": "My daily life has been filled with things that interest me"
},
"sci": {
"1": "… how long does it take you to fall asleep?",
"2": "… if you then wake up during the night … how long are you awake for in total? (add all the wakenings up)",
"3": "… how many nights a week do you have a problem with your sleep?",
"4": "… how would you rate your sleep quality?",
"5": "… affected your mood, energy, or relationships?",
"6": "… affected your concentration, productivity, or ability to stay awake?",
"7": "… troubled you in general?",
"8": "… how long have you had a problem with your sleep?"
}
},
"crisisResource": {
@@ -7755,14 +7784,79 @@
},
"instrumentDescription": {
"phq9": "9-question depression screen. Answer for the last two weeks.",
"gad7": "7-question anxiety screen. Answer for the last two weeks."
"gad7": "7-question anxiety screen. Answer for the last two weeks.",
"who5": "5-question well-being check. Answer for the last two weeks; higher numbers mean better well-being.",
"sci": "8-question sleep check. Most questions ask about the past month; higher scores mean better sleep."
},
"progress": "{current} / {total}",
"next": "Next",
"lastResult": "Last test",
"lastScore": "Last result",
"noResultYet": "No check-in yet",
"backToList": "Overview"
"backToList": "Overview",
"followUpHint": {
"phq9": "Your score is in a range where talking to a health professional may help. This is a gentle suggestion, not a diagnosis.",
"gad7": "Your score is in a range where talking to a health professional may help. This is a gentle suggestion, not a diagnosis.",
"who5": "A well-being score of 50 or below can be worth a closer look. If you like, the PHQ-9 check-in on this page looks at mood in more depth. This is a gentle suggestion, not a diagnosis.",
"sci": "In the study behind this questionnaire, scores of 16 or below were associated with probable insomnia. If poor sleep keeps troubling you, talking to a health professional may help. This is not a diagnosis."
},
"validatedInEnglishNote": "This questionnaire is validated in English; the questions are shown in the original English wording.",
"who5Options": {
"5": "All of the time",
"4": "Most of the time",
"3": "More than half of the time",
"2": "Less than half of the time",
"1": "Some of the time",
"0": "At no time"
},
"sciOptions": {
"duration": {
"4": "0–15 min",
"3": "16–30 min",
"2": "31–45 min",
"1": "46–60 min",
"0": "≥ 61 min"
},
"nights": {
"4": "0–1",
"3": "2",
"2": "3",
"1": "4",
"0": "5–7"
},
"quality": {
"4": "Very good",
"3": "Good",
"2": "Average",
"1": "Poor",
"0": "Very poor"
},
"impact": {
"4": "Not at all",
"3": "A little",
"2": "Somewhat",
"1": "Much",
"0": "Very much"
},
"problemDuration": {
"4": "I don't have a problem / < 1 month",
"3": "1–2 months",
"2": "3–6 months",
"1": "7–12 months",
"0": "> 1 year"
}
},
"stems": {
"who5": {
"period": "Over the last two weeks …"
},
"sci": {
"night": "Thinking about a typical night in the last month …",
"impact": "Thinking about the past month, to what extent has poor sleep …",
"finally": "Finally …"
}
},
"openDetail": "Show details"
},
"records": {
"disclaimer": "These are your own records, not a medical diagnosis. HealthLog does not diagnose, treat, or interpret them.",
+106 -12
View File
@@ -599,7 +599,9 @@
"typeWaistCircumference": "Perímetro de cintura",
"typeWaistToHeight": "Índice cintura-altura",
"unitScore": "pts",
"unitRatio": "ratio"
"unitRatio": "ratio",
"typeWho5Score": "Puntuación WHO-5",
"typeSciScore": "Puntuación SCI"
},
"mood": {
"title": "Estado de ánimo",
@@ -7639,8 +7641,8 @@
},
"mentalHealth": {
"pageTitle": "Revisión de bienestar mental",
"pageDescription": "Dos autoevaluaciones validadas y opcionales — PHQ-9 (estado de ánimo) y GAD-7 (preocupación) — junto a tu registro de ánimo. Lo valioso es la tendencia en el tiempo, no un único número.",
"pageRhythmHint": "Ambos tests se refieren a las últimas dos semanas, por lo que un check-in cada dos a cuatro semanas es un buen ritmo.",
"pageDescription": "Cuatro autoevaluaciones validadas y opcionales — PHQ-9 (estado de ánimo), GAD-7 (preocupación), WHO-5 (bienestar) y SCI (sueño) — junto a tu registro de ánimo. Lo valioso es la tendencia en el tiempo, no un único número.",
"pageRhythmHint": "El PHQ-9, el GAD-7 y el test de bienestar se refieren a las últimas dos semanas; el test de sueño pregunta por el último mes. Un check-in cada dos a cuatro semanas es un buen ritmo.",
"attributionLabel": "Fuente",
"choosePrompt": "Elige una revisión",
"start": "Empezar",
@@ -7649,14 +7651,17 @@
"submitting": "Guardando…",
"saved": "Guardado",
"error": "Algo salió mal. Inténtalo de nuevo.",
"considerProfessional": "Tu puntuación está en un rango en el que hablar con un profesional de salud puede ayudar. Es una sugerencia amable, no un diagnóstico.",
"instrument": {
"phq9": "PHQ-9 · ánimo",
"gad7": "GAD-7 · preocupación"
"gad7": "GAD-7 · preocupación",
"who5": "WHO-5 · bienestar",
"sci": "SCI · sueño"
},
"instrumentSub": {
"phq9": "Cribado de depresión, 9 preguntas",
"gad7": "Cribado de ansiedad, 7 preguntas"
"gad7": "Cribado de ansiedad, 7 preguntas",
"who5": "Test de bienestar, 5 preguntas",
"sci": "Test de sueño, 8 preguntas"
},
"options": {
"0": "Nunca",
@@ -7691,16 +7696,23 @@
"mild": "Leve",
"moderate": "Moderada",
"severe": "Grave"
},
"WHO5": {
"low": "Bienestar más bajo",
"good": "Buen bienestar"
},
"SCI": {
"belowThreshold": "Rango de posible insomnio",
"aboveThreshold": "Sin indicios de insomnio"
}
},
"history": {
"title": "Tu historial",
"empty": "Aún no hay revisiones. Tu tendencia aparecerá aquí cuando completes una.",
"flaggedBadge": "Apoyo mostrado",
"tab": {
"phq9": "PHQ-9",
"gad7": "GAD-7"
}
"chartTitle": "Evolución de la puntuación",
"totalLabel": "Puntuación",
"bandLabel": "Categoría"
},
"crisis": {
"title": "No tienes que afrontar esto a solas",
@@ -7728,6 +7740,23 @@
"5": "Estar tan inquieto/a que es difícil quedarse sentado/a tranquilamente",
"6": "Molestarse o irritarse fácilmente",
"7": "Sentir miedo como si algo terrible fuera a suceder"
},
"who5": {
"1": "Me he sentido alegre y de buen humor",
"2": "Me he sentido tranquilo y relajado",
"3": "Me he sentido activo y enérgico",
"4": "Me he despertado fresco y descansado",
"5": "Mi vida cotidiana ha estado llena de cosas que me interesan"
},
"sci": {
"1": "… how long does it take you to fall asleep?",
"2": "… if you then wake up during the night … how long are you awake for in total? (add all the wakenings up)",
"3": "… how many nights a week do you have a problem with your sleep?",
"4": "… how would you rate your sleep quality?",
"5": "… affected your mood, energy, or relationships?",
"6": "… affected your concentration, productivity, or ability to stay awake?",
"7": "… troubled you in general?",
"8": "… how long have you had a problem with your sleep?"
}
},
"crisisResource": {
@@ -7755,14 +7784,79 @@
},
"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."
"gad7": "Cribado de ansiedad de 7 preguntas. Responde pensando en las últimas dos semanas.",
"who5": "Test de bienestar de 5 preguntas. Responde pensando en las últimas dos semanas; cifras mayores significan mayor bienestar.",
"sci": "Test de sueño de 8 preguntas. La mayoría se refiere al último mes; puntuaciones mayores significan mejor sueño."
},
"progress": "{current} / {total}",
"next": "Siguiente",
"lastResult": "Última prueba",
"lastScore": "Último resultado",
"noResultYet": "Aún sin check-in",
"backToList": "Vista general"
"backToList": "Vista general",
"followUpHint": {
"phq9": "Tu puntuación está en un rango en el que hablar con un profesional de salud puede ayudar. Es una sugerencia amable, no un diagnóstico.",
"gad7": "Tu puntuación está en un rango en el que hablar con un profesional de salud puede ayudar. Es una sugerencia amable, no un diagnóstico.",
"who5": "Una puntuación de bienestar de 50 o menos puede merecer una mirada más atenta. Si quieres, el PHQ-9 de esta página explora el estado de ánimo con más detalle. Es una sugerencia amable, no un diagnóstico.",
"sci": "En el estudio de este cuestionario, puntuaciones de 16 o menos se asociaron con insomnio probable. Si dormir mal te sigue afectando, hablar con un profesional de salud puede ayudar. No es un diagnóstico."
},
"validatedInEnglishNote": "Este cuestionario está validado en inglés; las preguntas se muestran en su versión original en inglés.",
"who5Options": {
"5": "Todo el tiempo",
"4": "La mayor parte del tiempo",
"3": "Más de la mitad del tiempo",
"2": "Menos de la mitad del tiempo",
"1": "De vez en cuando",
"0": "Nunca"
},
"sciOptions": {
"duration": {
"4": "0–15 min",
"3": "16–30 min",
"2": "31–45 min",
"1": "46–60 min",
"0": "≥ 61 min"
},
"nights": {
"4": "0–1",
"3": "2",
"2": "3",
"1": "4",
"0": "5–7"
},
"quality": {
"4": "Very good",
"3": "Good",
"2": "Average",
"1": "Poor",
"0": "Very poor"
},
"impact": {
"4": "Not at all",
"3": "A little",
"2": "Somewhat",
"1": "Much",
"0": "Very much"
},
"problemDuration": {
"4": "I don't have a problem / < 1 month",
"3": "1–2 months",
"2": "3–6 months",
"1": "7–12 months",
"0": "> 1 year"
}
},
"stems": {
"who5": {
"period": "Durante las últimas dos semanas:"
},
"sci": {
"night": "Thinking about a typical night in the last month …",
"impact": "Thinking about the past month, to what extent has poor sleep …",
"finally": "Finally …"
}
},
"openDetail": "Ver detalles"
},
"records": {
"disclaimer": "Son tus propios registros, no un diagnóstico médico. HealthLog no diagnostica, no trata ni los interpreta.",
+106 -12
View File
@@ -599,7 +599,9 @@
"typeWaistCircumference": "Tour de taille",
"typeWaistToHeight": "Rapport taille-taille",
"unitScore": "pts",
"unitRatio": "ratio"
"unitRatio": "ratio",
"typeWho5Score": "Score WHO-5",
"typeSciScore": "Score SCI"
},
"mood": {
"title": "Humeur",
@@ -7639,8 +7641,8 @@
},
"mentalHealth": {
"pageTitle": "Point sur le bien-être mental",
"pageDescription": "Deux auto-évaluations validées et facultatives — PHQ-9 (humeur) et GAD-7 (inquiétude) — en complément de ton suivi de l'humeur. Ce qui compte, c'est la tendance dans le temps, pas un chiffre isolé.",
"pageRhythmHint": "Les deux tests portent sur les deux dernières semaines — un check-in toutes les deux à quatre semaines est donc un bon rythme.",
"pageDescription": "Quatre auto-évaluations validées et facultatives — PHQ-9 (humeur), GAD-7 (inquiétude), WHO-5 (bien-être) et SCI (sommeil) — en complément de ton suivi de l'humeur. Ce qui compte, c'est la tendance dans le temps, pas un chiffre isolé.",
"pageRhythmHint": "Le PHQ-9, le GAD-7 et le test de bien-être portent sur les deux dernières semaines ; le test de sommeil porte sur le dernier mois. Un check-in toutes les deux à quatre semaines est un bon rythme.",
"attributionLabel": "Source",
"choosePrompt": "Choisis un bilan",
"start": "Commencer",
@@ -7649,14 +7651,17 @@
"submitting": "Enregistrement…",
"saved": "Enregistré",
"error": "Une erreur est survenue. Réessaie.",
"considerProfessional": "Ton score se situe dans une plage où parler à un professionnel de santé peut aider. C'est une suggestion bienveillante, pas un diagnostic.",
"instrument": {
"phq9": "PHQ-9 · humeur",
"gad7": "GAD-7 · inquiétude"
"gad7": "GAD-7 · inquiétude",
"who5": "WHO-5 · bien-être",
"sci": "SCI · sommeil"
},
"instrumentSub": {
"phq9": "Dépistage de la dépression, 9 questions",
"gad7": "Dépistage de l'anxiété, 7 questions"
"gad7": "Dépistage de l'anxiété, 7 questions",
"who5": "Test de bien-être, 5 questions",
"sci": "Test de sommeil, 8 questions"
},
"options": {
"0": "Jamais",
@@ -7691,16 +7696,23 @@
"mild": "Légère",
"moderate": "Modérée",
"severe": "Sévère"
},
"WHO5": {
"low": "Bien-être plus faible",
"good": "Bon bien-être"
},
"SCI": {
"belowThreshold": "Zone d'insomnie possible",
"aboveThreshold": "Pas d'indice d'insomnie"
}
},
"history": {
"title": "Ton historique",
"empty": "Aucun bilan pour le moment. Ta tendance apparaîtra ici dès que tu en auras terminé un.",
"flaggedBadge": "Aide affichée",
"tab": {
"phq9": "PHQ-9",
"gad7": "GAD-7"
}
"chartTitle": "Évolution du score",
"totalLabel": "Score",
"bandLabel": "Catégorie"
},
"crisis": {
"title": "Tu n'as pas à affronter cela seul(e)",
@@ -7728,6 +7740,23 @@
"5": "Être si agité(e) qu'il est difficile de rester tranquille",
"6": "Devenir facilement contrarié(e) ou irritable",
"7": "Avoir peur que quelque chose d'épouvantable puisse arriver"
},
"who5": {
"1": "Je me suis senti(e) bien et de bonne humeur",
"2": "Je me suis senti(e) calme et tranquille",
"3": "Je me suis senti(e) plein(e) d'énergie et vigoureux(se)",
"4": "Je me suis réveillé(e) en me sentant frais(che) et dispos(e)",
"5": "Ma vie quotidienne a été remplie de choses intéressantes"
},
"sci": {
"1": "… how long does it take you to fall asleep?",
"2": "… if you then wake up during the night … how long are you awake for in total? (add all the wakenings up)",
"3": "… how many nights a week do you have a problem with your sleep?",
"4": "… how would you rate your sleep quality?",
"5": "… affected your mood, energy, or relationships?",
"6": "… affected your concentration, productivity, or ability to stay awake?",
"7": "… troubled you in general?",
"8": "… how long have you had a problem with your sleep?"
}
},
"crisisResource": {
@@ -7755,14 +7784,79 @@
},
"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."
"gad7": "Dépistage de l'anxiété en 7 questions. Répondez en pensant aux deux dernières semaines.",
"who5": "Test de bien-être en 5 questions. Répondez en pensant aux deux dernières semaines ; des chiffres plus élevés signifient un meilleur bien-être.",
"sci": "Test de sommeil en 8 questions. La plupart des questions portent sur le dernier mois ; des scores plus élevés signifient un meilleur sommeil."
},
"progress": "{current} / {total}",
"next": "Suivant",
"lastResult": "Dernier test",
"lastScore": "Dernier résultat",
"noResultYet": "Pas encore de check-in",
"backToList": "Vue d'ensemble"
"backToList": "Vue d'ensemble",
"followUpHint": {
"phq9": "Ton score se situe dans une plage où parler à un professionnel de santé peut aider. C'est une suggestion bienveillante, pas un diagnostic.",
"gad7": "Ton score se situe dans une plage où parler à un professionnel de santé peut aider. C'est une suggestion bienveillante, pas un diagnostic.",
"who5": "Un score de bien-être de 50 ou moins peut mériter un regard plus attentif. Si tu le souhaites, le PHQ-9 sur cette page explore l'humeur plus en détail. C'est une suggestion bienveillante, pas un diagnostic.",
"sci": "Dans l'étude de ce questionnaire, des scores de 16 ou moins étaient associés à une insomnie probable. Si un mauvais sommeil continue de te peser, parler à un professionnel de santé peut aider. Ce n'est pas un diagnostic."
},
"validatedInEnglishNote": "Ce questionnaire est validé en anglais ; les questions sont affichées dans leur version originale anglaise.",
"who5Options": {
"5": "Tout le temps",
"4": "La plupart du temps",
"3": "Plus de la moitié du temps",
"2": "Moins de la moitié du temps",
"1": "De temps en temps",
"0": "Jamais"
},
"sciOptions": {
"duration": {
"4": "0–15 min",
"3": "16–30 min",
"2": "31–45 min",
"1": "46–60 min",
"0": "≥ 61 min"
},
"nights": {
"4": "0–1",
"3": "2",
"2": "3",
"1": "4",
"0": "5–7"
},
"quality": {
"4": "Very good",
"3": "Good",
"2": "Average",
"1": "Poor",
"0": "Very poor"
},
"impact": {
"4": "Not at all",
"3": "A little",
"2": "Somewhat",
"1": "Much",
"0": "Very much"
},
"problemDuration": {
"4": "I don't have a problem / < 1 month",
"3": "1–2 months",
"2": "3–6 months",
"1": "7–12 months",
"0": "> 1 year"
}
},
"stems": {
"who5": {
"period": "Au cours des deux dernières semaines"
},
"sci": {
"night": "Thinking about a typical night in the last month …",
"impact": "Thinking about the past month, to what extent has poor sleep …",
"finally": "Finally …"
}
},
"openDetail": "Voir les détails"
},
"records": {
"disclaimer": "Ce sont vos propres informations, pas un diagnostic médical. HealthLog ne diagnostique pas, ne traite pas et ne les interprète pas.",
+106 -12
View File
@@ -599,7 +599,9 @@
"typeWaistCircumference": "Circonferenza vita",
"typeWaistToHeight": "Rapporto vita-altezza",
"unitScore": "pti",
"unitRatio": "rapporto"
"unitRatio": "rapporto",
"typeWho5Score": "Punteggio WHO-5",
"typeSciScore": "Punteggio SCI"
},
"mood": {
"title": "Umore",
@@ -7639,8 +7641,8 @@
},
"mentalHealth": {
"pageTitle": "Check-in sul benessere mentale",
"pageDescription": "Due autovalutazioni validate e facoltative — PHQ-9 (umore) e GAD-7 (preoccupazione) — accanto al tuo diario dell'umore. Ciò che conta è l'andamento nel tempo, non un singolo numero.",
"pageRhythmHint": "Entrambi i test riguardano le ultime due settimane, quindi un check-in ogni due-quattro settimane è un buon ritmo.",
"pageDescription": "Quattro autovalutazioni validate e facoltative — PHQ-9 (umore), GAD-7 (preoccupazione), WHO-5 (benessere) e SCI (sonno) — accanto al tuo diario dell'umore. Ciò che conta è l'andamento nel tempo, non un singolo numero.",
"pageRhythmHint": "PHQ-9, GAD-7 e il test sul benessere riguardano le ultime due settimane; il test sul sonno chiede dell'ultimo mese. Un check-in ogni due-quattro settimane è un buon ritmo.",
"attributionLabel": "Fonte",
"choosePrompt": "Scegli un check-in",
"start": "Inizia",
@@ -7649,14 +7651,17 @@
"submitting": "Salvataggio…",
"saved": "Salvato",
"error": "Qualcosa è andato storto. Riprova.",
"considerProfessional": "Il tuo punteggio è in un intervallo in cui parlare con un professionista sanitario può aiutare. È un suggerimento gentile, non una diagnosi.",
"instrument": {
"phq9": "PHQ-9 · umore",
"gad7": "GAD-7 · preoccupazione"
"gad7": "GAD-7 · preoccupazione",
"who5": "WHO-5 · benessere",
"sci": "SCI · sonno"
},
"instrumentSub": {
"phq9": "Screening della depressione, 9 domande",
"gad7": "Screening dell'ansia, 7 domande"
"gad7": "Screening dell'ansia, 7 domande",
"who5": "Test sul benessere, 5 domande",
"sci": "Test sul sonno, 8 domande"
},
"options": {
"0": "Per niente",
@@ -7691,16 +7696,23 @@
"mild": "Lieve",
"moderate": "Moderata",
"severe": "Grave"
},
"WHO5": {
"low": "Benessere più basso",
"good": "Buon benessere"
},
"SCI": {
"belowThreshold": "Fascia di possibile insonnia",
"aboveThreshold": "Nessun indizio di insonnia"
}
},
"history": {
"title": "La tua cronologia",
"empty": "Ancora nessun check-in. Il tuo andamento apparirà qui dopo che ne avrai completato uno.",
"flaggedBadge": "Supporto mostrato",
"tab": {
"phq9": "PHQ-9",
"gad7": "GAD-7"
}
"chartTitle": "Andamento del punteggio",
"totalLabel": "Punteggio",
"bandLabel": "Categoria"
},
"crisis": {
"title": "Non devi affrontare tutto questo da solo/a",
@@ -7728,6 +7740,23 @@
"5": "Essere così irrequieto/a da far fatica a stare seduto/a fermo/a",
"6": "Infastidirsi o irritarsi facilmente",
"7": "Avere paura come se potesse accadere qualcosa di terribile"
},
"who5": {
"1": "Mi sono sentito/a allegro/a e di buon umore",
"2": "Mi sono sentito/a calmo/a e rilassato/a",
"3": "Mi sono sentito/a attivo/a ed energico/a",
"4": "Mi sono svegliato/a sentendomi fresco/a e riposato/a",
"5": "La mia vita di tutti i giorni è stata piena di cose che mi interessano"
},
"sci": {
"1": "… how long does it take you to fall asleep?",
"2": "… if you then wake up during the night … how long are you awake for in total? (add all the wakenings up)",
"3": "… how many nights a week do you have a problem with your sleep?",
"4": "… how would you rate your sleep quality?",
"5": "… affected your mood, energy, or relationships?",
"6": "… affected your concentration, productivity, or ability to stay awake?",
"7": "… troubled you in general?",
"8": "… how long have you had a problem with your sleep?"
}
},
"crisisResource": {
@@ -7755,14 +7784,79 @@
},
"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."
"gad7": "Screening dell'ansia in 7 domande. Rispondi pensando alle ultime due settimane.",
"who5": "Test sul benessere in 5 domande. Rispondi pensando alle ultime due settimane; numeri più alti indicano un maggior benessere.",
"sci": "Test sul sonno in 8 domande. La maggior parte riguarda l'ultimo mese; punteggi più alti indicano un sonno migliore."
},
"progress": "{current} / {total}",
"next": "Avanti",
"lastResult": "Ultimo test",
"lastScore": "Ultimo risultato",
"noResultYet": "Ancora nessun check-in",
"backToList": "Panoramica"
"backToList": "Panoramica",
"followUpHint": {
"phq9": "Il tuo punteggio è in un intervallo in cui parlare con un professionista sanitario può aiutare. È un suggerimento gentile, non una diagnosi.",
"gad7": "Il tuo punteggio è in un intervallo in cui parlare con un professionista sanitario può aiutare. È un suggerimento gentile, non una diagnosi.",
"who5": "Un punteggio di benessere pari o inferiore a 50 può meritare uno sguardo più attento. Se vuoi, il PHQ-9 in questa pagina esplora l'umore più in dettaglio. È un suggerimento gentile, non una diagnosi.",
"sci": "Nello studio di questo questionario, punteggi pari o inferiori a 16 erano associati a insonnia probabile. Se dormire male continua a pesarti, parlare con un professionista sanitario può aiutare. Non è una diagnosi."
},
"validatedInEnglishNote": "Questo questionario è validato in inglese; le domande sono mostrate nella versione originale inglese.",
"who5Options": {
"5": "Sempre",
"4": "La maggior parte del tempo",
"3": "Più della metà del tempo",
"2": "Meno della metà del tempo",
"1": "A volte",
"0": "Mai"
},
"sciOptions": {
"duration": {
"4": "0–15 min",
"3": "16–30 min",
"2": "31–45 min",
"1": "46–60 min",
"0": "≥ 61 min"
},
"nights": {
"4": "0–1",
"3": "2",
"2": "3",
"1": "4",
"0": "5–7"
},
"quality": {
"4": "Very good",
"3": "Good",
"2": "Average",
"1": "Poor",
"0": "Very poor"
},
"impact": {
"4": "Not at all",
"3": "A little",
"2": "Somewhat",
"1": "Much",
"0": "Very much"
},
"problemDuration": {
"4": "I don't have a problem / < 1 month",
"3": "1–2 months",
"2": "3–6 months",
"1": "7–12 months",
"0": "> 1 year"
}
},
"stems": {
"who5": {
"period": "Nelle ultime due settimane"
},
"sci": {
"night": "Thinking about a typical night in the last month …",
"impact": "Thinking about the past month, to what extent has poor sleep …",
"finally": "Finally …"
}
},
"openDetail": "Mostra dettagli"
},
"records": {
"disclaimer": "Sono i tuoi dati, non una diagnosi medica. HealthLog non diagnostica, non cura e non li interpreta.",
+106 -12
View File
@@ -599,7 +599,9 @@
"typeWaistCircumference": "Obwód talii",
"typeWaistToHeight": "Stosunek talii do wzrostu",
"unitScore": "pkt",
"unitRatio": "stosunek"
"unitRatio": "stosunek",
"typeWho5Score": "Wynik WHO-5",
"typeSciScore": "Wynik SCI"
},
"mood": {
"title": "Nastrój",
@@ -7639,8 +7641,8 @@
},
"mentalHealth": {
"pageTitle": "Sprawdzenie dobrostanu psychicznego",
"pageDescription": "Dwa zwalidowane, dobrowolne testy — PHQ-9 (nastrój) i GAD-7 (lęk) — obok Twojego dziennika nastroju. Liczy się trend w czasie, a nie pojedynczy wynik.",
"pageRhythmHint": "Oba testy dotyczą ostatnich dwóch tygodni, dlatego check-in co dwa–cztery tygodnie to dobry rytm.",
"pageDescription": "Cztery zwalidowane, dobrowolne testy — PHQ-9 (nastrój), GAD-7 (lęk), WHO-5 (samopoczucie) i SCI (sen) — obok Twojego dziennika nastroju. Liczy się trend w czasie, a nie pojedynczy wynik.",
"pageRhythmHint": "PHQ-9, GAD-7 i test samopoczucia dotyczą ostatnich dwóch tygodni; test snu pyta o ostatni miesiąc. Check-in co dwa–cztery tygodnie to dobry rytm.",
"attributionLabel": "Źródło",
"choosePrompt": "Wybierz sprawdzenie",
"start": "Rozpocznij",
@@ -7649,14 +7651,17 @@
"submitting": "Zapisywanie…",
"saved": "Zapisano",
"error": "Coś poszło nie tak. Spróbuj ponownie.",
"considerProfessional": "Twój wynik mieści się w zakresie, w którym rozmowa ze specjalistą może pomóc. To życzliwa sugestia, a nie diagnoza.",
"instrument": {
"phq9": "PHQ-9 · nastrój",
"gad7": "GAD-7 · lęk"
"gad7": "GAD-7 · lęk",
"who5": "WHO-5 · samopoczucie",
"sci": "SCI · sen"
},
"instrumentSub": {
"phq9": "Przesiew depresji, 9 pytań",
"gad7": "Przesiew lęku, 7 pytań"
"gad7": "Przesiew lęku, 7 pytań",
"who5": "Test samopoczucia, 5 pytań",
"sci": "Test snu, 8 pytań"
},
"options": {
"0": "Wcale",
@@ -7691,16 +7696,23 @@
"mild": "Łagodny",
"moderate": "Umiarkowany",
"severe": "Ciężki"
},
"WHO5": {
"low": "Niższe samopoczucie",
"good": "Dobre samopoczucie"
},
"SCI": {
"belowThreshold": "Zakres możliwej bezsenności",
"aboveThreshold": "Brak wskazań na bezsenność"
}
},
"history": {
"title": "Twoja historia",
"empty": "Brak sprawdzeń. Twój trend pojawi się tutaj po ukończeniu pierwszego.",
"flaggedBadge": "Pokazano wsparcie",
"tab": {
"phq9": "PHQ-9",
"gad7": "GAD-7"
}
"chartTitle": "Przebieg wyniku",
"totalLabel": "Wynik",
"bandLabel": "Kategoria"
},
"crisis": {
"title": "Nie musisz mierzyć się z tym sam(a)",
@@ -7728,6 +7740,23 @@
"5": "Niepokój tak duży, że trudno usiedzieć w miejscu",
"6": "Łatwe wpadanie w rozdrażnienie lub irytację",
"7": "Odczuwanie lęku, jakby miało się zdarzyć coś strasznego"
},
"who5": {
"1": "Czułam się wesoła/Czułem się wesoły i w dobrym nastroju",
"2": "Czułam się spokojna i odprężona/Czułem się spokojny i odprężony",
"3": "Czułam się aktywna i energiczna/Czułem się aktywny i energiczny",
"4": "Budziłam się z uczuciem świeżości i wypoczęta/Budziłem się z uczuciem świeżości i wypoczęty",
"5": "Moje życie codzienne było wypełnione interesującymi mnie sprawami"
},
"sci": {
"1": "… how long does it take you to fall asleep?",
"2": "… if you then wake up during the night … how long are you awake for in total? (add all the wakenings up)",
"3": "… how many nights a week do you have a problem with your sleep?",
"4": "… how would you rate your sleep quality?",
"5": "… affected your mood, energy, or relationships?",
"6": "… affected your concentration, productivity, or ability to stay awake?",
"7": "… troubled you in general?",
"8": "… how long have you had a problem with your sleep?"
}
},
"crisisResource": {
@@ -7755,14 +7784,79 @@
},
"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."
"gad7": "Przesiew w kierunku lęku, 7 pytań. Odpowiadaj, myśląc o ostatnich dwóch tygodniach.",
"who5": "Test samopoczucia, 5 pytań. Odpowiadaj, myśląc o ostatnich dwóch tygodniach; wyższe cyfry oznaczają lepsze samopoczucie.",
"sci": "Test snu, 8 pytań. Większość pytań dotyczy ostatniego miesiąca; wyższe wyniki oznaczają lepszy sen."
},
"progress": "{current} / {total}",
"next": "Dalej",
"lastResult": "Ostatni test",
"lastScore": "Ostatni wynik",
"noResultYet": "Brak check-inów",
"backToList": "Przegląd"
"backToList": "Przegląd",
"followUpHint": {
"phq9": "Twój wynik mieści się w zakresie, w którym rozmowa ze specjalistą może pomóc. To życzliwa sugestia, a nie diagnoza.",
"gad7": "Twój wynik mieści się w zakresie, w którym rozmowa ze specjalistą może pomóc. To życzliwa sugestia, a nie diagnoza.",
"who5": "Wynik samopoczucia 50 lub niższy może być wart bliższego spojrzenia. Jeśli chcesz, PHQ-9 na tej stronie przygląda się nastrojowi dokładniej. To życzliwa sugestia, a nie diagnoza.",
"sci": "W badaniu tego kwestionariusza wyniki 16 lub niższe wiązały się z prawdopodobną bezsennością. Jeśli zły sen nadal Ci doskwiera, rozmowa ze specjalistą może pomóc. To nie jest diagnoza."
},
"validatedInEnglishNote": "Ten kwestionariusz jest zwalidowany w języku angielskim; pytania są wyświetlane w angielskim oryginale.",
"who5Options": {
"5": "Cały czas",
"4": "Prawie cały czas",
"3": "Więcej niż połowę czasu",
"2": "Mniej niż połowę czasu",
"1": "Od czasu do czasu",
"0": "Nigdy"
},
"sciOptions": {
"duration": {
"4": "0–15 min",
"3": "16–30 min",
"2": "31–45 min",
"1": "46–60 min",
"0": "≥ 61 min"
},
"nights": {
"4": "0–1",
"3": "2",
"2": "3",
"1": "4",
"0": "5–7"
},
"quality": {
"4": "Very good",
"3": "Good",
"2": "Average",
"1": "Poor",
"0": "Very poor"
},
"impact": {
"4": "Not at all",
"3": "A little",
"2": "Somewhat",
"1": "Much",
"0": "Very much"
},
"problemDuration": {
"4": "I don't have a problem / < 1 month",
"3": "1–2 months",
"2": "3–6 months",
"1": "7–12 months",
"0": "> 1 year"
}
},
"stems": {
"who5": {
"period": "W ciągu ostatnich dwóch tygodni"
},
"sci": {
"night": "Thinking about a typical night in the last month …",
"impact": "Thinking about the past month, to what extent has poor sleep …",
"finally": "Finally …"
}
},
"openDetail": "Pokaż szczegóły"
},
"records": {
"disclaimer": "To Twoje własne wpisy, a nie diagnoza lekarska. HealthLog nie diagnozuje, nie leczy i ich nie interpretuje.",
@@ -155,6 +155,39 @@ const LEGIT_IDENTICAL = new Set<string>([
"mentalHealth.items.phq9.7",
"mentalHealth.items.phq9.8",
"mentalHealth.items.phq9.9",
// v1.27.9 — the Sleep Condition Indicator is validated in English only
// (Espie 2014, CC BY-NC). Item text, section stems and response anchors
// ship VERBATIM in the source language in every locale — a self-made
// translation would invalidate the instrument — and the UI shows a
// localized "validated in English" note instead. Identical-to-EN is the
// intended state here, not a leak.
"mentalHealth.items.sci.1",
"mentalHealth.items.sci.2",
"mentalHealth.items.sci.3",
"mentalHealth.items.sci.4",
"mentalHealth.items.sci.5",
"mentalHealth.items.sci.6",
"mentalHealth.items.sci.7",
"mentalHealth.items.sci.8",
"mentalHealth.stems.sci.night",
"mentalHealth.stems.sci.impact",
"mentalHealth.stems.sci.finally",
"mentalHealth.sciOptions.duration.0",
"mentalHealth.sciOptions.quality.4",
"mentalHealth.sciOptions.quality.3",
"mentalHealth.sciOptions.quality.2",
"mentalHealth.sciOptions.quality.1",
"mentalHealth.sciOptions.quality.0",
"mentalHealth.sciOptions.impact.4",
"mentalHealth.sciOptions.impact.3",
"mentalHealth.sciOptions.impact.2",
"mentalHealth.sciOptions.impact.1",
"mentalHealth.sciOptions.impact.0",
"mentalHealth.sciOptions.problemDuration.4",
"mentalHealth.sciOptions.problemDuration.3",
"mentalHealth.sciOptions.problemDuration.2",
"mentalHealth.sciOptions.problemDuration.1",
"mentalHealth.sciOptions.problemDuration.0",
]);
describe("i18n English-leak guard", () => {
@@ -116,11 +116,11 @@ const TYPE_GROUPS = [
],
},
// v1.27.6 — the mental-wellbeing screenings become plannable like any
// other Vorsorge item. Auto-resolve rides the server-written PHQ9_SCORE /
// GAD7_SCORE row a completed check-in produces.
// other Vorsorge item. Auto-resolve rides the server-written *_SCORE row a
// completed check-in produces (v1.27.9 adds WHO-5 + SCI).
{
group: "mentalWellbeing",
types: ["PHQ9_SCORE", "GAD7_SCORE"],
types: ["PHQ9_SCORE", "GAD7_SCORE", "WHO5_SCORE", "SCI_SCORE"],
},
] as const;
@@ -11,9 +11,9 @@ import en from "../../../../messages/en.json";
* 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
* dated list with severity + "support shown" badges — and NO trend chart
* (v1.27.6: the score curve lives in Insights / Measurements, not here).
* a positive item-9 surfaces the crisis card, and (v1.27.9) the landing is
* intro + instrument cards only — the dated history + trend chart live in the
* per-instrument detail a card click opens, pinned to one instrument.
*/
vi.mock("@/lib/api/api-fetch", () => ({
@@ -26,6 +26,7 @@ import { CheckInWizard } from "../check-in-wizard";
import { AssessmentResult } from "../assessment-result";
import { AssessmentHistory } from "../assessment-history";
import { InstrumentCard } from "../instrument-card";
import { InstrumentDetail } from "../instrument-detail";
import type { AssessmentRow, CreateResponse } from "../types";
const mh = en.mentalHealth;
@@ -178,18 +179,17 @@ describe("history rendering", () => {
},
];
const html = withProviders(<AssessmentHistory rows={rows} />);
const html = withProviders(
<AssessmentHistory rows={rows} instrument="PHQ9" />,
);
it("paints the dated list WITHOUT a trend chart shell", () => {
it("paints the pinned dated list with the trend-chart shell, no instrument toggle", () => {
// v1.27.9 — the history is pinned to one instrument inside the detail
// surface: no PHQ-9/GAD-7 tablist, and the lazy chart's skeleton shell
// holds the layout in SSR (recharts itself stays off first-load JS).
expect(html).toContain('data-slot="history-list"');
// v1.27.6 — no chart on this surface: no lazy-chart skeleton, no
// recharts container.
expect(html).not.toContain('data-slot="skeleton"');
expect(html).not.toContain("recharts");
});
it("shows the PHQ-9 / GAD-7 toggle and a severity badge", () => {
expect(html).toContain('role="tablist"');
expect(html).toContain('data-pinned="PHQ9"');
expect(html).not.toContain('role="tablist"');
expect(html).toContain(mh.band.PHQ9.modSevere);
});
@@ -198,14 +198,175 @@ describe("history rendering", () => {
expect(html).toContain(mh.history.flaggedBadge);
});
it("renders the empty state with no rows", () => {
const empty = withProviders(<AssessmentHistory rows={[]} />);
it("renders the empty state with no rows for the pinned instrument", () => {
const empty = withProviders(
<AssessmentHistory rows={rows} instrument="SCI" />,
);
expect(empty).toContain(mh.history.empty);
expect(empty).not.toContain('data-slot="history-list"');
});
it("stays OFF the landing entirely (intro + cards only)", () => {
const landing = withProviders(<MentalWellbeing />);
expect(landing).not.toContain('data-slot="mental-health-history"');
expect(landing).not.toContain('data-slot="history-list"');
});
});
describe("instrument card (v1.27.6 — med-/Vorsorge card anatomy)", () => {
describe("WHO-5 / SCI on the same infrastructure (v1.27.9)", () => {
it("renders all four instrument cards on the landing", () => {
const html = withProviders(<MentalWellbeing />);
expect(html).toContain(mh.instrument.phq9);
expect(html).toContain(mh.instrument.gad7);
expect(html).toContain(mh.instrument.who5);
expect(html).toContain(mh.instrument.sci);
// The rhythm hint states both recall windows honestly.
expect(html).toContain(mh.pageRhythmHint);
});
it("WHO-5 wizard opens on item 1 with the six-point scale in source order and the recall stem", () => {
const html = withProviders(
<CheckInWizard
instrument="WHO5"
onSubmit={() => {}}
onBack={() => {}}
isPending={false}
isError={false}
/>,
);
expect(html).toContain(mh.items.who5["1"]);
expect(html).toContain('data-slot="check-in-stem"');
expect(html).toContain(mh.stems.who5.period);
// Six anchors, 5 → 0 (the WHO form leads with "All of the time").
for (const v of ["5", "4", "3", "2", "1", "0"] as const) {
expect(html).toContain(mh.who5Options[v]);
}
// English items are the validated wording for en — no translation note.
expect(html).not.toContain('data-slot="check-in-validated-note"');
// No functional follow-up outside the PHQ-9: 5 steps total.
expect(html).toContain("1 / 5");
});
it("SCI wizard opens on item 1 with the item-specific duration anchors and section stem", () => {
const html = withProviders(
<CheckInWizard
instrument="SCI"
onSubmit={() => {}}
onBack={() => {}}
isPending={false}
isError={false}
/>,
);
expect(html).toContain(mh.items.sci["1"]);
expect(html).toContain(mh.stems.sci.night);
// Item 1 carries the sleep-latency time ranges, 4 → 0.
for (const v of ["4", "3", "2", "1", "0"] as const) {
expect(html).toContain(mh.sciOptions.duration[v]);
}
expect(html).toContain("1 / 8");
});
it("shows the gentle PHQ-9 pointer on a WHO-5 total of 50 or below", () => {
const result: CreateResponse = {
assessment: {
id: "w1",
instrument: "WHO5",
locale: "en",
totalScore: 48,
severityBand: "low",
item9Flagged: false,
crisisShownAt: null,
takenAt: "2026-06-20T00:00:00.000Z",
},
actionThreshold: 50,
crisis: null,
};
const html = withProviders(
<AssessmentResult
result={result}
onTakeAnother={() => {}}
onBack={() => {}}
/>,
);
expect(html).toContain('data-slot="result-follow-up-hint"');
expect(html).toContain(mh.followUpHint.who5);
// Required WHO attribution rides the result view.
expect(html).toContain("CC BY-NC-SA 3.0 IGO");
expect(html).not.toContain('data-slot="mental-health-crisis-card"');
});
it("suppresses the hint on a good WHO-5 total (direction is inverted vs PHQ-9)", () => {
const result: CreateResponse = {
assessment: {
id: "w2",
instrument: "WHO5",
locale: "en",
totalScore: 80,
severityBand: "good",
item9Flagged: false,
crisisShownAt: null,
takenAt: "2026-06-20T00:00:00.000Z",
},
actionThreshold: 50,
crisis: null,
};
const html = withProviders(
<AssessmentResult
result={result}
onTakeAnother={() => {}}
onBack={() => {}}
/>,
);
expect(html).not.toContain('data-slot="result-follow-up-hint"');
});
it("shows the neutral SCI band wording at 16 or below, with the Sleepio/BMJ attribution", () => {
const result: CreateResponse = {
assessment: {
id: "s1",
instrument: "SCI",
locale: "en",
totalScore: 14,
severityBand: "belowThreshold",
item9Flagged: false,
crisisShownAt: null,
takenAt: "2026-06-20T00:00:00.000Z",
},
actionThreshold: 16,
crisis: null,
};
const html = withProviders(
<AssessmentResult
result={result}
onTakeAnother={() => {}}
onBack={() => {}}
/>,
);
expect(html).toContain(mh.followUpHint.sci);
expect(html).toContain(mh.band.SCI.belowThreshold);
expect(html).toContain("BMJ Open");
});
it("pinned history renders the WHO-5 / SCI series through the same component", () => {
const row: AssessmentRow = {
id: "t1",
instrument: "WHO5",
locale: "en",
totalScore: 72,
severityBand: "good",
item9Flagged: false,
crisisShownAt: null,
takenAt: "2026-06-20T00:00:00.000Z",
};
const html = withProviders(
<AssessmentHistory rows={[row]} instrument="WHO5" />,
);
expect(html).toContain('data-pinned="WHO5"');
expect(html).toContain(mh.band.WHO5.good);
});
});
describe("instrument card (med-/Vorsorge card anatomy + detail opener)", () => {
const lastRow: AssessmentRow = {
id: "l1",
instrument: "PHQ9",
@@ -217,25 +378,43 @@ describe("instrument card (v1.27.6 — med-/Vorsorge card anatomy)", () => {
takenAt: "2026-06-20T00:00:00.000Z",
};
it("renders the shared med-card header (title + category badge), no trend target", () => {
const html = withProviders(
<InstrumentCard instrument="PHQ9" last={undefined} onStart={() => {}} />,
function card(
instrument: AssessmentRow["instrument"],
last?: AssessmentRow,
): string {
return withProviders(
<InstrumentCard
instrument={instrument}
last={last}
onStart={() => {}}
onOpenDetail={() => {}}
/>,
);
}
it("renders the shared med-card header and the clickable detail body", () => {
const html = card("PHQ9");
// The med-card header primitive paints the bold name + category badge.
expect(html).toContain(mh.instrument.phq9);
expect(html).toContain(mh.instrumentSub.phq9);
// The former trend-detail button is gone…
expect(html).not.toContain('data-slot="instrument-card-open"');
// v1.27.9 — the card body opens the per-instrument detail…
expect(html).toContain('data-slot="instrument-card-open"');
expect(html).toContain(mh.openDetail);
// …and the Start action remains the single bottom-pinned action.
expect(html).toContain(mh.start);
// No history yet → the calm no-check-in line, no fabricated dashes.
expect(html).toContain(mh.noResultYet);
// v1.27.9 — the required attribution footer rides every card.
expect(html).toContain('data-slot="instrument-card-attribution"');
});
it("carries the instrument's licence line on the WHO-5 / SCI cards", () => {
expect(card("WHO5")).toContain("CC BY-NC-SA 3.0 IGO");
expect(card("SCI")).toContain("Sleepio Limited");
});
it("shows last test (relative) and last result (score + band word)", () => {
const html = withProviders(
<InstrumentCard instrument="PHQ9" last={lastRow} onStart={() => {}} />,
);
const html = card("PHQ9", lastRow);
expect(html).toContain(mh.lastResult);
expect(html).toContain(mh.lastScore);
// Score + band word ride one value slot ("8 · Mild").
@@ -244,3 +423,46 @@ describe("instrument card (v1.27.6 — med-/Vorsorge card anatomy)", () => {
expect(html).not.toContain(mh.noResultYet);
});
});
describe("instrument detail (v1.27.9 — opened from a card)", () => {
const rows: AssessmentRow[] = [
{
id: "d1",
instrument: "SCI",
locale: "en",
totalScore: 22,
severityBand: "aboveThreshold",
item9Flagged: false,
crisisShownAt: null,
takenAt: "2026-06-20T00:00:00.000Z",
},
{
id: "d2",
instrument: "PHQ9",
locale: "en",
totalScore: 4,
severityBand: "minimal",
item9Flagged: false,
crisisShownAt: null,
takenAt: "2026-06-01T00:00:00.000Z",
},
];
it("shows last score + band, the Start action, the pinned history and the attribution", () => {
const html = withProviders(
<InstrumentDetail instrument="SCI" rows={rows} onStart={() => {}} />,
);
expect(html).toContain('data-slot="instrument-detail"');
expect(html).toContain('data-slot="instrument-detail-last-score"');
expect(html).toContain(mh.band.SCI.aboveThreshold);
expect(html).toContain('data-slot="instrument-detail-start"');
expect(html).toContain(mh.start);
// The pinned history (chart shell + dated list) rides inside the detail.
expect(html).toContain('data-pinned="SCI"');
expect(html).toContain('data-slot="history-list"');
// …filtered to the pinned instrument: the PHQ-9 row stays out.
expect(html).not.toContain(mh.band.PHQ9.minimal);
// Required attribution line.
expect(html).toContain("Sleepio Limited");
});
});
@@ -0,0 +1,167 @@
"use client";
/**
* v1.25.3 — per-instrument screener trend chart.
*
* v1.25.5 — aligned to the dashboard's chart language exactly: a single
* `type="monotone"` line over the primary colour with a soft gradient area
* fill beneath (same `fillOpacity` + margins the dashboard line charts use),
* the shared grid / axis tokens, and `RichChartTooltip`. The earlier
* severity-band `ReferenceArea`s — unique to this surface — are gone so it
* reads like every other trend; the band still surfaces in the tooltip.
*
* v1.27.9 — back on the surface, but only inside the per-instrument detail
* (opened from a card): the landing stays free of any score curve, so the
* trend is opt-in behind a deliberate click. The Y domain and band wording
* come from the instrument registry, so the WHO-5 (0–100, higher = better)
* and the SCI (0–32, higher = better) render through the same component.
*
* One instrument at a time: the ranges and band sets differ, so the parent
* pins which series this paints.
*/
import { useMemo } from "react";
import {
Area,
CartesianGrid,
ComposedChart,
Line,
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;
band: string;
}
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,
band: r.severityBand,
}));
}, [rows]);
const animate = !prefersReducedMotion();
const primary = "var(--primary)";
const gradientId = `mh-trend-${instrument}`;
if (points.length === 0) {
return (
<p className="text-muted-foreground py-10 text-center text-sm">
{t("mentalHealth.history.empty")}
</p>
);
}
return (
<ResponsiveContainer width="100%" height={CHART_HEIGHT_PX}>
<ComposedChart
data={points}
margin={{ top: 10, right: 8, bottom: 8, left: 8 }}
>
<defs>
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={primary} stopOpacity={0.18} />
<stop offset="95%" stopColor={primary} stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid
strokeDasharray="3 3"
stroke="var(--border)"
vertical={false}
/>
<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,
},
{
name: t("mentalHealth.history.bandLabel"),
value: t(`mentalHealth.band.${instrument}.${point.band}`),
color: "var(--muted-foreground)",
},
];
return (
<RichChartTooltip
active
label={formatDateShort(new Date(point.timestamp))}
rows={rowsOut}
/>
);
}}
/>
<Area
type="monotone"
dataKey="value"
stroke="none"
fill={`url(#${gradientId})`}
isAnimationActive={animate}
/>
<Line
type="monotone"
dataKey="value"
stroke={primary}
strokeWidth={2}
dot={{ r: 3, fill: primary }}
activeDot={{ r: 5 }}
isAnimationActive={animate}
/>
</ComposedChart>
</ResponsiveContainer>
);
}
@@ -1,30 +1,31 @@
"use client";
/**
* v1.25.3 — the history card: a dated list with severity badges.
* v1.25.3 — the dated result history with severity badges.
*
* v1.27.6 — the trend CHART is gone from this surface. A rising score curve
* on the check-in page itself reads as a verdict at the exact moment someone
* is taking stock of how they feel; the page stays a calm list of past
* check-ins. The score trend still exists — the totals ride PHQ9_SCORE /
* GAD7_SCORE measurement rows, so Insights / Measurements carry the curve
* for whoever seeks it out.
* v1.27.9 — pinned-only: the combined landing card (with the instrument
* toggle) is gone; the landing shows intro + instrument cards and nothing
* else. This component now renders ONE instrument's detail body — trend
* chart (lazy Recharts, the labs-detail precedent) over the dated list —
* inside the detail surface a card click opens. The history is deliberately
* opt-in behind that click, never pushed onto the main page.
*
* PHQ-9 / GAD-7 toggle: the two instruments have different score ranges +
* bands, so the list shows 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.
* 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";
@@ -32,8 +33,24 @@ import { Activity } from "lucide-react";
import { CrisisCard } from "./crisis-card";
import type { AssessmentRow, CrisisSet, InstrumentId } from "./types";
function lower(id: InstrumentId): "phq9" | "gad7" {
return id === "PHQ9" ? "phq9" : "gad7";
// 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>
);
}
/** Re-derive the STATIC crisis set from a row's locale — no decryption. */
@@ -48,122 +65,92 @@ function crisisFromRow(row: AssessmentRow): CrisisSet {
};
}
export function AssessmentHistory({ rows }: { rows: AssessmentRow[] }) {
export function AssessmentHistory({
rows,
instrument,
}: {
/** ALL assessments (any instrument); filtered internally. */
rows: AssessmentRow[];
/** The instrument this detail surface is pinned to. */
instrument: InstrumentId;
}) {
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(
const forInstrument = useMemo(
() =>
rows
.filter((r) => r.instrument === tab)
.filter((r) => r.instrument === instrument)
.sort(
(a, b) =>
new Date(b.takenAt).getTime() - new Date(a.takenAt).getTime(),
),
[rows, tab],
[rows, instrument],
);
const isEmpty = rows.length === 0;
if (forInstrument.length === 0) {
return (
<div data-slot="mental-health-history" data-pinned={instrument}>
<EmptyState
icon={<Activity className="size-6" />}
title={t("mentalHealth.history.title")}
description={t("mentalHealth.history.empty")}
/>
</div>
);
}
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 differ, so
the list shows one instrument at a time. */}
<div
className="flex gap-1"
role="tablist"
aria-label={t("mentalHealth.history.title")}
>
{(["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>
<div
data-slot="mental-health-history"
data-pinned={instrument}
className="flex flex-col gap-4"
>
<AssessmentHistoryChart instrument={instrument} rows={forInstrument} />
{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>
<ul className="flex flex-col gap-1.5" data-slot="history-list">
{forInstrument.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>
</div>
);
}
@@ -6,6 +6,12 @@
* 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.
*
* v1.27.9 — direction-aware follow-up hints from the registry: PHQ-9/GAD-7
* keep the ≥10 professional-help suggestion, a WHO-5 total of 50 or below
* gets a gentle pointer to the PHQ-9 check-in (per the WHO scoring comment),
* and an SCI total of 16 or below gets the paper's neutral band wording.
* The attribution footer now comes from the instrument definition.
*
* 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.
@@ -16,14 +22,10 @@ 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 { INSTRUMENTS, needsFollowUp } 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";
}
import type { CreateResponse } from "./types";
export function AssessmentResult({
result,
@@ -36,7 +38,8 @@ export function AssessmentResult({
}) {
const { t } = useTranslations();
const instrument = result.assessment.instrument;
const key = lower(instrument);
const def = INSTRUMENTS[instrument];
const key = def.i18nKey;
// 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
@@ -104,9 +107,16 @@ export function AssessmentResult({
)}
</span>
</div>
{result.assessment.totalScore >= result.actionThreshold && (
<p className="text-muted-foreground bg-muted/40 rounded-md p-3 text-xs">
{t("mentalHealth.considerProfessional")}
{/* Direction-aware follow-up hint: PHQ-9/GAD-7 point up (≥ 10),
WHO-5 (≤ 50 → gentle PHQ-9 pointer) and SCI (≤ 16 → the paper's
neutral band wording) point down. Soft suggestions, never a
diagnosis. */}
{needsFollowUp(instrument, result.assessment.totalScore) && (
<p
className="text-muted-foreground bg-muted/40 rounded-md p-3 text-xs"
data-slot="result-follow-up-hint"
>
{t(`mentalHealth.followUpHint.${key}`)}
</p>
)}
</CardContent>
@@ -114,11 +124,11 @@ export function AssessmentResult({
{result.crisis && <CrisisCard crisis={result.crisis} />}
<p className="text-muted-foreground text-[11px] leading-snug">
<p className="text-muted-foreground text-xs leading-snug">
<span className="font-medium">
{t("mentalHealth.attributionLabel")}:
</span>{" "}
{PHQ_GAD_ATTRIBUTION}
{def.attribution}
</p>
<Button variant="outline" onClick={onTakeAnother}>
@@ -12,6 +12,14 @@
* Answering the last question arms the submit directly — the next thing
* the user sees is the dignified result view.
*
* v1.27.9 — the wizard drives from the instrument registry instead of a
* hard-wired 0–3 scale: option values/order, option-label scheme (shared
* anchors vs the SCI's per-item anchor groups), and the recall-stem caption
* (the WHO-5 / SCI items are fragments under a shared stem on the source
* forms) all come from `INSTRUMENTS`. Locales without a validated item
* translation (SCI outside English) get an honest chrome note; the items
* themselves stay in the validated source language.
*
* Header: instrument title + ONE standardized explanation line, NO
* disclaimer (§2 — the "voluntary self-test, not a diagnosis" disclaimer
* renders only on the landing, never while taking the test).
@@ -22,7 +30,12 @@ import { ArrowLeft } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { useTranslations } from "@/lib/i18n/context";
import { INSTRUMENTS } from "@/lib/mental-health/instruments";
import {
INSTRUMENTS,
hasValidatedItems,
optionLabelKey,
stemKey,
} from "@/lib/mental-health/instruments";
import {
isComplete,
@@ -33,11 +46,7 @@ import {
} from "./check-in-nav";
import type { InstrumentId } from "./types";
const SCALE = [0, 1, 2, 3] as const;
function lower(id: InstrumentId): "phq9" | "gad7" {
return id === "PHQ9" ? "phq9" : "gad7";
}
const FUNCTIONAL_SCALE = [0, 1, 2, 3] as const;
export function CheckInWizard({
instrument,
@@ -54,15 +63,16 @@ export function CheckInWizard({
isPending: boolean;
isError: boolean;
}) {
const { t } = useTranslations();
const key = lower(instrument);
const itemCount = INSTRUMENTS[instrument].itemCount;
const { t, locale } = useTranslations();
const def = INSTRUMENTS[instrument];
const key = def.i18nKey;
const itemCount = def.itemCount;
// v1.27.8 — the PHQ-9 asks its validated functional-impairment
// follow-up as a regular LAST question (10 of 10) instead of a
// separate review-step widget. It is OPTIONAL per the instrument
// (answering is not required to submit) and never scores into the
// total — the value rides the existing `functionalDifficulty` API
// field. GAD-7 has no such item and keeps its 7 steps.
// field. The other instruments have no such item.
const hasFunctionalStep = instrument === "PHQ9";
const totalSteps = itemCount + (hasFunctionalStep ? 1 : 0);
@@ -79,6 +89,9 @@ export function CheckInWizard({
const questionLabel = onFunctionalStep
? t("mentalHealth.functionalTitle")
: t(`mentalHealth.items.${key}.${step}`);
// Recall-stem caption above the question (WHO-5 / SCI fragments).
const stem = onFunctionalStep ? null : stemKey(instrument, questionIndex);
const scale = onFunctionalStep ? FUNCTIONAL_SCALE : def.optionOrder;
// a11y: announce each step by moving focus to its heading (the question
// text). Mirrors the medication dialog's per-step title focus so a
@@ -135,6 +148,16 @@ export function CheckInWizard({
<p className="text-muted-foreground text-sm">
{t(`mentalHealth.instrumentDescription.${key}`)}
</p>
{/* Honest translation note: the instrument is validated in its source
language; the surrounding chrome stays localized. */}
{!hasValidatedItems(instrument, locale) && (
<p
className="text-muted-foreground text-xs"
data-slot="check-in-validated-note"
>
{t("mentalHealth.validatedInEnglishNote")}
</p>
)}
</header>
<Card className="gap-0 p-0">
@@ -146,6 +169,11 @@ export function CheckInWizard({
total: totalSteps,
})}
</p>
{stem && (
<p className="text-sm" data-slot="check-in-stem">
{t(`mentalHealth.stems.${stem}`)}
</p>
)}
<h2
ref={headingRef}
tabIndex={-1}
@@ -163,7 +191,7 @@ export function CheckInWizard({
role="group"
aria-label={questionLabel}
>
{SCALE.map((v) => {
{scale.map((v) => {
const selected = onFunctionalStep
? functional === v
: items[questionIndex] === v;
@@ -180,7 +208,9 @@ export function CheckInWizard({
>
{onFunctionalStep
? t(`mentalHealth.functional.${v}`)
: t(`mentalHealth.options.${v}`)}
: t(
`mentalHealth.${optionLabelKey(instrument, questionIndex, v)}`,
)}
</Button>
);
})}
@@ -3,54 +3,81 @@
/**
* v1.25.3 — one neutral card per instrument on the landing.
*
* v1.27.6 — rebuilt onto the shared med-/Vorsorge card anatomy so the two
* test cards read exactly like their peers: `MedicationCardHeader` (bold
* v1.27.6 — rebuilt onto the shared med-/Vorsorge card anatomy so the test
* cards read exactly like their peers: `MedicationCardHeader` (bold
* name + outline category badge), a label/value block in the next-last
* grammar ("last test" → relative day, "last result" → score + band word),
* and a single bottom-pinned Start action. The surface stays NEUTRAL
* regardless of the last band — no severity tint (house rule: status reads
* through discreet text only, never a card wash).
*
* v1.27.9 — the card generalises over the four-instrument registry and
* carries the instrument's required attribution/licence line as a small
* factual footer (WHO CC BY-NC-SA citation for the WHO-5, the Sleepio
* CC BY-NC citation for the SCI, the Pfizer grant for PHQ/GAD). A locale
* without validated item wording additionally gets the honest
* "validated in English" note. The card BODY (header + last-result block)
* opens the per-instrument detail — the Vorsorge-/med-card interaction —
* while the bottom-pinned Start button stays a separate, non-overlapping
* action.
*/
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { MedicationCardHeader } from "@/components/medications/medication-card-header";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { relativeCalendarDate } from "@/lib/i18n/relative-time";
import {
INSTRUMENTS,
hasValidatedItems,
} from "@/lib/mental-health/instruments";
import type { AssessmentRow, InstrumentId } from "./types";
function lower(id: InstrumentId): "phq9" | "gad7" {
return id === "PHQ9" ? "phq9" : "gad7";
}
export function InstrumentCard({
instrument,
last,
onStart,
onOpenDetail,
}: {
instrument: InstrumentId;
/** Most-recent assessment for this instrument, or undefined when none yet. */
last: AssessmentRow | undefined;
onStart: () => void;
/**
* Open this instrument's detail (last score + band, trend chart, dated
* history). The card body is the target; Start stays separate.
*/
onOpenDetail: () => void;
}) {
const { t } = useTranslations();
const { t, locale } = useTranslations();
const { date: formatDate } = useFormatters();
const key = lower(instrument);
const def = INSTRUMENTS[instrument];
const key = def.i18nKey;
const title = t(`mentalHealth.instrument.${key}`);
return (
<Card className="h-full gap-3 md:gap-3" data-slot="instrument-card">
<MedicationCardHeader
name={title}
dose=""
categoryLabel={t(`mentalHealth.instrumentSub.${key}`)}
/>
<CardContent className="flex h-full flex-col space-y-3.5">
{/* Body is the detail target: clicking the header / last-result block
opens this instrument's detail (chart + dated history), like a
medication card. The Start button below stays a separate action. */}
<button
type="button"
onClick={onOpenDetail}
data-slot="instrument-card-open"
aria-label={`${title} — ${t("mentalHealth.openDetail")}`}
className="focus-visible:ring-ring/60 hover:bg-accent/40 flex cursor-pointer flex-col gap-3 rounded-t-xl text-left transition-colors focus-visible:ring-2 focus-visible:outline-none"
>
<MedicationCardHeader
name={title}
dose=""
categoryLabel={t(`mentalHealth.instrumentSub.${key}`)}
/>
{/* Label/value block in the Vorsorge card's next-last grammar. With
no history yet, one calm line replaces the pair — no dashes
pretending a value exists. */}
{last ? (
<div className="min-h-[2.75rem] space-y-1.5 text-sm">
<div className="min-h-[2.75rem] w-full space-y-1.5 px-4 text-sm md:px-6">
<div className="text-muted-foreground flex items-baseline justify-between gap-3">
<span className="min-w-0 flex-shrink truncate font-medium">
{t("mentalHealth.lastResult")}
@@ -74,16 +101,29 @@ export function InstrumentCard({
</div>
</div>
) : (
<div className="text-muted-foreground flex min-h-[2.75rem] items-center text-sm">
<div className="text-muted-foreground flex min-h-[2.75rem] items-center px-4 text-sm md:px-6">
{t("mentalHealth.noResultYet")}
</div>
)}
</button>
<CardContent className="flex h-full flex-col">
{/* Bottom-pinned single primary action — the med-/Vorsorge card slot. */}
<div className="mt-auto pt-0">
<div className="mt-auto space-y-2 pt-0">
<Button type="button" className="min-h-11 w-full" onClick={onStart}>
{t("mentalHealth.start")}
</Button>
{/* Required attribution — factual and unobtrusive; identical slot on
every card so the grid stays symmetric. */}
<p
className="text-muted-foreground text-xs leading-snug"
data-slot="instrument-card-attribution"
>
{!hasValidatedItems(instrument, locale) && (
<>{t("mentalHealth.validatedInEnglishNote")} </>
)}
{def.attribution}
</p>
</div>
</CardContent>
</Card>
@@ -0,0 +1,86 @@
"use client";
/**
* v1.27.9 — per-instrument detail surface, opened by clicking an instrument
* card (the Vorsorge-/med-card detail interaction). Hosted in a
* `ResponsiveSheet` by the page orchestrator; this component owns the calm
* detail spine: last score + band → Start action → trend chart + dated
* history (via the pinned `AssessmentHistory`) → the instrument's required
* attribution line. The history and the score curve live ONLY here — the
* landing stays a quiet card grid, so the trend is opt-in behind a
* deliberate click, never pushed at the moment someone arrives to check in.
*/
import { Button } from "@/components/ui/button";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { relativeCalendarDate } from "@/lib/i18n/relative-time";
import { INSTRUMENTS } from "@/lib/mental-health/instruments";
import { AssessmentHistory } from "./assessment-history";
import type { AssessmentRow, InstrumentId } from "./types";
export function InstrumentDetail({
instrument,
rows,
onStart,
}: {
instrument: InstrumentId;
/** ALL assessments; the pinned history filters to this instrument. */
rows: AssessmentRow[];
/** Start a check-in for this instrument (closes the host sheet). */
onStart: () => void;
}) {
const { t } = useTranslations();
const { date: formatDate } = useFormatters();
const def = INSTRUMENTS[instrument];
const last = rows.find((r) => r.instrument === instrument);
return (
<div className="flex flex-col gap-4" data-slot="instrument-detail">
{/* Last result, in the card's label/value grammar — score + band word
on one slot, the relative day on the other. Neutral text, no tint. */}
{last && (
<div className="space-y-1.5 text-sm">
<div className="text-muted-foreground flex items-baseline justify-between gap-3">
<span className="min-w-0 flex-shrink truncate font-medium">
{t("mentalHealth.lastResult")}
</span>
<span className="text-foreground text-right">
{relativeCalendarDate(last.takenAt, t, formatDate)}
</span>
</div>
<div className="text-muted-foreground flex items-baseline justify-between gap-3">
<span className="min-w-0 flex-shrink truncate font-medium">
{t("mentalHealth.lastScore")}
</span>
<span
className="text-foreground text-right"
data-slot="instrument-detail-last-score"
>
{last.totalScore}
{" · "}
{t(`mentalHealth.band.${last.instrument}.${last.severityBand}`)}
</span>
</div>
</div>
)}
{/* The Start action stays reachable here too — the detail is a calm
reading surface, not a dead end. */}
<Button
type="button"
className="min-h-11 w-full"
onClick={onStart}
data-slot="instrument-detail-start"
>
{t("mentalHealth.start")}
</Button>
<AssessmentHistory rows={rows} instrument={instrument} />
{/* Required attribution — same factual line as the card + result view. */}
<p className="text-muted-foreground text-xs leading-snug">
{def.attribution}
</p>
</div>
);
}
@@ -20,9 +20,12 @@
* cards (last-result line + Start) + a history card. The disclaimer renders
* ONLY here (a muted caption) and behind the InfoHint — never while testing.
*
* v1.27.6 — the trend chart (and the per-card trend sheet) left this surface;
* the score curve lives in Insights / Measurements via the PHQ9_SCORE /
* GAD7_SCORE measurement rows. The landing keeps the calm dated list only.
* v1.27.9 — the landing is intro + instrument cards, nothing else: the
* combined history card left the main page entirely. Each card opens a
* per-instrument detail sheet (the Vorsorge-/med-card detail interaction)
* carrying last score + band, the trend chart, the dated history, and the
* Start action — the Verlauf is opt-in behind that click, never pushed onto
* the page someone arrives at to take stock.
*/
import { useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
@@ -30,12 +33,14 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useTranslations } from "@/lib/i18n/context";
import { apiGet, apiPost } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
import { INSTRUMENTS, INSTRUMENT_ORDER } from "@/lib/mental-health/instruments";
import { PageHeader } from "@/components/ui/page-header";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { AssessmentHistory } from "./assessment-history";
import { AssessmentResult } from "./assessment-result";
import { CheckInWizard } from "./check-in-wizard";
import { InstrumentCard } from "./instrument-card";
import { InstrumentDetail } from "./instrument-detail";
import type {
AssessmentRow,
CreateResponse,
@@ -50,6 +55,11 @@ export function MentalWellbeing() {
const [phase, setPhase] = useState<Phase>("choose");
const [instrument, setInstrument] = useState<InstrumentId>("PHQ9");
const [result, setResult] = useState<CreateResponse | null>(null);
// Which instrument's detail sheet is open (null = closed). Clicking a card
// body opens THAT instrument's detail; Start stays a separate action.
const [detailInstrument, setDetailInstrument] = useState<InstrumentId | null>(
null,
);
const { data: history } = useQuery({
queryKey: queryKeys.mentalHealthAssessments(),
@@ -82,6 +92,7 @@ export function MentalWellbeing() {
}, [history]);
function begin(id: InstrumentId) {
setDetailInstrument(null);
setInstrument(id);
setResult(null);
mutation.reset();
@@ -116,9 +127,10 @@ export function MentalWellbeing() {
description={t("mentalHealth.pageDescription")}
/>
{/* v1.27.6 — one gentle cadence hint: both instruments ask about
the last two weeks, so every 2–4 weeks is a sensible rhythm.
Body prose (foreground), not another muted description line. */}
{/* v1.27.6 — one gentle cadence hint; v1.27.9 states each recall
window honestly (PHQ-9 / GAD-7 / WHO-5 look back two weeks, the
SCI asks about the past month). Body prose (foreground), not
another muted description line. */}
<p className="max-w-prose text-sm">
{t("mentalHealth.pageRhythmHint")}
</p>
@@ -126,19 +138,45 @@ export function MentalWellbeing() {
<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) => (
{INSTRUMENT_ORDER.map((id) => (
<li key={id} className="contents">
<InstrumentCard
instrument={id}
last={lastByInstrument.get(id)}
onStart={() => begin(id)}
onOpenDetail={() => setDetailInstrument(id)}
/>
</li>
))}
</ul>
</section>
<AssessmentHistory rows={history?.assessments ?? []} />
{/* Per-instrument detail, opened from a card body: last result,
trend chart, dated history, Start — the Verlauf lives here,
not on the landing. */}
<ResponsiveSheet
open={detailInstrument !== null}
onOpenChange={(open) => {
if (!open) setDetailInstrument(null);
}}
contentWidth="lg"
title={
detailInstrument
? t(
`mentalHealth.instrument.${INSTRUMENTS[detailInstrument].i18nKey}`,
)
: t("mentalHealth.history.title")
}
description={t("mentalHealth.history.chartTitle")}
>
{detailInstrument && (
<InstrumentDetail
instrument={detailInstrument}
rows={history?.assessments ?? []}
onStart={() => begin(detailInstrument)}
/>
)}
</ResponsiveSheet>
</>
)}