mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Merge branch 'feat/w-g-screenings' into release/v1.27.10
This commit is contained in:
commit
6e59fe5ce0
40 files changed
+2180
-352
No files matched your search
+34
-9
@@ -2103,6 +2103,8 @@ paths:
|
||||
- PAIN_NRS
|
||||
- WAIST_CIRCUMFERENCE
|
||||
- WAIST_TO_HEIGHT
|
||||
- WHO5_SCORE
|
||||
- SCI_SCORE
|
||||
- in: query
|
||||
name: from
|
||||
schema:
|
||||
@@ -2299,6 +2301,8 @@ paths:
|
||||
- PAIN_NRS
|
||||
- WAIST_CIRCUMFERENCE
|
||||
- WAIST_TO_HEIGHT
|
||||
- WHO5_SCORE
|
||||
- SCI_SCORE
|
||||
value:
|
||||
type: number
|
||||
measuredAt:
|
||||
@@ -5878,6 +5882,8 @@ paths:
|
||||
- PAIN_NRS
|
||||
- WAIST_CIRCUMFERENCE
|
||||
- WAIST_TO_HEIGHT
|
||||
- WHO5_SCORE
|
||||
- SCI_SCORE
|
||||
description: "The measurement type to read (single metric — no fan-out). Closed enum: an unknown type 422s."
|
||||
required: true
|
||||
description: "The measurement type to read (single metric — no fan-out). Closed enum: an unknown type 422s."
|
||||
@@ -8116,8 +8122,8 @@ paths:
|
||||
tags:
|
||||
- Mental health
|
||||
summary: List the caller's screener history
|
||||
description: Returns PHQ-9 / GAD-7 administrations (newest first). Totals + bands + flags only; raw item answers are
|
||||
never returned.
|
||||
description: Returns PHQ-9 / GAD-7 / WHO-5 / SCI administrations (newest first). Totals + bands + flags only; raw item
|
||||
answers are never returned.
|
||||
parameters:
|
||||
- in: query
|
||||
name: instrument
|
||||
@@ -8126,6 +8132,8 @@ paths:
|
||||
enum:
|
||||
- PHQ9
|
||||
- GAD7
|
||||
- WHO5
|
||||
- SCI
|
||||
- in: query
|
||||
name: limit
|
||||
schema:
|
||||
@@ -8151,10 +8159,11 @@ paths:
|
||||
post:
|
||||
tags:
|
||||
- Mental health
|
||||
summary: Record a completed PHQ-9 / GAD-7 screener
|
||||
summary: Record a completed screener
|
||||
description: Stores one administration (item answers encrypted) and writes the derived total as a server-owned
|
||||
PHQ9_SCORE / GAD7_SCORE measurement (source COMPUTED — never client-writable). On a positive PHQ-9 item-9 the
|
||||
response carries the locale-aware crisis-resource set.
|
||||
PHQ9_SCORE / GAD7_SCORE / WHO5_SCORE / SCI_SCORE measurement (source COMPUTED — never client-writable). WHO-5
|
||||
and SCI totals run HIGHER = better; their follow-up thresholds point downwards (≤ 50 / ≤ 16). On a positive
|
||||
PHQ-9 item-9 the response carries the locale-aware crisis-resource set.
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
@@ -8166,6 +8175,8 @@ paths:
|
||||
enum:
|
||||
- PHQ9
|
||||
- GAD7
|
||||
- WHO5
|
||||
- SCI
|
||||
items:
|
||||
minItems: 1
|
||||
maxItems: 12
|
||||
@@ -8173,7 +8184,7 @@ paths:
|
||||
items:
|
||||
type: integer
|
||||
minimum: 0
|
||||
maximum: 3
|
||||
maximum: 5
|
||||
functionalDifficulty:
|
||||
type: integer
|
||||
minimum: 0
|
||||
@@ -11158,9 +11169,11 @@ components:
|
||||
- BODY_MASS_INDEX
|
||||
- PHQ9_SCORE
|
||||
- GAD7_SCORE
|
||||
description: Auto-resolve target metric (vitals + body-composition family + the PHQ-9 / GAD-7 screenings). BP resolves
|
||||
on BLOOD_PRESSURE_SYS (the SYS row is the 'a BP was measured' sentinel); a screening reminder resolves on the
|
||||
server-written PHQ9_SCORE / GAD7_SCORE row when a check-in completes. Omit for a free-text Vorsorge that
|
||||
- WHO5_SCORE
|
||||
- SCI_SCORE
|
||||
description: Auto-resolve target metric (vitals + body-composition family + the PHQ-9 / GAD-7 / WHO-5 / SCI screenings).
|
||||
BP resolves on BLOOD_PRESSURE_SYS (the SYS row is the 'a BP was measured' sentinel); a screening reminder
|
||||
resolves on the server-written *_SCORE row when a check-in completes. Omit for a free-text Vorsorge that
|
||||
resolves only on a manual satisfy or a matching lab result.
|
||||
MeasurementReminderUpdate:
|
||||
type: object
|
||||
@@ -13917,6 +13930,8 @@ components:
|
||||
- PAIN_NRS
|
||||
- WAIST_CIRCUMFERENCE
|
||||
- WAIST_TO_HEIGHT
|
||||
- WHO5_SCORE
|
||||
- SCI_SCORE
|
||||
value:
|
||||
type: number
|
||||
unit:
|
||||
@@ -14240,6 +14255,8 @@ components:
|
||||
- PAIN_NRS
|
||||
- WAIST_CIRCUMFERENCE
|
||||
- WAIST_TO_HEIGHT
|
||||
- WHO5_SCORE
|
||||
- SCI_SCORE
|
||||
additionalProperties:
|
||||
type: array
|
||||
items:
|
||||
@@ -14323,6 +14340,8 @@ components:
|
||||
- PAIN_NRS
|
||||
- WAIST_CIRCUMFERENCE
|
||||
- WAIST_TO_HEIGHT
|
||||
- WHO5_SCORE
|
||||
- SCI_SCORE
|
||||
value:
|
||||
type: number
|
||||
description: Daily mean (spot metrics) or SUM (cumulative).
|
||||
@@ -14427,6 +14446,8 @@ components:
|
||||
- PAIN_NRS
|
||||
- WAIST_CIRCUMFERENCE
|
||||
- WAIST_TO_HEIGHT
|
||||
- WHO5_SCORE
|
||||
- SCI_SCORE
|
||||
description: Per-type daily series, keyed by MeasurementType.
|
||||
required:
|
||||
- series
|
||||
@@ -14666,6 +14687,8 @@ components:
|
||||
- PAIN_NRS
|
||||
- WAIST_CIRCUMFERENCE
|
||||
- WAIST_TO_HEIGHT
|
||||
- WHO5_SCORE
|
||||
- SCI_SCORE
|
||||
value:
|
||||
type: number
|
||||
unit:
|
||||
@@ -25232,6 +25255,8 @@ components:
|
||||
enum:
|
||||
- PHQ9
|
||||
- GAD7
|
||||
- WHO5
|
||||
- SCI
|
||||
locale:
|
||||
type: string
|
||||
version:
|
||||
|
||||
+106
-12
@@ -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",
|
||||
@@ -7667,8 +7669,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",
|
||||
@@ -7677,14 +7679,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",
|
||||
@@ -7719,16 +7724,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",
|
||||
@@ -7756,6 +7768,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": {
|
||||
@@ -7783,14 +7812,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
@@ -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",
|
||||
@@ -7667,8 +7669,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",
|
||||
@@ -7677,14 +7679,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",
|
||||
@@ -7719,16 +7724,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",
|
||||
@@ -7756,6 +7768,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": {
|
||||
@@ -7783,14 +7812,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
@@ -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",
|
||||
@@ -7667,8 +7669,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",
|
||||
@@ -7677,14 +7679,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",
|
||||
@@ -7719,16 +7724,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",
|
||||
@@ -7756,6 +7768,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": {
|
||||
@@ -7783,14 +7812,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
@@ -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",
|
||||
@@ -7667,8 +7669,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",
|
||||
@@ -7677,14 +7679,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",
|
||||
@@ -7719,16 +7724,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)",
|
||||
@@ -7756,6 +7768,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": {
|
||||
@@ -7783,14 +7812,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
@@ -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",
|
||||
@@ -7667,8 +7669,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",
|
||||
@@ -7677,14 +7679,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",
|
||||
@@ -7719,16 +7724,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",
|
||||
@@ -7756,6 +7768,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": {
|
||||
@@ -7783,14 +7812,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
@@ -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",
|
||||
@@ -7667,8 +7669,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",
|
||||
@@ -7677,14 +7679,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",
|
||||
@@ -7719,16 +7724,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)",
|
||||
@@ -7756,6 +7768,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": {
|
||||
@@ -7783,14 +7812,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.",
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
-- v1.27.9 — screening-score enum values for the two new self-report
|
||||
-- instruments: the WHO-5 well-being index (percentage 0–100, raw sum × 4)
|
||||
-- and the Sleep Condition Indicator (total 0–32, higher = better sleep).
|
||||
--
|
||||
-- Same contract as PHQ9_SCORE / GAD7_SCORE (0213/0225 lineage): the value is
|
||||
-- server-derived from a completed MentalHealthAssessment and written as a
|
||||
-- `COMPUTED`-source Measurement row, so clients can never forge a trend point
|
||||
-- through the measurement write surfaces. Additive, idempotent.
|
||||
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'WHO5_SCORE';
|
||||
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'SCI_SCORE';
|
||||
|
||||
-- The assessment discriminator learns the two new instruments (0213 created
|
||||
-- it with PHQ9/GAD7 only).
|
||||
ALTER TYPE "assessment_instrument" ADD VALUE IF NOT EXISTS 'WHO5';
|
||||
ALTER TYPE "assessment_instrument" ADD VALUE IF NOT EXISTS 'SCI';
|
||||
+11
-2
@@ -1284,17 +1284,26 @@ enum MeasurementType {
|
||||
// waist-to-height ratio (WHtR; keep waist < half your height ⇒ < 0.5).
|
||||
WAIST_CIRCUMFERENCE // cm — waist circumference (WHO bands; ethnicity-aware at display).
|
||||
WAIST_TO_HEIGHT // ratio — waist-to-height ratio (WHtR ≥ 0.5 = increased risk).
|
||||
// ── v1.27.9 — screening scores (WHO-5 well-being + Sleep Condition Indicator) ──
|
||||
// Same contract as PHQ9_SCORE / GAD7_SCORE: server-derived from a completed
|
||||
// `MentalHealthAssessment`, `source = COMPUTED`, one row per administration.
|
||||
// Note the inverted direction versus PHQ-9 / GAD-7: HIGHER is better for both.
|
||||
WHO5_SCORE // score (0–100) — WHO-5 well-being percentage (raw 0–25 × 4). Opt-in. See MentalHealthAssessment.
|
||||
SCI_SCORE // score (0–32) — Sleep Condition Indicator total; higher = better sleep. Opt-in. See MentalHealthAssessment.
|
||||
|
||||
@@map("measurement_type")
|
||||
}
|
||||
|
||||
/// v1.25 — validated mental-health self-assessment instruments: PHQ-9
|
||||
/// (depression) + GAD-7 (anxiety). The discriminator on
|
||||
/// `MentalHealthAssessment`; extensible (WHO-5 / PSS-10 are natural future
|
||||
/// siblings) without a schema change.
|
||||
/// `MentalHealthAssessment`. v1.27.9 adds the WHO-5 well-being index and the
|
||||
/// Sleep Condition Indicator (SCI) — same administration contract, inverted
|
||||
/// score direction (higher = better).
|
||||
enum AssessmentInstrument {
|
||||
PHQ9
|
||||
GAD7
|
||||
WHO5
|
||||
SCI
|
||||
|
||||
@@map("assessment_instrument")
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -284,6 +284,98 @@ describe("POST /api/mental-health/assessments", () => {
|
||||
expect(createArg.data.externalId).toBe("outbox-aa01");
|
||||
});
|
||||
|
||||
it("scores a WHO-5 check-in as raw-sum × 4 and projects a WHO5_SCORE measurement", async () => {
|
||||
// Raw 3+3+2+2+2 = 12 → reported 48 (below the WHO "below 50" cut → low).
|
||||
vi.mocked(prisma.mentalHealthAssessment.create).mockResolvedValue({
|
||||
id: "mha_who5",
|
||||
instrument: "WHO5",
|
||||
locale: "en",
|
||||
version: "standard",
|
||||
totalScore: 48,
|
||||
severityBand: "low",
|
||||
item9Flagged: false,
|
||||
crisisShownAt: null,
|
||||
takenAt: new Date("2026-06-28T00:00:00.000Z"),
|
||||
createdAt: new Date("2026-06-28T00:00:00.000Z"),
|
||||
} as never);
|
||||
|
||||
const res = await callPost(
|
||||
makeReq({ instrument: "WHO5", items: [3, 3, 2, 2, 2] }),
|
||||
);
|
||||
expect(res.status).toBe(201);
|
||||
|
||||
const createArg = vi.mocked(prisma.mentalHealthAssessment.create).mock
|
||||
.calls[0][0] as {
|
||||
data: { totalScore: number; severityBand: string; item9Flagged: boolean };
|
||||
};
|
||||
expect(createArg.data.totalScore).toBe(48);
|
||||
expect(createArg.data.severityBand).toBe("low");
|
||||
// Positively-worded instrument, no safety item — never flagged.
|
||||
expect(createArg.data.item9Flagged).toBe(false);
|
||||
|
||||
const measArg = vi.mocked(prisma.measurement.create).mock.calls[0][0] as {
|
||||
data: { type: string; value: number; source: string };
|
||||
};
|
||||
expect(measArg.data.type).toBe("WHO5_SCORE");
|
||||
expect(measArg.data.value).toBe(48);
|
||||
expect(measArg.data.source).toBe("COMPUTED");
|
||||
|
||||
const body = (await res.json()) as {
|
||||
data: { crisis: unknown; actionThreshold: number };
|
||||
};
|
||||
// No crisis routing on the WHO-5 — the ≤50 pointer is client-side copy.
|
||||
expect(body.data.crisis).toBeNull();
|
||||
expect(body.data.actionThreshold).toBe(50);
|
||||
});
|
||||
|
||||
it("accepts an SCI check-in (0–4 anchors) and projects an SCI_SCORE measurement", async () => {
|
||||
vi.mocked(prisma.mentalHealthAssessment.create).mockResolvedValue({
|
||||
id: "mha_sci",
|
||||
instrument: "SCI",
|
||||
locale: "en",
|
||||
version: "standard",
|
||||
totalScore: 16,
|
||||
severityBand: "belowThreshold",
|
||||
item9Flagged: false,
|
||||
crisisShownAt: null,
|
||||
takenAt: new Date("2026-06-28T00:00:00.000Z"),
|
||||
createdAt: new Date("2026-06-28T00:00:00.000Z"),
|
||||
} as never);
|
||||
|
||||
// Total 4+3+2+1+0+4+1+1 = 16 → the paper's ≤16 probable-insomnia range.
|
||||
const res = await callPost(
|
||||
makeReq({ instrument: "SCI", items: [4, 3, 2, 1, 0, 4, 1, 1] }),
|
||||
);
|
||||
expect(res.status).toBe(201);
|
||||
|
||||
const createArg = vi.mocked(prisma.mentalHealthAssessment.create).mock
|
||||
.calls[0][0] as { data: { totalScore: number; severityBand: string } };
|
||||
expect(createArg.data.totalScore).toBe(16);
|
||||
expect(createArg.data.severityBand).toBe("belowThreshold");
|
||||
|
||||
const measArg = vi.mocked(prisma.measurement.create).mock.calls[0][0] as {
|
||||
data: { type: string; value: number; source: string };
|
||||
};
|
||||
expect(measArg.data.type).toBe("SCI_SCORE");
|
||||
expect(measArg.data.value).toBe(16);
|
||||
expect(measArg.data.source).toBe("COMPUTED");
|
||||
});
|
||||
|
||||
it("rejects an out-of-scale answer for the instrument with 422 (SCI max 4, PHQ-9 max 3)", async () => {
|
||||
// 5 is a legal WHO-5 answer but out of scale for the SCI…
|
||||
const sciRes = await callPost(
|
||||
makeReq({ instrument: "SCI", items: [5, 3, 2, 1, 0, 4, 1, 1] }),
|
||||
);
|
||||
expect(sciRes.status).toBe(422);
|
||||
// …and 4 is out of scale for the PHQ-9.
|
||||
const phqRes = await callPost(
|
||||
makeReq({ instrument: "PHQ9", items: [4, 0, 0, 0, 0, 0, 0, 0, 0] }),
|
||||
);
|
||||
expect(phqRes.status).toBe(422);
|
||||
expect(prisma.mentalHealthAssessment.create).not.toHaveBeenCalled();
|
||||
expect(prisma.measurement.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a wrong item count with 422 and never writes", async () => {
|
||||
const res = await callPost(
|
||||
makeReq({ instrument: "PHQ9", items: [1, 2, 3] }), // 3 ≠ 9
|
||||
|
||||
@@ -229,8 +229,9 @@ async function postAssessment(request: NextRequest): Promise<Response> {
|
||||
if (existing) return respondExisting(existing);
|
||||
}
|
||||
|
||||
// Server-authoritative scoring (the client never computes these).
|
||||
const total = scoreTotal(items);
|
||||
// Server-authoritative scoring (the client never computes these). The
|
||||
// total is the REPORTED score — the WHO-5 projects raw-sum × 4 (0–100).
|
||||
const total = scoreTotal(id, items);
|
||||
const band = severityBand(id, total);
|
||||
const flagged = isSafetyFlagged(id, items);
|
||||
const presentedLocale = locale ?? user.locale ?? "en";
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -125,6 +125,9 @@ export const MEASUREMENT_TYPE_LABEL_KEYS: Record<string, string> = {
|
||||
// ── v1.25 — clinical-signals wave ──
|
||||
PHQ9_SCORE: "measurements.typePhq9Score",
|
||||
GAD7_SCORE: "measurements.typeGad7Score",
|
||||
// ── v1.27.9 — screening scores ──
|
||||
WHO5_SCORE: "measurements.typeWho5Score",
|
||||
SCI_SCORE: "measurements.typeSciScore",
|
||||
GRIP_STRENGTH: "measurements.typeGripStrength",
|
||||
PAIN_NRS: "measurements.typePainNrs",
|
||||
WAIST_CIRCUMFERENCE: "measurements.typeWaistCircumference",
|
||||
@@ -257,6 +260,9 @@ export const MEASUREMENT_TYPE_ICONS: Record<string, LucideIcon> = {
|
||||
// Activity the pain trace; Scale the anthropometric waist pair.
|
||||
PHQ9_SCORE: Gauge,
|
||||
GAD7_SCORE: Gauge,
|
||||
// ── v1.27.9 — screening scores ride the same composite-dial icon ──
|
||||
WHO5_SCORE: Gauge,
|
||||
SCI_SCORE: Gauge,
|
||||
GRIP_STRENGTH: Dumbbell,
|
||||
PAIN_NRS: Activity,
|
||||
WAIST_CIRCUMFERENCE: Scale,
|
||||
@@ -380,6 +386,9 @@ export const MEASUREMENT_TYPE_COLORS: Record<string, string> = {
|
||||
// chart-3 (cardio/alert family) for pain; chart-4 (body family) for waist.
|
||||
PHQ9_SCORE: "bg-chart-1/20 text-chart-1",
|
||||
GAD7_SCORE: "bg-chart-1/20 text-chart-1",
|
||||
// ── v1.27.9 — screening scores join the composite/score colour family ──
|
||||
WHO5_SCORE: "bg-chart-1/20 text-chart-1",
|
||||
SCI_SCORE: "bg-chart-1/20 text-chart-1",
|
||||
GRIP_STRENGTH: "bg-chart-1/20 text-chart-1",
|
||||
PAIN_NRS: "bg-chart-3/20 text-chart-3",
|
||||
WAIST_CIRCUMFERENCE: "bg-chart-4/20 text-chart-4",
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -105,10 +105,14 @@ const EXPECTED_TYPES = [
|
||||
"PAIN_NRS",
|
||||
"WAIST_CIRCUMFERENCE",
|
||||
"WAIST_TO_HEIGHT",
|
||||
// ── v1.27.9 — screening scores (WHO-5 percentage 0–100, SCI total 0–32;
|
||||
// both server-derived from a completed assessment, higher = better) ──
|
||||
"WHO5_SCORE",
|
||||
"SCI_SCORE",
|
||||
] as const;
|
||||
|
||||
describe("measurementTypeEnum coverage", () => {
|
||||
it("exposes the 73 canonical measurement types", () => {
|
||||
it("exposes the 75 canonical measurement types", () => {
|
||||
expect([...measurementTypeEnum.options].sort()).toEqual(
|
||||
[...EXPECTED_TYPES].sort(),
|
||||
);
|
||||
@@ -264,6 +268,12 @@ describe("measurementTypeEnum coverage", () => {
|
||||
"PAIN_NRS",
|
||||
"WAIST_CIRCUMFERENCE",
|
||||
"WAIST_TO_HEIGHT",
|
||||
// v1.27.9 — WHO-5 / SCI screening totals follow the PHQ-9 / GAD-7
|
||||
// precedent: opt-in questionnaire derivations, excluded from the vitals
|
||||
// table; they ride the module-gated mental-health export section
|
||||
// instead. See doctor-report-pdf-core.ts for the matching rationale.
|
||||
"WHO5_SCORE",
|
||||
"SCI_SCORE",
|
||||
]);
|
||||
|
||||
it("doctor-report PDF vital types cover the canonical enum minus documented exclusions", () => {
|
||||
|
||||
@@ -1620,6 +1620,9 @@ const MEASUREMENT_TYPE_MODULE: Record<string, ModuleKey> = {
|
||||
// opted into.
|
||||
PHQ9_SCORE: "mentalHealth",
|
||||
GAD7_SCORE: "mentalHealth",
|
||||
// v1.27.9 — the WHO-5 / SCI totals ride the same module gate.
|
||||
WHO5_SCORE: "mentalHealth",
|
||||
SCI_SCORE: "mentalHealth",
|
||||
};
|
||||
|
||||
function filterMeasurementKeys<T>(
|
||||
|
||||
@@ -201,6 +201,11 @@ export const DOCTOR_REPORT_TYPE_UNIT_KEYS: Record<string, string | null> = {
|
||||
* v1.19.0 — Oura resilience. `RESILIENCE` is an ordinal-encoded categorical
|
||||
* band (limited=1 … exceptional=5), a derived recovery composite — descriptive,
|
||||
* not a measured clinical vital — so it is excluded too.
|
||||
*
|
||||
* v1.27.9 — WHO-5 / SCI screening totals. `WHO5_SCORE` + `SCI_SCORE` follow
|
||||
* the PHQ-9 / GAD-7 precedent: opt-in, derived from an in-app questionnaire,
|
||||
* excluded from the vitals table (they ride the module-gated mental-health
|
||||
* export section instead, see `doctor-report-data.ts`).
|
||||
*/
|
||||
export const DOCTOR_REPORT_VITAL_TYPES = [
|
||||
"WEIGHT",
|
||||
|
||||
@@ -133,6 +133,10 @@ export const ALLOWED_CHART_TOKENS = [
|
||||
// generic chart.
|
||||
"metric:PHQ9_SCORE",
|
||||
"metric:GAD7_SCORE",
|
||||
// v1.27.9 — WHO-5 (0–100 percentage) + SCI (0–32) screening totals; same
|
||||
// continuous-series contract, higher = better.
|
||||
"metric:WHO5_SCORE",
|
||||
"metric:SCI_SCORE",
|
||||
"metric:GRIP_STRENGTH",
|
||||
"metric:PAIN_NRS",
|
||||
"metric:WAIST_CIRCUMFERENCE",
|
||||
|
||||
@@ -184,6 +184,28 @@ describe("runReminderSatisfyForUser", () => {
|
||||
expect(updates[0].data.lastSatisfiedAt).toEqual(measuredAt);
|
||||
});
|
||||
|
||||
it("satisfies the WHO-5 / SCI screening reminders on the identical contract (v1.27.9)", async () => {
|
||||
for (const measurementType of ["WHO5_SCORE", "SCI_SCORE"]) {
|
||||
const measuredAt = new Date("2026-07-01T10:00:00Z");
|
||||
const { prisma, updates } = makePrisma({
|
||||
reminders: [reminderRow({ measurementType, intervalDays: 28 })],
|
||||
measurement: { measuredAt },
|
||||
});
|
||||
const isModuleEnabled = vi.fn(async () => true);
|
||||
|
||||
const summary = await runReminderSatisfyForUser(
|
||||
prisma as never,
|
||||
"u1",
|
||||
new Date("2026-07-01T12:00:00Z"),
|
||||
{ isModuleEnabled },
|
||||
);
|
||||
|
||||
expect(summary.satisfied, `${measurementType} should satisfy`).toBe(1);
|
||||
expect(isModuleEnabled).toHaveBeenCalledWith("u1", "mentalHealth");
|
||||
expect(updates[0].data.lastSatisfiedAt).toEqual(measuredAt);
|
||||
}
|
||||
});
|
||||
|
||||
it("produces no engine activity for a screening reminder when the mental-health module is off", async () => {
|
||||
const { prisma, updates } = makePrisma({
|
||||
reminders: [
|
||||
|
||||
@@ -67,6 +67,8 @@ function moduleForMeasurementType(
|
||||
return "sleep";
|
||||
case "PHQ9_SCORE":
|
||||
case "GAD7_SCORE":
|
||||
case "WHO5_SCORE":
|
||||
case "SCI_SCORE":
|
||||
return "mentalHealth";
|
||||
default:
|
||||
return null;
|
||||
|
||||
@@ -89,6 +89,10 @@ const MEASUREMENT_TYPES_WITHOUT_HK_COUNTERPART = new Set<MeasurementType>([
|
||||
"GRIP_STRENGTH",
|
||||
"PAIN_NRS",
|
||||
"WAIST_TO_HEIGHT",
|
||||
// v1.27.9 — WHO-5 / SCI screening totals: same in-app questionnaire
|
||||
// derivation as PHQ-9 / GAD-7. No HK mapping by design.
|
||||
"WHO5_SCORE",
|
||||
"SCI_SCORE",
|
||||
]);
|
||||
|
||||
describe("APPLE_HEALTH_TYPE_MAP", () => {
|
||||
|
||||
@@ -209,6 +209,9 @@ export const MEASUREMENT_CATEGORIES: ReadonlyMap<
|
||||
// PHQ-9 / GAD-7 totals are composite screener indices → scores cluster.
|
||||
["PHQ9_SCORE", "scores"],
|
||||
["GAD7_SCORE", "scores"],
|
||||
// ── v1.27.9 — WHO-5 / SCI totals join the same screener-index cluster.
|
||||
["WHO5_SCORE", "scores"],
|
||||
["SCI_SCORE", "scores"],
|
||||
// Grip strength is a strength/fitness reading → activity cluster.
|
||||
["GRIP_STRENGTH", "activity"],
|
||||
// Pain NRS is a patient-reported clinical point-in-time signal → vitals.
|
||||
|
||||
@@ -2,9 +2,14 @@ import { describe, it, expect } from "vitest";
|
||||
|
||||
import {
|
||||
INSTRUMENTS,
|
||||
INSTRUMENT_MEASUREMENT_TYPE,
|
||||
hasValidatedItems,
|
||||
isSafetyFlagged,
|
||||
needsFollowUp,
|
||||
optionLabelKey,
|
||||
scoreTotal,
|
||||
severityBand,
|
||||
isSafetyFlagged,
|
||||
stemKey,
|
||||
} from "../instruments";
|
||||
|
||||
/**
|
||||
@@ -12,19 +17,40 @@ import {
|
||||
* GAD-7 totals, the validated severity-band edges (5 / 10 / 15 / 20 and
|
||||
* 5 / 10 / 15), the ≥10 action threshold, and the item-9 ANY-non-zero safety
|
||||
* flag. A future off-by-one on any band edge or the safety predicate must fail
|
||||
* here. Ground truth: Kroenke 2001 (PHQ-9) / Spitzer 2006 (GAD-7), mirrored in
|
||||
* `.planning/research/v125/clinical-instruments.md`.
|
||||
* here. Ground truth: Kroenke 2001 (PHQ-9) / Spitzer 2006 (GAD-7).
|
||||
*
|
||||
* v1.27.9 — the WHO-5 / SCI pins join. Ground truth: the official WHO
|
||||
* publication WHO/UCN/MSD/MHE/2024.1 ("raw score … zero to 25 … multiplied by
|
||||
* four" → 0–100; "a percentage score below 50 … an indication for further
|
||||
* assessment") and Espie et al., BMJ Open 2014;4:e004183 ("total score ranges
|
||||
* from 0 to 32, with higher values indicative of better sleep"; "an SCI
|
||||
* cut-off ≤16 … 'probable insomnia disorder'").
|
||||
*/
|
||||
describe("mental-health instruments", () => {
|
||||
describe("scoreTotal", () => {
|
||||
it("sums the item answers", () => {
|
||||
expect(scoreTotal([0, 1, 2, 3, 0, 1, 2, 3, 1])).toBe(13);
|
||||
expect(scoreTotal([0, 0, 0, 0, 0, 0, 0])).toBe(0);
|
||||
it("sums the item answers for PHQ-9 / GAD-7 (multiplier 1)", () => {
|
||||
expect(scoreTotal("PHQ9", [0, 1, 2, 3, 0, 1, 2, 3, 1])).toBe(13);
|
||||
expect(scoreTotal("GAD7", [0, 0, 0, 0, 0, 0, 0])).toBe(0);
|
||||
});
|
||||
|
||||
it("returns the max when every item is 3", () => {
|
||||
expect(scoreTotal(Array(9).fill(3))).toBe(27); // PHQ-9 max
|
||||
expect(scoreTotal(Array(7).fill(3))).toBe(21); // GAD-7 max
|
||||
expect(scoreTotal("PHQ9", Array(9).fill(3))).toBe(27); // PHQ-9 max
|
||||
expect(scoreTotal("GAD7", Array(7).fill(3))).toBe(21); // GAD-7 max
|
||||
});
|
||||
|
||||
it("WHO-5 reports raw-sum × 4 (the official 0–100 percentage)", () => {
|
||||
// WHO scoring page: raw 0–25, "multiplied by four" → 0–100.
|
||||
expect(scoreTotal("WHO5", [0, 0, 0, 0, 0])).toBe(0);
|
||||
expect(scoreTotal("WHO5", [5, 5, 5, 5, 5])).toBe(100);
|
||||
expect(scoreTotal("WHO5", [3, 3, 3, 3, 3])).toBe(60);
|
||||
// Raw 12 (below the raw-13 cut) → 48 on the percentage scale.
|
||||
expect(scoreTotal("WHO5", [3, 3, 2, 2, 2])).toBe(48);
|
||||
});
|
||||
|
||||
it("SCI sums to 0–32 (higher = better sleep)", () => {
|
||||
expect(scoreTotal("SCI", Array(8).fill(0))).toBe(0);
|
||||
expect(scoreTotal("SCI", Array(8).fill(4))).toBe(32);
|
||||
expect(scoreTotal("SCI", [4, 3, 2, 1, 0, 4, 1, 1])).toBe(16);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -62,12 +88,47 @@ describe("mental-health instruments", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("action threshold is ≥10 for both instruments", () => {
|
||||
it("PHQ-9", () => {
|
||||
expect(INSTRUMENTS.PHQ9.actionThreshold).toBe(10);
|
||||
describe("severityBand — WHO-5 (below 50 = low; achievable totals are ×4)", () => {
|
||||
const cases: Array<[number, string]> = [
|
||||
[0, "low"],
|
||||
[48, "low"], // raw 12 — below the WHO raw-13 cut
|
||||
[52, "good"], // raw 13 — at/above the cut
|
||||
[100, "good"],
|
||||
];
|
||||
it.each(cases)("WHO5 total %i → %s", (total, band) => {
|
||||
expect(severityBand("WHO5", total)).toBe(band);
|
||||
});
|
||||
it("GAD-7", () => {
|
||||
expect(INSTRUMENTS.GAD7.actionThreshold).toBe(10);
|
||||
});
|
||||
|
||||
describe("severityBand — SCI (≤16 probable-insomnia range per Espie 2014)", () => {
|
||||
const cases: Array<[number, string]> = [
|
||||
[0, "belowThreshold"],
|
||||
[16, "belowThreshold"],
|
||||
[17, "aboveThreshold"],
|
||||
[32, "aboveThreshold"],
|
||||
];
|
||||
it.each(cases)("SCI total %i → %s", (total, band) => {
|
||||
expect(severityBand("SCI", total)).toBe(band);
|
||||
});
|
||||
});
|
||||
|
||||
describe("needsFollowUp — direction-aware thresholds", () => {
|
||||
it("PHQ-9 / GAD-7 point up (≥ 10)", () => {
|
||||
expect(needsFollowUp("PHQ9", 9)).toBe(false);
|
||||
expect(needsFollowUp("PHQ9", 10)).toBe(true);
|
||||
expect(needsFollowUp("GAD7", 9)).toBe(false);
|
||||
expect(needsFollowUp("GAD7", 10)).toBe(true);
|
||||
});
|
||||
|
||||
it("WHO-5 points down (≤ 50 → gentle PHQ-9 pointer)", () => {
|
||||
expect(needsFollowUp("WHO5", 48)).toBe(true);
|
||||
expect(needsFollowUp("WHO5", 50)).toBe(true);
|
||||
expect(needsFollowUp("WHO5", 52)).toBe(false);
|
||||
});
|
||||
|
||||
it("SCI points down (≤ 16)", () => {
|
||||
expect(needsFollowUp("SCI", 16)).toBe(true);
|
||||
expect(needsFollowUp("SCI", 17)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -82,13 +143,70 @@ describe("mental-health instruments", () => {
|
||||
it("does not flag when item 9 = 0 even with a high total", () => {
|
||||
// Every other item maxed (24), item 9 = 0 → severe band, no flag.
|
||||
const items = [3, 3, 3, 3, 3, 3, 3, 3, 0];
|
||||
expect(severityBand("PHQ9", scoreTotal(items))).toBe("severe");
|
||||
expect(severityBand("PHQ9", scoreTotal("PHQ9", items))).toBe("severe");
|
||||
expect(isSafetyFlagged("PHQ9", items)).toBe(false);
|
||||
});
|
||||
|
||||
it("never flags GAD-7 (no safety item)", () => {
|
||||
it("never flags the instruments without a safety item", () => {
|
||||
expect(isSafetyFlagged("GAD7", [3, 3, 3, 3, 3, 3, 3])).toBe(false);
|
||||
expect(isSafetyFlagged("WHO5", [0, 0, 0, 0, 0])).toBe(false);
|
||||
expect(isSafetyFlagged("SCI", [0, 0, 0, 0, 0, 0, 0, 0])).toBe(false);
|
||||
expect(INSTRUMENTS.GAD7.safetyItemIndex).toBeNull();
|
||||
expect(INSTRUMENTS.WHO5.safetyItemIndex).toBeNull();
|
||||
expect(INSTRUMENTS.SCI.safetyItemIndex).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("registry shape — the config the surfaces drive from", () => {
|
||||
it("presents options in the source order (WHO-5 leads with 5, SCI with 4)", () => {
|
||||
expect(INSTRUMENTS.WHO5.optionOrder).toEqual([5, 4, 3, 2, 1, 0]);
|
||||
expect(INSTRUMENTS.SCI.optionOrder).toEqual([4, 3, 2, 1, 0]);
|
||||
expect(INSTRUMENTS.PHQ9.optionOrder).toEqual([0, 1, 2, 3]);
|
||||
});
|
||||
|
||||
it("resolves option-label keys per scheme (shared vs SCI per-item groups)", () => {
|
||||
expect(optionLabelKey("PHQ9", 0, 2)).toBe("options.2");
|
||||
expect(optionLabelKey("WHO5", 4, 5)).toBe("who5Options.5");
|
||||
// SCI: items 1–2 duration, 3 nights, 4 quality, 5–7 impact, 8 problem duration.
|
||||
expect(optionLabelKey("SCI", 0, 4)).toBe("sciOptions.duration.4");
|
||||
expect(optionLabelKey("SCI", 2, 0)).toBe("sciOptions.nights.0");
|
||||
expect(optionLabelKey("SCI", 3, 2)).toBe("sciOptions.quality.2");
|
||||
expect(optionLabelKey("SCI", 6, 1)).toBe("sciOptions.impact.1");
|
||||
expect(optionLabelKey("SCI", 7, 3)).toBe("sciOptions.problemDuration.3");
|
||||
});
|
||||
|
||||
it("maps recall stems (WHO-5 one stem; SCI three sections; PHQ/GAD none)", () => {
|
||||
expect(stemKey("WHO5", 0)).toBe("who5.period");
|
||||
expect(stemKey("SCI", 0)).toBe("sci.night");
|
||||
expect(stemKey("SCI", 3)).toBe("sci.night");
|
||||
expect(stemKey("SCI", 4)).toBe("sci.impact");
|
||||
expect(stemKey("SCI", 7)).toBe("sci.finally");
|
||||
expect(stemKey("PHQ9", 0)).toBeNull();
|
||||
expect(stemKey("GAD7", 0)).toBeNull();
|
||||
});
|
||||
|
||||
it("declares validated item locales honestly (SCI = English only)", () => {
|
||||
for (const locale of ["de", "en", "es", "fr", "it", "pl"]) {
|
||||
expect(hasValidatedItems("WHO5", locale)).toBe(true);
|
||||
expect(hasValidatedItems("PHQ9", locale)).toBe(true);
|
||||
}
|
||||
expect(hasValidatedItems("SCI", "en")).toBe(true);
|
||||
expect(hasValidatedItems("SCI", "en-GB")).toBe(true);
|
||||
expect(hasValidatedItems("SCI", "de")).toBe(false);
|
||||
expect(hasValidatedItems("SCI", "pl")).toBe(false);
|
||||
});
|
||||
|
||||
it("projects each instrument onto its measurement type", () => {
|
||||
expect(INSTRUMENT_MEASUREMENT_TYPE.WHO5).toBe("WHO5_SCORE");
|
||||
expect(INSTRUMENT_MEASUREMENT_TYPE.SCI).toBe("SCI_SCORE");
|
||||
});
|
||||
|
||||
it("every instrument carries a non-empty attribution line", () => {
|
||||
for (const def of Object.values(INSTRUMENTS)) {
|
||||
expect(def.attribution.length).toBeGreaterThan(20);
|
||||
}
|
||||
expect(INSTRUMENTS.WHO5.attribution).toContain("CC BY-NC-SA 3.0 IGO");
|
||||
expect(INSTRUMENTS.SCI.attribution).toContain("BMJ Open");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,58 +1,123 @@
|
||||
/**
|
||||
* v1.25 — validated mental-health screener definitions (PHQ-9 + GAD-7).
|
||||
* v1.27.9 — the registry generalises to carry the WHO-5 well-being index and
|
||||
* the Sleep Condition Indicator (SCI) on the same infrastructure: per-item
|
||||
* answer scales, response-option schemes, score direction, and the reported
|
||||
* total (WHO-5 reports raw-sum × 4 as a 0–100 percentage) are now instrument
|
||||
* properties instead of PHQ/GAD assumptions.
|
||||
*
|
||||
* Pure, server-authoritative scoring. The instrument item wording, 0–3 scale,
|
||||
* totals, and severity bands are taken from the primary sources (Kroenke 2001
|
||||
* for PHQ-9; Spitzer 2006 for GAD-7). This module owns the canonical NUMERIC
|
||||
* contract; the human wording lives in `messages/*.json` under
|
||||
* `mentalHealth.items.*` (officially-validated translations per locale, English
|
||||
* fallback otherwise — re-wording invalidates the score). The de/es/fr/it/pl
|
||||
* item wordings are the public-domain Pfizer/PRIME-MD validated localized
|
||||
* versions distributed via the MAPI Research Trust (de: Löwe/Gräfe et al.); they
|
||||
* are NOT machine-translated. See `.planning/research/v125/clinical-instruments.md`.
|
||||
* Pure, server-authoritative scoring. Item wording, response anchors, scoring
|
||||
* and band thresholds are taken from the primary sources only:
|
||||
* - PHQ-9: Kroenke 2001; GAD-7: Spitzer 2006 (public-domain Pfizer grant).
|
||||
* - WHO-5: WHO/UCN/MSD/MHE/2024.1 (© WHO 2024, CC BY-NC-SA 3.0 IGO) — raw
|
||||
* 0–25, × 4 → 0–100; "a percentage score below 50 … an indication for
|
||||
* further assessment". WHO publishes official translations for all six
|
||||
* app locales; they ship verbatim.
|
||||
* - SCI: Espie et al., BMJ Open 2014;4:e004183 (© Sleepio Ltd, licensed
|
||||
* free for non-commercial use, CC BY-NC) — 8 items, 0–4 each, total 0–32,
|
||||
* HIGHER = better sleep; "a score of ≤16 … seems reasonable to detect
|
||||
* possible insomnia disorder". Item text is validated in English; app
|
||||
* locales without a validated translation present the English items.
|
||||
* This module owns the canonical NUMERIC contract; the human wording lives in
|
||||
* `messages/*.json` under `mentalHealth.items.*` (validated translations per
|
||||
* locale where they exist — re-wording invalidates the score). See
|
||||
* `.planning/research/screening-instruments-2026-07.md`.
|
||||
*
|
||||
* PHQ-9 item 9 (index 8) is the passive self-harm ideation screen and is the
|
||||
* safety-critical field (see `crisis-resources.ts` + the API handler): any
|
||||
* non-zero answer triggers a calm, non-alarmist crisis-resource signpost. The
|
||||
* raw item answers are NEVER exposed to the Coach / Insights / export surfaces —
|
||||
* only the derived total score rides a `*_SCORE` Measurement row.
|
||||
* non-zero answer triggers a calm, non-alarmist crisis-resource signpost.
|
||||
* Neither the WHO-5 (all items positively worded) nor the SCI (sleep-specific
|
||||
* items only) carries a crisis item. The raw item answers are NEVER exposed
|
||||
* to the Coach / Insights / export surfaces — only the derived total score
|
||||
* rides a `*_SCORE` Measurement row.
|
||||
*/
|
||||
|
||||
export type InstrumentId = "PHQ9" | "GAD7";
|
||||
export type InstrumentId = "PHQ9" | "GAD7" | "WHO5" | "SCI";
|
||||
|
||||
export interface InstrumentDefinition {
|
||||
id: InstrumentId;
|
||||
/** Lowercase i18n slug — the key segment under `mentalHealth.*`. */
|
||||
i18nKey: "phq9" | "gad7" | "who5" | "sci";
|
||||
/** Number of scored items (PHQ-9 = 9, GAD-7 = 7, WHO-5 = 5, SCI = 8). */
|
||||
itemCount: number;
|
||||
/** Highest per-item answer value (PHQ/GAD 3, SCI 4, WHO-5 5). */
|
||||
itemMax: number;
|
||||
/**
|
||||
* Answer values in the order the source instrument presents them. The
|
||||
* PHQ/GAD forms run 0→3; the WHO-5 form leads with 5 ("All of the time");
|
||||
* the SCI form leads with 4.
|
||||
*/
|
||||
optionOrder: readonly number[];
|
||||
/**
|
||||
* i18n namespace for the response-option labels under `mentalHealth.`:
|
||||
* without `optionGroups` one shared anchor set (`<ns>.<value>`); with
|
||||
* `optionGroups` (SCI) per-item anchor groups (`<ns>.<group>.<value>`,
|
||||
* group = `optionGroups[itemIndex]`).
|
||||
*/
|
||||
optionNamespace: string;
|
||||
optionGroups?: readonly string[];
|
||||
/**
|
||||
* Recall-stem caption key per item under `mentalHealth.stems.` — the WHO-5
|
||||
* and SCI items are fragments under a shared stem on the source forms, so
|
||||
* the wizard paints the applicable stem above the question. PHQ/GAD keep
|
||||
* their frame in the instrument description line (shipped contract).
|
||||
*/
|
||||
stemKeys?: readonly string[];
|
||||
/** Multiplier from item-sum to the reported total (WHO-5: × 4). */
|
||||
scoreMultiplier: number;
|
||||
/** Inclusive REPORTED total range (WHO-5: 0–100 percentage). */
|
||||
minScore: number;
|
||||
maxScore: number;
|
||||
/** Whether a HIGHER total is the good direction (WHO-5 / SCI). */
|
||||
higherIsBetter: boolean;
|
||||
/** Total-score LOINC for the FHIR/export contract; null when none exists. */
|
||||
totalLoinc: string | null;
|
||||
/** Severity bands, ordered low→high. `min`/`max` inclusive over the total. */
|
||||
bands: ReadonlyArray<{ key: string; min: number; max: number }>;
|
||||
/**
|
||||
* Follow-up threshold on the reported scale. The concerning side follows
|
||||
* the direction: PHQ/GAD ≥ threshold, WHO-5/SCI ≤ threshold (WHO-5 "below
|
||||
* 50" — 50 itself is unreachable on the ×4 scale; SCI ≤ 16 per Espie 2014).
|
||||
*/
|
||||
actionThreshold: number;
|
||||
/** Item index (0-based) of the safety-critical self-harm item, or null. */
|
||||
safetyItemIndex: number | null;
|
||||
/**
|
||||
* Locales whose bundled item wording is a validated translation. A locale
|
||||
* outside this list renders the validated source-language items plus an
|
||||
* honest chrome note — NEVER a self-made translation.
|
||||
*/
|
||||
validatedItemLocales: readonly string[];
|
||||
/**
|
||||
* Required attribution — render VERBATIM on the instrument's start card and
|
||||
* the result view (and in exports). Stays in English by convention (the
|
||||
* developers' names and licence identifiers are not translated).
|
||||
*/
|
||||
attribution: string;
|
||||
}
|
||||
|
||||
const ALL_APP_LOCALES = ["de", "en", "es", "fr", "it", "pl"] as const;
|
||||
|
||||
/**
|
||||
* Required attribution — render VERBATIM on every screen displaying the
|
||||
* instrument and in the PDF/FHIR export. The PHQ family is public-domain (Pfizer
|
||||
* 2010); this is the canonical footer text. By convention it stays in English
|
||||
* (the developers' names are not translated).
|
||||
* The PHQ family is public-domain (Pfizer 2010); this is the canonical footer
|
||||
* text per the instrument's own permission grant.
|
||||
*/
|
||||
export const PHQ_GAD_ATTRIBUTION =
|
||||
const PHQ_GAD_ATTRIBUTION =
|
||||
"Developed by Drs. Robert L. Spitzer, Janet B.W. Williams, Kurt Kroenke and " +
|
||||
"colleagues, with an educational grant from Pfizer Inc. No permission " +
|
||||
"required to reproduce, translate, display or distribute.";
|
||||
|
||||
export interface InstrumentDefinition {
|
||||
id: InstrumentId;
|
||||
/** Number of scored items (PHQ-9 = 9, GAD-7 = 7). */
|
||||
itemCount: number;
|
||||
/** Inclusive total range. */
|
||||
minScore: number;
|
||||
maxScore: number;
|
||||
/** Total-score LOINC for the FHIR/export contract. */
|
||||
totalLoinc: string;
|
||||
/** Severity bands, ordered low→high. `min`/`max` inclusive over the total. */
|
||||
bands: ReadonlyArray<{ key: string; min: number; max: number }>;
|
||||
/** Optimal screening cut-off (≥ this total = consider professional follow-up). */
|
||||
actionThreshold: number;
|
||||
/** Item index (0-based) of the safety-critical self-harm item, or null. */
|
||||
safetyItemIndex: number | null;
|
||||
}
|
||||
|
||||
const PHQ9: InstrumentDefinition = {
|
||||
id: "PHQ9",
|
||||
i18nKey: "phq9",
|
||||
itemCount: 9,
|
||||
itemMax: 3,
|
||||
optionOrder: [0, 1, 2, 3],
|
||||
optionNamespace: "options",
|
||||
scoreMultiplier: 1,
|
||||
minScore: 0,
|
||||
maxScore: 27,
|
||||
higherIsBetter: false,
|
||||
totalLoinc: "44261-6",
|
||||
bands: [
|
||||
{ key: "minimal", min: 0, max: 4 },
|
||||
@@ -64,13 +129,21 @@ const PHQ9: InstrumentDefinition = {
|
||||
actionThreshold: 10,
|
||||
// Item 9 ("Thoughts that you would be better off dead…") — the safety field.
|
||||
safetyItemIndex: 8,
|
||||
validatedItemLocales: ALL_APP_LOCALES,
|
||||
attribution: PHQ_GAD_ATTRIBUTION,
|
||||
};
|
||||
|
||||
const GAD7: InstrumentDefinition = {
|
||||
id: "GAD7",
|
||||
i18nKey: "gad7",
|
||||
itemCount: 7,
|
||||
itemMax: 3,
|
||||
optionOrder: [0, 1, 2, 3],
|
||||
optionNamespace: "options",
|
||||
scoreMultiplier: 1,
|
||||
minScore: 0,
|
||||
maxScore: 21,
|
||||
higherIsBetter: false,
|
||||
totalLoinc: "70274-6",
|
||||
bands: [
|
||||
{ key: "minimal", min: 0, max: 4 },
|
||||
@@ -80,25 +153,137 @@ const GAD7: InstrumentDefinition = {
|
||||
],
|
||||
actionThreshold: 10,
|
||||
safetyItemIndex: null,
|
||||
validatedItemLocales: ALL_APP_LOCALES,
|
||||
attribution: PHQ_GAD_ATTRIBUTION,
|
||||
};
|
||||
|
||||
/**
|
||||
* WHO-5 well-being index — five positively-worded items over the last two
|
||||
* weeks, 6-point 5→0 scale. Reported score = raw sum × 4 (0–100, higher =
|
||||
* better well-being) per the official WHO scoring. "A percentage score below
|
||||
* 50 … has been suggested as a cut-off for poor mental well-being and as an
|
||||
* indication for further assessment" (WHO/UCN/MSD/MHE/2024.1) — the result
|
||||
* view answers a ≤ 50 total with a gentle pointer to the PHQ-9 check-in.
|
||||
* Achievable totals are multiples of 4, so the 0–50 band equals "below 50".
|
||||
*/
|
||||
const WHO5: InstrumentDefinition = {
|
||||
id: "WHO5",
|
||||
i18nKey: "who5",
|
||||
itemCount: 5,
|
||||
itemMax: 5,
|
||||
optionOrder: [5, 4, 3, 2, 1, 0],
|
||||
optionNamespace: "who5Options",
|
||||
stemKeys: Array(5).fill("who5.period"),
|
||||
scoreMultiplier: 4,
|
||||
minScore: 0,
|
||||
maxScore: 100,
|
||||
higherIsBetter: true,
|
||||
totalLoinc: null,
|
||||
bands: [
|
||||
{ key: "low", min: 0, max: 50 },
|
||||
{ key: "good", min: 51, max: 100 },
|
||||
],
|
||||
actionThreshold: 50,
|
||||
safetyItemIndex: null,
|
||||
// WHO publishes official de/en/es/fr/it/pl versions — all bundled verbatim.
|
||||
validatedItemLocales: ALL_APP_LOCALES,
|
||||
attribution:
|
||||
"World Health Organization. The World Health Organization-Five Well-Being " +
|
||||
"Index (WHO-5). Geneva: World Health Organization; 2024. Licence: " +
|
||||
"CC BY-NC-SA 3.0 IGO. WHO does not endorse this application.",
|
||||
};
|
||||
|
||||
/**
|
||||
* Sleep Condition Indicator — eight DSM-5-aligned items, 0–4 each with
|
||||
* item-specific anchors, total 0–32, HIGHER = better sleep. "A score of ≤16
|
||||
* on the SCI seems reasonable to detect possible insomnia disorder" (Espie
|
||||
* et al. 2014) — the band wording stays neutral per the paper, no diagnosis
|
||||
* language. Items 1–4 ask about a typical night in the last month, items 5–7
|
||||
* about the past month's daytime impact, item 8 about problem duration.
|
||||
*/
|
||||
const SCI: InstrumentDefinition = {
|
||||
id: "SCI",
|
||||
i18nKey: "sci",
|
||||
itemCount: 8,
|
||||
itemMax: 4,
|
||||
optionOrder: [4, 3, 2, 1, 0],
|
||||
optionNamespace: "sciOptions",
|
||||
optionGroups: [
|
||||
"duration",
|
||||
"duration",
|
||||
"nights",
|
||||
"quality",
|
||||
"impact",
|
||||
"impact",
|
||||
"impact",
|
||||
"problemDuration",
|
||||
],
|
||||
stemKeys: [
|
||||
"sci.night",
|
||||
"sci.night",
|
||||
"sci.night",
|
||||
"sci.night",
|
||||
"sci.impact",
|
||||
"sci.impact",
|
||||
"sci.impact",
|
||||
"sci.finally",
|
||||
],
|
||||
scoreMultiplier: 1,
|
||||
minScore: 0,
|
||||
maxScore: 32,
|
||||
higherIsBetter: true,
|
||||
totalLoinc: null,
|
||||
bands: [
|
||||
{ key: "belowThreshold", min: 0, max: 16 },
|
||||
{ key: "aboveThreshold", min: 17, max: 32 },
|
||||
],
|
||||
actionThreshold: 16,
|
||||
safetyItemIndex: null,
|
||||
// Validated in English (the CC BY-NC BMJ Open publication). French/Italian
|
||||
// validations exist in the literature but their item text is not openly
|
||||
// redistributable, so every non-EN locale presents the English items with
|
||||
// an honest note — never a self-made translation.
|
||||
validatedItemLocales: ["en"],
|
||||
attribution:
|
||||
"Espie CA, Kyle SD, Hames P, et al. The Sleep Condition Indicator: a " +
|
||||
"clinical screening tool to evaluate insomnia disorder. BMJ Open " +
|
||||
"2014;4:e004183. © Sleepio Limited; free for non-commercial use (CC BY-NC).",
|
||||
};
|
||||
|
||||
export const INSTRUMENTS: Record<InstrumentId, InstrumentDefinition> = {
|
||||
PHQ9,
|
||||
GAD7,
|
||||
WHO5,
|
||||
SCI,
|
||||
};
|
||||
|
||||
/** Landing / history ordering — the shipped pair first, then the new pair. */
|
||||
export const INSTRUMENT_ORDER: readonly InstrumentId[] = [
|
||||
"PHQ9",
|
||||
"GAD7",
|
||||
"WHO5",
|
||||
"SCI",
|
||||
];
|
||||
|
||||
/** The DB MeasurementType the instrument's total score projects to. */
|
||||
export const INSTRUMENT_MEASUREMENT_TYPE: Record<InstrumentId, string> = {
|
||||
PHQ9: "PHQ9_SCORE",
|
||||
GAD7: "GAD7_SCORE",
|
||||
WHO5: "WHO5_SCORE",
|
||||
SCI: "SCI_SCORE",
|
||||
};
|
||||
|
||||
/** Sum the item answers into a total (server-authoritative). */
|
||||
export function scoreTotal(items: readonly number[]): number {
|
||||
return items.reduce((sum, v) => sum + v, 0);
|
||||
/**
|
||||
* The REPORTED total for a completed administration (server-authoritative):
|
||||
* item-sum × the instrument's multiplier (WHO-5 reports the 0–100 percentage,
|
||||
* everything else the raw sum).
|
||||
*/
|
||||
export function scoreTotal(id: InstrumentId, items: readonly number[]): number {
|
||||
const sum = items.reduce((acc, v) => acc + v, 0);
|
||||
return sum * INSTRUMENTS[id].scoreMultiplier;
|
||||
}
|
||||
|
||||
/** Resolve the severity-band key for a total score. */
|
||||
/** Resolve the severity-band key for a reported total score. */
|
||||
export function severityBand(id: InstrumentId, total: number): string {
|
||||
const def = INSTRUMENTS[id];
|
||||
const band = def.bands.find((b) => total >= b.min && total <= b.max);
|
||||
@@ -106,6 +291,50 @@ export function severityBand(id: InstrumentId, total: number): string {
|
||||
return band?.key ?? def.bands[def.bands.length - 1].key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the gentle follow-up hint applies for a reported total. Direction-
|
||||
* aware: PHQ-9/GAD-7 point up (≥ 10), WHO-5 and the SCI point down (≤ 50 /
|
||||
* ≤ 16 — lower totals mean lower well-being / worse sleep).
|
||||
*/
|
||||
export function needsFollowUp(id: InstrumentId, total: number): boolean {
|
||||
const def = INSTRUMENTS[id];
|
||||
return def.higherIsBetter
|
||||
? total <= def.actionThreshold
|
||||
: total >= def.actionThreshold;
|
||||
}
|
||||
|
||||
/**
|
||||
* i18n key (under `mentalHealth.`) for one response option of one item —
|
||||
* shared anchors (`options.2`), instrument-specific shared anchors
|
||||
* (`who5Options.5`), or the SCI's per-item anchor groups
|
||||
* (`sciOptions.duration.4`).
|
||||
*/
|
||||
export function optionLabelKey(
|
||||
id: InstrumentId,
|
||||
itemIndex: number,
|
||||
value: number,
|
||||
): string {
|
||||
const def = INSTRUMENTS[id];
|
||||
return def.optionGroups
|
||||
? `${def.optionNamespace}.${def.optionGroups[itemIndex]}.${value}`
|
||||
: `${def.optionNamespace}.${value}`;
|
||||
}
|
||||
|
||||
/** Stem caption key (under `mentalHealth.stems.`) for an item, or null. */
|
||||
export function stemKey(id: InstrumentId, itemIndex: number): string | null {
|
||||
return INSTRUMENTS[id].stemKeys?.[itemIndex] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the bundled item wording for `locale` is a validated translation.
|
||||
* When false the surface shows the validated source-language items plus a
|
||||
* localized "validated in English" chrome note.
|
||||
*/
|
||||
export function hasValidatedItems(id: InstrumentId, locale: string): boolean {
|
||||
const base = locale.toLowerCase().split("-")[0];
|
||||
return INSTRUMENTS[id].validatedItemLocales.includes(base);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the safety signpost must be shown for these answers. True iff the
|
||||
* instrument has a safety item and that item was answered with any non-zero
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* OpenAPI route table for the opt-in mental-health screeners
|
||||
* (`/api/mental-health/assessments`). PHQ-9 / GAD-7.
|
||||
* (`/api/mental-health/assessments`). PHQ-9 / GAD-7 / WHO-5 / SCI.
|
||||
*
|
||||
* Part of the OpenAPI route table; aggregated in `./index.ts`. The request body
|
||||
* reuses the runtime Zod schema from `@/lib/validations/mental-health` so the
|
||||
@@ -23,7 +23,7 @@ import { dataEnvelope, stdResponses } from "./shared";
|
||||
createAssessmentSchema.meta({
|
||||
id: "CreateMentalHealthAssessmentRequest",
|
||||
description:
|
||||
"Record one completed PHQ-9 (9 items) / GAD-7 (7 items) administration. Each item answer is 0–3; the array length must match the instrument. The item answers are encrypted at rest and never returned. The server computes the total + severity band + item-9 safety flag. Opt-in, beside mood tracking — this is a screening surface, not a diagnosis.",
|
||||
"Record one completed screener administration: PHQ-9 (9 items, 0–3), GAD-7 (7 items, 0–3), WHO-5 (5 items, 0–5) or SCI (8 items, 0–4). The array length and per-item ceiling must match the instrument. The item answers are encrypted at rest and never returned. The server computes the total (WHO-5 reports raw-sum × 4 as 0–100) + severity band + item-9 safety flag. Opt-in, beside mood tracking — this is a screening surface, not a diagnosis.",
|
||||
});
|
||||
|
||||
listAssessmentsSchema.meta({
|
||||
@@ -35,7 +35,7 @@ listAssessmentsSchema.meta({
|
||||
const assessmentRow = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
instrument: z.enum(["PHQ9", "GAD7"]),
|
||||
instrument: z.enum(["PHQ9", "GAD7", "WHO5", "SCI"]),
|
||||
locale: z.string(),
|
||||
version: z.string(),
|
||||
totalScore: z.number(),
|
||||
@@ -87,7 +87,7 @@ export const mentalHealthPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Mental health"],
|
||||
summary: "List the caller's screener history",
|
||||
description:
|
||||
"Returns PHQ-9 / GAD-7 administrations (newest first). Totals + bands + flags only; raw item answers are never returned.",
|
||||
"Returns PHQ-9 / GAD-7 / WHO-5 / SCI administrations (newest first). Totals + bands + flags only; raw item answers are never returned.",
|
||||
requestParams: { query: listAssessmentsSchema },
|
||||
responses: {
|
||||
"200": {
|
||||
@@ -106,9 +106,9 @@ export const mentalHealthPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
post: {
|
||||
tags: ["Mental health"],
|
||||
summary: "Record a completed PHQ-9 / GAD-7 screener",
|
||||
summary: "Record a completed screener",
|
||||
description:
|
||||
"Stores one administration (item answers encrypted) and writes the derived total as a server-owned PHQ9_SCORE / GAD7_SCORE measurement (source COMPUTED — never client-writable). On a positive PHQ-9 item-9 the response carries the locale-aware crisis-resource set.",
|
||||
"Stores one administration (item answers encrypted) and writes the derived total as a server-owned PHQ9_SCORE / GAD7_SCORE / WHO5_SCORE / SCI_SCORE measurement (source COMPUTED — never client-writable). WHO-5 and SCI totals run HIGHER = better; their follow-up thresholds point downwards (≤ 50 / ≤ 16). On a positive PHQ-9 item-9 the response carries the locale-aware crisis-resource set.",
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": { schema: createAssessmentSchema },
|
||||
|
||||
@@ -205,6 +205,10 @@ export function getPRDirection(
|
||||
// all). All defer to a null PR direction.
|
||||
case "PHQ9_SCORE":
|
||||
case "GAD7_SCORE":
|
||||
// v1.27.9 — WHO-5 / SCI screening totals follow the same no-gamification
|
||||
// safety rule (no streaks / "best score" framing around a screener).
|
||||
case "WHO5_SCORE":
|
||||
case "SCI_SCORE":
|
||||
case "PAIN_NRS":
|
||||
case "WAIST_CIRCUMFERENCE":
|
||||
case "WAIST_TO_HEIGHT":
|
||||
|
||||
@@ -1272,6 +1272,47 @@ export const SIGNALS: Record<string, SignalDefinition> = {
|
||||
category: "survey",
|
||||
},
|
||||
},
|
||||
// ── v1.27.9 — WHO-5 well-being + Sleep Condition Indicator totals ───────
|
||||
// Same privacy posture as PHQ-9 / GAD-7 (off Coach snapshot + MCP; item
|
||||
// content never rides a Measurement row). Direction inverts: HIGHER is
|
||||
// better for both. No verified total-score LOINC exists for either, so no
|
||||
// FHIR coding is attached (the export skips uncoded signals rather than
|
||||
// shipping an invented code).
|
||||
WHO5_SCORE: {
|
||||
key: "WHO5_SCORE",
|
||||
kind: "score",
|
||||
source: { measurementType: "WHO5_SCORE" },
|
||||
displayName: "WHO-5 well-being index (0–100)",
|
||||
unit: "score",
|
||||
direction: "higher-better",
|
||||
archetype: "physiological-vital",
|
||||
// "Below 50" is the WHO-suggested cut-off; achievable totals are
|
||||
// multiples of 4, so ≥ 52 is the good side.
|
||||
normalRange: { low: 52, high: 100 },
|
||||
surfaces: {
|
||||
detailPage: false,
|
||||
correlationEligible: false,
|
||||
coachSnapshot: false,
|
||||
mcp: false,
|
||||
},
|
||||
},
|
||||
SCI_SCORE: {
|
||||
key: "SCI_SCORE",
|
||||
kind: "score",
|
||||
source: { measurementType: "SCI_SCORE" },
|
||||
displayName: "Sleep Condition Indicator (total)",
|
||||
unit: "score",
|
||||
direction: "higher-better",
|
||||
archetype: "physiological-vital",
|
||||
// > 16 identified "no insomnia disorder" in Espie et al. 2014.
|
||||
normalRange: { low: 17, high: 32 },
|
||||
surfaces: {
|
||||
detailPage: false,
|
||||
correlationEligible: false,
|
||||
coachSnapshot: false,
|
||||
mcp: false,
|
||||
},
|
||||
},
|
||||
// ── v1.25 clinical-signals wave — physical / clinical measurements ──────
|
||||
GRIP_STRENGTH: {
|
||||
key: "GRIP_STRENGTH",
|
||||
|
||||
@@ -37,10 +37,26 @@ describe("measurementReminderTypeEnum (V3 — completeness)", () => {
|
||||
// v1.27.6 — plannable mental-wellbeing screenings
|
||||
"PHQ9_SCORE",
|
||||
"GAD7_SCORE",
|
||||
// v1.27.9 — WHO-5 + SCI join on the identical contract
|
||||
"WHO5_SCORE",
|
||||
"SCI_SCORE",
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts the WHO-5 / SCI screenings with the 4-week default cadence", () => {
|
||||
for (const measurementType of ["WHO5_SCORE", "SCI_SCORE"]) {
|
||||
const parsed = createMeasurementReminderSchema.safeParse({
|
||||
label: "Screening check-in",
|
||||
measurementType,
|
||||
intervalDays: 28,
|
||||
});
|
||||
expect(parsed.success, `expected ${measurementType} to be allowed`).toBe(
|
||||
true,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts a plannable screening reminder with the 4-week default cadence", () => {
|
||||
const parsed = createMeasurementReminderSchema.safeParse({
|
||||
label: "PHQ-9 Check-in",
|
||||
|
||||
@@ -473,9 +473,15 @@ describe("measurement validation", () => {
|
||||
it("rejects a COMPUTED-attributed screener score row on a client write", () => {
|
||||
// v1.27.6 — the mental-wellbeing screener projects each completed
|
||||
// administration onto a COMPUTED-source PHQ9_SCORE / GAD7_SCORE row
|
||||
// (the RECOVERY_SCORE precedent). COMPUTED is not client-attributable,
|
||||
// so a client can never mint a server-owned score trend point.
|
||||
for (const type of ["PHQ9_SCORE", "GAD7_SCORE"]) {
|
||||
// (the RECOVERY_SCORE precedent; v1.27.9 adds WHO5_SCORE / SCI_SCORE).
|
||||
// COMPUTED is not client-attributable, so a client can never mint a
|
||||
// server-owned score trend point.
|
||||
for (const type of [
|
||||
"PHQ9_SCORE",
|
||||
"GAD7_SCORE",
|
||||
"WHO5_SCORE",
|
||||
"SCI_SCORE",
|
||||
]) {
|
||||
const parsed = createMeasurementSchema.safeParse({
|
||||
type,
|
||||
value: 5,
|
||||
|
||||
@@ -64,11 +64,16 @@ export const measurementReminderTypeEnum = z
|
||||
// the dispatcher (see `moduleForMeasurementType`).
|
||||
"PHQ9_SCORE",
|
||||
"GAD7_SCORE",
|
||||
// v1.27.9 — WHO-5 + SCI join the plannable screenings on the identical
|
||||
// contract (auto-resolve on the server-owned COMPUTED score row, module-
|
||||
// gated in the dispatcher).
|
||||
"WHO5_SCORE",
|
||||
"SCI_SCORE",
|
||||
])
|
||||
.meta({
|
||||
id: "MeasurementReminderType",
|
||||
description:
|
||||
"Auto-resolve target metric (vitals + body-composition family + the PHQ-9 / GAD-7 screenings). BP resolves on BLOOD_PRESSURE_SYS (the SYS row is the 'a BP was measured' sentinel); a screening reminder resolves on the server-written PHQ9_SCORE / GAD7_SCORE row when a check-in completes. Omit for a free-text Vorsorge that resolves only on a manual satisfy or a matching lab result.",
|
||||
"Auto-resolve target metric (vitals + body-composition family + the PHQ-9 / GAD-7 / WHO-5 / SCI screenings). BP resolves on BLOOD_PRESSURE_SYS (the SYS row is the 'a BP was measured' sentinel); a screening reminder resolves on the server-written *_SCORE row when a check-in completes. Omit for a free-text Vorsorge that resolves only on a manual satisfy or a matching lab result.",
|
||||
});
|
||||
|
||||
export type MeasurementReminderType = z.infer<
|
||||
@@ -85,6 +90,8 @@ export type MeasurementReminderType = z.infer<
|
||||
export const SCREENING_REMINDER_TYPES: ReadonlySet<string> = new Set([
|
||||
"PHQ9_SCORE",
|
||||
"GAD7_SCORE",
|
||||
"WHO5_SCORE",
|
||||
"SCI_SCORE",
|
||||
]);
|
||||
|
||||
/** Whether a reminder's `measurementType` targets a screening check-in. */
|
||||
|
||||
@@ -135,6 +135,11 @@ export const measurementTypeEnum = z.enum([
|
||||
"PAIN_NRS",
|
||||
"WAIST_CIRCUMFERENCE",
|
||||
"WAIST_TO_HEIGHT",
|
||||
// ── v1.27.9 — screening scores (same assessment contract as PHQ-9/GAD-7,
|
||||
// inverted direction: HIGHER is better). WHO-5 stores the reported 0–100
|
||||
// percentage (raw sum × 4); SCI stores the 0–32 total.
|
||||
"WHO5_SCORE",
|
||||
"SCI_SCORE",
|
||||
]);
|
||||
|
||||
/**
|
||||
@@ -379,6 +384,9 @@ const unitMap: Record<string, string> = {
|
||||
// ── v1.25 — clinical-signals wave ──
|
||||
PHQ9_SCORE: "score",
|
||||
GAD7_SCORE: "score",
|
||||
// ── v1.27.9 — screening scores ──
|
||||
WHO5_SCORE: "score",
|
||||
SCI_SCORE: "score",
|
||||
GRIP_STRENGTH: "kg",
|
||||
PAIN_NRS: "score",
|
||||
WAIST_CIRCUMFERENCE: "cm",
|
||||
@@ -581,6 +589,10 @@ export const VALUE_RANGES: Record<string, { min: number; max: number }> = {
|
||||
// PHQ-9 total 0–27, GAD-7 total 0–21 — server-derived from the screener.
|
||||
PHQ9_SCORE: { min: 0, max: 27 },
|
||||
GAD7_SCORE: { min: 0, max: 21 },
|
||||
// ── v1.27.9 — screening scores. WHO-5 reported percentage 0–100 (raw × 4),
|
||||
// SCI total 0–32 — both server-derived, higher = better.
|
||||
WHO5_SCORE: { min: 0, max: 100 },
|
||||
SCI_SCORE: { min: 0, max: 32 },
|
||||
// Grip strength kg — a child can read in the single digits, an elite athlete
|
||||
// tops ~90 kg; 120 is a generous ceiling that still rejects sensor noise.
|
||||
GRIP_STRENGTH: { min: 0, max: 120 },
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
/**
|
||||
* v1.25 — request/response validation for the opt-in mental-health screeners
|
||||
* (PHQ-9 / GAD-7). The item answers are 0–3 integers; the array length must
|
||||
* match the instrument (PHQ-9 = 9, GAD-7 = 7). `userId` is NEVER a body field —
|
||||
* it is narrowed from the session in the handler (CLAUDE.md convention).
|
||||
* (PHQ-9 / GAD-7; v1.27.9 adds WHO-5 + SCI). The item answers are small
|
||||
* integers whose ceiling is instrument-specific (PHQ/GAD 0–3, SCI 0–4,
|
||||
* WHO-5 0–5); the array length must match the instrument. `userId` is NEVER
|
||||
* a body field — it is narrowed from the session in the handler (CLAUDE.md
|
||||
* convention).
|
||||
*/
|
||||
import { z } from "zod/v4";
|
||||
import {
|
||||
@@ -10,7 +12,7 @@ import {
|
||||
type InstrumentId,
|
||||
} from "@/lib/mental-health/instruments";
|
||||
|
||||
export const assessmentInstrumentEnum = z.enum(["PHQ9", "GAD7"]);
|
||||
export const assessmentInstrumentEnum = z.enum(["PHQ9", "GAD7", "WHO5", "SCI"]);
|
||||
|
||||
/**
|
||||
* Client provenance for an administration. Closed enum so the column can never
|
||||
@@ -21,12 +23,15 @@ export const assessmentInstrumentEnum = z.enum(["PHQ9", "GAD7"]);
|
||||
*/
|
||||
export const assessmentSourceEnum = z.enum(["WEB", "IOS"]);
|
||||
|
||||
const itemAnswer = z.number().int().min(0).max(3);
|
||||
// Structural ceiling — the WHO-5's 0–5 scale is the widest. The exact
|
||||
// per-instrument ceiling (PHQ/GAD 3, SCI 4) is enforced in the superRefine
|
||||
// below, where the instrument is known.
|
||||
const itemAnswer = z.number().int().min(0).max(5);
|
||||
|
||||
export const createAssessmentSchema = z
|
||||
.object({
|
||||
instrument: assessmentInstrumentEnum,
|
||||
/** Per-item answers, each 0–3, ordered as the instrument presents them. */
|
||||
/** Per-item answers, ordered as the instrument presents them. */
|
||||
items: z.array(itemAnswer).min(1).max(12),
|
||||
/** Optional functional-impairment follow-up (not scored into the total). */
|
||||
functionalDifficulty: z.number().int().min(0).max(3).optional(),
|
||||
@@ -55,6 +60,15 @@ export const createAssessmentSchema = z
|
||||
message: `${val.instrument} expects exactly ${def.itemCount} item answers`,
|
||||
});
|
||||
}
|
||||
val.items.forEach((answer, index) => {
|
||||
if (answer > def.itemMax) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["items", index],
|
||||
message: `${val.instrument} answers range 0–${def.itemMax}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
export const listAssessmentsSchema = z.object({
|
||||
|
||||
Reference in new issue
Block a user