From 38437052962442cbc6d1242715c058b4103251b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 23 Oct 2026 08:13:45 +0200 Subject: [PATCH] feat(mental-health): keep questionnaire wording in code, one cited file per language The PHQ-9, GAD-7, WHO-5 and SCI now have text in every app language instead of falling back to the English original. Each instrument and language is one file under src/lib/mental-health/wording/ that declares what it is: a published validated translation reproduced word for word, with its source and terms in `source`, or a translation written for HealthLog, faithful to the English and on the same scale. Validated versions added: the Pfizer PHQ-9 for France, the Netherlands, Korea and Brazil, the Pfizer GAD-7 for the Netherlands, Korea and Brazil, the PRU Dutch WHO-5, the Korean WHO-5 validation (J Korean Geriatr Psychiatry 2010) and the Polish SCI validation (Sci Rep 2026). The SCI in the other languages and the WHO-5 in Brazilian Portuguese carry HealthLog's own translation; each file names any validated version found and why it is not used. Where the text is unvalidated, the check-in shows a note that the result is orientation and a one-tap switch to the English original. Each language loads as its own chunk when a questionnaire surface needs it. The item text, answers, stems and the PHQ-9 follow-up leave every message bundle, so a translator never sees them; the i18n guard now requires that no bundle carries them, and the registry test requires every instrument in every language with the item count and anchor scores the scoring expects. CONTRIBUTING explains where the wording lives and how to propose a validated version. --- CONTRIBUTING.md | 15 +- messages/de.json | 75 +-- messages/en.json | 127 +---- messages/es.json | 75 +-- messages/fr.json | 48 +- messages/it.json | 75 +-- messages/ko.json | 8 +- messages/nl.json | 8 +- messages/pl.json | 75 +-- messages/pt-BR.json | 8 +- .../helpers/questionnaire-wording.ts | 23 + src/__tests__/i18n-english-leak-guard.test.ts | 7 +- src/__tests__/i18n-no-dot-separator.test.ts | 19 +- .../assessment-item-breakdown.test.tsx | 90 ++-- .../__tests__/mental-wellbeing.test.tsx | 165 ++++--- .../mental-health/assessment-history.tsx | 12 + .../assessment-item-breakdown.tsx | 55 +-- .../mental-health/assessment-result.tsx | 12 +- .../mental-health/check-in-wizard.tsx | 201 +++++--- .../mental-health/instrument-card.tsx | 19 +- .../mental-health/mental-wellbeing.tsx | 14 +- src/components/mental-health/types.ts | 14 +- src/hooks/use-questionnaire-wording.ts | 25 + .../__tests__/i18n-locale-integrity.test.ts | 53 ++- .../__tests__/instrument-wording.test.ts | 434 ++++++++++-------- .../__tests__/instruments.test.ts | 84 ++-- .../__tests__/wording-registry.test.ts | 122 +++++ src/lib/mental-health/instrument-wording.ts | 221 ++------- src/lib/mental-health/instruments.ts | 167 +------ src/lib/mental-health/wording/gad7/de.ts | 33 ++ src/lib/mental-health/wording/gad7/en.ts | 35 ++ src/lib/mental-health/wording/gad7/es.ts | 33 ++ src/lib/mental-health/wording/gad7/fr.ts | 33 ++ src/lib/mental-health/wording/gad7/it.ts | 37 ++ src/lib/mental-health/wording/gad7/ko.ts | 36 ++ src/lib/mental-health/wording/gad7/nl.ts | 37 ++ src/lib/mental-health/wording/gad7/pl.ts | 33 ++ src/lib/mental-health/wording/gad7/pt-br.ts | 35 ++ src/lib/mental-health/wording/index.ts | 113 +++++ src/lib/mental-health/wording/load.ts | 27 ++ src/lib/mental-health/wording/locales/de.ts | 10 + src/lib/mental-health/wording/locales/en.ts | 10 + src/lib/mental-health/wording/locales/es.ts | 10 + src/lib/mental-health/wording/locales/fr.ts | 10 + src/lib/mental-health/wording/locales/it.ts | 10 + src/lib/mental-health/wording/locales/ko.ts | 10 + src/lib/mental-health/wording/locales/nl.ts | 10 + src/lib/mental-health/wording/locales/pl.ts | 10 + .../mental-health/wording/locales/pt-br.ts | 10 + src/lib/mental-health/wording/phq9/de.ts | 49 ++ src/lib/mental-health/wording/phq9/en.ts | 47 ++ src/lib/mental-health/wording/phq9/es.ts | 45 ++ src/lib/mental-health/wording/phq9/fr.ts | 48 ++ src/lib/mental-health/wording/phq9/it.ts | 45 ++ src/lib/mental-health/wording/phq9/ko.ts | 49 ++ src/lib/mental-health/wording/phq9/nl.ts | 49 ++ src/lib/mental-health/wording/phq9/pl.ts | 45 ++ src/lib/mental-health/wording/phq9/pt-br.ts | 50 ++ src/lib/mental-health/wording/resolve.ts | 44 ++ src/lib/mental-health/wording/sci/de.ts | 62 +++ src/lib/mental-health/wording/sci/en.ts | 58 +++ src/lib/mental-health/wording/sci/es.ts | 55 +++ src/lib/mental-health/wording/sci/fr.ts | 67 +++ src/lib/mental-health/wording/sci/it.ts | 64 +++ src/lib/mental-health/wording/sci/ko.ts | 66 +++ src/lib/mental-health/wording/sci/nl.ts | 62 +++ src/lib/mental-health/wording/sci/pl.ts | 69 +++ src/lib/mental-health/wording/sci/pt-br.ts | 65 +++ src/lib/mental-health/wording/types.ts | 76 +++ src/lib/mental-health/wording/who5/de.ts | 30 ++ src/lib/mental-health/wording/who5/en.ts | 30 ++ src/lib/mental-health/wording/who5/es.ts | 33 ++ src/lib/mental-health/wording/who5/fr.ts | 30 ++ src/lib/mental-health/wording/who5/it.ts | 30 ++ src/lib/mental-health/wording/who5/ko.ts | 36 ++ src/lib/mental-health/wording/who5/nl.ts | 32 ++ src/lib/mental-health/wording/who5/pl.ts | 33 ++ src/lib/mental-health/wording/who5/pt-br.ts | 37 ++ src/lib/query-keys/mental-health.ts | 3 + 79 files changed, 2959 insertions(+), 1243 deletions(-) create mode 100644 src/__tests__/helpers/questionnaire-wording.ts create mode 100644 src/hooks/use-questionnaire-wording.ts create mode 100644 src/lib/mental-health/__tests__/wording-registry.test.ts create mode 100644 src/lib/mental-health/wording/gad7/de.ts create mode 100644 src/lib/mental-health/wording/gad7/en.ts create mode 100644 src/lib/mental-health/wording/gad7/es.ts create mode 100644 src/lib/mental-health/wording/gad7/fr.ts create mode 100644 src/lib/mental-health/wording/gad7/it.ts create mode 100644 src/lib/mental-health/wording/gad7/ko.ts create mode 100644 src/lib/mental-health/wording/gad7/nl.ts create mode 100644 src/lib/mental-health/wording/gad7/pl.ts create mode 100644 src/lib/mental-health/wording/gad7/pt-br.ts create mode 100644 src/lib/mental-health/wording/index.ts create mode 100644 src/lib/mental-health/wording/load.ts create mode 100644 src/lib/mental-health/wording/locales/de.ts create mode 100644 src/lib/mental-health/wording/locales/en.ts create mode 100644 src/lib/mental-health/wording/locales/es.ts create mode 100644 src/lib/mental-health/wording/locales/fr.ts create mode 100644 src/lib/mental-health/wording/locales/it.ts create mode 100644 src/lib/mental-health/wording/locales/ko.ts create mode 100644 src/lib/mental-health/wording/locales/nl.ts create mode 100644 src/lib/mental-health/wording/locales/pl.ts create mode 100644 src/lib/mental-health/wording/locales/pt-br.ts create mode 100644 src/lib/mental-health/wording/phq9/de.ts create mode 100644 src/lib/mental-health/wording/phq9/en.ts create mode 100644 src/lib/mental-health/wording/phq9/es.ts create mode 100644 src/lib/mental-health/wording/phq9/fr.ts create mode 100644 src/lib/mental-health/wording/phq9/it.ts create mode 100644 src/lib/mental-health/wording/phq9/ko.ts create mode 100644 src/lib/mental-health/wording/phq9/nl.ts create mode 100644 src/lib/mental-health/wording/phq9/pl.ts create mode 100644 src/lib/mental-health/wording/phq9/pt-br.ts create mode 100644 src/lib/mental-health/wording/resolve.ts create mode 100644 src/lib/mental-health/wording/sci/de.ts create mode 100644 src/lib/mental-health/wording/sci/en.ts create mode 100644 src/lib/mental-health/wording/sci/es.ts create mode 100644 src/lib/mental-health/wording/sci/fr.ts create mode 100644 src/lib/mental-health/wording/sci/it.ts create mode 100644 src/lib/mental-health/wording/sci/ko.ts create mode 100644 src/lib/mental-health/wording/sci/nl.ts create mode 100644 src/lib/mental-health/wording/sci/pl.ts create mode 100644 src/lib/mental-health/wording/sci/pt-br.ts create mode 100644 src/lib/mental-health/wording/types.ts create mode 100644 src/lib/mental-health/wording/who5/de.ts create mode 100644 src/lib/mental-health/wording/who5/en.ts create mode 100644 src/lib/mental-health/wording/who5/es.ts create mode 100644 src/lib/mental-health/wording/who5/fr.ts create mode 100644 src/lib/mental-health/wording/who5/it.ts create mode 100644 src/lib/mental-health/wording/who5/ko.ts create mode 100644 src/lib/mental-health/wording/who5/nl.ts create mode 100644 src/lib/mental-health/wording/who5/pl.ts create mode 100644 src/lib/mental-health/wording/who5/pt-br.ts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 22f7d22e5..78d3286d8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -151,18 +151,27 @@ What a reviewer checks on a translation pull request: - Medical terms are the ones the language's clinical guidelines use, and the register matches the rest of the locale (`du` in German, `você` in Brazilian Portuguese). - Words the app or the Telegram bot matches literally stay as they are: slash commands, `taken `, and the unit symbols. - Machine translation is fine as a first draft, but someone who reads the language has gone through every string before the pull request is opened, and the description says so. -- Validated questionnaire items (the PHQ-9, GAD-7, WHO-5 and SCI items, their answer options and their stems under `mentalHealth.items`, `mentalHealth.options`, `mentalHealth.gad7Options`, `mentalHealth.who5Options`, `mentalHealth.sciOptions`, `mentalHealth.stems`, `mentalHealth.functional*`) are never translated freely, not even as a draft. A language gets them only from a published, licensed validated version, copied verbatim and named in `src/lib/mental-health/instruments.ts`; until then the app shows the English original and the bundle carries none of those keys, which the i18n tests enforce. +- No questionnaire text. The bundles hold the app's own copy around the questionnaires (titles, notes, buttons), never an item, an answer option or a stem; see the next section. To run the checks Fink shows on a local checkout: `npx @inlang/cli@3.4.4 check --project ./project.inlang`. It reads the files and changes nothing. +### Questionnaire wording + +The PHQ-9, GAD-7, WHO-5 and SCI are not translated through the bundles, and Fink never shows them. Their text lives in code, one file per instrument and language under `src/lib/mental-health/wording//.ts`, because changing a word in a validated questionnaire changes what it measures. Each file declares what it is: + +- `validated: true` with a `source`: a published validated translation, reproduced word for word (stem, items, answer options and, for the PHQ-9, the follow-up question, in the form of address the source uses). The source names who made it, the document it was read from with its link, and the terms that allow reproducing it in software. The file's header comment names anything that differs from the printed form, such as a corrected misprint. +- `validated: false`: no validated version exists that may be reproduced, so the file carries a translation written for HealthLog, faithful to the English and on the same scale. The app shows a note beside it that the result is orientation, offers the English original in one tap, and records on the result that it was answered in an unvalidated translation. The header comment names any validated version that was found and why it is not used (paywalled, terms that allow private study only). + +To propose a validated version for a language that has none, or a better source for one that does, open an issue or a pull request with the publication, a link to the PDF it can be read from, and its terms of use. A pull request replaces the language's file: the text copied exactly from the PDF, `validated: true`, and the `source` filled in. The tests in `src/lib/mental-health/__tests__/` check that every instrument has text in every language, that every validated entry names its source, and that each answer is bound to the score the instrument gives it; the spot checks in `instrument-wording.test.ts` are the place to pin a line that is easy to transcribe wrongly. A correction to HealthLog's own translation of an unvalidated entry is welcome too; it stays `validated: false`. + ### Adding a new locale If you want to introduce a locale that isn't shipped (e.g. `sv`, `cs`): 1. Open an issue first using the **Translation request** template. We'll confirm the locale is in scope and decide whether it joins the AI-initial-community-maintained tier or the maintained tier. -2. Copy `messages/en.json` to `messages/.json`, then delete the questionnaire wording the new language has no validated version of (the test output lists the keys). +2. Copy `messages/en.json` to `messages/.json`. Questionnaire text is not in it; the new language needs a wording file per instrument instead (see Questionnaire wording above), which step 4 makes impossible to forget. 3. Translate. AI assistance is welcome as a starting point but the PR description should note whether it's an AI-initial pass or a fully human-reviewed one — that information drives the banner state. -4. Add the locale to `locales` in `src/lib/i18n/config.ts` and to `locales` in `project.inlang/settings.json`, then run `pnpm typecheck`. Every per-locale table that still lacks the new locale fails to compile, and that list is the work: date and number formatting, the reply-language footers of the Coach and insights prompts, the outbound safety banks, the prose-grounding patterns and the no-key fallback copy. Include those in the same PR. +4. Add the locale to `locales` in `src/lib/i18n/config.ts` and to `locales` in `project.inlang/settings.json`, then run `pnpm typecheck`. Every per-locale table that still lacks the new locale fails to compile, and that list is the work: date and number formatting, the reply-language footers of the Coach and insights prompts, the outbound safety banks, the prose-grounding patterns, the no-key fallback copy and the questionnaire wording registry. Include those in the same PR. 5. Run `pnpm test` before submitting. The i18n guards hold the new bundle to the English keys and placeholders. ### Style guide for translators diff --git a/messages/de.json b/messages/de.json index 6c5b6ba81..a1637297b 100644 --- a/messages/de.json +++ b/messages/de.json @@ -10947,25 +10947,6 @@ "who5": "Wohlbefindens-Check, 5 Fragen", "sci": "Schlaf-Check, 8 Fragen" }, - "options": { - "0": "Überhaupt nicht", - "1": "An einzelnen Tagen", - "2": "An mehr als der Hälfte der Tage", - "3": "Beinahe jeden Tag" - }, - "gad7Options": { - "0": "Überhaupt nicht", - "1": "An einzelnen Tagen", - "2": "An mehr als der Hälfte der Tage", - "3": "Beinahe jeden Tag" - }, - "functionalTitle": "Wenn eines oder mehrere der bisher in diesem Fragebogen beschriebenen Probleme bei Ihnen vorliegen, geben Sie bitte an, wie sehr diese Probleme es Ihnen erschwert haben, Ihre Arbeit zu tun, Ihren Haushalt zu regeln oder mit anderen Menschen zurecht zu kommen:", - "functional": { - "0": "Überhaupt nicht erschwert", - "1": "Etwas erschwert", - "2": "Stark erschwert", - "3": "Extrem erschwert" - }, "functionalOptionalHint": "Optional: Du kannst auch ohne Antwort auf diese Frage absenden.", "result": { "title": "Dein Ergebnis", @@ -11015,35 +10996,6 @@ "ifDanger": "Wenn du in unmittelbarer Gefahr bist, ruf jetzt {emergency} an.", "resourcesTitle": "Menschen, die helfen können" }, - "items": { - "phq9": { - "1": "Wenig Interesse oder Freude an Ihren Tätigkeiten", - "2": "Niedergeschlagenheit, Schwermut oder Hoffnungslosigkeit", - "3": "Schwierigkeiten, ein- oder durchzuschlafen, oder vermehrter Schlaf", - "4": "Müdigkeit oder Gefühl, keine Energie zu haben", - "5": "Verminderter Appetit oder übermäßiges Bedürfnis zu essen", - "6": "Schlechte Meinung von sich selbst; Gefühl, ein Versager zu sein oder die Familie enttäuscht zu haben", - "7": "Schwierigkeiten, sich auf etwas zu konzentrieren, z. B. beim Zeitungslesen oder Fernsehen", - "8": "Waren Ihre Bewegungen oder Ihre Sprache so verlangsamt, dass es auch anderen auffallen würde? Oder waren Sie im Gegenteil „zappelig“ oder ruhelos und hatten dadurch einen stärkeren Bewegungsdrang als sonst?", - "9": "Gedanken, dass Sie lieber tot wären oder sich Leid zufügen möchten" - }, - "gad7": { - "1": "Nervosität, Ängstlichkeit oder Anspannung", - "2": "Nicht in der Lage sein, Sorgen zu stoppen oder zu kontrollieren", - "3": "Übermäßige Sorgen bezüglich verschiedener Angelegenheiten", - "4": "Schwierigkeiten zu entspannen", - "5": "Rastlosigkeit, so dass Stillsitzen schwer fällt", - "6": "Schnelle Verärgerung oder Gereiztheit", - "7": "Gefühl der Angst, so 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" - } - }, "crisisResource": { "telefonSeelsorge": { "name": "TelefonSeelsorge (rund um die Uhr, kostenlos, vertraulich)" @@ -11102,26 +11054,6 @@ "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": { - "0": "Zu keinem Zeitpunkt", - "1": "Ab und zu", - "2": "Etwas weniger als die Hälfte der Zeit", - "3": "Etwas mehr als die Hälfte der Zeit", - "4": "Meistens", - "5": "Die ganze Zeit" - }, - "stems": { - "phq9": { - "period": "Wie oft fühlten Sie sich im Verlauf der letzten 2 Wochen durch die folgenden Beschwerden beeinträchtigt?" - }, - "gad7": { - "period": "Wie oft fühlten Sie sich im Verlauf der letzten 2 Wochen durch die folgenden Beschwerden beeinträchtigt?" - }, - "who5": { - "period": "In den letzten zwei Wochen …" - } - }, "openDetail": "Details anzeigen", "historyLoadError": "Dein Testverlauf konnte nicht geladen werden", "historyLoadErrorHint": "Das heißt nicht, dass keine Ergebnisse vorliegen. Die Liste konnte nicht abgerufen werden. Versuche es erneut.", @@ -11171,6 +11103,13 @@ "belowThreshold": "Dein Schlaf lag im letzten Monat in dem Bereich, in dem die Studie hinter diesem Fragebogen eine Insomnie für wahrscheinlich hielt.", "aboveThreshold": "Dein Schlaf lag im letzten Monat außerhalb des Bereichs, den diese Studie mit Insomnie verbindet." } + }, + "wording": { + "unvalidatedNote": "Diese Übersetzung ist nicht wissenschaftlich validiert; nimm das Ergebnis als Orientierung.", + "showOriginal": "Englischen Originaltext zeigen", + "showTranslation": "Übersetzung zeigen", + "resultUnvalidated": "Beantwortet in einer Übersetzung, die nicht wissenschaftlich validiert ist; nimm das Ergebnis als Orientierung.", + "historyUnvalidated": "Beantwortet in einer nicht wissenschaftlich validierten Übersetzung" } }, "records": { diff --git a/messages/en.json b/messages/en.json index 26878b719..fba41c53b 100644 --- a/messages/en.json +++ b/messages/en.json @@ -10947,25 +10947,6 @@ "who5": "Well-being check, 5 questions", "sci": "Sleep check, 8 questions" }, - "options": { - "0": "Not at all", - "1": "Several days", - "2": "More than half the days", - "3": "Nearly every day" - }, - "gad7Options": { - "0": "Not at all", - "1": "Several days", - "2": "More than half the days", - "3": "Nearly every day" - }, - "functionalTitle": "If you checked off any problems, how difficult have these problems made it for you to do your work, take care of things at home, or get along with other people?", - "functional": { - "0": "Not difficult at all", - "1": "Somewhat difficult", - "2": "Very difficult", - "3": "Extremely difficult" - }, "functionalOptionalHint": "Optional: you can submit without answering this question.", "result": { "title": "Your result", @@ -11015,45 +10996,6 @@ "ifDanger": "If you are in immediate danger, call {emergency} now.", "resourcesTitle": "People who can help" }, - "items": { - "phq9": { - "1": "Little interest or pleasure in doing things", - "2": "Feeling down, depressed, or hopeless", - "3": "Trouble falling or staying asleep, or sleeping too much", - "4": "Feeling tired or having little energy", - "5": "Poor appetite or overeating", - "6": "Feeling bad about yourself — or that you are a failure or have let yourself or your family down", - "7": "Trouble concentrating on things, such as reading the newspaper or watching television", - "8": "Moving or speaking so slowly that other people could have noticed. Or the opposite — being so fidgety or restless that you have been moving around a lot more than usual", - "9": "Thoughts that you would be better off dead, or of hurting yourself in some way" - }, - "gad7": { - "1": "Feeling nervous, anxious, or on edge", - "2": "Not being able to stop or control worrying", - "3": "Worrying too much about different things", - "4": "Trouble relaxing", - "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": { "telefonSeelsorge": { "name": "TelefonSeelsorge (24/7, free, confidential)" @@ -11112,68 +11054,6 @@ "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": { - "0": "At no time", - "1": "Some of the time", - "2": "Less than half of the time", - "3": "More than half of the time", - "4": "Most of the time", - "5": "All of the time" - }, - "sciOptions": { - "duration": { - "0": "≥ 61 min", - "1": "46–60 min", - "2": "31–45 min", - "3": "16–30 min", - "4": "0–15 min" - }, - "nights": { - "0": "5–7", - "1": "4", - "2": "3", - "3": "2", - "4": "0–1" - }, - "quality": { - "0": "Very poor", - "1": "Poor", - "2": "Average", - "3": "Good", - "4": "Very good" - }, - "impact": { - "0": "Very much", - "1": "Much", - "2": "Somewhat", - "3": "A little", - "4": "Not at all" - }, - "problemDuration": { - "0": "> 1 year", - "1": "7–12 months", - "2": "3–6 months", - "3": "1–2 months", - "4": "I don't have a problem / < 1 month" - } - }, - "stems": { - "phq9": { - "period": "Over the last 2 weeks, how often have you been bothered by any of the following problems?" - }, - "gad7": { - "period": "Over the last 2 weeks, how often have you been bothered by the following problems?" - }, - "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", "historyLoadError": "Your assessment history could not be loaded", "historyLoadErrorHint": "This does not mean you have no results; the list could not be fetched. Try again.", @@ -11223,6 +11103,13 @@ "belowThreshold": "Your sleep over the last month was in the range where the study behind this questionnaire found insomnia likely.", "aboveThreshold": "Your sleep over the last month was outside the range that study linked with insomnia." } + }, + "wording": { + "unvalidatedNote": "This translation has not been scientifically validated; treat the result as orientation.", + "showOriginal": "Show original English wording", + "showTranslation": "Show translation", + "resultUnvalidated": "Answered in a translation that has not been scientifically validated; treat the result as orientation.", + "historyUnvalidated": "Answered in a translation that has not been scientifically validated" } }, "records": { diff --git a/messages/es.json b/messages/es.json index cddc6c79b..bcf55e6a5 100644 --- a/messages/es.json +++ b/messages/es.json @@ -10947,25 +10947,6 @@ "who5": "Test de bienestar, 5 preguntas", "sci": "Test de sueño, 8 preguntas" }, - "options": { - "0": "Nunca", - "1": "Varios días", - "2": "Más de la mitad de los días", - "3": "Casi cada día" - }, - "gad7Options": { - "0": "Nunca", - "1": "Varios días", - "2": "Más de la mitad de los días", - "3": "Casi cada día" - }, - "functionalTitle": "Si ha marcado alguno de los problemas de este cuestionario, ¿hasta qué punto estos problemas le han creado dificultades para hacer su trabajo, ocuparse de la casa o relacionarse con los demás?", - "functional": { - "0": "Ninguna dificultad", - "1": "Algunas dificultades", - "2": "Muchas dificultades", - "3": "Muchísimas dificultades" - }, "functionalOptionalHint": "Opcional: puedes enviar sin responder esta pregunta.", "result": { "title": "Tu resultado", @@ -11015,35 +10996,6 @@ "ifDanger": "Si estás en peligro inmediato, llama ahora al {emergency}.", "resourcesTitle": "Personas que pueden ayudar" }, - "items": { - "phq9": { - "1": "Poco interés o alegría por hacer cosas", - "2": "Sensación de estar decaído/a, deprimido/a o desesperanzado/a", - "3": "Problemas para quedarse dormido/a, para seguir durmiendo o dormir demasiado", - "4": "Sensación de cansancio o de tener poca energía", - "5": "Poco apetito o comer demasiado", - "6": "Sentirse mal consigo mismo/a; sentir que es un/a fracasado/a o que ha decepcionado a su familia o a sí mismo/a", - "7": "Problemas para concentrarse en algo, como leer el periódico o ver la televisión", - "8": "Moverse o hablar tan despacio que los demás pueden haberlo notado. O lo contrario: estar tan inquieto/a o agitado/a que se ha estado moviendo de un lado a otro más de lo habitual", - "9": "Pensamientos de que estaría mejor muerto/a o de querer hacerse daño de algún modo" - }, - "gad7": { - "1": "Sentirse nervioso/a, angustiado/a o muy tenso/a", - "2": "Ser incapaz de dejar de preocuparse o de controlar la preocupación", - "3": "Preocuparse demasiado por diferentes cuestiones", - "4": "Tener problemas para relajarse", - "5": "Estar tan inquieto/a que le resulta difícil permanecer sentado/a", - "6": "Enfadarse o irritarse con facilidad", - "7": "Sentir miedo de que algo terrible pueda ocurrir" - }, - "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" - } - }, "crisisResource": { "telefonSeelsorge": { "name": "TelefonSeelsorge (24/7, gratuito y confidencial)" @@ -11102,26 +11054,6 @@ "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": { - "0": "Nunca", - "1": "De vez en cuando", - "2": "Menos de la mitad del tiempo", - "3": "Más de la mitad del tiempo", - "4": "La mayor parte del tiempo", - "5": "Todo el tiempo" - }, - "stems": { - "phq9": { - "period": "Durante las últimas 2 semanas ¿con qué frecuencia le ha molestado alguno de los siguientes problemas?" - }, - "gad7": { - "period": "En los últimos 14 días, ¿con qué frecuencia le han supuesto una molestia los siguientes problemas?" - }, - "who5": { - "period": "Durante las últimas dos semanas:" - } - }, "openDetail": "Ver detalles", "historyLoadError": "No se pudo cargar tu historial de evaluaciones", "historyLoadErrorHint": "Esto no significa que no tengas resultados: no se pudo obtener la lista. Vuelve a intentarlo.", @@ -11171,6 +11103,13 @@ "belowThreshold": "Tu sueño del último mes estuvo en el rango en el que el estudio detrás de este cuestionario consideró probable un insomnio.", "aboveThreshold": "Tu sueño del último mes estuvo fuera del rango que ese estudio asocia con el insomnio." } + }, + "wording": { + "unvalidatedNote": "Esta traducción no ha sido validada científicamente; toma el resultado como orientación.", + "showOriginal": "Ver el texto original en inglés", + "showTranslation": "Ver la traducción", + "resultUnvalidated": "Respondido con una traducción que no ha sido validada científicamente; toma el resultado como orientación.", + "historyUnvalidated": "Respondido con una traducción no validada científicamente" } }, "records": { diff --git a/messages/fr.json b/messages/fr.json index c11135c11..16cda5d4a 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -10947,12 +10947,6 @@ "who5": "Test de bien-être, 5 questions", "sci": "Test de sommeil, 8 questions" }, - "gad7Options": { - "0": "Jamais", - "1": "Plusieurs jours", - "2": "Plus de la moitié du temps", - "3": "Presque tous les jours" - }, "functionalOptionalHint": "Facultatif : tu peux envoyer sans répondre à cette question.", "result": { "title": "Ton résultat", @@ -11002,24 +10996,6 @@ "ifDanger": "Si tu es en danger immédiat, appelle le {emergency} maintenant.", "resourcesTitle": "Des personnes qui peuvent aider" }, - "items": { - "gad7": { - "1": "Un sentiment de nervosité, d’anxiété ou de tension", - "2": "Une incapacité à arrêter de s’inquiéter ou à contrôler ses inquiétudes", - "3": "Une inquiétude excessive à propos de différentes choses", - "4": "Des difficultés à se détendre", - "5": "Une agitation telle qu’il est difficile à tenir en place", - "6": "Une tendance à être facilement contrarié(e) ou irritable", - "7": "Un sentiment de peur comme si quelque chose de terrible risquait de se produire" - }, - "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" - } - }, "crisisResource": { "telefonSeelsorge": { "name": "TelefonSeelsorge (24h/24, 7j/7, gratuit et confidentiel)" @@ -11078,23 +11054,6 @@ "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": { - "0": "Jamais", - "1": "De temps en temps", - "2": "Moins de la moitié du temps", - "3": "Plus de la moitié du temps", - "4": "La plupart du temps", - "5": "Tout le temps" - }, - "stems": { - "gad7": { - "period": "Au cours des 2 dernières semaines, selon quelle fréquence avez-vous été gêné(e) par les problèmes suivants ?" - }, - "who5": { - "period": "Au cours des deux dernières semaines" - } - }, "openDetail": "Voir les détails", "historyLoadError": "Ton historique d'évaluations n'a pas pu être chargé", "historyLoadErrorHint": "Cela ne veut pas dire que tu n'as aucun résultat : la liste n'a pas pu être récupérée. Réessaie.", @@ -11144,6 +11103,13 @@ "belowThreshold": "Votre sommeil du dernier mois se situait dans la zone où l’étude à l’origine de ce questionnaire jugeait une insomnie probable.", "aboveThreshold": "Votre sommeil du dernier mois se situait en dehors de la zone que cette étude associe à l’insomnie." } + }, + "wording": { + "unvalidatedNote": "Cette traduction n’a pas été validée scientifiquement ; considérez le résultat comme une indication.", + "showOriginal": "Afficher le texte original en anglais", + "showTranslation": "Afficher la traduction", + "resultUnvalidated": "Réponses données dans une traduction qui n’a pas été validée scientifiquement ; considérez le résultat comme une indication.", + "historyUnvalidated": "Réponses données dans une traduction non validée scientifiquement" } }, "records": { diff --git a/messages/it.json b/messages/it.json index c6ad4adf8..a620474fa 100644 --- a/messages/it.json +++ b/messages/it.json @@ -10947,25 +10947,6 @@ "who5": "Test sul benessere, 5 domande", "sci": "Test sul sonno, 8 domande" }, - "options": { - "0": "Mai", - "1": "Alcuni giorni", - "2": "Per più della metà dei giorni", - "3": "Quasi ogni giorno" - }, - "gad7Options": { - "0": "Mai", - "1": "Alcuni giorni", - "2": "Per oltre la metà dei giorni", - "3": "Quasi ogni giorno" - }, - "functionalTitle": "Se ha fatto una crocetta su uno qualsiasi di questi problemi, quanto questi problemi le hanno reso difficile fare il suo lavoro, occuparsi delle sue cose a casa o avere buoni rapporti con gli altri?", - "functional": { - "0": "Per niente difficile", - "1": "Abbastanza difficile", - "2": "Molto difficile", - "3": "Estremamente difficile" - }, "functionalOptionalHint": "Facoltativo: puoi inviare anche senza rispondere a questa domanda.", "result": { "title": "Il tuo risultato", @@ -11015,35 +10996,6 @@ "ifDanger": "Se sei in pericolo immediato, chiama subito il {emergency}.", "resourcesTitle": "Persone che possono aiutare" }, - "items": { - "phq9": { - "1": "Scarso interesse o piacere nel fare le cose", - "2": "Sentirsi giù, triste o disperato/a", - "3": "Problemi ad addormentarsi o a dormire tutta la notte senza svegliarsi, o a dormire troppo", - "4": "Sentirsi stanco/a o avere poca energia", - "5": "Scarso appetito o mangiare troppo", - "6": "Avere una scarsa opinione di sé, o sentirsi un/una fallito/a o aver deluso se stesso/a o i propri familiari", - "7": "Difficoltà a concentrarsi su qualcosa, per esempio leggere il giornale o guardare la televisione", - "8": "Muoversi o parlare così lentamente da poter essere notato/a da altre persone. O, al contrario, essere così irrequieto/a da muoversi molto più del solito", - "9": "Pensare che sarebbe meglio morire o farsi del male in un modo o nell’altro" - }, - "gad7": { - "1": "Sentirsi nervoso/a, ansioso/a o teso/a", - "2": "Non riuscire a smettere di preoccuparsi o a tenere sotto controllo le preoccupazioni", - "3": "Preoccuparsi troppo per varie cose", - "4": "Avere difficoltà a rilassarsi", - "5": "Essere talmente irrequieto/a da far fatica a stare seduto/a fermo/a", - "6": "Infastidirsi o irritarsi facilmente", - "7": "Avere paura che possa succedere 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" - } - }, "crisisResource": { "telefonSeelsorge": { "name": "TelefonSeelsorge (24/7, gratuito e riservato)" @@ -11102,26 +11054,6 @@ "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": { - "0": "Mai", - "1": "A volte", - "2": "Meno della metà del tempo", - "3": "Più della metà del tempo", - "4": "La maggior parte del tempo", - "5": "Sempre" - }, - "stems": { - "phq9": { - "period": "Nelle ultime 2 settimane, con quale frequenza le ha dato fastidio ciascuno dei seguenti problemi?" - }, - "gad7": { - "period": "Nelle ultime 2 settimane, con quale frequenza le ha dato fastidio ciascuno dei seguenti problemi?" - }, - "who5": { - "period": "Nelle ultime due settimane" - } - }, "openDetail": "Mostra dettagli", "historyLoadError": "Non è stato possibile caricare la cronologia dei test", "historyLoadErrorHint": "Questo non significa che non ci siano risultati: non è stato possibile recuperare l'elenco. Riprova.", @@ -11171,6 +11103,13 @@ "belowThreshold": "Il tuo sonno nell’ultimo mese era nella fascia in cui lo studio alla base di questo questionario riteneva probabile un’insonnia.", "aboveThreshold": "Il tuo sonno nell’ultimo mese era fuori dalla fascia che quello studio collega all’insonnia." } + }, + "wording": { + "unvalidatedNote": "Questa traduzione non è stata validata scientificamente; considera il risultato come un orientamento.", + "showOriginal": "Mostra il testo originale in inglese", + "showTranslation": "Mostra la traduzione", + "resultUnvalidated": "Risposte date in una traduzione non validata scientificamente; considera il risultato come un orientamento.", + "historyUnvalidated": "Risposte date in una traduzione non validata scientificamente" } }, "records": { diff --git a/messages/ko.json b/messages/ko.json index 2485ebc17..0e935e3a6 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -11054,7 +11054,6 @@ "who5": "웰빙 점수가 50점 이하라면 좀 더 살펴볼 만해요. 원한다면 이 페이지의 PHQ-9 점검이 기분을 더 자세히 살펴봐요. 진단이 아니라 가벼운 제안이에요.", "sci": "이 설문의 바탕이 된 연구에서는 16점 이하가 불면증 가능성과 관련이 있었어요. 수면 문제가 계속 힘들게 한다면 전문가와 이야기해 보는 게 도움이 될 수 있어요. 진단은 아니에요." }, - "validatedInEnglishNote": "이 설문은 영어로 검증된 도구예요. 문항은 원문 영어 표현 그대로 보여줘요.", "openDetail": "자세히 보기", "historyLoadError": "검사 기록을 불러오지 못했어요", "historyLoadErrorHint": "결과가 없다는 뜻은 아니에요. 목록을 가져오지 못했을 뿐이에요. 다시 시도해 주세요.", @@ -11104,6 +11103,13 @@ "belowThreshold": "지난 한 달 동안의 수면이 이 설문의 바탕이 된 연구에서 불면증 가능성이 높다고 본 범위에 있었어요.", "aboveThreshold": "지난 한 달 동안의 수면이 그 연구가 불면증과 연결한 범위 밖에 있었어요." } + }, + "wording": { + "unvalidatedNote": "이 번역은 과학적으로 검증되지 않았어요. 결과는 참고용으로만 봐 주세요.", + "showOriginal": "영어 원문 보기", + "showTranslation": "번역 보기", + "resultUnvalidated": "과학적으로 검증되지 않은 번역으로 답했어요. 결과는 참고용으로만 봐 주세요.", + "historyUnvalidated": "과학적으로 검증되지 않은 번역으로 답함" } }, "records": { diff --git a/messages/nl.json b/messages/nl.json index 08d578b27..c3d4af302 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -11054,7 +11054,6 @@ "who5": "Een welzijnsscore van 50 of lager kan het waard zijn om beter naar te kijken. Als je wilt, gaat de PHQ-9-check-in op deze pagina dieper in op je stemming. Dit is een voorzichtige suggestie, geen diagnose.", "sci": "In het onderzoek achter deze vragenlijst hingen scores van 16 of lager samen met waarschijnlijke slapeloosheid. Blijf je last houden van slecht slapen, dan kan een gesprek met een zorgverlener helpen. Dit is geen diagnose." }, - "validatedInEnglishNote": "Deze vragenlijst is in het Engels gevalideerd; de vragen worden in de oorspronkelijke Engelse formulering getoond.", "openDetail": "Details tonen", "historyLoadError": "Je testgeschiedenis kon niet worden geladen", "historyLoadErrorHint": "Dit betekent niet dat je geen resultaten hebt; de lijst kon niet worden opgehaald. Probeer het opnieuw.", @@ -11104,6 +11103,13 @@ "belowThreshold": "Je slaap in de afgelopen maand lag in het bereik waarin het onderzoek achter deze vragenlijst slapeloosheid waarschijnlijk vond.", "aboveThreshold": "Je slaap in de afgelopen maand lag buiten het bereik dat dat onderzoek met slapeloosheid verbindt." } + }, + "wording": { + "unvalidatedNote": "Deze vertaling is niet wetenschappelijk gevalideerd; zie de uitkomst als een indicatie.", + "showOriginal": "Oorspronkelijke Engelse tekst tonen", + "showTranslation": "Vertaling tonen", + "resultUnvalidated": "Beantwoord in een vertaling die niet wetenschappelijk gevalideerd is; zie de uitkomst als een indicatie.", + "historyUnvalidated": "Beantwoord in een niet wetenschappelijk gevalideerde vertaling" } }, "records": { diff --git a/messages/pl.json b/messages/pl.json index 3dee081df..4249dbe91 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -10947,25 +10947,6 @@ "who5": "Test samopoczucia, 5 pytań", "sci": "Test snu, 8 pytań" }, - "options": { - "0": "Wcale nie dokuczały", - "1": "Kilka dni", - "2": "Więcej niż połowę dni", - "3": "Niemal codziennie" - }, - "gad7Options": { - "0": "Wcale nie dokuczały", - "1": "Kilka dni", - "2": "Więcej niż połowę dni", - "3": "Niemal codziennie" - }, - "functionalTitle": "Jeżeli zaznaczył/-a Pan/Pani którekolwiek z problemów, jak bardzo utrudniły one Panu/Pani wykonywanie pracy, zajmowanie się domem lub relacje z innymi ludźmi?", - "functional": { - "0": "W ogóle nie utrudniły", - "1": "Trochę utrudniły", - "2": "Bardzo utrudniły", - "3": "Niezmiernie utrudniły" - }, "functionalOptionalHint": "Opcjonalne: możesz wysłać bez odpowiedzi na to pytanie.", "result": { "title": "Twój wynik", @@ -11015,35 +10996,6 @@ "ifDanger": "Jeśli grozi Ci bezpośrednie niebezpieczeństwo, zadzwoń teraz pod {emergency}.", "resourcesTitle": "Osoby, które mogą pomóc" }, - "items": { - "phq9": { - "1": "Niewielkie zainteresowanie lub odczuwanie przyjemności z wykonywania czynności", - "2": "Uczucie smutku, przygnębienia lub beznadziejności", - "3": "Kłopoty z zaśnięciem lub przerywany sen, albo zbyt długi sen", - "4": "Uczucie zmęczenia lub brak energii", - "5": "Brak apetytu lub przejadanie się", - "6": "Poczucie niezadowolenia z siebie — lub uczucie, że jest się do niczego, albo że zawiódł/zawiodła Pan/Pani siebie lub rodzinę", - "7": "Problemy ze skupieniem się na przykład przy czytaniu gazety lub oglądaniu telewizji", - "8": "Poruszanie się lub mówienie tak wolno, że inni mogliby to zauważyć? Albo wręcz przeciwnie — niemożność usiedzenia w miejscu lub podenerwowanie powodujące ruchliwość znacznie większą niż zwykle", - "9": "Myśli, że lepiej byłoby umrzeć, albo chęć zrobienia sobie jakiejś krzywdy" - }, - "gad7": { - "1": "Czuł(a) się Pan(i) podenerwowany(a), niespokojny(a), mocno spięty(a)", - "2": "Nie mógł(a) Pan(i) przestać się martwić albo zapanować nad tym", - "3": "Za bardzo się Pan(i) martwił(a) różnymi rzeczami", - "4": "Miał(a) Pan(i) trudności z relaksowaniem się", - "5": "Był(a) Pan(i) tak niespokojny(a), że nie mógł(a) usiedzieć na miejscu", - "6": "Łatwo stawał(a) się Pan(i) rozdrażniony(a) lub poirytowany(a)", - "7": "Obawiał(a) się Pan(i), tak jakby miało się stać 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" - } - }, "crisisResource": { "telefonSeelsorge": { "name": "TelefonSeelsorge (całodobowo, bezpłatnie i poufnie)" @@ -11102,26 +11054,6 @@ "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": { - "0": "Nigdy", - "1": "Od czasu do czasu", - "2": "Mniej niż połowę czasu", - "3": "Więcej niż połowę czasu", - "4": "Prawie cały czas", - "5": "Cały czas" - }, - "stems": { - "phq9": { - "period": "Jak często w ciągu ostatnich 2 tygodni dokuczały Panu/Pani następujące problemy?" - }, - "gad7": { - "period": "Jak często w ciągu ostatnich 2 tygodni dokuczały Panu(i) następujące problemy?" - }, - "who5": { - "period": "W ciągu ostatnich dwóch tygodni" - } - }, "openDetail": "Pokaż szczegóły", "historyLoadError": "Nie udało się wczytać historii testów", "historyLoadErrorHint": "To nie znaczy, że nie masz wyników; nie udało się pobrać listy. Spróbuj ponownie.", @@ -11171,6 +11103,13 @@ "belowThreshold": "Twój sen w ostatnim miesiącu mieścił się w zakresie, w którym badanie stojące za tym kwestionariuszem uznało bezsenność za prawdopodobną.", "aboveThreshold": "Twój sen w ostatnim miesiącu był poza zakresem, który to badanie wiąże z bezsennością." } + }, + "wording": { + "unvalidatedNote": "To tłumaczenie nie zostało zwalidowane naukowo; traktuj wynik jako orientacyjny.", + "showOriginal": "Pokaż oryginalny tekst angielski", + "showTranslation": "Pokaż tłumaczenie", + "resultUnvalidated": "Odpowiedzi udzielone w tłumaczeniu, które nie zostało zwalidowane naukowo; traktuj wynik jako orientacyjny.", + "historyUnvalidated": "Odpowiedzi w tłumaczeniu niezwalidowanym naukowo" } }, "records": { diff --git a/messages/pt-BR.json b/messages/pt-BR.json index e3e7ddb9a..f73571d6b 100644 --- a/messages/pt-BR.json +++ b/messages/pt-BR.json @@ -11054,7 +11054,6 @@ "who5": "Uma pontuação de bem-estar de 50 ou abaixo pode valer uma olhada mais de perto. Se quiser, o check-in PHQ-9 nesta página examina o humor com mais profundidade. Esta é uma sugestão gentil, não um diagnóstico.", "sci": "No estudo por trás deste questionário, pontuações de 16 ou abaixo foram associadas a provável insônia. Se o sono ruim continuar incomodando, conversar com um profissional de saúde pode ajudar. Isto não é um diagnóstico." }, - "validatedInEnglishNote": "Este questionário é validado em inglês; as perguntas são exibidas na redação original em inglês.", "openDetail": "Mostrar detalhes", "historyLoadError": "Não foi possível carregar seu histórico de avaliações", "historyLoadErrorHint": "Isso não significa que você não tem resultados; a lista não pôde ser buscada. Tente novamente.", @@ -11104,6 +11103,13 @@ "belowThreshold": "Seu sono no último mês ficou na faixa em que o estudo por trás deste questionário considerou provável uma insônia.", "aboveThreshold": "Seu sono no último mês ficou fora da faixa que esse estudo associa à insônia." } + }, + "wording": { + "unvalidatedNote": "Esta tradução não foi validada cientificamente; considere o resultado como uma orientação.", + "showOriginal": "Ver o texto original em inglês", + "showTranslation": "Ver a tradução", + "resultUnvalidated": "Respondido em uma tradução que não foi validada cientificamente; considere o resultado como uma orientação.", + "historyUnvalidated": "Respondido em uma tradução não validada cientificamente" } }, "records": { diff --git a/src/__tests__/helpers/questionnaire-wording.ts b/src/__tests__/helpers/questionnaire-wording.ts new file mode 100644 index 000000000..adab6a10f --- /dev/null +++ b/src/__tests__/helpers/questionnaire-wording.ts @@ -0,0 +1,23 @@ +/** + * Seed a test's query cache with every language's questionnaire text, the + * way the client holds it once the language's chunk has loaded, so a + * server-rendered surface paints the text instead of its loading state. + */ +import type { QueryClient } from "@tanstack/react-query"; + +import { locales } from "@/lib/i18n/config"; +import { WORDING } from "@/lib/mental-health/wording"; +import type { LocaleWording } from "@/lib/mental-health/wording/types"; +import { queryKeys } from "@/lib/query-keys"; + +export function seedQuestionnaireWording(queryClient: QueryClient): void { + for (const locale of locales) { + const chunk: LocaleWording = { + PHQ9: WORDING.PHQ9[locale], + GAD7: WORDING.GAD7[locale], + WHO5: WORDING.WHO5[locale], + SCI: WORDING.SCI[locale], + }; + queryClient.setQueryData(queryKeys.mentalHealthWording(locale), chunk); + } +} diff --git a/src/__tests__/i18n-english-leak-guard.test.ts b/src/__tests__/i18n-english-leak-guard.test.ts index a4082a409..4c1a46d9d 100644 --- a/src/__tests__/i18n-english-leak-guard.test.ts +++ b/src/__tests__/i18n-english-leak-guard.test.ts @@ -153,10 +153,9 @@ const LEGIT_IDENTICAL = new Set([ "mentalHealth.crisisResource.lifeline988.name", "mentalHealth.crisisResource.nummerGegenKummer.name", "mentalHealth.crisisResource.telefonSeelsorge.name", - // Questionnaire items, answers and stems are absent from every bundle whose - // language an instrument is not validated in (`instrument-wording.ts`), so - // no entry is needed for them here: a locale either carries a validated - // translation or nothing. + // Questionnaire items, answers and stems live in code with their sources + // (`src/lib/mental-health/wording/`), never in a bundle, so no entry is + // needed for them here. // Proper nouns, a URL label and an acronym — the accepted written form in // Korean is the Latin original, so these stay identical to en.json. "auth.emailPlaceholder", diff --git a/src/__tests__/i18n-no-dot-separator.test.ts b/src/__tests__/i18n-no-dot-separator.test.ts index d315ec9c1..f464e6809 100644 --- a/src/__tests__/i18n-no-dot-separator.test.ts +++ b/src/__tests__/i18n-no-dot-separator.test.ts @@ -30,20 +30,6 @@ const hasSeparator = (value: string) => /** Key path -> why the separator is right there. Empty by intent. */ const ALLOWED: Record = {}; -/** - * Subtrees whose wording is not ours to change. The questionnaire items are - * the licensed, validated text of PHQ-9, GAD-7 and the other instruments in - * each language; a dash there is part of the instrument (PHQ-9 item 6 reads - * "Feeling bad about yourself — or that you are a failure ..."), and - * rewording it would change what the score measures. - */ -const EXEMPT_PREFIXES: Record = { - "mentalHealth.items.": "validated questionnaire wording, verbatim", -}; - -const isExempt = (key: string) => - Object.keys(EXEMPT_PREFIXES).some((prefix) => key.startsWith(prefix)); - function flatten( obj: unknown, prefix: string, @@ -80,10 +66,7 @@ describe("message bundles carry no ' · ', ' — ' or ' – ' separator", () => it(`${file} has no dot or dash separator outside the allowlist`, () => { const offenders = [...values] - .filter( - ([key, value]) => - hasSeparator(value) && !(key in ALLOWED) && !isExempt(key), - ) + .filter(([key, value]) => hasSeparator(value) && !(key in ALLOWED)) .map(([key, value]) => `${key} = ${JSON.stringify(value)}`); expect(offenders).toEqual([]); }); diff --git a/src/components/mental-health/__tests__/assessment-item-breakdown.test.tsx b/src/components/mental-health/__tests__/assessment-item-breakdown.test.tsx index 05acf9f76..1f4031a34 100644 --- a/src/components/mental-health/__tests__/assessment-item-breakdown.test.tsx +++ b/src/components/mental-health/__tests__/assessment-item-breakdown.test.tsx @@ -5,18 +5,20 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { I18nProvider } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import en from "../../../../messages/en.json"; -import ptBR from "../../../../messages/pt-BR.json"; +import { WORDING } from "@/lib/mental-health/wording"; +import { seedQuestionnaireWording } from "@/__tests__/helpers/questionnaire-wording"; /** * The expandable per-item answer breakdown (the read side of * `responsesEncrypted`). SSR-only convention (`renderToStaticMarkup`) — the * detail query is seeded into the cache, the fetch never runs. * - * Pins: the official item texts render from the SAME `mentalHealth.items.*` - * keys the check-in wizard uses (validated instrument copy, never re-worded), - * each answer shows its option label + value, PHQ-9 item 9 gets NO alarm - * styling, and the server's decrypt-failure degrade renders as the honest - * unavailable line rather than a blank. + * Pins: the item texts render from the SAME wording the check-in wizard + * reads (`wordingFor`, never re-worded), in the wording the answers were + * given in (the English original when the row says so), each answer shows + * its option label + value, PHQ-9 item 9 gets NO alarm styling, and the + * server's decrypt-failure degrade renders as the honest unavailable line + * rather than a blank. */ vi.mock("@/lib/api/api-fetch", () => ({ @@ -28,12 +30,16 @@ import { AssessmentItemBreakdown } from "../assessment-item-breakdown"; import type { AssessmentDetail } from "../types"; const mh = en.mentalHealth; +const PHQ9_EN = WORDING.PHQ9.en; +const PHQ9_PT = WORDING.PHQ9["pt-BR"]; +const WHO5_EN = WORDING.WHO5.en; function detailRow(overrides: Partial): AssessmentDetail { return { id: "mha_1", instrument: "PHQ9", locale: "en", + wording: "validated", totalScore: 10, severityBand: "moderate", item9Flagged: true, @@ -49,7 +55,6 @@ function detailRow(overrides: Partial): AssessmentDetail { function render( detail: AssessmentDetail, locale: "en" | "pt-BR" = "en", - messages?: Record, ): string { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } }, @@ -57,12 +62,14 @@ function render( queryClient.setQueryData(queryKeys.mentalHealthAssessmentDetail(detail.id), { assessment: detail, }); + seedQuestionnaireWording(queryClient); return renderToStaticMarkup( - + , @@ -70,34 +77,28 @@ function render( } describe("AssessmentItemBreakdown", () => { - it("renders every official PHQ-9 item text with its answer label + value", () => { + it("renders every PHQ-9 item text with its answer label + value", () => { const html = render(detailRow({})); - // All nine validated item texts, from the wizard's own keys. - for (let n = 1; n <= 9; n++) { - expect(html).toContain( - mh.items.phq9[String(n) as keyof typeof mh.items.phq9], - ); - } - // Answer labels are the shared PHQ/GAD anchors. - expect(html).toContain(mh.options["1"]); - expect(html).toContain(mh.options["2"]); + for (const item of PHQ9_EN.items) expect(html).toContain(item); + expect(html).toContain(PHQ9_EN.anchors.all[1]); + expect(html).toContain(PHQ9_EN.anchors.all[2]); }); it("gives PHQ-9 item 9 no alarm styling — it renders like every other item", () => { const html = render(detailRow({})); - expect(html).toContain(mh.items.phq9["9"]); + expect(html).toContain(PHQ9_EN.items[8]); expect(html).not.toContain("text-destructive"); }); it("renders the unscored functional follow-up when it was answered", () => { const html = render(detailRow({ functionalDifficulty: 2 })); - expect(html).toContain(mh.functionalTitle); - expect(html).toContain(mh.functional["2"]); + expect(html).toContain(PHQ9_EN.functional?.title); + expect(html).toContain(PHQ9_EN.functional?.anchors[2]); }); it("omits the functional follow-up row when it was not answered", () => { const html = render(detailRow({ functionalDifficulty: null })); - expect(html).not.toContain(mh.functionalTitle); + expect(html).not.toContain(PHQ9_EN.functional?.title); }); it("paints the WHO-5 recall stem once above its run of items", () => { @@ -109,41 +110,36 @@ describe("AssessmentItemBreakdown", () => { }), ); // The shared stem appears exactly once, not per item. - const stem = mh.stems.who5.period; + const stem = WHO5_EN.stems.period; expect(html.split(stem).length - 1).toBe(1); - expect(html).toContain(mh.items.who5["1"]); - expect(html).toContain(mh.who5Options["5"]); + expect(html).toContain(WHO5_EN.items[0]); + expect(html).toContain(WHO5_EN.anchors.all[5]); }); it("renders the honest unavailable line on the server's decrypt degrade", () => { const html = render(detailRow({ items: null, itemsUnavailable: true })); expect(html).toContain(mh.history.answersUnavailable); - expect(html).not.toContain(mh.items.phq9["1"]); + expect(html).not.toContain(PHQ9_EN.items[0]); }); - it("shows the English PHQ-9 items to a pt-BR reader, even from a bundle that carries a translation", () => { - // A drifted pt-BR bundle with the self-made wording the release once - // shipped. The PHQ-9 is not validated in Brazilian Portuguese, so none - // of it may reach the page. - const drifted = structuredClone(ptBR) as Record; - const mhDrifted = drifted.mentalHealth as Record; - mhDrifted.items = { - phq9: { - "9": "Pensamentos de que estaria melhor morto, ou de se machucar de alguma forma", - }, - }; - mhDrifted.options = { "2": "Mais da metade dos dias" }; - mhDrifted.functionalTitle = "Se você assinalou algum problema"; + it("shows a pt-BR reader the Brazilian PHQ-9 the answers were given in", () => { const html = render( - detailRow({ functionalDifficulty: 1 }), + detailRow({ locale: "pt-BR", functionalDifficulty: 1 }), "pt-BR", - drifted, ); - expect(html).toContain(mh.items.phq9["9"]); - expect(html).toContain(mh.options["2"]); - expect(html).toContain(mh.functionalTitle); - expect(html).not.toContain("Pensamentos de que estaria melhor morto"); - expect(html).not.toContain("Mais da metade dos dias"); - expect(html).not.toContain("Se você assinalou"); + expect(html).toContain(PHQ9_PT.items[8]); + expect(html).toContain(PHQ9_PT.anchors.all[2]); + expect(html).toContain(PHQ9_PT.functional?.title); + expect(html).not.toContain(PHQ9_EN.items[8]); + }); + + it("shows the English original when the answers were given in it", () => { + const html = render( + detailRow({ locale: "pt-BR", wording: "original_en" }), + "pt-BR", + ); + expect(html).toContain(PHQ9_EN.items[8]); + expect(html).toContain('lang="en"'); + expect(html).not.toContain(PHQ9_PT.items[8]); }); }); diff --git a/src/components/mental-health/__tests__/mental-wellbeing.test.tsx b/src/components/mental-health/__tests__/mental-wellbeing.test.tsx index fb1f2a260..f31aff775 100644 --- a/src/components/mental-health/__tests__/mental-wellbeing.test.tsx +++ b/src/components/mental-health/__tests__/mental-wellbeing.test.tsx @@ -6,6 +6,8 @@ import { I18nProvider } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import en from "../../../../messages/en.json"; import nl from "../../../../messages/nl.json"; +import { WORDING } from "@/lib/mental-health/wording"; +import { seedQuestionnaireWording } from "@/__tests__/helpers/questionnaire-wording"; /** * Mental-wellbeing surface redesign (v1.25.3). The SSR-only convention @@ -47,6 +49,7 @@ function withProviders(node: React.ReactNode): string { data: { assessments: [] }, cap: null, }); + seedQuestionnaireWording(queryClient); return renderToStaticMarkup( {node} @@ -109,7 +112,7 @@ describe("check-in wizard scaffolding (v1.27.6 — questions only)", () => { }); it("opens on the first question with the Back/Next footer", () => { - expect(html).toContain(mh.items.phq9["1"]); + expect(html).toContain(WORDING.PHQ9.en.items[0]); expect(html).toContain('data-slot="check-in-back"'); expect(html).toContain('data-slot="check-in-next"'); // Back is disabled on the first step. @@ -126,6 +129,7 @@ describe("item-9 crisis surfacing on the result", () => { id: "a1", instrument: "PHQ9", locale: "en", + wording: "validated", totalScore: 18, severityBand: "modSevere", item9Flagged: crisis !== null, @@ -173,6 +177,7 @@ describe("history rendering", () => { id: "h1", instrument: "PHQ9", locale: "en", + wording: "validated", totalScore: 16, severityBand: "modSevere", item9Flagged: true, @@ -183,6 +188,7 @@ describe("history rendering", () => { id: "h2", instrument: "PHQ9", locale: "en", + wording: "validated", totalScore: 4, severityBand: "minimal", item9Flagged: false, @@ -247,15 +253,16 @@ describe("WHO-5 / SCI on the same infrastructure (v1.27.9)", () => { isError={false} />, ); - expect(html).toContain(mh.items.who5["1"]); + expect(html).toContain(WORDING.WHO5.en.items[0]); expect(html).toContain('data-slot="check-in-stem"'); - expect(html).toContain(mh.stems.who5.period); + expect(html).toContain(WORDING.WHO5.en.stems.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]); + for (const v of [5, 4, 3, 2, 1, 0]) { + expect(html).toContain(WORDING.WHO5.en.anchors.all[v]); } // English items are the validated wording for en — no translation note. - expect(html).not.toContain('data-slot="check-in-validated-note"'); + expect(html).not.toContain('data-slot="check-in-unvalidated-note"'); + expect(html).not.toContain('data-slot="check-in-wording-toggle"'); // No functional follow-up outside the PHQ-9: 5 steps total. expect(html).toContain("Question 1 of 5"); }); @@ -270,11 +277,11 @@ describe("WHO-5 / SCI on the same infrastructure (v1.27.9)", () => { isError={false} />, ); - expect(html).toContain(mh.items.sci["1"]); - expect(html).toContain(mh.stems.sci.night); + expect(html).toContain(WORDING.SCI.en.items[0]); + expect(html).toContain(WORDING.SCI.en.stems.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]); + for (const v of [4, 3, 2, 1, 0]) { + expect(html).toContain(WORDING.SCI.en.anchors.duration[v]); } expect(html).toContain("Question 1 of 8"); }); @@ -285,6 +292,7 @@ describe("WHO-5 / SCI on the same infrastructure (v1.27.9)", () => { id: "w1", instrument: "WHO5", locale: "en", + wording: "validated", totalScore: 48, severityBand: "low", item9Flagged: false, @@ -314,6 +322,7 @@ describe("WHO-5 / SCI on the same infrastructure (v1.27.9)", () => { id: "w2", instrument: "WHO5", locale: "en", + wording: "validated", totalScore: 80, severityBand: "good", item9Flagged: false, @@ -339,6 +348,7 @@ describe("WHO-5 / SCI on the same infrastructure (v1.27.9)", () => { id: "s1", instrument: "SCI", locale: "en", + wording: "validated", totalScore: 14, severityBand: "belowThreshold", item9Flagged: false, @@ -365,6 +375,7 @@ describe("WHO-5 / SCI on the same infrastructure (v1.27.9)", () => { id: "t1", instrument: "WHO5", locale: "en", + wording: "validated", totalScore: 72, severityBand: "good", item9Flagged: false, @@ -384,6 +395,7 @@ describe("instrument card (med-/Vorsorge card anatomy + detail opener)", () => { id: "l1", instrument: "PHQ9", locale: "en", + wording: "validated", totalScore: 8, severityBand: "mild", item9Flagged: false, @@ -463,6 +475,7 @@ describe("instrument detail (v1.27.9 — opened from a card)", () => { id: "d1", instrument: "SCI", locale: "en", + wording: "validated", totalScore: 22, severityBand: "aboveThreshold", item9Flagged: false, @@ -473,6 +486,7 @@ describe("instrument detail (v1.27.9 — opened from a card)", () => { id: "d2", instrument: "PHQ9", locale: "en", + wording: "validated", totalScore: 4, severityBand: "minimal", item9Flagged: false, @@ -500,65 +514,112 @@ describe("instrument detail (v1.27.9 — opened from a card)", () => { }); }); -describe("unvalidated languages see the English instrument wording", () => { - function renderIn( - locale: "nl", - messages: Record, - node: React.ReactNode, - ): string { +describe("questionnaires in the reader's language", () => { + function renderIn(locale: "nl" | "de", node: React.ReactNode): string { + const queryClient = new QueryClient(); + seedQuestionnaireWording(queryClient); return renderToStaticMarkup( - - + + {node} , ); } - it("the PHQ-9 wizard in Dutch shows the English item and answers under the honest note", () => { - // A bundle that still carries the self-made Dutch wording: the surface - // must not read it. - const drifted = structuredClone(nl) as Record; - const mhDrifted = drifted.mentalHealth as Record; - mhDrifted.items = { - phq9: { "1": "Weinig interesse in of plezier aan activiteiten" }, - }; - mhDrifted.options = { "0": "Helemaal niet", "3": "Bijna elke dag" }; - const html = renderIn( - "nl", - drifted, + function wizard(instrument: "PHQ9" | "SCI") { + return ( {}} onBack={() => {}} isPending={false} isError={false} - />, + /> ); - expect(html).toContain(mh.items.phq9["1"]); - for (const v of ["0", "1", "2", "3"] as const) { - expect(html).toContain(mh.options[v]); - } - expect(html).not.toContain("Weinig interesse"); - expect(html).not.toContain("Helemaal niet"); - expect(html).toContain('data-slot="check-in-validated-note"'); - expect(html).toContain(nl.mentalHealth.validatedInEnglishNote); + } + + it("holds the step's place while the language's text loads", () => { + const html = renderToStaticMarkup( + + + {wizard("SCI")} + + , + ); + expect(html).toContain('data-slot="check-in-loading"'); + expect(html).not.toContain('data-slot="check-in-question"'); + expect(html).not.toContain('data-slot="check-in-unvalidated-note"'); }); - it("the WHO-5 wizard in Dutch shows the English stem and anchors", () => { - const html = renderIn( + it("the PHQ-9 in Dutch is the validated Dutch form, with no note and no switch", () => { + const html = renderIn("nl", wizard("PHQ9")); + const dutch = WORDING.PHQ9.nl; + expect(html).toContain(dutch.stems.period); + expect(html).toContain(dutch.items[0]); + for (const v of [0, 1, 2, 3]) expect(html).toContain(dutch.anchors.all[v]); + expect(html).not.toContain(WORDING.PHQ9.en.items[0]); + expect(html).not.toContain('data-slot="check-in-unvalidated-note"'); + expect(html).not.toContain('data-slot="check-in-wording-toggle"'); + }); + + it("the SCI in Dutch shows the translation under the note, with the switch to the English original", () => { + const html = renderIn("nl", wizard("SCI")); + const dutch = WORDING.SCI.nl; + expect(dutch.validated).toBe(false); + expect(html).toContain(dutch.stems.night); + expect(html).toContain(dutch.items[0]); + expect(html).not.toContain(WORDING.SCI.en.items[0]); + expect(html).toContain('data-slot="check-in-unvalidated-note"'); + expect(html).toContain(nl.mentalHealth.wording.unvalidatedNote); + expect(html).toContain('data-slot="check-in-wording-toggle"'); + expect(html).toContain(nl.mentalHealth.wording.showOriginal); + }); + + it("the result and the history mark an unvalidated translation, and only that", () => { + const row = (wording: AssessmentRow["wording"]): AssessmentRow => ({ + id: `s-${wording}`, + instrument: "SCI", + locale: "nl", + wording, + totalScore: 20, + severityBand: "aboveThreshold", + item9Flagged: false, + crisisShownAt: null, + takenAt: "2026-06-20T00:00:00.000Z", + }); + const result = (wording: AssessmentRow["wording"]) => + renderIn( + "nl", + {}} + onBack={() => {}} + />, + ); + expect(result("unvalidated_translation")).toContain( + nl.mentalHealth.wording.resultUnvalidated, + ); + for (const other of ["validated", "original_en", null] as const) { + expect(result(other)).not.toContain('data-slot="result-wording-note"'); + } + const history = renderIn( "nl", - nl as Record, - {}} - onBack={() => {}} - isPending={false} - isError={false} + , ); - expect(html).toContain(mh.stems.who5.period); - expect(html).toContain(mh.items.who5["1"]); - expect(html).toContain(mh.who5Options["5"]); + expect(history.split('data-slot="history-wording-note"').length - 1).toBe( + 1, + ); + expect(history).toContain(nl.mentalHealth.wording.historyUnvalidated); }); }); diff --git a/src/components/mental-health/assessment-history.tsx b/src/components/mental-health/assessment-history.tsx index 685742cfe..92ca55e65 100644 --- a/src/components/mental-health/assessment-history.tsx +++ b/src/components/mental-health/assessment-history.tsx @@ -16,6 +16,9 @@ * reveals the item-9 answer, and surfacing it triggers no third-party alert. * No new data leaves the safety boundary. * + * A row answered in a translation that has not been validated says so under + * its date, so the score reads as the orientation it is. + * * Each row also carries an opt-in answer breakdown: expanding it fetches the * per-administration detail read, where the server decrypts the stored * per-item answers (`AssessmentItemBreakdown`). Item 9 renders there like any @@ -185,12 +188,21 @@ export function AssessmentHistory({ + {row.wording === "unvalidated_translation" && ( +

+ {t("mentalHealth.wording.historyUnvalidated")} +

+ )} {flagged && open && } {breakdownOpen && (
)} diff --git a/src/components/mental-health/assessment-item-breakdown.tsx b/src/components/mental-health/assessment-item-breakdown.tsx index d400a1757..5961461bd 100644 --- a/src/components/mental-health/assessment-item-breakdown.tsx +++ b/src/components/mental-health/assessment-item-breakdown.tsx @@ -6,11 +6,11 @@ * where the server decrypts `responsesEncrypted` — the answers never ride the * list payload. * - * Grammar: the detail surface's label/value rows — official item text muted - * (validated instrument copy resolved through `instrumentText`, exactly as - * the check-in wizard does: the validated translation, or the English source - * for a language the instrument is not validated in; never re-worded here), the chosen answer label + - * its value in foreground. The WHO-5 / SCI recall stems paint once above each + * Grammar: the detail surface's label/value rows — the item text muted, in + * the wording the answers were given in (the English original when the + * reader chose it, otherwise the reader's language, as the check-in wizard + * shows it; never re-worded here), the chosen answer label + its value in + * foreground. The WHO-5 / SCI recall stems paint once above each * run of items, as on the source forms. PHQ-9 item 9 renders like every other * item — no alarm colour; the row's existing "support shown" marker is the * crisis affordance and is not duplicated here. @@ -21,17 +21,10 @@ import { Skeleton } from "@/components/ui/skeleton"; import { useTranslations } from "@/lib/i18n/context"; import { apiGet } from "@/lib/api/api-fetch"; import { queryKeys } from "@/lib/query-keys"; -import { - INSTRUMENTS, - optionLabelKey, - stemKey, -} from "@/lib/mental-health/instruments"; -import { - FUNCTIONAL_TITLE_KEY, - functionalOptionKey, - instrumentText, - itemKey, -} from "@/lib/mental-health/instrument-wording"; +import { INSTRUMENTS } from "@/lib/mental-health/instruments"; +import { anchorText, stemText } from "@/lib/mental-health/instrument-wording"; +import { useQuestionnaireWording } from "@/hooks/use-questionnaire-wording"; +import type { AssessmentWording } from "@/lib/mental-health/wording/types"; import type { AssessmentDetail, InstrumentId } from "./types"; import { QueryErrorRow } from "@/components/ui/query-error-row"; @@ -39,13 +32,17 @@ import { QueryErrorRow } from "@/components/ui/query-error-row"; export function AssessmentItemBreakdown({ assessmentId, instrument, + wording, }: { assessmentId: string; instrument: InstrumentId; + /** The wording the answers were given in (the row's stored marker). */ + wording: AssessmentWording | null; }) { const { t, locale } = useTranslations(); const def = INSTRUMENTS[instrument]; - const wording = (k: string) => instrumentText(instrument, k, locale, t); + const lang = wording === "original_en" ? "en" : locale; + const text = useQuestionnaireWording(lang)?.[instrument]; const { data, isPending, isError, error, refetch } = useQuery({ queryKey: queryKeys.mentalHealthAssessmentDetail(assessmentId), @@ -55,7 +52,7 @@ export function AssessmentItemBreakdown({ ), }); - if (isPending) { + if (isPending || !text) { return (
{ // Stem captions group runs of items exactly as the wizard presents // them: paint the stem only where it differs from the previous item's. - const stem = stemKey(instrument, index); + const stem = stemText(instrument, text, index); const caption = - stem !== (index > 0 ? stemKey(instrument, index - 1) : null) + stem !== (index > 0 ? stemText(instrument, text, index - 1) : null) ? stem : null; return ( -
+
{caption && ( -

- {wording(`mentalHealth.stems.${caption}`)} -

+

{caption}

)}
- {wording(itemKey(instrument, index))} + {text.items[index]} - {wording(optionLabelKey(instrument, index, value))} + {anchorText(instrument, text, index, value)} {" · "} {value} @@ -128,15 +123,15 @@ export function AssessmentItemBreakdown({
); })} - {detail.functionalDifficulty !== null && ( -
+ {detail.functionalDifficulty !== null && text.functional && ( +
- {wording(FUNCTIONAL_TITLE_KEY)} + {text.functional.title} {/* Unscored follow-up: the label alone, no value — it never counts into the total and a number would imply it does. */} - {wording(functionalOptionKey(detail.functionalDifficulty))} + {text.functional.anchors[detail.functionalDifficulty]}
)} diff --git a/src/components/mental-health/assessment-result.tsx b/src/components/mental-health/assessment-result.tsx index df36ed551..2b306f20b 100644 --- a/src/components/mental-health/assessment-result.tsx +++ b/src/components/mental-health/assessment-result.tsx @@ -7,7 +7,9 @@ * (support before numbers), then one result card with the score on the * instrument's own scale, its band, what the band means in plain words, the * change since the last check-in measured against the meaningful-change - * threshold, and the next step: the direction-aware follow-up hint when the + * threshold, a note when the answers were given in a translation that has + * not been validated (the score then reads as orientation), and the next + * step: the direction-aware follow-up hint when the * score crosses the instrument's threshold, a routine note otherwise. The * attribution and "take another" close the view. * @@ -122,6 +124,14 @@ export function AssessmentResult({

{t(`mentalHealth.bandMeaning.${instrument}.${band}`)}

+ {assessment.wording === "unvalidated_translation" && ( +

+ {t("mentalHealth.wording.resultUnvalidated")} +

+ )} {ownTrend && ownTrend.previous ? ( ) : ownTrend ? ( diff --git a/src/components/mental-health/check-in-wizard.tsx b/src/components/mental-health/check-in-wizard.tsx index f7a38ca67..0eb87c3d6 100644 --- a/src/components/mental-health/check-in-wizard.tsx +++ b/src/components/mental-health/check-in-wizard.tsx @@ -10,10 +10,12 @@ * question is one more, optional step. A review step then lists every * answer with a way back to each one, and only the review submits. * - * Items, answers and stems come from `instrumentText`: the validated - * translation where the language has one, the English original otherwise, - * under an honest note. The header carries the app's own title and one - * explanation line; the disclaimer stays on the landing. + * Items, answers and stems come from the instrument's wording for the + * reader's language (one chunk per language, `useQuestionnaireWording`): the + * published validated translation where one exists, otherwise a translation + * written for HealthLog under a note that the result is orientation, with a + * switch to the English original. The header carries the app's own title and + * one explanation line; the disclaimer stays on the landing. * * Keyboard: Tab reaches the selected answer (or the first), the arrow keys * move between answers, Space or Enter chooses, and the number keys choose @@ -34,20 +36,16 @@ import { Card, CardContent } from "@/components/ui/card"; import { InlineAction } from "@/components/ui/inline-action"; import { PageHeader } from "@/components/ui/page-header"; import { Progress } from "@/components/ui/progress"; +import { Skeleton } from "@/components/ui/skeleton"; import { useTranslations } from "@/lib/i18n/context"; +import { INSTRUMENTS } from "@/lib/mental-health/instruments"; import { - INSTRUMENTS, - hasValidatedItems, - optionLabelKey, - stemKey, -} from "@/lib/mental-health/instruments"; -import { - FUNCTIONAL_TITLE_KEY, - functionalOptionKey, + anchorText, hasFunctionalItem, - instrumentText, - itemKey, + stemText, } from "@/lib/mental-health/instrument-wording"; +import { useQuestionnaireWording } from "@/hooks/use-questionnaire-wording"; +import type { InstrumentWording } from "@/lib/mental-health/wording/types"; import { UNANSWERED, @@ -57,11 +55,15 @@ import { nextStep, prevStep, } from "./check-in-nav"; -import type { InstrumentId } from "./types"; +import type { CheckInAnswers, InstrumentId } from "./types"; const FUNCTIONAL_SCALE = [0, 1, 2, 3] as const; -type Wording = (key: string) => string; +/** The text a step shows, and the language it is written in. */ +interface StepWording { + text: InstrumentWording; + lang: string; +} export function CheckInWizard({ instrument, @@ -71,16 +73,17 @@ export function CheckInWizard({ isError, }: { instrument: InstrumentId; - /** Submit the completed check-in (+ the optional PHQ-9 follow-up). */ - onSubmit: (items: number[], functionalDifficulty?: number) => void; + /** Submit the completed check-in and the wording it was answered in. */ + onSubmit: (answers: CheckInAnswers) => void; /** Return to the landing (abandons the in-progress check-in). */ onBack: () => void; isPending: boolean; isError: boolean; }) { - const { t, locale } = useTranslations(); + const { t } = useTranslations(); const def = INSTRUMENTS[instrument]; - const wording: Wording = (k) => instrumentText(instrument, k, locale, t); + const { wording, validated, englishOriginal, toggle } = + useCheckInWording(instrument); const steps = checkInSteps(def.itemCount, hasFunctionalItem(instrument)); const [step, setStep] = useState(1); @@ -158,13 +161,11 @@ export function CheckInWizard({ title={t(`mentalHealth.instrument.${def.i18nKey}`)} description={t(`mentalHealth.instrumentDescription.${def.i18nKey}`)} /> - {!hasValidatedItems(instrument, locale) && ( -

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

+ {!validated && ( + )} @@ -236,7 +237,7 @@ export function CheckInWizard({ type="button" className="h-11" onClick={() => - onSubmit(items, functional >= 0 ? functional : undefined) + onSubmit(checkInAnswers(items, functional, englishOriginal)) } disabled={!isComplete(items) || isPending} aria-busy={isPending || undefined} @@ -269,6 +270,86 @@ export function CheckInWizard({ ); } +/** What the review submits; the follow-up only when it was answered. */ +function checkInAnswers( + items: number[], + functional: number, + englishOriginal: boolean, +): CheckInAnswers { + const functionalDifficulty = functional >= 0 ? functional : undefined; + return { items, functionalDifficulty, englishOriginal }; +} + +/** + * The text the wizard shows: the reader's language, or the English original + * when the reader switched to it, which only an unvalidated translation + * offers. + */ +function useCheckInWording(instrument: InstrumentId) { + const { locale } = useTranslations(); + const [showOriginal, setShowOriginal] = useState(false); + const own = useQuestionnaireWording(locale)?.[instrument]; + const wantsOriginal = showOriginal && own?.validated === false; + const english = useQuestionnaireWording(wantsOriginal ? "en" : locale)?.[ + instrument + ]; + // The original counts as shown once its text is on the page. + const englishOriginal = wantsOriginal && english !== undefined; + const wording: StepWording | undefined = englishOriginal + ? { text: english, lang: "en" } + : own && { text: own, lang: locale }; + const toggle = () => setShowOriginal((v) => !v); + return { + wording, + validated: own?.validated ?? true, + englishOriginal, + toggle, + }; +} + +/** The space a step takes while its language's text loads. */ +function StepLoading() { + return ( +
+ + + +
+ ); +} + +/** The note beside an unvalidated translation, and its switch. */ +function UnvalidatedNote({ + showingOriginal, + onToggle, +}: { + showingOriginal: boolean; + onToggle: () => void; +}) { + const { t } = useTranslations(); + return ( +
+

+ {t("mentalHealth.wording.unvalidatedNote")} +

+ + {showingOriginal + ? t("mentalHealth.wording.showTranslation") + : t("mentalHealth.wording.showOriginal")} + +
+ ); +} + function CheckInProgress({ answered, current, @@ -312,24 +393,26 @@ function QuestionStep({ step: number; onFunctional: boolean; selected: number; - wording: Wording; + /** Undefined while the language's text is still loading. */ + wording: StepWording | undefined; headingRef: RefObject; disabled: boolean; onChoose: (value: number) => void; }) { const { t } = useTranslations(); + if (!wording) return ; const def = INSTRUMENTS[instrument]; const index = step - 1; - const stem = onFunctional ? null : stemKey(instrument, index); - const question = onFunctional - ? wording(FUNCTIONAL_TITLE_KEY) - : wording(itemKey(instrument, index)); - const scale = onFunctional ? FUNCTIONAL_SCALE : def.optionOrder; + const { text, lang } = wording; + const functional = onFunctional ? text.functional : undefined; + const stem = onFunctional ? null : stemText(instrument, text, index); + const question = functional ? functional.title : text.items[index]; + const scale = functional ? FUNCTIONAL_SCALE : def.optionOrder; const options = scale.map((value) => ({ value, - label: onFunctional - ? wording(functionalOptionKey(value)) - : wording(optionLabelKey(instrument, index, value)), + label: functional + ? functional.anchors[value] + : anchorText(instrument, text, index, value), })); const questionId = `check-in-question-${instrument}-${step}`; @@ -341,14 +424,15 @@ function QuestionStep({ data-functional={onFunctional || undefined} > {stem && ( -

- {wording(`mentalHealth.stems.${stem}`)} +

+ {stem}

)}

@@ -361,6 +445,7 @@ function QuestionStep({ )} ; selected: number; disabled: boolean; @@ -424,6 +511,7 @@ function AnswerGroup({
; onEdit: (step: number) => void; }) { const { t } = useTranslations(); + if (!wording) return ; + const { text, lang } = wording; const rows = items.map((value, index) => ({ step: index + 1, - question: wording(itemKey(instrument, index)), - answer: wording(optionLabelKey(instrument, index, value)), + question: text.items[index], + answer: anchorText(instrument, text, index, value), + // The answer is questionnaire text; "not answered" is the app's own. + answerLang: lang, value: value as number | null, - stem: stemKey(instrument, index), + stem: stemText(instrument, text, index), })); - if (functionalStep !== null) { + if (functionalStep !== null && text.functional) { + const answered = functional >= 0; rows.push({ step: functionalStep, - question: wording(FUNCTIONAL_TITLE_KEY), - answer: - functional >= 0 - ? wording(functionalOptionKey(functional)) - : t("mentalHealth.review.notAnswered"), + question: text.functional.title, + answer: answered + ? text.functional.anchors[functional] + : t("mentalHealth.review.notAnswered"), + answerLang: answered ? lang : "", value: null, stem: null, }); @@ -515,12 +608,14 @@ function ReviewStep({ data-value={row.value ?? undefined} > {row.stem && row.stem !== rows[i - 1]?.stem && ( -

- {wording(`mentalHealth.stems.${row.stem}`)} +

+ {row.stem}

)}
- {row.question} + + {row.question} +
- {row.answer} + {row.answer} {row.value !== null && ( {" "} diff --git a/src/components/mental-health/instrument-card.tsx b/src/components/mental-health/instrument-card.tsx index 61f66c6c9..47d49e57b 100644 --- a/src/components/mental-health/instrument-card.tsx +++ b/src/components/mental-health/instrument-card.tsx @@ -14,8 +14,9 @@ * v1.27.9 — the card generalises over the four-instrument registry and * carries the instrument's required attribution/licence line (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 + * the SCI, the Pfizer grant for PHQ/GAD). A language whose text is the + * app's own translation additionally gets the note that it has not been + * validated. 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. @@ -23,7 +24,7 @@ * v1.27.24 — the attribution left the space UNDER the Start button (which * crowded the action) for a discreet info affordance at the top-right of * the card header, at the heading's height. A single icon button opens a - * popover carrying the verbatim citation (+ the "validated in English" + * popover carrying the verbatim citation (+ the unvalidated-translation * note where it applies), so the Start button stands alone with clean * space below it. The citation still renders verbatim in full view on the * result and detail surfaces; here it is one tap away. The trigger is a @@ -48,10 +49,8 @@ import { useDisplayTimezone, } from "@/lib/i18n/context"; import { relativeCalendarDate } from "@/lib/i18n/relative-time"; -import { - INSTRUMENTS, - hasValidatedItems, -} from "@/lib/mental-health/instruments"; +import { INSTRUMENTS } from "@/lib/mental-health/instruments"; +import { useQuestionnaireWording } from "@/hooks/use-questionnaire-wording"; import type { AssessmentRow, InstrumentId } from "./types"; @@ -83,6 +82,8 @@ export function InstrumentCard({ const displayTz = useDisplayTimezone(); const def = INSTRUMENTS[instrument]; const key = def.i18nKey; + const unvalidated = + useQuestionnaireWording(locale)?.[instrument].validated === false; const title = t(`mentalHealth.instrument.${key}`); return ( @@ -116,9 +117,7 @@ export function InstrumentCard({ className="text-muted-foreground mt-1 text-xs leading-snug" data-slot="instrument-card-attribution" > - {!hasValidatedItems(instrument, locale) && ( - <>{t("mentalHealth.validatedInEnglishNote")} - )} + {unvalidated && <>{t("mentalHealth.wording.unvalidatedNote")} } {def.attribution}

diff --git a/src/components/mental-health/mental-wellbeing.tsx b/src/components/mental-health/mental-wellbeing.tsx index a51b548ef..d275f31c3 100644 --- a/src/components/mental-health/mental-wellbeing.tsx +++ b/src/components/mental-health/mental-wellbeing.tsx @@ -46,6 +46,7 @@ import { InstrumentDetail } from "./instrument-detail"; import type { AssessmentRow, AssessmentTrendDto, + CheckInAnswers, CreateResponse, InstrumentId, Phase, @@ -133,16 +134,9 @@ export function MentalWellbeing() { setPhase("choose"); } - // v1.27.8 — the PHQ-9 functional-impairment follow-up returned as the - // wizard's regular last question (optional, unscored); the value rides - // the existing `functionalDifficulty` field when the user answered it. - function submit(items: number[], functionalDifficulty?: number) { - mutation.mutate({ - instrument, - items, - locale, - ...(functionalDifficulty !== undefined ? { functionalDifficulty } : {}), - }); + // The server records the wording from `locale` and `englishOriginal`. + function submit(answers: CheckInAnswers) { + mutation.mutate({ instrument, locale, ...answers }); } return ( diff --git a/src/components/mental-health/types.ts b/src/components/mental-health/types.ts index b5b679876..48d16c9f3 100644 --- a/src/components/mental-health/types.ts +++ b/src/components/mental-health/types.ts @@ -9,16 +9,28 @@ * them for the history's expandable answer breakdown. */ import type { InstrumentId } from "@/lib/mental-health/instruments"; +import type { AssessmentWording } from "@/lib/mental-health/wording/types"; export type { InstrumentId }; export type Phase = "choose" | "form" | "result"; +/** What a completed check-in submits. */ +export interface CheckInAnswers { + items: number[]; + /** The unscored PHQ-9 follow-up, when it was answered. */ + functionalDifficulty?: number; + /** The reader answered in the English original of their language's text. */ + englishOriginal: boolean; +} + export interface AssessmentRow { id: string; instrument: InstrumentId; - /** Locale of the validated wording presented — re-derives crisis resources. */ + /** The reader's app language — re-derives crisis resources. */ locale: string; + /** Which text the answers were given in; null before v1.46 recorded it. */ + wording: AssessmentWording | null; totalScore: number; severityBand: string; item9Flagged: boolean; diff --git a/src/hooks/use-questionnaire-wording.ts b/src/hooks/use-questionnaire-wording.ts new file mode 100644 index 000000000..e89c34ec3 --- /dev/null +++ b/src/hooks/use-questionnaire-wording.ts @@ -0,0 +1,25 @@ +/** + * The questionnaire text in one language, loaded as its own chunk the first + * time a questionnaire surface asks for it and kept for the session. Static + * data: it never goes stale and is never refetched. Undefined while the + * chunk is on its way. + */ +import { useQuery } from "@tanstack/react-query"; + +import { wordingLocale } from "@/lib/mental-health/instrument-wording"; +import { loadLocaleWording } from "@/lib/mental-health/wording/load"; +import type { LocaleWording } from "@/lib/mental-health/wording/types"; +import { queryKeys } from "@/lib/query-keys"; + +export function useQuestionnaireWording( + locale: string, +): LocaleWording | undefined { + const resolved = wordingLocale(locale); + const { data } = useQuery({ + queryKey: queryKeys.mentalHealthWording(resolved), + queryFn: () => loadLocaleWording(resolved), + staleTime: Infinity, + gcTime: Infinity, + }); + return data; +} diff --git a/src/lib/__tests__/i18n-locale-integrity.test.ts b/src/lib/__tests__/i18n-locale-integrity.test.ts index 4ef553f79..b00ef844d 100644 --- a/src/lib/__tests__/i18n-locale-integrity.test.ts +++ b/src/lib/__tests__/i18n-locale-integrity.test.ts @@ -3,7 +3,6 @@ import { readFileSync, readdirSync } from "node:fs"; import { join } from "node:path"; import { requireFloor } from "@/__tests__/helpers/source-files"; -import { englishOnlyWordingKeys } from "@/lib/mental-health/instrument-wording"; const ROOT = join(__dirname, "../../.."); const MESSAGES_DIR = join(ROOT, "messages"); @@ -217,25 +216,45 @@ describe("i18n locale file integrity", () => { enArr.forEach((k) => enKeys.add(k)); otherArr.forEach((k) => otherKeys.add(k)); - // The one sanctioned gap: questionnaire wording of an instrument whose - // validated set excludes this language. Those keys must be ABSENT — - // the surface shows the English source held by instrument-wording, so a - // translation here would be a self-made questionnaire waiting for a - // renderer to pick it up. The list is exact in both directions: a - // missing key outside it is drift, a carried key inside it fails too. - const englishOnly = englishOnlyWordingKeys(locale); - const englishOnlySet = new Set(englishOnly); - const onlyInEn = [...enKeys] - .filter((k) => !otherKeys.has(k) && !englishOnlySet.has(k)) - .sort(); + const onlyInEn = [...enKeys].filter((k) => !otherKeys.has(k)).sort(); const onlyInOther = [...otherKeys].filter((k) => !enKeys.has(k)).sort(); - const carriedEnglishOnly = englishOnly.filter((k) => otherKeys.has(k)); expect( - { onlyInEn, onlyInOther, carriedEnglishOnly }, - `Locale files drifted apart — every key in en.json must exist in ${locale}.json and vice versa, ` + - `except validated questionnaire wording, which ${locale}.json must not carry for an instrument not validated in it.`, - ).toEqual({ onlyInEn: [], onlyInOther: [], carriedEnglishOnly: [] }); + { onlyInEn, onlyInOther }, + `Locale files drifted apart — every key in en.json must exist in ${locale}.json and vice versa.`, + ).toEqual({ onlyInEn: [], onlyInOther: [] }); + }, + ); + + // Questionnaire wording lives in code with its source + // (`src/lib/mental-health/wording/`), never in a bundle: a translator + // working through the bundles must not see an item, an answer, a stem or + // the PHQ-9 follow-up, let alone reword one. The app's own copy around the + // questionnaires (`mentalHealth.instrument.*`, `mentalHealth.wording.*`, + // the notes and buttons) stays here. + const QUESTIONNAIRE_SUBTREES = [ + "mentalHealth.items", + "mentalHealth.options", + "mentalHealth.gad7Options", + "mentalHealth.who5Options", + "mentalHealth.sciOptions", + "mentalHealth.stems", + "mentalHealth.functional", + "mentalHealth.functionalTitle", + ]; + + it.each(ALL_LOCALES)( + "$locale carries no questionnaire wording", + ({ path }) => { + const keys: string[] = []; + flatten(JSON.parse(readFileSync(path, "utf8")), "", keys); + expect(keys.length).toBeGreaterThan(3000); + const carried = keys.filter((key) => + QUESTIONNAIRE_SUBTREES.some( + (tree) => key === tree || key.startsWith(`${tree}.`), + ), + ); + expect(carried).toEqual([]); }, ); diff --git a/src/lib/mental-health/__tests__/instrument-wording.test.ts b/src/lib/mental-health/__tests__/instrument-wording.test.ts index 9cf9a6fba..20023b924 100644 --- a/src/lib/mental-health/__tests__/instrument-wording.test.ts +++ b/src/lib/mental-health/__tests__/instrument-wording.test.ts @@ -1,209 +1,279 @@ -import { readFileSync, readdirSync } from "node:fs"; -import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import { readSourceFolder } from "@/__tests__/helpers/source-files"; +import { locales, type Locale } from "@/lib/i18n/config"; +import type { InstrumentWording } from "../wording/types"; -import { INSTRUMENTS, INSTRUMENT_ORDER } from "../instruments"; +import { INSTRUMENT_ORDER, isSafetyFlagged } from "../instruments"; +import { anchorText, stemText } from "../instrument-wording"; +import { WORDING } from "../wording"; +import { loadLocaleWording } from "../wording/load"; import { - SOURCE_WORDING, - englishOnlyWordingKeys, - instrumentText, - instrumentWordingKeys, -} from "../instrument-wording"; + isValidatedWording, + presentedWording, + wordingFor, +} from "../wording/resolve"; /** - * Validated questionnaire wording is never freely translated. A language - * outside an instrument's validated set sees the English source, and its - * bundle carries none of that instrument's wording, so no renderer can pick a - * self-made translation up by accident. + * How a surface reads questionnaire text, and which wording an + * administration records. Then spot checks of the validated entries against + * the published forms named in their `source`: the places a transcription + * slips (a column header split over lines, an anchor that differs between + * two forms of one language, a form of address). */ -const ROOT = join(__dirname, "../../../.."); -const MESSAGES = join(ROOT, "messages"); -const LOCALES = readdirSync(MESSAGES) - .filter((f) => f.endsWith(".json")) - .map((f) => f.replace(/\.json$/, "")); - -function flatten(obj: unknown, prefix = "", out = new Map()) { - if (obj && typeof obj === "object") { - for (const [k, v] of Object.entries(obj as Record)) { - const key = prefix ? `${prefix}.${k}` : k; - if (typeof v === "string") out.set(key, v); - else flatten(v, key, out); - } - } - return out; -} - -function bundle(locale: string): Map { - return flatten( - JSON.parse(readFileSync(join(MESSAGES, `${locale}.json`), "utf8")), - ); -} - -describe("instrument wording", () => { - it("holds the English source of every instrument's wording, word for word as en.json", () => { - const en = bundle("en"); - const expected = new Set(INSTRUMENT_ORDER.flatMap(instrumentWordingKeys)); - // PHQ-9 9 + GAD-7 7 + WHO-5 5 + SCI 8 items alone are 29 keys. - expect(expected.size).toBeGreaterThan(29); - expect(Object.keys(SOURCE_WORDING).sort()).toEqual([...expected].sort()); - for (const [key, text] of Object.entries(SOURCE_WORDING)) { - expect(en.get(key), key).toBe(text); - } +describe("reading the wording", () => { + it("reads the reader's language, and English for a language the app does not ship", () => { + expect(wordingFor("PHQ9", "nl")).toBe(WORDING.PHQ9.nl); + expect(wordingFor("PHQ9", "pt-br")).toBe(WORDING.PHQ9["pt-BR"]); + expect(wordingFor("PHQ9", "de-AT")).toBe(WORDING.PHQ9.de); + expect(wordingFor("SCI", "ja")).toBe(WORDING.SCI.en); }); - it("names PHQ-9 item 9 English-only for the languages its wording is not validated in", () => { - for (const locale of ["nl", "pt-BR"]) { - const keys = englishOnlyWordingKeys(locale); - expect(keys).toContain("mentalHealth.items.phq9.9"); - expect(keys).toContain("mentalHealth.options.3"); - expect(keys).toContain("mentalHealth.functionalTitle"); - expect(keys).toContain("mentalHealth.who5Options.5"); - expect(keys).toContain("mentalHealth.stems.who5.period"); - expect(keys).toContain("mentalHealth.items.sci.1"); - } - // German is validated for the PHQ family and the WHO-5, not the SCI. - const de = englishOnlyWordingKeys("de"); - expect(de).not.toContain("mentalHealth.items.phq9.9"); - expect(de).toContain("mentalHealth.items.sci.1"); - expect(de).toContain("mentalHealth.sciOptions.duration.4"); - expect(englishOnlyWordingKeys("en")).toEqual([]); + it("resolves an answer label through the item's anchor group", () => { + const sci = WORDING.SCI.en; + expect(anchorText("SCI", sci, 0, 4)).toBe("0–15 min"); + expect(anchorText("SCI", sci, 2, 0)).toBe("5–7"); + expect(anchorText("SCI", sci, 3, 2)).toBe("Average"); + expect(anchorText("SCI", sci, 6, 1)).toBe("Much"); + expect(anchorText("SCI", sci, 7, 3)).toBe("1–2 months"); + expect(anchorText("PHQ9", WORDING.PHQ9.en, 8, 3)).toBe("Nearly every day"); }); - it.each(LOCALES.filter((l) => l !== "en"))( - "messages/%s.json carries no wording of an instrument it is not validated for", - (locale) => { - const flat = bundle(locale); - expect(flat.size).toBeGreaterThan(3000); - const englishOnly = englishOnlyWordingKeys(locale); - expect(englishOnly.length).toBeGreaterThan(0); - expect(englishOnly.filter((key) => flat.has(key))).toEqual([]); - }, - ); - - it("resolves the English source, not the bundle, outside the validated set", () => { - const drifted = (key: string) => `TRANSLATED(${key})`; - for (const locale of ["nl", "pt-BR"]) { - expect( - instrumentText("PHQ9", "mentalHealth.items.phq9.9", locale, drifted), - ).toBe( - "Thoughts that you would be better off dead, or of hurting yourself in some way", - ); - expect( - instrumentText("GAD7", "mentalHealth.options.0", locale, drifted), - ).toBe("Not at all"); - } - expect( - instrumentText("SCI", "mentalHealth.items.sci.1", "fr", drifted), - ).toBe(SOURCE_WORDING["mentalHealth.items.sci.1"]); - }); - - it("resolves the bundle's validated translation inside the validated set", () => { - const t = (key: string) => `VALIDATED(${key})`; - expect(instrumentText("PHQ9", "mentalHealth.items.phq9.9", "de", t)).toBe( - "VALIDATED(mentalHealth.items.phq9.9)", + it("resolves the stem the form prints above an item", () => { + expect(stemText("SCI", WORDING.SCI.en, 0)).toBe( + "Thinking about a typical night in the last month …", ); - expect(instrumentText("SCI", "mentalHealth.items.sci.1", "en", t)).toBe( - "VALIDATED(mentalHealth.items.sci.1)", + expect(stemText("SCI", WORDING.SCI.en, 7)).toBe("Finally …"); + expect(stemText("WHO5", WORDING.WHO5.nl, 4)).toBe( + "Gedurende de laatste twee weken", ); }); - - it("every validated locale's bundle carries a complete set for its instruments", () => { - for (const locale of LOCALES) { - const flat = bundle(locale); - for (const id of INSTRUMENT_ORDER) { - if (!INSTRUMENTS[id].validatedItemLocales.includes(locale)) continue; - const missing = instrumentWordingKeys(id).filter((k) => !flat.has(k)); - expect(missing, `${locale} ${id}`).toEqual([]); - } - } - }); }); -describe("validated languages carry the published wording", () => { - // Spot checks against the published forms named at the locale lists in - // instruments.ts: the places where the bundles had drifted (an informal - // follow-up question, a reworded anchor, a modernised spelling, the - // Italian GAD-7 anchor that differs from the Italian PHQ-9 one). - const PUBLISHED: Record> = { - de: { - "mentalHealth.stems.phq9.period": - "Wie oft fühlten Sie sich im Verlauf der letzten 2 Wochen durch die folgenden Beschwerden beeinträchtigt?", - "mentalHealth.items.phq9.7": - "Schwierigkeiten, sich auf etwas zu konzentrieren, z. B. beim Zeitungslesen oder Fernsehen", - "mentalHealth.items.gad7.5": - "Rastlosigkeit, so dass Stillsitzen schwer fällt", - "mentalHealth.items.gad7.7": - "Gefühl der Angst, so als würde etwas Schlimmes passieren", - "mentalHealth.functional.2": "Stark erschwert", - "mentalHealth.who5Options.5": "Die ganze Zeit", +describe("one language's chunk, as the client loads it", () => { + it.each(locales)( + "%s carries that language's file of every instrument", + async (locale) => { + const chunk = await loadLocaleWording(locale); + for (const id of INSTRUMENT_ORDER) { + expect(chunk[id], `${id} ${locale}`).toBe(WORDING[id][locale]); + } + expect(Object.keys(chunk).sort()).toEqual([...INSTRUMENT_ORDER].sort()); }, - es: { - "mentalHealth.options.3": "Casi cada día", - "mentalHealth.items.phq9.9": - "Pensamientos de que estaría mejor muerto/a o de querer hacerse daño de algún modo", - "mentalHealth.items.gad7.1": - "Sentirse nervioso/a, angustiado/a o muy tenso/a", - }, - it: { - "mentalHealth.options.2": "Per più della metà dei giorni", - "mentalHealth.gad7Options.2": "Per oltre la metà dei giorni", - "mentalHealth.items.phq9.2": "Sentirsi giù, triste o disperato/a", - }, - pl: { - "mentalHealth.options.0": "Wcale nie dokuczały", - "mentalHealth.items.gad7.1": - "Czuł(a) się Pan(i) podenerwowany(a), niespokojny(a), mocno spięty(a)", - }, - fr: { - "mentalHealth.gad7Options.2": "Plus de la moitié du temps", - "mentalHealth.items.gad7.1": - "Un sentiment de nervosité, d’anxiété ou de tension", - }, - }; + ); +}); - it.each(Object.keys(PUBLISHED))("%s", (locale) => { - const flat = bundle(locale); - for (const [key, text] of Object.entries(PUBLISHED[locale])) { - expect(flat.get(key), `${locale} ${key}`).toBe(text); - } +describe("the wording an administration records", () => { + it("records a validated language's text as validated", () => { + expect(presentedWording("PHQ9", "nl", false)).toBe("validated"); + expect(presentedWording("WHO5", "ko", false)).toBe("validated"); + expect(presentedWording("SCI", "pl", false)).toBe("validated"); }); - it("keeps the German follow-up question in the published formal address", () => { - const title = bundle("de").get("mentalHealth.functionalTitle") ?? ""; + it("records HealthLog's own translation as an unvalidated translation", () => { + expect(presentedWording("SCI", "nl", false)).toBe( + "unvalidated_translation", + ); + expect(presentedWording("SCI", "pt-br", false)).toBe( + "unvalidated_translation", + ); + expect(presentedWording("WHO5", "pt-BR", false)).toBe( + "unvalidated_translation", + ); + }); + + it("records the English original when the reader chose it", () => { + expect(presentedWording("SCI", "nl", true)).toBe("original_en"); + expect(presentedWording("PHQ9", "de", true)).toBe("original_en"); + }); + + it("records English for an English reader as validated, whatever the switch says", () => { + expect(presentedWording("SCI", "en", false)).toBe("validated"); + expect(presentedWording("SCI", "en-GB", true)).toBe("validated"); + }); + + it("records the English original for a language the app does not ship", () => { + expect(presentedWording("PHQ9", "ja", false)).toBe("original_en"); + }); + + it("leaves the item-9 crisis flag to the answers alone", () => { + // The flag reads the ninth answer; no wording or language enters it. + const answers = [0, 0, 0, 0, 0, 0, 0, 0, 1]; + for (const locale of Object.keys(WORDING.PHQ9) as Locale[]) { + expect(WORDING.PHQ9[locale].items).toHaveLength(answers.length); + } + expect(isSafetyFlagged("PHQ9", answers)).toBe(true); + expect(isSafetyFlagged("PHQ9", [...answers.slice(0, 8), 0])).toBe(false); + }); +}); + +describe("validated entries carry the published wording", () => { + // [instrument, locale, path into the entry, published text] + const PUBLISHED: ReadonlyArray< + readonly [ + keyof typeof WORDING, + Locale, + (w: InstrumentWording) => unknown, + string, + ] + > = [ + [ + "PHQ9", + "de", + (w) => w.stems.period, + "Wie oft fühlten Sie sich im Verlauf der letzten 2 Wochen durch die folgenden Beschwerden beeinträchtigt?", + ], + [ + "PHQ9", + "de", + (w) => w.items[6], + "Schwierigkeiten, sich auf etwas zu konzentrieren, z. B. beim Zeitungslesen oder Fernsehen", + ], + ["PHQ9", "de", (w) => w.functional?.anchors[2], "Stark erschwert"], + [ + "GAD7", + "de", + (w) => w.items[4], + "Rastlosigkeit, so dass Stillsitzen schwer fällt", + ], + [ + "GAD7", + "de", + (w) => w.items[6], + "Gefühl der Angst, so als würde etwas Schlimmes passieren", + ], + ["WHO5", "de", (w) => w.anchors.all[5], "Die ganze Zeit"], + ["PHQ9", "es", (w) => w.anchors.all[3], "Casi cada día"], + [ + "PHQ9", + "es", + (w) => w.items[8], + "Pensamientos de que estaría mejor muerto/a o de querer hacerse daño de algún modo", + ], + [ + "GAD7", + "es", + (w) => w.items[0], + "Sentirse nervioso/a, angustiado/a o muy tenso/a", + ], + ["PHQ9", "it", (w) => w.anchors.all[2], "Per più della metà dei giorni"], + ["GAD7", "it", (w) => w.anchors.all[2], "Per oltre la metà dei giorni"], + ["PHQ9", "it", (w) => w.items[1], "Sentirsi giù, triste o disperato/a"], + ["PHQ9", "pl", (w) => w.anchors.all[0], "Wcale nie dokuczały"], + [ + "GAD7", + "pl", + (w) => w.items[0], + "Czuł(a) się Pan(i) podenerwowany(a), niespokojny(a), mocno spięty(a)", + ], + ["GAD7", "fr", (w) => w.anchors.all[2], "Plus de la moitié du temps"], + [ + "GAD7", + "fr", + (w) => w.items[0], + "Un sentiment de nervosité, d’anxiété ou de tension", + ], + // Pfizer "French for France". + ["PHQ9", "fr", (w) => w.anchors.all[0], "Jamais"], + [ + "PHQ9", + "fr", + (w) => w.items[8], + "Penser qu’il vaudrait mieux mourir ou envisager de vous faire du mal d’une manière ou d’une autre", + ], + ["PHQ9", "fr", (w) => w.functional?.anchors[3], "Extrêmement difficile(s)"], + // Pfizer "Dutch for the Netherlands": the header split over two lines. + ["PHQ9", "nl", (w) => w.anchors.all[1], "Verscheidene dagen"], + [ + "PHQ9", + "nl", + (w) => w.items[8], + "De gedachte dat u beter dood zou kunnen zijn of de gedachte uzelf op een bepaalde manier pijn te doen", + ], + ["GAD7", "nl", (w) => w.anchors.all[0], "Helemaal niet"], + [ + "GAD7", + "nl", + (w) => w.items[1], + "Niet kunnen stoppen met u zorgen maken of dit niet kunnen beteugelen", + ], + // Pfizer "Korean for Korea": headers printed over three or four lines. + ["PHQ9", "ko", (w) => w.anchors.all[2], "7 일 이상 방해 받았다"], + [ + "PHQ9", + "ko", + (w) => w.items[8], + "자신이 죽는 것이 더 낫다고 생각하거나 어떤 식으로든 자신을 해칠 것이라고 생각함", + ], + ["GAD7", "ko", (w) => w.anchors.all[2], "2 주 중 절반 이상 방해 받았다"], + [ + "GAD7", + "ko", + (w) => w.stems.period, + "지난 2 주 동안 당신은 다음의 문제들로 인해서 얼마나 자주 방해를 받았습니까?", + ], + // Pfizer "Portuguese for Brazil", with its own spelling. + [ + "PHQ9", + "pt-BR", + (w) => w.stems.period, + "Durante as últimas 2 semanas, com que freqüência você foi incomodado/a por qualquer um dos problemas abaixo?", + ], + [ + "PHQ9", + "pt-BR", + (w) => w.items[8], + "Pensar em se ferir de alguma maneira ou que seria melhor estar morto/a", + ], + ["GAD7", "pt-BR", (w) => w.anchors.all[3], "Quase todos os dias"], + // PRU Dutch WHO-5 (1999) and the Korean WHO-5 (Kim et al. 2010). + ["WHO5", "nl", (w) => w.anchors.all[5], "Constant"], + ["WHO5", "nl", (w) => w.items[2], "Ik voelde me actief en doelbewust"], + ["WHO5", "ko", (w) => w.anchors.all[0], "전혀 그렇지 않다"], + [ + "WHO5", + "ko", + (w) => w.items[3], + "나는 자고 일어나면 상쾌하고 푹 쉬었다는 느낌이 든다", + ], + // SCI-PL (Bidzan-Wiącek et al. 2026), its "45-60 min" kept. + ["SCI", "pl", (w) => w.anchors.duration[1], "45-60 min"], + ["SCI", "pl", (w) => w.anchors.impact[2], "Lekko"], + ]; + + it.each( + PUBLISHED.map(([id, locale, read, text]) => ({ id, locale, read, text })), + )("$id / $locale: $text", ({ id, locale, read, text }) => { + const wording = WORDING[id][locale]; + expect(wording.validated).toBe(true); + expect(read(wording)).toBe(text); + }); + + it("keeps the German PHQ-9 follow-up question in the published formal address", () => { + const title = WORDING.PHQ9.de.functional?.title ?? ""; expect(title).toMatch(/^Wenn eines oder mehrere der bisher/); expect(title).not.toMatch(/\b(?:du|dich|dir|dein\w*)\b/i); }); -}); -describe("no surface renders instrument wording through a bare t()", () => { - // Item text, answer labels and stems must go through `instrumentText`. A - // bare `t()` on one of these keys reads the bundle whatever the language. - const BARE = [ - /\bt\(\s*`mentalHealth\.items\./, - /\bt\(\s*`mentalHealth\.stems\./, - /\bt\(\s*`mentalHealth\.functional\./, - /\bt\(\s*"mentalHealth\.functionalTitle"/, - /\bt\(\s*optionLabelKey\(/, - /\bt\(\s*itemKey\(/, - ]; - - it("finds the item surfaces and no bare lookup anywhere", () => { - const files = readSourceFolder("src", { - floor: 500, - include: (rel) => - !rel.startsWith("generated/") && - !rel.includes("__tests__/") && - !/\.test\.tsx?$/.test(rel), - }); - const users = files.filter(([, text]) => text.includes("instrumentText(")); - // The wizard and the history breakdown, plus the module itself. - expect(users.length).toBeGreaterThanOrEqual(3); - const offenders = files - .filter(([, text]) => BARE.some((re) => re.test(text))) - .map(([rel]) => rel); - expect(offenders).toEqual([]); + it("names which cells carry HealthLog's own translation", () => { + const unvalidated = Object.entries(WORDING).flatMap(([id, byLocale]) => + Object.entries(byLocale) + .filter(([, w]) => !w.validated) + .map(([locale]) => `${id}.${locale}`), + ); + expect(unvalidated.sort()).toEqual( + [ + "SCI.de", + "SCI.es", + "SCI.fr", + "SCI.it", + "SCI.ko", + "SCI.nl", + "SCI.pt-BR", + "WHO5.pt-BR", + ].sort(), + ); + expect(isValidatedWording("SCI", "de")).toBe(false); + expect(isValidatedWording("PHQ9", "ko")).toBe(true); }); }); diff --git a/src/lib/mental-health/__tests__/instruments.test.ts b/src/lib/mental-health/__tests__/instruments.test.ts index e585b8e1f..f794e619e 100644 --- a/src/lib/mental-health/__tests__/instruments.test.ts +++ b/src/lib/mental-health/__tests__/instruments.test.ts @@ -3,13 +3,10 @@ import { describe, it, expect } from "vitest"; import { INSTRUMENTS, INSTRUMENT_MEASUREMENT_TYPE, - hasValidatedItems, isSafetyFlagged, needsFollowUp, - optionLabelKey, scoreTotal, severityBand, - stemKey, } from "../instruments"; /** @@ -164,63 +161,40 @@ describe("mental-health instruments", () => { 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("mentalHealth.options.2"); - expect(optionLabelKey("GAD7", 0, 2)).toBe("mentalHealth.gad7Options.2"); - expect(optionLabelKey("WHO5", 4, 5)).toBe("mentalHealth.who5Options.5"); - // SCI: items 1–2 duration, 3 nights, 4 quality, 5–7 impact, 8 problem duration. - expect(optionLabelKey("SCI", 0, 4)).toBe( - "mentalHealth.sciOptions.duration.4", - ); - expect(optionLabelKey("SCI", 2, 0)).toBe( - "mentalHealth.sciOptions.nights.0", - ); - expect(optionLabelKey("SCI", 3, 2)).toBe( - "mentalHealth.sciOptions.quality.2", - ); - expect(optionLabelKey("SCI", 6, 1)).toBe( - "mentalHealth.sciOptions.impact.1", - ); - expect(optionLabelKey("SCI", 7, 3)).toBe( - "mentalHealth.sciOptions.problemDuration.3", - ); + it("names the SCI's item-specific anchor groups, one per item", () => { + // Items 1–2 duration, 3 nights, 4 quality, 5–7 impact, 8 problem duration. + expect(INSTRUMENTS.SCI.optionGroups).toEqual([ + "duration", + "duration", + "nights", + "quality", + "impact", + "impact", + "impact", + "problemDuration", + ]); + for (const id of ["PHQ9", "GAD7", "WHO5"] as const) { + expect(INSTRUMENTS[id].optionGroups).toBeUndefined(); + } }); it("maps every item to its form's stem (PHQ/GAD/WHO-5 one each; SCI three sections)", () => { - 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)).toBe("phq9.period"); - expect(stemKey("PHQ9", 8)).toBe("phq9.period"); - expect(stemKey("GAD7", 6)).toBe("gad7.period"); - for (const def of Object.values(INSTRUMENTS)) { - expect(def.stemKeys).toHaveLength(def.itemCount); - } - }); - - it("declares validated item locales honestly, per instrument", () => { - // Each listed language reproduces a published version word for word - // (sources at the lists in instruments.ts). - for (const locale of ["de", "en", "es", "it", "pl"]) { - expect(hasValidatedItems("PHQ9", locale)).toBe(true); - } - for (const locale of ["de", "en", "es", "fr", "it", "pl"]) { - expect(hasValidatedItems("GAD7", locale)).toBe(true); - expect(hasValidatedItems("WHO5", locale)).toBe(true); - } - // No obtainable French-for-France PHQ-9; no checkable Korean forms. - expect(hasValidatedItems("PHQ9", "fr")).toBe(false); + expect(INSTRUMENTS.SCI.stemIds).toEqual([ + "night", + "night", + "night", + "night", + "impact", + "impact", + "impact", + "finally", + ]); for (const id of ["PHQ9", "GAD7", "WHO5"] as const) { - expect(hasValidatedItems(id, "ko")).toBe(false); - expect(hasValidatedItems(id, "nl")).toBe(false); - expect(hasValidatedItems(id, "pt-BR")).toBe(false); + expect(new Set(INSTRUMENTS[id].stemIds)).toEqual(new Set(["period"])); + } + for (const def of Object.values(INSTRUMENTS)) { + expect(def.stemIds).toHaveLength(def.itemCount); } - 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", () => { diff --git a/src/lib/mental-health/__tests__/wording-registry.test.ts b/src/lib/mental-health/__tests__/wording-registry.test.ts new file mode 100644 index 000000000..5b56f5c69 --- /dev/null +++ b/src/lib/mental-health/__tests__/wording-registry.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from "vitest"; + +import { locales } from "@/lib/i18n/config"; + +import { INSTRUMENTS, INSTRUMENT_ORDER } from "../instruments"; +import { hasFunctionalItem } from "../instrument-wording"; +import { WORDING } from "../wording"; +import { SINGLE_ANCHOR_GROUP } from "../wording/types"; + +/** + * Every questionnaire has wording in every app language, and every entry says + * what it is: a validated translation with its source, or a translation + * written for HealthLog. The shape is checked against the scoring contract in + * `instruments.ts`, so an entry cannot carry an item too few or an answer + * bound to the wrong score. + */ + +const CELLS = INSTRUMENT_ORDER.flatMap((id) => + locales.map((locale) => ({ id, locale, wording: WORDING[id][locale] })), +); + +const sorted = (values: Iterable) => + [...values].map(String).sort(); + +describe("questionnaire wording registry", () => { + it("covers every instrument in every app language, and nothing else", () => { + expect(CELLS).toHaveLength(INSTRUMENT_ORDER.length * locales.length); + for (const id of INSTRUMENT_ORDER) { + expect(sorted(Object.keys(WORDING[id]))).toEqual(sorted(locales)); + } + expect(sorted(Object.keys(WORDING))).toEqual(sorted(INSTRUMENT_ORDER)); + }); + + it("holds the English original of every instrument as validated", () => { + for (const id of INSTRUMENT_ORDER) { + expect(WORDING[id].en.validated, id).toBe(true); + } + }); + + it.each(CELLS)( + "$id / $locale declares validated with a source, or not validated", + ({ wording }) => { + if (wording.validated) { + // A citation that names where the text was read: a link or a DOI, + // and a year. + expect(wording.source).toMatch(/https?:\/\/|doi:/); + expect(wording.source).toMatch(/\b(19|20)\d{2}\b|phqscreeners/); + } else { + expect(wording).not.toHaveProperty("source"); + } + }, + ); + + it.each(CELLS)( + "$id / $locale carries every item, stem and answer the scoring expects", + ({ id, wording }) => { + const def = INSTRUMENTS[id]; + const clean = (text: string) => { + expect(text.trim()).toBe(text); + expect(text.length).toBeGreaterThan(0); + }; + + expect(wording.items).toHaveLength(def.itemCount); + wording.items.forEach(clean); + + // A stem for every stem the form prints, and none it does not. + expect(sorted(Object.keys(wording.stems))).toEqual( + sorted(new Set(def.stemIds)), + ); + Object.values(wording.stems).forEach(clean); + + // One anchor set per group, each keyed by exactly the scores the item + // can take, with distinct labels. + const groups = new Set(def.optionGroups ?? [SINGLE_ANCHOR_GROUP]); + expect(sorted(Object.keys(wording.anchors))).toEqual(sorted(groups)); + for (const group of groups) { + const set = wording.anchors[group]; + expect(sorted(Object.keys(set)), group).toEqual( + sorted(def.optionOrder), + ); + const labels = def.optionOrder.map((value) => set[value]); + labels.forEach(clean); + expect(new Set(labels).size, group).toBe(labels.length); + } + + if (hasFunctionalItem(id)) { + expect(wording.functional).toBeDefined(); + clean(wording.functional?.title ?? ""); + expect(sorted(Object.keys(wording.functional?.anchors ?? {}))).toEqual([ + "0", + "1", + "2", + "3", + ]); + } else { + expect(wording.functional).toBeUndefined(); + } + }, + ); + + it("keeps PHQ-9 item 9 the ninth item in every language", () => { + // The crisis signpost keys on the item's position; a language that + // reordered or dropped an item would move it. + expect(INSTRUMENTS.PHQ9.safetyItemIndex).toBe(8); + for (const locale of locales) { + expect(WORDING.PHQ9[locale].items).toHaveLength(9); + } + expect(WORDING.PHQ9.en.items[8]).toBe( + "Thoughts that you would be better off dead, or of hurting yourself in some way", + ); + }); + + it("binds every translation's answers to the English scores", () => { + // The worst score sits at the same end in every language: the anchors + // for score 0 of the PHQ-9 and of the WHO-5 are the "not at all" / + // "at no time" ends, which the published spot checks pin per language. + expect(WORDING.PHQ9.en.anchors.all[0]).toBe("Not at all"); + expect(WORDING.WHO5.en.anchors.all[5]).toBe("All of the time"); + expect(WORDING.SCI.en.anchors.duration[4]).toBe("0–15 min"); + expect(WORDING.SCI.en.anchors.impact[4]).toBe("Not at all"); + }); +}); diff --git a/src/lib/mental-health/instrument-wording.ts b/src/lib/mental-health/instrument-wording.ts index e7741370a..8c7318c46 100644 --- a/src/lib/mental-health/instrument-wording.ts +++ b/src/lib/mental-health/instrument-wording.ts @@ -1,199 +1,56 @@ /** - * The English source wording of every validated questionnaire, and the one - * function every surface resolves item text through. + * How a surface reads questionnaire text out of one language's wording: the + * answer label of an item, the stem above it. Data-free, so a client surface + * imports it without pulling every language in; the text itself comes from + * `./wording/load.ts` on the client and `./wording/resolve.ts` on the server. * - * An app language outside an instrument's `validatedItemLocales` must show the - * instrument as published in English: the items, the answer options, the - * recall stems, and for the PHQ-9 its unscored follow-up question. Changing a - * word in a validated questionnaire changes what it measures, so a freely made - * translation is never an acceptable stand-in, even with a note beside it. - * - * The text is held here rather than looked up in `messages/en.json` because - * the client does not hold the English bundle when it renders another - * language (`load-locale.ts` fetches it only on a key miss), and the items - * must not flash as raw keys or wait on a network round trip. A guard pins - * this map to `messages/en.json` key for key, and pins every bundle of a - * language outside an instrument's validated set to carry none of these keys: - * `instrument-wording.test.ts` and the locale-parity check in - * `i18n-locale-integrity.test.ts`. + * Every instrument has wording in every app language (`./wording/`). Where a + * validated translation is published, that translation is reproduced word for + * word; where none is, a translation written for HealthLog stands in, and the + * surfaces show a note beside it with a one-tap switch to the English + * original. The text never lives in `messages/*.json`, so a translator editing + * the bundles cannot reword an item by accident (`i18n-locale-integrity.test.ts` + * holds every bundle clear of it). */ -import { - INSTRUMENTS, - INSTRUMENT_ORDER, - hasValidatedItems, - optionLabelKey, - stemKey, - type InstrumentId, -} from "./instruments"; +import { localeFromLanguageTag, type Locale } from "@/lib/i18n/config"; -/** English source text, keyed by the i18n key a validated bundle uses. */ -export const SOURCE_WORDING: Readonly> = { - "mentalHealth.items.phq9.1": "Little interest or pleasure in doing things", - "mentalHealth.items.phq9.2": "Feeling down, depressed, or hopeless", - "mentalHealth.items.phq9.3": - "Trouble falling or staying asleep, or sleeping too much", - "mentalHealth.items.phq9.4": "Feeling tired or having little energy", - "mentalHealth.items.phq9.5": "Poor appetite or overeating", - "mentalHealth.items.phq9.6": - "Feeling bad about yourself — or that you are a failure or have let yourself or your family down", - "mentalHealth.items.phq9.7": - "Trouble concentrating on things, such as reading the newspaper or watching television", - "mentalHealth.items.phq9.8": - "Moving or speaking so slowly that other people could have noticed. Or the opposite — being so fidgety or restless that you have been moving around a lot more than usual", - "mentalHealth.items.phq9.9": - "Thoughts that you would be better off dead, or of hurting yourself in some way", - "mentalHealth.items.gad7.1": "Feeling nervous, anxious, or on edge", - "mentalHealth.items.gad7.2": "Not being able to stop or control worrying", - "mentalHealth.items.gad7.3": "Worrying too much about different things", - "mentalHealth.items.gad7.4": "Trouble relaxing", - "mentalHealth.items.gad7.5": "Being so restless that it is hard to sit still", - "mentalHealth.items.gad7.6": "Becoming easily annoyed or irritable", - "mentalHealth.items.gad7.7": - "Feeling afraid as if something awful might happen", - "mentalHealth.items.who5.1": "I have felt cheerful and in good spirits", - "mentalHealth.items.who5.2": "I have felt calm and relaxed", - "mentalHealth.items.who5.3": "I have felt active and vigorous", - "mentalHealth.items.who5.4": "I woke up feeling fresh and rested", - "mentalHealth.items.who5.5": - "My daily life has been filled with things that interest me", - "mentalHealth.items.sci.1": "… how long does it take you to fall asleep?", - "mentalHealth.items.sci.2": - "… if you then wake up during the night … how long are you awake for in total? (add all the wakenings up)", - "mentalHealth.items.sci.3": - "… how many nights a week do you have a problem with your sleep?", - "mentalHealth.items.sci.4": "… how would you rate your sleep quality?", - "mentalHealth.items.sci.5": "… affected your mood, energy, or relationships?", - "mentalHealth.items.sci.6": - "… affected your concentration, productivity, or ability to stay awake?", - "mentalHealth.items.sci.7": "… troubled you in general?", - "mentalHealth.items.sci.8": - "… how long have you had a problem with your sleep?", - "mentalHealth.options.0": "Not at all", - "mentalHealth.options.1": "Several days", - "mentalHealth.options.2": "More than half the days", - "mentalHealth.options.3": "Nearly every day", - "mentalHealth.gad7Options.0": "Not at all", - "mentalHealth.gad7Options.1": "Several days", - "mentalHealth.gad7Options.2": "More than half the days", - "mentalHealth.gad7Options.3": "Nearly every day", - "mentalHealth.functionalTitle": - "If you checked off any problems, how difficult have these problems made it for you to do your work, take care of things at home, or get along with other people?", - "mentalHealth.functional.0": "Not difficult at all", - "mentalHealth.functional.1": "Somewhat difficult", - "mentalHealth.functional.2": "Very difficult", - "mentalHealth.functional.3": "Extremely difficult", - "mentalHealth.who5Options.0": "At no time", - "mentalHealth.who5Options.1": "Some of the time", - "mentalHealth.who5Options.2": "Less than half of the time", - "mentalHealth.who5Options.3": "More than half of the time", - "mentalHealth.who5Options.4": "Most of the time", - "mentalHealth.who5Options.5": "All of the time", - "mentalHealth.sciOptions.duration.0": "≥ 61 min", - "mentalHealth.sciOptions.duration.1": "46–60 min", - "mentalHealth.sciOptions.duration.2": "31–45 min", - "mentalHealth.sciOptions.duration.3": "16–30 min", - "mentalHealth.sciOptions.duration.4": "0–15 min", - "mentalHealth.sciOptions.nights.0": "5–7", - "mentalHealth.sciOptions.nights.1": "4", - "mentalHealth.sciOptions.nights.2": "3", - "mentalHealth.sciOptions.nights.3": "2", - "mentalHealth.sciOptions.nights.4": "0–1", - "mentalHealth.sciOptions.quality.0": "Very poor", - "mentalHealth.sciOptions.quality.1": "Poor", - "mentalHealth.sciOptions.quality.2": "Average", - "mentalHealth.sciOptions.quality.3": "Good", - "mentalHealth.sciOptions.quality.4": "Very good", - "mentalHealth.sciOptions.impact.0": "Very much", - "mentalHealth.sciOptions.impact.1": "Much", - "mentalHealth.sciOptions.impact.2": "Somewhat", - "mentalHealth.sciOptions.impact.3": "A little", - "mentalHealth.sciOptions.impact.4": "Not at all", - "mentalHealth.sciOptions.problemDuration.0": "> 1 year", - "mentalHealth.sciOptions.problemDuration.1": "7–12 months", - "mentalHealth.sciOptions.problemDuration.2": "3–6 months", - "mentalHealth.sciOptions.problemDuration.3": "1–2 months", - "mentalHealth.sciOptions.problemDuration.4": - "I don't have a problem / < 1 month", - "mentalHealth.stems.phq9.period": - "Over the last 2 weeks, how often have you been bothered by any of the following problems?", - "mentalHealth.stems.gad7.period": - "Over the last 2 weeks, how often have you been bothered by the following problems?", - "mentalHealth.stems.who5.period": "Over the last two weeks …", - "mentalHealth.stems.sci.night": - "Thinking about a typical night in the last month …", - "mentalHealth.stems.sci.impact": - "Thinking about the past month, to what extent has poor sleep …", - "mentalHealth.stems.sci.finally": "Finally …", -}; +import { INSTRUMENTS, type InstrumentId } from "./instruments"; +import { SINGLE_ANCHOR_GROUP, type InstrumentWording } from "./wording/types"; -/** The PHQ-9's unscored follow-up question and its four answers. */ -export const FUNCTIONAL_TITLE_KEY = "mentalHealth.functionalTitle"; -export function functionalOptionKey(value: number): string { - return `mentalHealth.functional.${value}`; +/** + * The app language whose questionnaire text a reader of `locale` is shown; + * a language the app does not ship reads the English original. + */ +export function wordingLocale(locale: string): Locale { + return localeFromLanguageTag(locale) ?? "en"; } -const FUNCTIONAL_VALUES = [0, 1, 2, 3] as const; /** Instruments whose form carries the unscored functional follow-up. */ export function hasFunctionalItem(id: InstrumentId): boolean { return id === "PHQ9"; } -export function itemKey(id: InstrumentId, itemIndex: number): string { - return `mentalHealth.items.${INSTRUMENTS[id].i18nKey}.${itemIndex + 1}`; +/** The anchor group an item's answers come from. */ +export function anchorGroup(id: InstrumentId, itemIndex: number): string { + return INSTRUMENTS[id].optionGroups?.[itemIndex] ?? SINGLE_ANCHOR_GROUP; } -/** - * Every i18n key that carries this instrument's own wording: items, answer - * options, recall stems and the functional follow-up. - */ -export function instrumentWordingKeys(id: InstrumentId): string[] { - const def = INSTRUMENTS[id]; - const keys = new Set(); - for (let i = 0; i < def.itemCount; i++) { - keys.add(itemKey(id, i)); - for (const value of def.optionOrder) { - keys.add(optionLabelKey(id, i, value)); - } - const stem = stemKey(id, i); - if (stem) keys.add(`mentalHealth.stems.${stem}`); - } - if (hasFunctionalItem(id)) { - keys.add(FUNCTIONAL_TITLE_KEY); - for (const value of FUNCTIONAL_VALUES) keys.add(functionalOptionKey(value)); - } - return [...keys]; -} - -/** - * The keys a bundle for `locale` must NOT carry: wording of an instrument - * whose validated set excludes the language. A key shared by several - * instruments (the PHQ-9 / GAD-7 answer options) is English-only only when - * every instrument using it excludes the language. - */ -export function englishOnlyWordingKeys(locale: string): string[] { - const allowed = new Set(); - const all = new Set(); - for (const id of INSTRUMENT_ORDER) { - for (const key of instrumentWordingKeys(id)) { - all.add(key); - if (hasValidatedItems(id, locale)) allowed.add(key); - } - } - return [...all].filter((key) => !allowed.has(key)).sort(); -} - -/** - * Resolve one piece of instrument wording for the reader's language: the - * bundle's validated translation where one exists, the English source - * otherwise. Every surface that shows an item, an answer label or a stem goes - * through here, never through a bare `t()`. - */ -export function instrumentText( +/** The label of answer `value` to item `itemIndex`. */ +export function anchorText( id: InstrumentId, - key: string, - locale: string, - t: (key: string) => string, + wording: InstrumentWording, + itemIndex: number, + value: number, ): string { - if (hasValidatedItems(id, locale)) return t(key); - return SOURCE_WORDING[key] ?? key; + return wording.anchors[anchorGroup(id, itemIndex)][value]; +} + +/** The stem the source form prints above item `itemIndex`, or null. */ +export function stemText( + id: InstrumentId, + wording: InstrumentWording, + itemIndex: number, +): string | null { + const stemId = INSTRUMENTS[id].stemIds[itemIndex]; + return stemId ? (wording.stems[stemId] ?? null) : null; } diff --git a/src/lib/mental-health/instruments.ts b/src/lib/mental-health/instruments.ts index 6a3c32a32..f9714e487 100644 --- a/src/lib/mental-health/instruments.ts +++ b/src/lib/mental-health/instruments.ts @@ -11,19 +11,14 @@ * - 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". Five other app languages ship the PRU - * translations WHO republishes, verbatim (sources at `WHO5_LOCALES`). + * further assessment". * - 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.*` for the languages in an - * instrument's validated set, and in `instrument-wording.ts` as the English - * source every other language is shown (re-wording invalidates the score). - * Surfaces resolve it through `instrumentText`, never a bare `t()`. See - * `.planning/research/screening-instruments-2026-07.md`. + * possible insomnia disorder". + * This module owns the canonical NUMERIC contract. The wording, per + * instrument and language with its source, lives in `./wording/` and is read + * through `instrument-wording.ts`; it never lives in `messages/*.json`. * * 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 @@ -38,7 +33,7 @@ export type InstrumentId = "PHQ9" | "GAD7" | "WHO5" | "SCI"; export interface InstrumentDefinition { id: InstrumentId; - /** Lowercase i18n slug — the key segment under `mentalHealth.*`. */ + /** Lowercase slug — the key segment of the app's own copy under `mentalHealth.*`. */ i18nKey: "phq9" | "gad7" | "who5" | "sci"; /** Number of scored items (PHQ-9 = 9, GAD-7 = 7, WHO-5 = 5, SCI = 8). */ itemCount: number; @@ -51,22 +46,17 @@ export interface InstrumentDefinition { */ optionOrder: readonly number[]; /** - * Fully-qualified i18n namespace for the response-option labels: without - * `optionGroups` one shared anchor set (`.`); with - * `optionGroups` (SCI) per-item anchor groups (`..`, - * group = `optionGroups[itemIndex]`). v1.32.36 — qualified rather than - * relative so the reverse-i18n guard resolves the subtree from the literal - * instead of being handed the bare `mentalHealth` namespace. + * Item-specific anchor groups (SCI), one per item, naming the anchor set in + * the wording's `anchors`. Without it every item uses the single set `all`. */ - optionNamespace: string; optionGroups?: readonly string[]; /** - * Stem key per item under `mentalHealth.stems.`: the question the source - * form prints above its items (the PHQ-9 / GAD-7 "Over the last 2 weeks…", - * the WHO-5 "Over the last two weeks …", the SCI section stems). The - * wizard keeps it visible above every item. + * Stem id per item, naming the text in the wording's `stems`: the question + * the source form prints above its items (the PHQ-9 / GAD-7 "Over the last + * 2 weeks…", the WHO-5 "Over the last two weeks …", the SCI section stems). + * The wizard keeps it visible above every item. */ - stemKeys: readonly string[]; + stemIds: readonly string[]; /** Multiplier from item-sum to the reported total (WHO-5: × 4). */ scoreMultiplier: number; /** Inclusive REPORTED total range (WHO-5: 0–100 percentage). */ @@ -86,12 +76,6 @@ export interface InstrumentDefinition { 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 @@ -100,59 +84,6 @@ export interface InstrumentDefinition { attribution: string; } -/* - * Which languages carry an instrument's own wording, and where it comes from. - * - * A language is listed only when its bundle reproduces a published version - * word for word: the question stem, every item, every answer option and, for - * the PHQ-9, the follow-up question. Each list below names its sources. A - * language that is not listed shows the English original with the honest - * note, and its bundle carries none of the instrument's keys - * (`instrument-wording.ts`). Adding a language means bundling its published - * form verbatim in the same change, with the source named here. - * - * Paper-form instructions ("mark your answer with a cross") and the - * row-end full stops of the printed tables are left out; nothing else is - * changed. The app's own copy may address the reader informally; the - * instruments keep the address form they were published in. - */ - -/** - * PHQ-9. English: the Pfizer form (phqscreeners.com). The others are the - * official Pfizer translations, read from the PDFs as distributed: - * - de: "Gesundheitsfragebogen für Patienten (PHQ-9)", German for Germany, - * the PHQ-D wording of Löwe, Spitzer, Zipfel & Herzog (Pfizer 2002). - * - es: Spanish for Spain. it: Italian for Italy. pl: Polish for Poland. - * (ECFS Mental Health working group mirror of the Pfizer files: - * ecfs.eu/sites/default/files/general-content-files/working-groups/ - * Mental Health/PHQ9_ for .pdf) - * Not listed: fr (the France version could not be obtained; the bundle's - * wording matched neither it nor the Belgian one), ko (no published Korean - * form could be checked), nl and pt-BR (never bundled). - */ -const PHQ9_LOCALES = ["en", "de", "es", "it", "pl"] as const; - -/** - * GAD-7. English: the Pfizer form. de: Löwe B et al., "Gesundheitsfragebogen - * für Patienten (GAD-7)", German version © Bernd Löwe 2015, Universitätsklinikum - * Hamburg-Eppendorf (validation: Löwe et al., Med Care 2008;46:266-274). es, - * fr, it, pl: the Pfizer translations for Spain, France, Italy and Poland - * (same ECFS mirror, GAD7_ for .pdf). Not listed: ko, nl, - * pt-BR, as for the PHQ-9. - */ -const GAD7_LOCALES = ["en", "de", "es", "fr", "it", "pl"] as const; - -/** - * WHO-5. English: WHO/UCN/MSD/MHE/2024.1. de, es, fr, it, pl: the Psychiatric - * Research Unit translations (1998/1999 versions) that WHO republishes beside - * the 2024 English text (cdn.who.int …/five-well-being-index-(who-5)/ - * who-5_.pdf; the Spanish one via infocop.es). The Spanish sheet - * misprints item 4 as "descandado"; the bundle keeps "descansado". Not - * listed: ko (the Kim 2010 translation could not be checked against the - * paper), nl and pt-BR. - */ -const WHO5_LOCALES = ["en", "de", "es", "fr", "it", "pl"] as const; - /** * The PHQ family is public-domain (Pfizer 2010); this is the canonical footer * text per the instrument's own permission grant. @@ -168,7 +99,6 @@ const PHQ9: InstrumentDefinition = { itemCount: 9, itemMax: 3, optionOrder: [0, 1, 2, 3], - optionNamespace: "mentalHealth.options", scoreMultiplier: 1, minScore: 0, maxScore: 27, @@ -183,11 +113,10 @@ const PHQ9: InstrumentDefinition = { ], // The form's own question, "Over the last 2 weeks, how often have you been // bothered by any of the following problems?", stands above every item. - stemKeys: Array(9).fill("phq9.period"), + stemIds: Array(9).fill("period"), actionThreshold: 10, // Item 9 ("Thoughts that you would be better off dead…") — the safety field. safetyItemIndex: 8, - validatedItemLocales: PHQ9_LOCALES, attribution: PHQ_GAD_ATTRIBUTION, }; @@ -197,10 +126,7 @@ const GAD7: InstrumentDefinition = { itemCount: 7, itemMax: 3, optionOrder: [0, 1, 2, 3], - // Its own anchors: the Italian GAD-7 words option 2 differently from the - // Italian PHQ-9, so the two cannot share one set. - optionNamespace: "mentalHealth.gad7Options", - stemKeys: Array(7).fill("gad7.period"), + stemIds: Array(7).fill("period"), scoreMultiplier: 1, minScore: 0, maxScore: 21, @@ -214,7 +140,6 @@ const GAD7: InstrumentDefinition = { ], actionThreshold: 10, safetyItemIndex: null, - validatedItemLocales: GAD7_LOCALES, attribution: PHQ_GAD_ATTRIBUTION, }; @@ -233,8 +158,7 @@ const WHO5: InstrumentDefinition = { itemCount: 5, itemMax: 5, optionOrder: [5, 4, 3, 2, 1, 0], - optionNamespace: "mentalHealth.who5Options", - stemKeys: Array(5).fill("who5.period"), + stemIds: Array(5).fill("period"), scoreMultiplier: 4, minScore: 0, maxScore: 100, @@ -246,7 +170,6 @@ const WHO5: InstrumentDefinition = { ], actionThreshold: 50, safetyItemIndex: null, - validatedItemLocales: WHO5_LOCALES, attribution: "World Health Organization. The World Health Organization-Five Well-Being " + "Index (WHO-5). Geneva: World Health Organization; 2024. Licence: " + @@ -267,7 +190,6 @@ const SCI: InstrumentDefinition = { itemCount: 8, itemMax: 4, optionOrder: [4, 3, 2, 1, 0], - optionNamespace: "mentalHealth.sciOptions", optionGroups: [ "duration", "duration", @@ -278,15 +200,15 @@ const SCI: InstrumentDefinition = { "impact", "problemDuration", ], - stemKeys: [ - "sci.night", - "sci.night", - "sci.night", - "sci.night", - "sci.impact", - "sci.impact", - "sci.impact", - "sci.finally", + stemIds: [ + "night", + "night", + "night", + "night", + "impact", + "impact", + "impact", + "finally", ], scoreMultiplier: 1, minScore: 0, @@ -299,13 +221,6 @@ const SCI: InstrumentDefinition = { ], 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, and no validated German version is published (a search - // in October 2026 found the German ISI, Gerber et al. 2016, not the SCI), - // 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 " + @@ -365,38 +280,6 @@ export function needsFollowUp(id: InstrumentId, total: number): boolean { : 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 diff --git a/src/lib/mental-health/wording/gad7/de.ts b/src/lib/mental-health/wording/gad7/de.ts new file mode 100644 index 000000000..6a117b603 --- /dev/null +++ b/src/lib/mental-health/wording/gad7/de.ts @@ -0,0 +1,33 @@ +/** + * GAD-7, German: a validated translation, reproduced word for word. + */ +import type { InstrumentWording } from "../types"; + +const wording: InstrumentWording = { + validated: true, + source: + 'Löwe B et al., "Gesundheitsfragebogen für Patienten (GAD-7)", German version © Bernd Löwe 2015, Universitätsklinikum Hamburg-Eppendorf; validation: Löwe et al., Med Care 2008;46:266-274. Read from the copy distributed by LMU München, https://cms-cdn.lmu.de/media/lmu/downloads/diversity/wecare/gad-7_deutsch.pdf (the UKE distributes the same form). Pfizer distributes no "German for Germany" GAD-7; this is the German form the validation used. Reproduction free, as for the PHQ family.', + stems: { + period: + "Wie oft fühlten Sie sich im Verlauf der letzten 2 Wochen durch die folgenden Beschwerden beeinträchtigt?", + }, + items: [ + "Nervosität, Ängstlichkeit oder Anspannung", + "Nicht in der Lage sein, Sorgen zu stoppen oder zu kontrollieren", + "Übermäßige Sorgen bezüglich verschiedener Angelegenheiten", + "Schwierigkeiten zu entspannen", + "Rastlosigkeit, so dass Stillsitzen schwer fällt", + "Schnelle Verärgerung oder Gereiztheit", + "Gefühl der Angst, so als würde etwas Schlimmes passieren", + ], + anchors: { + all: { + 0: "Überhaupt nicht", + 1: "An einzelnen Tagen", + 2: "An mehr als der Hälfte der Tage", + 3: "Beinahe jeden Tag", + }, + }, +}; + +export default wording; diff --git a/src/lib/mental-health/wording/gad7/en.ts b/src/lib/mental-health/wording/gad7/en.ts new file mode 100644 index 000000000..9535f19a6 --- /dev/null +++ b/src/lib/mental-health/wording/gad7/en.ts @@ -0,0 +1,35 @@ +/** + * GAD-7, English: a validated translation, reproduced word for word. + * + * Spitzer, Kroenke, Williams & Löwe, Arch Intern Med 2006;166:1092-1097. + */ +import type { InstrumentWording } from "../types"; + +const wording: InstrumentWording = { + validated: true, + source: + "Pfizer Inc., Generalized Anxiety Disorder 7-item scale (GAD-7), the English original, read from https://www.phqscreeners.com/images/sites/g/files/g10060481/f/201412/GAD-7_English.pdf. 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.", + stems: { + period: + "Over the last 2 weeks, how often have you been bothered by the following problems?", + }, + items: [ + "Feeling nervous, anxious, or on edge", + "Not being able to stop or control worrying", + "Worrying too much about different things", + "Trouble relaxing", + "Being so restless that it is hard to sit still", + "Becoming easily annoyed or irritable", + "Feeling afraid as if something awful might happen", + ], + anchors: { + all: { + 0: "Not at all", + 1: "Several days", + 2: "More than half the days", + 3: "Nearly every day", + }, + }, +}; + +export default wording; diff --git a/src/lib/mental-health/wording/gad7/es.ts b/src/lib/mental-health/wording/gad7/es.ts new file mode 100644 index 000000000..0d3462ddd --- /dev/null +++ b/src/lib/mental-health/wording/gad7/es.ts @@ -0,0 +1,33 @@ +/** + * GAD-7, Spanish: a validated translation, reproduced word for word. + */ +import type { InstrumentWording } from "../types"; + +const wording: InstrumentWording = { + validated: true, + source: + 'Pfizer Inc., GAD-7, official translation "Spanish for Spain", read from https://www.phqscreeners.com/images/sites/g/files/g10060481/f/201412/GAD7_Spanish%20for%20Spain.pdf. 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.', + stems: { + period: + "En los últimos 14 días, ¿con qué frecuencia le han supuesto una molestia los siguientes problemas?", + }, + items: [ + "Sentirse nervioso/a, angustiado/a o muy tenso/a", + "Ser incapaz de dejar de preocuparse o de controlar la preocupación", + "Preocuparse demasiado por diferentes cuestiones", + "Tener problemas para relajarse", + "Estar tan inquieto/a que le resulta difícil permanecer sentado/a", + "Enfadarse o irritarse con facilidad", + "Sentir miedo de que algo terrible pueda ocurrir", + ], + anchors: { + all: { + 0: "Nunca", + 1: "Varios días", + 2: "Más de la mitad de los días", + 3: "Casi cada día", + }, + }, +}; + +export default wording; diff --git a/src/lib/mental-health/wording/gad7/fr.ts b/src/lib/mental-health/wording/gad7/fr.ts new file mode 100644 index 000000000..24045e8c0 --- /dev/null +++ b/src/lib/mental-health/wording/gad7/fr.ts @@ -0,0 +1,33 @@ +/** + * GAD-7, French: a validated translation, reproduced word for word. + */ +import type { InstrumentWording } from "../types"; + +const wording: InstrumentWording = { + validated: true, + source: + 'Pfizer Inc., GAD-7, official translation "French for France", read from https://www.phqscreeners.com/images/sites/g/files/g10060481/f/201412/GAD7_French%20for%20France.pdf. 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.', + stems: { + period: + "Au cours des 2 dernières semaines, selon quelle fréquence avez-vous été gêné(e) par les problèmes suivants ?", + }, + items: [ + "Un sentiment de nervosité, d’anxiété ou de tension", + "Une incapacité à arrêter de s’inquiéter ou à contrôler ses inquiétudes", + "Une inquiétude excessive à propos de différentes choses", + "Des difficultés à se détendre", + "Une agitation telle qu’il est difficile à tenir en place", + "Une tendance à être facilement contrarié(e) ou irritable", + "Un sentiment de peur comme si quelque chose de terrible risquait de se produire", + ], + anchors: { + all: { + 0: "Jamais", + 1: "Plusieurs jours", + 2: "Plus de la moitié du temps", + 3: "Presque tous les jours", + }, + }, +}; + +export default wording; diff --git a/src/lib/mental-health/wording/gad7/it.ts b/src/lib/mental-health/wording/gad7/it.ts new file mode 100644 index 000000000..4c31ef166 --- /dev/null +++ b/src/lib/mental-health/wording/gad7/it.ts @@ -0,0 +1,37 @@ +/** + * GAD-7, Italian: a validated translation, reproduced word for word. + * + * Option 2 reads "Per oltre la metà dei giorni" here and "Per più della + * metà dei giorni" on the Italian PHQ-9: the two forms differ, and each + * keeps its own. + */ +import type { InstrumentWording } from "../types"; + +const wording: InstrumentWording = { + validated: true, + source: + 'Pfizer Inc., GAD-7, official translation "Italian for Italy", read from https://www.phqscreeners.com/images/sites/g/files/g10060481/f/201412/GAD7_Italian%20for%20Italy.pdf. 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.', + stems: { + period: + "Nelle ultime 2 settimane, con quale frequenza le ha dato fastidio ciascuno dei seguenti problemi?", + }, + items: [ + "Sentirsi nervoso/a, ansioso/a o teso/a", + "Non riuscire a smettere di preoccuparsi o a tenere sotto controllo le preoccupazioni", + "Preoccuparsi troppo per varie cose", + "Avere difficoltà a rilassarsi", + "Essere talmente irrequieto/a da far fatica a stare seduto/a fermo/a", + "Infastidirsi o irritarsi facilmente", + "Avere paura che possa succedere qualcosa di terribile", + ], + anchors: { + all: { + 0: "Mai", + 1: "Alcuni giorni", + 2: "Per oltre la metà dei giorni", + 3: "Quasi ogni giorno", + }, + }, +}; + +export default wording; diff --git a/src/lib/mental-health/wording/gad7/ko.ts b/src/lib/mental-health/wording/gad7/ko.ts new file mode 100644 index 000000000..6720ca2cd --- /dev/null +++ b/src/lib/mental-health/wording/gad7/ko.ts @@ -0,0 +1,36 @@ +/** + * GAD-7, Korean: a validated translation, reproduced word for word. + * + * Each column header is one answer printed over four lines; the third + * reads "2 주 중 / 절반 이상 / 방해 / 받았다". + */ +import type { InstrumentWording } from "../types"; + +const wording: InstrumentWording = { + validated: true, + source: + 'Pfizer Inc., GAD-7, official translation "Korean for Korea", read from https://www.phqscreeners.com/images/sites/g/files/g10060481/f/201412/GAD7_Korean%20for%20Korea.pdf. 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.', + stems: { + period: + "지난 2 주 동안 당신은 다음의 문제들로 인해서 얼마나 자주 방해를 받았습니까?", + }, + items: [ + "초조하거나 불안하거나 조마조마하게 느낀다", + "걱정하는 것을 멈추거나 조절할 수가 없다", + "여러 가지 것들에 대해 걱정을 너무 많이 한다", + "편하게 있기가 어렵다", + "너무 안절부절못해서 가만히 있기가 힘들다", + "쉽게 짜증이 나거나 쉽게 성을 내게 된다", + "마치 끔찍한 일이 생길 것처럼 두렵게 느껴진다", + ], + anchors: { + all: { + 0: "전혀 방해 받지 않았다", + 1: "며칠 동안 방해 받았다", + 2: "2 주 중 절반 이상 방해 받았다", + 3: "거의 매일 방해 받았다", + }, + }, +}; + +export default wording; diff --git a/src/lib/mental-health/wording/gad7/nl.ts b/src/lib/mental-health/wording/gad7/nl.ts new file mode 100644 index 000000000..bf865101a --- /dev/null +++ b/src/lib/mental-health/wording/gad7/nl.ts @@ -0,0 +1,37 @@ +/** + * GAD-7, Dutch: a validated translation, reproduced word for word. + * + * The GAD-7 sheet sets its headers as "Helemaal / Niet" and + * "Verschei- / Dene dagen": line-break artefacts of the same words the + * PHQ-9 sheet prints as "Helemaal niet" and "Verscheidene dagen". + */ +import type { InstrumentWording } from "../types"; + +const wording: InstrumentWording = { + validated: true, + source: + 'Pfizer Inc., GAD-7, official translation "Dutch for the Netherlands", read from https://www.phqscreeners.com/images/sites/g/files/g10060481/f/201412/GAD7_Dutch%20for%20the%20Netherlands.pdf. 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.', + stems: { + period: + "Hoe vaak hebt u in de afgelopen 2 weken last gehad van één of meer van de volgende problemen?", + }, + items: [ + "U nerveus, angstig of gespannen voelen", + "Niet kunnen stoppen met u zorgen maken of dit niet kunnen beteugelen", + "U te veel zorgen maken om verschillende dingen", + "Moeite hebben u te ontspannen", + "Zo rusteloos zijn dat het moeilijk is om stil te zitten", + "Snel geërgerd of geïrriteerd zijn", + "Bang zijn dat er iets vreselijks zou kunnen gebeuren", + ], + anchors: { + all: { + 0: "Helemaal niet", + 1: "Verscheidene dagen", + 2: "Meer dan de helft van de dagen", + 3: "Bijna elke dag", + }, + }, +}; + +export default wording; diff --git a/src/lib/mental-health/wording/gad7/pl.ts b/src/lib/mental-health/wording/gad7/pl.ts new file mode 100644 index 000000000..e71c83a6c --- /dev/null +++ b/src/lib/mental-health/wording/gad7/pl.ts @@ -0,0 +1,33 @@ +/** + * GAD-7, Polish: a validated translation, reproduced word for word. + */ +import type { InstrumentWording } from "../types"; + +const wording: InstrumentWording = { + validated: true, + source: + 'Pfizer Inc., GAD-7, official translation "Polish for Poland", read from https://www.phqscreeners.com/images/sites/g/files/g10060481/f/201412/GAD7_Polish%20for%20Poland.pdf. 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.', + stems: { + period: + "Jak często w ciągu ostatnich 2 tygodni dokuczały Panu(i) następujące problemy?", + }, + items: [ + "Czuł(a) się Pan(i) podenerwowany(a), niespokojny(a), mocno spięty(a)", + "Nie mógł(a) Pan(i) przestać się martwić albo zapanować nad tym", + "Za bardzo się Pan(i) martwił(a) różnymi rzeczami", + "Miał(a) Pan(i) trudności z relaksowaniem się", + "Był(a) Pan(i) tak niespokojny(a), że nie mógł(a) usiedzieć na miejscu", + "Łatwo stawał(a) się Pan(i) rozdrażniony(a) lub poirytowany(a)", + "Obawiał(a) się Pan(i), tak jakby miało się stać coś strasznego", + ], + anchors: { + all: { + 0: "Wcale nie dokuczały", + 1: "Kilka dni", + 2: "Więcej niż połowę dni", + 3: "Niemal codziennie", + }, + }, +}; + +export default wording; diff --git a/src/lib/mental-health/wording/gad7/pt-br.ts b/src/lib/mental-health/wording/gad7/pt-br.ts new file mode 100644 index 000000000..ecee7e77c --- /dev/null +++ b/src/lib/mental-health/wording/gad7/pt-br.ts @@ -0,0 +1,35 @@ +/** + * GAD-7, Portuguese (Brazil): a validated translation, reproduced word for word. + * + * Spelling as printed, including the pre-2009 "freqüência". + */ +import type { InstrumentWording } from "../types"; + +const wording: InstrumentWording = { + validated: true, + source: + 'Pfizer Inc., GAD-7, official translation "Portuguese for Brazil", read from https://www.phqscreeners.com/images/sites/g/files/g10060481/f/201412/GAD7_Portuguese%20for%20Brazil.pdf. 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.', + stems: { + period: + "Durante as últimas 2 semanas, com que freqüência você foi incomodado/a pelos problemas abaixo?", + }, + items: [ + "Sentir-se nervoso/a, ansioso/a ou muito tenso/a", + "Não ser capaz de impedir ou de controlar as preocupações", + "Preocupar-se muito com diversas coisas", + "Dificuldade para relaxar", + "Ficar tão agitado/a que se torna difícil permanecer sentado/a", + "Ficar facilmente aborrecido/a ou irritado/a", + "Sentir medo como se algo horrível fosse acontecer", + ], + anchors: { + all: { + 0: "Nenhuma vez", + 1: "Vários dias", + 2: "Mais da metade dos dias", + 3: "Quase todos os dias", + }, + }, +}; + +export default wording; diff --git a/src/lib/mental-health/wording/index.ts b/src/lib/mental-health/wording/index.ts new file mode 100644 index 000000000..3552e07bd --- /dev/null +++ b/src/lib/mental-health/wording/index.ts @@ -0,0 +1,113 @@ +/** + * Every questionnaire's wording in every app language. + * + * One file per instrument and language under this folder, each declaring + * whether it reproduces a published validated translation (and which one) or + * is a translation written for HealthLog. `wording-registry.test.ts` holds the + * map complete: every instrument in every locale of `src/lib/i18n/config.ts`, + * with the item count and the anchor values the scoring expects. + * + * A validated entry reproduces its source word for word: the stem, every + * item, every answer and, for the PHQ-9, the follow-up question, in the form + * of address the source uses. Left out are only the paper-form instructions + * ("mark your answer with a cross") and the full stop that closes a printed + * table cell; anything else an entry changes (a misprint, a stray character) + * is named in its file. A published version is used only when its terms + * allow reproducing it in software; a version whose text is behind a paywall + * or under terms that allow private study only is named in the file of the + * language it would have served, which then carries HealthLog's translation. + * + * Adding or replacing a language's wording is a change to the file here, with + * the source in `source` and the PDF it was read from named in it. The app's + * own copy around a questionnaire (titles, notes, buttons) stays in the + * message bundles; the questionnaire itself never does. + */ +import type { Locale } from "@/lib/i18n/config"; +import type { InstrumentId } from "../instruments"; +import type { InstrumentWording } from "./types"; + +import phq9En from "./phq9/en"; +import phq9De from "./phq9/de"; +import phq9Es from "./phq9/es"; +import phq9Fr from "./phq9/fr"; +import phq9It from "./phq9/it"; +import phq9Pl from "./phq9/pl"; +import phq9Ko from "./phq9/ko"; +import phq9Nl from "./phq9/nl"; +import phq9PtBr from "./phq9/pt-br"; +import gad7En from "./gad7/en"; +import gad7De from "./gad7/de"; +import gad7Es from "./gad7/es"; +import gad7Fr from "./gad7/fr"; +import gad7It from "./gad7/it"; +import gad7Pl from "./gad7/pl"; +import gad7Ko from "./gad7/ko"; +import gad7Nl from "./gad7/nl"; +import gad7PtBr from "./gad7/pt-br"; +import who5En from "./who5/en"; +import who5De from "./who5/de"; +import who5Es from "./who5/es"; +import who5Fr from "./who5/fr"; +import who5It from "./who5/it"; +import who5Pl from "./who5/pl"; +import who5Ko from "./who5/ko"; +import who5Nl from "./who5/nl"; +import who5PtBr from "./who5/pt-br"; +import sciEn from "./sci/en"; +import sciDe from "./sci/de"; +import sciEs from "./sci/es"; +import sciFr from "./sci/fr"; +import sciIt from "./sci/it"; +import sciPl from "./sci/pl"; +import sciKo from "./sci/ko"; +import sciNl from "./sci/nl"; +import sciPtBr from "./sci/pt-br"; + +export const WORDING: Readonly< + Record>> +> = { + PHQ9: { + en: phq9En, + de: phq9De, + es: phq9Es, + fr: phq9Fr, + it: phq9It, + pl: phq9Pl, + ko: phq9Ko, + nl: phq9Nl, + "pt-BR": phq9PtBr, + }, + GAD7: { + en: gad7En, + de: gad7De, + es: gad7Es, + fr: gad7Fr, + it: gad7It, + pl: gad7Pl, + ko: gad7Ko, + nl: gad7Nl, + "pt-BR": gad7PtBr, + }, + WHO5: { + en: who5En, + de: who5De, + es: who5Es, + fr: who5Fr, + it: who5It, + pl: who5Pl, + ko: who5Ko, + nl: who5Nl, + "pt-BR": who5PtBr, + }, + SCI: { + en: sciEn, + de: sciDe, + es: sciEs, + fr: sciFr, + it: sciIt, + pl: sciPl, + ko: sciKo, + nl: sciNl, + "pt-BR": sciPtBr, + }, +}; diff --git a/src/lib/mental-health/wording/load.ts b/src/lib/mental-health/wording/load.ts new file mode 100644 index 000000000..308b3d12b --- /dev/null +++ b/src/lib/mental-health/wording/load.ts @@ -0,0 +1,27 @@ +/** + * The client's way to the questionnaire text: one chunk per language, loaded + * when a questionnaire surface needs it. A reader downloads their own + * language, and English only when they switch to the original; the server + * and the tests read every language at once through `./index.ts`. + */ +import type { Locale } from "@/lib/i18n/config"; + +import type { LocaleWording } from "./types"; + +const LOADERS: Record Promise<{ default: LocaleWording }>> = { + en: () => import("./locales/en"), + de: () => import("./locales/de"), + es: () => import("./locales/es"), + fr: () => import("./locales/fr"), + it: () => import("./locales/it"), + pl: () => import("./locales/pl"), + ko: () => import("./locales/ko"), + nl: () => import("./locales/nl"), + "pt-BR": () => import("./locales/pt-br"), +}; + +export async function loadLocaleWording( + locale: Locale, +): Promise { + return (await LOADERS[locale]()).default; +} diff --git a/src/lib/mental-health/wording/locales/de.ts b/src/lib/mental-health/wording/locales/de.ts new file mode 100644 index 000000000..8d5fe6dca --- /dev/null +++ b/src/lib/mental-health/wording/locales/de.ts @@ -0,0 +1,10 @@ +import gad7 from "../gad7/de"; +import phq9 from "../phq9/de"; +import sci from "../sci/de"; +import who5 from "../who5/de"; +import type { LocaleWording } from "../types"; + +/** Every questionnaire in one language, loaded as one chunk. */ +const wording: LocaleWording = { PHQ9: phq9, GAD7: gad7, WHO5: who5, SCI: sci }; + +export default wording; diff --git a/src/lib/mental-health/wording/locales/en.ts b/src/lib/mental-health/wording/locales/en.ts new file mode 100644 index 000000000..83bcf2cd7 --- /dev/null +++ b/src/lib/mental-health/wording/locales/en.ts @@ -0,0 +1,10 @@ +import gad7 from "../gad7/en"; +import phq9 from "../phq9/en"; +import sci from "../sci/en"; +import who5 from "../who5/en"; +import type { LocaleWording } from "../types"; + +/** Every questionnaire in one language, loaded as one chunk. */ +const wording: LocaleWording = { PHQ9: phq9, GAD7: gad7, WHO5: who5, SCI: sci }; + +export default wording; diff --git a/src/lib/mental-health/wording/locales/es.ts b/src/lib/mental-health/wording/locales/es.ts new file mode 100644 index 000000000..b3eb72952 --- /dev/null +++ b/src/lib/mental-health/wording/locales/es.ts @@ -0,0 +1,10 @@ +import gad7 from "../gad7/es"; +import phq9 from "../phq9/es"; +import sci from "../sci/es"; +import who5 from "../who5/es"; +import type { LocaleWording } from "../types"; + +/** Every questionnaire in one language, loaded as one chunk. */ +const wording: LocaleWording = { PHQ9: phq9, GAD7: gad7, WHO5: who5, SCI: sci }; + +export default wording; diff --git a/src/lib/mental-health/wording/locales/fr.ts b/src/lib/mental-health/wording/locales/fr.ts new file mode 100644 index 000000000..20b6467ff --- /dev/null +++ b/src/lib/mental-health/wording/locales/fr.ts @@ -0,0 +1,10 @@ +import gad7 from "../gad7/fr"; +import phq9 from "../phq9/fr"; +import sci from "../sci/fr"; +import who5 from "../who5/fr"; +import type { LocaleWording } from "../types"; + +/** Every questionnaire in one language, loaded as one chunk. */ +const wording: LocaleWording = { PHQ9: phq9, GAD7: gad7, WHO5: who5, SCI: sci }; + +export default wording; diff --git a/src/lib/mental-health/wording/locales/it.ts b/src/lib/mental-health/wording/locales/it.ts new file mode 100644 index 000000000..bfda5cdb9 --- /dev/null +++ b/src/lib/mental-health/wording/locales/it.ts @@ -0,0 +1,10 @@ +import gad7 from "../gad7/it"; +import phq9 from "../phq9/it"; +import sci from "../sci/it"; +import who5 from "../who5/it"; +import type { LocaleWording } from "../types"; + +/** Every questionnaire in one language, loaded as one chunk. */ +const wording: LocaleWording = { PHQ9: phq9, GAD7: gad7, WHO5: who5, SCI: sci }; + +export default wording; diff --git a/src/lib/mental-health/wording/locales/ko.ts b/src/lib/mental-health/wording/locales/ko.ts new file mode 100644 index 000000000..fad31d557 --- /dev/null +++ b/src/lib/mental-health/wording/locales/ko.ts @@ -0,0 +1,10 @@ +import gad7 from "../gad7/ko"; +import phq9 from "../phq9/ko"; +import sci from "../sci/ko"; +import who5 from "../who5/ko"; +import type { LocaleWording } from "../types"; + +/** Every questionnaire in one language, loaded as one chunk. */ +const wording: LocaleWording = { PHQ9: phq9, GAD7: gad7, WHO5: who5, SCI: sci }; + +export default wording; diff --git a/src/lib/mental-health/wording/locales/nl.ts b/src/lib/mental-health/wording/locales/nl.ts new file mode 100644 index 000000000..7efa3d036 --- /dev/null +++ b/src/lib/mental-health/wording/locales/nl.ts @@ -0,0 +1,10 @@ +import gad7 from "../gad7/nl"; +import phq9 from "../phq9/nl"; +import sci from "../sci/nl"; +import who5 from "../who5/nl"; +import type { LocaleWording } from "../types"; + +/** Every questionnaire in one language, loaded as one chunk. */ +const wording: LocaleWording = { PHQ9: phq9, GAD7: gad7, WHO5: who5, SCI: sci }; + +export default wording; diff --git a/src/lib/mental-health/wording/locales/pl.ts b/src/lib/mental-health/wording/locales/pl.ts new file mode 100644 index 000000000..a5b56f2cf --- /dev/null +++ b/src/lib/mental-health/wording/locales/pl.ts @@ -0,0 +1,10 @@ +import gad7 from "../gad7/pl"; +import phq9 from "../phq9/pl"; +import sci from "../sci/pl"; +import who5 from "../who5/pl"; +import type { LocaleWording } from "../types"; + +/** Every questionnaire in one language, loaded as one chunk. */ +const wording: LocaleWording = { PHQ9: phq9, GAD7: gad7, WHO5: who5, SCI: sci }; + +export default wording; diff --git a/src/lib/mental-health/wording/locales/pt-br.ts b/src/lib/mental-health/wording/locales/pt-br.ts new file mode 100644 index 000000000..ca0ba7a74 --- /dev/null +++ b/src/lib/mental-health/wording/locales/pt-br.ts @@ -0,0 +1,10 @@ +import gad7 from "../gad7/pt-br"; +import phq9 from "../phq9/pt-br"; +import sci from "../sci/pt-br"; +import who5 from "../who5/pt-br"; +import type { LocaleWording } from "../types"; + +/** Every questionnaire in one language, loaded as one chunk. */ +const wording: LocaleWording = { PHQ9: phq9, GAD7: gad7, WHO5: who5, SCI: sci }; + +export default wording; diff --git a/src/lib/mental-health/wording/phq9/de.ts b/src/lib/mental-health/wording/phq9/de.ts new file mode 100644 index 000000000..f15935608 --- /dev/null +++ b/src/lib/mental-health/wording/phq9/de.ts @@ -0,0 +1,49 @@ +/** + * PHQ-9, German: a validated translation, reproduced word for word. + * + * The PHQ-D wording of Löwe, Spitzer, Zipfel & Herzog (Pfizer 2002), + * "Gesundheitsfragebogen für Patienten (PHQ-9)". The published form + * addresses the reader formally, and so does this text. + */ +import type { InstrumentWording } from "../types"; + +const wording: InstrumentWording = { + validated: true, + source: + 'Pfizer Inc., PHQ-9, official translation "German for Germany", read from https://www.phqscreeners.com/images/sites/g/files/g10060481/f/201412/PHQ9_German%20for%20Germany.pdf. 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.', + stems: { + period: + "Wie oft fühlten Sie sich im Verlauf der letzten 2 Wochen durch die folgenden Beschwerden beeinträchtigt?", + }, + items: [ + "Wenig Interesse oder Freude an Ihren Tätigkeiten", + "Niedergeschlagenheit, Schwermut oder Hoffnungslosigkeit", + "Schwierigkeiten, ein- oder durchzuschlafen, oder vermehrter Schlaf", + "Müdigkeit oder Gefühl, keine Energie zu haben", + "Verminderter Appetit oder übermäßiges Bedürfnis zu essen", + "Schlechte Meinung von sich selbst; Gefühl, ein Versager zu sein oder die Familie enttäuscht zu haben", + "Schwierigkeiten, sich auf etwas zu konzentrieren, z. B. beim Zeitungslesen oder Fernsehen", + "Waren Ihre Bewegungen oder Ihre Sprache so verlangsamt, dass es auch anderen auffallen würde? Oder waren Sie im Gegenteil „zappelig“ oder ruhelos und hatten dadurch einen stärkeren Bewegungsdrang als sonst?", + "Gedanken, dass Sie lieber tot wären oder sich Leid zufügen möchten", + ], + anchors: { + all: { + 0: "Überhaupt nicht", + 1: "An einzelnen Tagen", + 2: "An mehr als der Hälfte der Tage", + 3: "Beinahe jeden Tag", + }, + }, + functional: { + title: + "Wenn eines oder mehrere der bisher in diesem Fragebogen beschriebenen Probleme bei Ihnen vorliegen, geben Sie bitte an, wie sehr diese Probleme es Ihnen erschwert haben, Ihre Arbeit zu tun, Ihren Haushalt zu regeln oder mit anderen Menschen zurecht zu kommen:", + anchors: { + 0: "Überhaupt nicht erschwert", + 1: "Etwas erschwert", + 2: "Stark erschwert", + 3: "Extrem erschwert", + }, + }, +}; + +export default wording; diff --git a/src/lib/mental-health/wording/phq9/en.ts b/src/lib/mental-health/wording/phq9/en.ts new file mode 100644 index 000000000..761ce8c2b --- /dev/null +++ b/src/lib/mental-health/wording/phq9/en.ts @@ -0,0 +1,47 @@ +/** + * PHQ-9, English: a validated translation, reproduced word for word. + * + * Kroenke, Spitzer & Williams, J Gen Intern Med 2001;16:606-613. + */ +import type { InstrumentWording } from "../types"; + +const wording: InstrumentWording = { + validated: true, + source: + "Pfizer Inc., Patient Health Questionnaire-9 (PHQ-9), the English original, read from https://www.phqscreeners.com/images/sites/g/files/g10060481/f/201412/PHQ-9_English.pdf. 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.", + stems: { + period: + "Over the last 2 weeks, how often have you been bothered by any of the following problems?", + }, + items: [ + "Little interest or pleasure in doing things", + "Feeling down, depressed, or hopeless", + "Trouble falling or staying asleep, or sleeping too much", + "Feeling tired or having little energy", + "Poor appetite or overeating", + "Feeling bad about yourself — or that you are a failure or have let yourself or your family down", + "Trouble concentrating on things, such as reading the newspaper or watching television", + "Moving or speaking so slowly that other people could have noticed. Or the opposite — being so fidgety or restless that you have been moving around a lot more than usual", + "Thoughts that you would be better off dead, or of hurting yourself in some way", + ], + anchors: { + all: { + 0: "Not at all", + 1: "Several days", + 2: "More than half the days", + 3: "Nearly every day", + }, + }, + functional: { + title: + "If you checked off any problems, how difficult have these problems made it for you to do your work, take care of things at home, or get along with other people?", + anchors: { + 0: "Not difficult at all", + 1: "Somewhat difficult", + 2: "Very difficult", + 3: "Extremely difficult", + }, + }, +}; + +export default wording; diff --git a/src/lib/mental-health/wording/phq9/es.ts b/src/lib/mental-health/wording/phq9/es.ts new file mode 100644 index 000000000..b01a96237 --- /dev/null +++ b/src/lib/mental-health/wording/phq9/es.ts @@ -0,0 +1,45 @@ +/** + * PHQ-9, Spanish: a validated translation, reproduced word for word. + */ +import type { InstrumentWording } from "../types"; + +const wording: InstrumentWording = { + validated: true, + source: + 'Pfizer Inc., PHQ-9, official translation "Spanish for Spain", read from https://www.phqscreeners.com/images/sites/g/files/g10060481/f/201412/PHQ9_Spanish%20for%20Spain.pdf. 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.', + stems: { + period: + "Durante las últimas 2 semanas ¿con qué frecuencia le ha molestado alguno de los siguientes problemas?", + }, + items: [ + "Poco interés o alegría por hacer cosas", + "Sensación de estar decaído/a, deprimido/a o desesperanzado/a", + "Problemas para quedarse dormido/a, para seguir durmiendo o dormir demasiado", + "Sensación de cansancio o de tener poca energía", + "Poco apetito o comer demasiado", + "Sentirse mal consigo mismo/a; sentir que es un/a fracasado/a o que ha decepcionado a su familia o a sí mismo/a", + "Problemas para concentrarse en algo, como leer el periódico o ver la televisión", + "Moverse o hablar tan despacio que los demás pueden haberlo notado. O lo contrario: estar tan inquieto/a o agitado/a que se ha estado moviendo de un lado a otro más de lo habitual", + "Pensamientos de que estaría mejor muerto/a o de querer hacerse daño de algún modo", + ], + anchors: { + all: { + 0: "Nunca", + 1: "Varios días", + 2: "Más de la mitad de los días", + 3: "Casi cada día", + }, + }, + functional: { + title: + "Si ha marcado alguno de los problemas de este cuestionario, ¿hasta qué punto estos problemas le han creado dificultades para hacer su trabajo, ocuparse de la casa o relacionarse con los demás?", + anchors: { + 0: "Ninguna dificultad", + 1: "Algunas dificultades", + 2: "Muchas dificultades", + 3: "Muchísimas dificultades", + }, + }, +}; + +export default wording; diff --git a/src/lib/mental-health/wording/phq9/fr.ts b/src/lib/mental-health/wording/phq9/fr.ts new file mode 100644 index 000000000..2bea16f4e --- /dev/null +++ b/src/lib/mental-health/wording/phq9/fr.ts @@ -0,0 +1,48 @@ +/** + * PHQ-9, French: a validated translation, reproduced word for word. + * + * The France version, which v1.45 could not obtain, read from the Pfizer + * distribution itself. Its own heading and column headers, unchanged. + */ +import type { InstrumentWording } from "../types"; + +const wording: InstrumentWording = { + validated: true, + source: + 'Pfizer Inc., PHQ-9, official translation "French for France", read from https://www.phqscreeners.com/images/sites/g/files/g10060481/f/201412/PHQ9_French%20for%20France.pdf. 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.', + stems: { + period: + "Au cours des 2 dernières semaines, selon quelle fréquence avez-vous été gêné(e) par les problèmes suivants ?", + }, + items: [ + "Peu d’intérêt ou de plaisir à faire les choses", + "Être triste, déprimé(e) ou désespéré(e)", + "Difficultés à s’endormir ou à rester endormi(e), ou dormir trop", + "Se sentir fatigué(e) ou manquer d’énergie", + "Avoir peu d’appétit ou manger trop", + "Avoir une mauvaise opinion de soi-même, ou avoir le sentiment d’être nul(le), ou d’avoir déçu sa famille ou s’être déçu(e) soi-même", + "Avoir du mal à se concentrer, par exemple, pour lire le journal ou regarder la télévision", + "Bouger ou parler si lentement que les autres auraient pu le remarquer. Ou au contraire, être si agité(e) que vous avez eu du mal à tenir en place par rapport à d’habitude", + "Penser qu’il vaudrait mieux mourir ou envisager de vous faire du mal d’une manière ou d’une autre", + ], + anchors: { + all: { + 0: "Jamais", + 1: "Plusieurs jours", + 2: "Plus de la moitié du temps", + 3: "Presque tous les jours", + }, + }, + functional: { + title: + "Si vous avez coché au moins un des problèmes évoqués, à quel point ce(s) problème(s) a-t-il (ont-ils) rendu votre travail, vos tâches à la maison ou votre capacité à vous entendre avec les autres difficile(s) ?", + anchors: { + 0: "Pas du tout difficile(s)", + 1: "Assez difficile(s)", + 2: "Très difficile(s)", + 3: "Extrêmement difficile(s)", + }, + }, +}; + +export default wording; diff --git a/src/lib/mental-health/wording/phq9/it.ts b/src/lib/mental-health/wording/phq9/it.ts new file mode 100644 index 000000000..eb37f7e06 --- /dev/null +++ b/src/lib/mental-health/wording/phq9/it.ts @@ -0,0 +1,45 @@ +/** + * PHQ-9, Italian: a validated translation, reproduced word for word. + */ +import type { InstrumentWording } from "../types"; + +const wording: InstrumentWording = { + validated: true, + source: + 'Pfizer Inc., PHQ-9, official translation "Italian for Italy", read from https://www.phqscreeners.com/images/sites/g/files/g10060481/f/201412/PHQ9_Italian%20for%20Italy.pdf. 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.', + stems: { + period: + "Nelle ultime 2 settimane, con quale frequenza le ha dato fastidio ciascuno dei seguenti problemi?", + }, + items: [ + "Scarso interesse o piacere nel fare le cose", + "Sentirsi giù, triste o disperato/a", + "Problemi ad addormentarsi o a dormire tutta la notte senza svegliarsi, o a dormire troppo", + "Sentirsi stanco/a o avere poca energia", + "Scarso appetito o mangiare troppo", + "Avere una scarsa opinione di sé, o sentirsi un/una fallito/a o aver deluso se stesso/a o i propri familiari", + "Difficoltà a concentrarsi su qualcosa, per esempio leggere il giornale o guardare la televisione", + "Muoversi o parlare così lentamente da poter essere notato/a da altre persone. O, al contrario, essere così irrequieto/a da muoversi molto più del solito", + "Pensare che sarebbe meglio morire o farsi del male in un modo o nell’altro", + ], + anchors: { + all: { + 0: "Mai", + 1: "Alcuni giorni", + 2: "Per più della metà dei giorni", + 3: "Quasi ogni giorno", + }, + }, + functional: { + title: + "Se ha fatto una crocetta su uno qualsiasi di questi problemi, quanto questi problemi le hanno reso difficile fare il suo lavoro, occuparsi delle sue cose a casa o avere buoni rapporti con gli altri?", + anchors: { + 0: "Per niente difficile", + 1: "Abbastanza difficile", + 2: "Molto difficile", + 3: "Estremamente difficile", + }, + }, +}; + +export default wording; diff --git a/src/lib/mental-health/wording/phq9/ko.ts b/src/lib/mental-health/wording/phq9/ko.ts new file mode 100644 index 000000000..8a82a0613 --- /dev/null +++ b/src/lib/mental-health/wording/phq9/ko.ts @@ -0,0 +1,49 @@ +/** + * PHQ-9, Korean: a validated translation, reproduced word for word. + * + * Each column header ("전혀 방해 / 받지 / 않았다" and so on) is one + * answer printed over three lines. The spacing of "7 일" and "2 주일" is + * the form's own. + */ +import type { InstrumentWording } from "../types"; + +const wording: InstrumentWording = { + validated: true, + source: + 'Pfizer Inc., PHQ-9, official translation "Korean for Korea", read from https://www.phqscreeners.com/images/sites/g/files/g10060481/f/201412/PHQ9_Korean%20for%20Korea.pdf. 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.', + stems: { + period: + "지난 2 주일 동안 당신은 다음의 문제들로 인해서 얼마나 자주 방해를 받았습니까?", + }, + items: [ + "일 또는 여가 활동을 하는 데 흥미나 즐거움을 느끼지 못함", + "기분이 가라앉거나, 우울하거나, 희망이 없음", + "잠이 들거나 계속 잠을 자는 것이 어려움, 또는 잠을 너무 많이 잠", + "피곤하다고 느끼거나 기운이 거의 없음", + "입맛이 없거나 과식을 함", + "자신을 부정적으로 봄 - 혹은 자신이 실패자라고 느끼거나 자신 또는 가족을 실망시킴", + "신문을 읽거나 텔레비전 보는 것과 같은 일에 집중하는 것이 어려움", + "다른 사람들이 주목할 정도로 너무 느리게 움직이거나 말을 함. 또는 반대로 평상시보다 많이 움직여서, 너무 안절부절 못하거나 들떠 있음", + "자신이 죽는 것이 더 낫다고 생각하거나 어떤 식으로든 자신을 해칠 것이라고 생각함", + ], + anchors: { + all: { + 0: "전혀 방해 받지 않았다", + 1: "며칠 동안 방해 받았다", + 2: "7 일 이상 방해 받았다", + 3: "거의 매일 방해 받았다", + }, + }, + functional: { + title: + '만일 당신이 위의 문제 중 하나 이상 "예" 라고 응답하셨으면, 이러한 문제들로 인해서 당신은 일을 하거나 가정일을 돌보거나 다른 사람과 어울리는 것이 얼마나 어려웠습니까?', + anchors: { + 0: "전혀 어렵지 않았다", + 1: "약간 어려웠다", + 2: "많이 어려웠다", + 3: "매우 많이 어려웠다", + }, + }, +}; + +export default wording; diff --git a/src/lib/mental-health/wording/phq9/nl.ts b/src/lib/mental-health/wording/phq9/nl.ts new file mode 100644 index 000000000..0b7a0d547 --- /dev/null +++ b/src/lib/mental-health/wording/phq9/nl.ts @@ -0,0 +1,49 @@ +/** + * PHQ-9, Dutch: a validated translation, reproduced word for word. + * + * The column header printed as "Verschei- / dene dagen" over two lines + * is the one word "Verscheidene". Item 8 keeps the question mark the + * form prints after its first sentence. + */ +import type { InstrumentWording } from "../types"; + +const wording: InstrumentWording = { + validated: true, + source: + 'Pfizer Inc., PHQ-9, official translation "Dutch for the Netherlands", read from https://www.phqscreeners.com/images/sites/g/files/g10060481/f/201412/PHQ9_Dutch%20for%20the%20Netherlands.pdf. 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.', + stems: { + period: + "Hoe vaak hebt u in de afgelopen 2 weken last gehad van één of meer van de volgende problemen?", + }, + items: [ + "Weinig interesse of plezier in activiteiten", + "Zich neerslachtig, depressief of hopeloos voelen", + "Moeilijk inslapen, moeilijk doorslapen of te veel slapen", + "Zich moe voelen of gebrek aan energie hebben", + "Weinig eetlust of overmatig eten", + "Een slecht gevoel hebben over uzelf — of het gevoel hebben dat u een mislukkeling bent of het gevoel dat u zichzelf of uw familie teleurgesteld hebt", + "Problemen om u te concentreren, bijvoorbeeld om de krant te lezen of om tv te kijken", + "Zo traag bewegen of zo langzaam spreken dat andere mensen dit opgemerkt kunnen hebben? Of het tegenovergestelde, zo zenuwachtig of rusteloos zijn dat u veel meer bewoog dan gebruikelijk", + "De gedachte dat u beter dood zou kunnen zijn of de gedachte uzelf op een bepaalde manier pijn te doen", + ], + anchors: { + all: { + 0: "Helemaal niet", + 1: "Verscheidene dagen", + 2: "Meer dan de helft van de dagen", + 3: "Bijna elke dag", + }, + }, + functional: { + title: + "Als u enig probleem hebt aangekruist, hoe moeilijk maakten deze problemen het dan voor u om uw werk of uw taken in en om het huis te doen, of om met andere mensen om te gaan?", + anchors: { + 0: "Helemaal niet moeilijk", + 1: "Enigszins moeilijk", + 2: "Erg moeilijk", + 3: "Extreem moeilijk", + }, + }, +}; + +export default wording; diff --git a/src/lib/mental-health/wording/phq9/pl.ts b/src/lib/mental-health/wording/phq9/pl.ts new file mode 100644 index 000000000..61bb67819 --- /dev/null +++ b/src/lib/mental-health/wording/phq9/pl.ts @@ -0,0 +1,45 @@ +/** + * PHQ-9, Polish: a validated translation, reproduced word for word. + */ +import type { InstrumentWording } from "../types"; + +const wording: InstrumentWording = { + validated: true, + source: + 'Pfizer Inc., PHQ-9, official translation "Polish for Poland", read from https://www.phqscreeners.com/images/sites/g/files/g10060481/f/201412/PHQ9_Polish%20for%20Poland.pdf. 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.', + stems: { + period: + "Jak często w ciągu ostatnich 2 tygodni dokuczały Panu/Pani następujące problemy?", + }, + items: [ + "Niewielkie zainteresowanie lub odczuwanie przyjemności z wykonywania czynności", + "Uczucie smutku, przygnębienia lub beznadziejności", + "Kłopoty z zaśnięciem lub przerywany sen, albo zbyt długi sen", + "Uczucie zmęczenia lub brak energii", + "Brak apetytu lub przejadanie się", + "Poczucie niezadowolenia z siebie — lub uczucie, że jest się do niczego, albo że zawiódł/zawiodła Pan/Pani siebie lub rodzinę", + "Problemy ze skupieniem się na przykład przy czytaniu gazety lub oglądaniu telewizji", + "Poruszanie się lub mówienie tak wolno, że inni mogliby to zauważyć? Albo wręcz przeciwnie — niemożność usiedzenia w miejscu lub podenerwowanie powodujące ruchliwość znacznie większą niż zwykle", + "Myśli, że lepiej byłoby umrzeć, albo chęć zrobienia sobie jakiejś krzywdy", + ], + anchors: { + all: { + 0: "Wcale nie dokuczały", + 1: "Kilka dni", + 2: "Więcej niż połowę dni", + 3: "Niemal codziennie", + }, + }, + functional: { + title: + "Jeżeli zaznaczył/-a Pan/Pani którekolwiek z problemów, jak bardzo utrudniły one Panu/Pani wykonywanie pracy, zajmowanie się domem lub relacje z innymi ludźmi?", + anchors: { + 0: "W ogóle nie utrudniły", + 1: "Trochę utrudniły", + 2: "Bardzo utrudniły", + 3: "Niezmiernie utrudniły", + }, + }, +}; + +export default wording; diff --git a/src/lib/mental-health/wording/phq9/pt-br.ts b/src/lib/mental-health/wording/phq9/pt-br.ts new file mode 100644 index 000000000..c0f1b68f4 --- /dev/null +++ b/src/lib/mental-health/wording/phq9/pt-br.ts @@ -0,0 +1,50 @@ +/** + * PHQ-9, Portuguese (Brazil): a validated translation, reproduced word for word. + * + * Spelling as printed, including the pre-2009 "freqüência" and the + * question marks closing item 8's first sentence and the follow-up. + * Item 9 names self-harm first and death second, as the form does; the + * item is the same ninth item and is scored and flagged the same way. + */ +import type { InstrumentWording } from "../types"; + +const wording: InstrumentWording = { + validated: true, + source: + 'Pfizer Inc., PHQ-9, official translation "Portuguese for Brazil", read from https://www.phqscreeners.com/images/sites/g/files/g10060481/f/201412/PHQ9_Portuguese%20for%20Brazil.pdf. 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.', + stems: { + period: + "Durante as últimas 2 semanas, com que freqüência você foi incomodado/a por qualquer um dos problemas abaixo?", + }, + items: [ + "Pouco interesse ou pouco prazer em fazer as coisas", + "Se sentir “para baixo”, deprimido/a ou sem perspectiva", + "Dificuldade para pegar no sono ou permanecer dormindo, ou dormir mais do que de costume", + "Se sentir cansado/a ou com pouca energia", + "Falta de apetite ou comendo demais", + "Se sentir mal consigo mesmo/a — ou achar que você é um fracasso ou que decepcionou sua família ou você mesmo/a", + "Dificuldade para se concentrar nas coisas, como ler o jornal ou ver televisão", + "Lentidão para se movimentar ou falar, a ponto das outras pessoas perceberem? Ou o oposto – estar tão agitado/a ou irrequieto/a que você fica andando de um lado para o outro muito mais do que de costume", + "Pensar em se ferir de alguma maneira ou que seria melhor estar morto/a", + ], + anchors: { + all: { + 0: "Nenhuma vez", + 1: "Vários dias", + 2: "Mais da metade dos dias", + 3: "Quase todos os dias", + }, + }, + functional: { + title: + "Se você assinalou qualquer um dos problemas, indique o grau de dificuldade que os mesmos lhe causaram para realizar seu trabalho, tomar conta das coisas em casa ou para se relacionar com as pessoas?", + anchors: { + 0: "Nenhuma dificuldade", + 1: "Alguma dificuldade", + 2: "Muita dificuldade", + 3: "Extrema dificuldade", + }, + }, +}; + +export default wording; diff --git a/src/lib/mental-health/wording/resolve.ts b/src/lib/mental-health/wording/resolve.ts new file mode 100644 index 000000000..7e89d2643 --- /dev/null +++ b/src/lib/mental-health/wording/resolve.ts @@ -0,0 +1,44 @@ +/** + * The questionnaire text as the server and the tests read it: every language + * at once, and the one answer to which wording an administration was given + * in. A client surface loads one language through `./load.ts` instead. + */ +import { localeFromLanguageTag } from "@/lib/i18n/config"; + +import type { InstrumentId } from "../instruments"; +import { wordingLocale } from "../instrument-wording"; +import { WORDING } from "./index"; +import type { AssessmentWording, InstrumentWording } from "./types"; + +/** + * The instrument's text for a reader of `locale`. A language the app does + * not ship reads the English original. + */ +export function wordingFor( + id: InstrumentId, + locale: string, +): InstrumentWording { + return WORDING[id][wordingLocale(locale)]; +} + +/** Whether the text for `locale` is a published validated translation. */ +export function isValidatedWording(id: InstrumentId, locale: string): boolean { + return wordingFor(id, locale).validated; +} + +/** + * The wording an administration was answered in, from what the reader saw: + * their language's text, or the English original they switched to. + */ +export function presentedWording( + id: InstrumentId, + locale: string, + englishOriginal: boolean, +): AssessmentWording { + const resolved = localeFromLanguageTag(locale); + if (resolved === "en") return "validated"; + if (englishOriginal || resolved === null) return "original_en"; + return WORDING[id][resolved].validated + ? "validated" + : "unvalidated_translation"; +} diff --git a/src/lib/mental-health/wording/sci/de.ts b/src/lib/mental-health/wording/sci/de.ts new file mode 100644 index 000000000..2512f58a0 --- /dev/null +++ b/src/lib/mental-health/wording/sci/de.ts @@ -0,0 +1,62 @@ +/** + * SCI, German: not a validated translation. + * + * No validated translation of the SCI in this language could be found whose + * item text is openly published for reuse. This is a translation written for + * HealthLog, faithful to the English and on the same scale; the surfaces say + * so beside it and offer the English original. + */ +import type { InstrumentWording } from "../types"; + +const wording: InstrumentWording = { + validated: false, + stems: { + night: "Wenn Sie an eine typische Nacht im letzten Monat denken …", + impact: + "Wenn Sie an den letzten Monat denken: In welchem Ausmaß hat schlechter Schlaf …", + finally: "Zum Schluss …", + }, + items: [ + "… wie lange brauchen Sie, um einzuschlafen?", + "… wenn Sie dann in der Nacht aufwachen … wie lange sind Sie insgesamt wach? (Zählen Sie alle Wachphasen zusammen.)", + "… in wie vielen Nächten pro Woche haben Sie ein Problem mit Ihrem Schlaf?", + "… wie würden Sie Ihre Schlafqualität bewerten?", + "… Ihre Stimmung, Ihre Energie oder Ihre Beziehungen beeinträchtigt?", + "… Ihre Konzentration, Ihre Leistungsfähigkeit oder Ihre Fähigkeit, wach zu bleiben, beeinträchtigt?", + "… Sie insgesamt belastet?", + "… wie lange haben Sie schon ein Problem mit Ihrem Schlaf?", + ], + anchors: { + duration: { + 0: "≥ 61 Min.", + 1: "46–60 Min.", + 2: "31–45 Min.", + 3: "16–30 Min.", + 4: "0–15 Min.", + }, + nights: { 0: "5–7", 1: "4", 2: "3", 3: "2", 4: "0–1" }, + quality: { + 0: "Sehr schlecht", + 1: "Schlecht", + 2: "Mittelmäßig", + 3: "Gut", + 4: "Sehr gut", + }, + impact: { + 0: "Sehr stark", + 1: "Stark", + 2: "Mäßig", + 3: "Ein wenig", + 4: "Überhaupt nicht", + }, + problemDuration: { + 0: "> 1 Jahr", + 1: "7–12 Monate", + 2: "3–6 Monate", + 3: "1–2 Monate", + 4: "Ich habe kein Problem / < 1 Monat", + }, + }, +}; + +export default wording; diff --git a/src/lib/mental-health/wording/sci/en.ts b/src/lib/mental-health/wording/sci/en.ts new file mode 100644 index 000000000..8488c5e2a --- /dev/null +++ b/src/lib/mental-health/wording/sci/en.ts @@ -0,0 +1,58 @@ +/** + * SCI, English: a validated translation, reproduced word for word. + */ +import type { InstrumentWording } from "../types"; + +const wording: InstrumentWording = { + validated: true, + source: + "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, https://bmjopen.bmj.com/content/4/3/e004183. © Sleepio Limited; free for non-commercial use (CC BY-NC).", + stems: { + night: "Thinking about a typical night in the last month …", + impact: "Thinking about the past month, to what extent has poor sleep …", + finally: "Finally …", + }, + items: [ + "… how long does it take you to fall asleep?", + "… if you then wake up during the night … how long are you awake for in total? (add all the wakenings up)", + "… how many nights a week do you have a problem with your sleep?", + "… how would you rate your sleep quality?", + "… affected your mood, energy, or relationships?", + "… affected your concentration, productivity, or ability to stay awake?", + "… troubled you in general?", + "… how long have you had a problem with your sleep?", + ], + anchors: { + duration: { + 0: "≥ 61 min", + 1: "46–60 min", + 2: "31–45 min", + 3: "16–30 min", + 4: "0–15 min", + }, + nights: { 0: "5–7", 1: "4", 2: "3", 3: "2", 4: "0–1" }, + quality: { + 0: "Very poor", + 1: "Poor", + 2: "Average", + 3: "Good", + 4: "Very good", + }, + impact: { + 0: "Very much", + 1: "Much", + 2: "Somewhat", + 3: "A little", + 4: "Not at all", + }, + problemDuration: { + 0: "> 1 year", + 1: "7–12 months", + 2: "3–6 months", + 3: "1–2 months", + 4: "I don't have a problem / < 1 month", + }, + }, +}; + +export default wording; diff --git a/src/lib/mental-health/wording/sci/es.ts b/src/lib/mental-health/wording/sci/es.ts new file mode 100644 index 000000000..cf49ef509 --- /dev/null +++ b/src/lib/mental-health/wording/sci/es.ts @@ -0,0 +1,55 @@ +/** + * SCI, Spanish: not a validated translation. + * + * No validated translation of the SCI in this language could be found whose + * item text is openly published for reuse. This is a translation written for + * HealthLog, faithful to the English and on the same scale; the surfaces say + * so beside it and offer the English original. + */ +import type { InstrumentWording } from "../types"; + +const wording: InstrumentWording = { + validated: false, + stems: { + night: "Pensando en una noche típica del último mes …", + impact: "Pensando en el último mes, ¿en qué medida dormir mal …", + finally: "Por último …", + }, + items: [ + "… ¿cuánto tiempo tarda en quedarse dormido/a?", + "… si después se despierta durante la noche … ¿cuánto tiempo permanece despierto/a en total? (sume todos los despertares)", + "… ¿cuántas noches a la semana tiene un problema con el sueño?", + "… ¿cómo valoraría la calidad de su sueño?", + "… ha afectado a su estado de ánimo, su energía o sus relaciones?", + "… ha afectado a su concentración, su productividad o su capacidad para mantenerse despierto/a?", + "… le ha molestado en general?", + "… ¿desde hace cuánto tiempo tiene un problema con el sueño?", + ], + anchors: { + duration: { + 0: "≥ 61 min", + 1: "46–60 min", + 2: "31–45 min", + 3: "16–30 min", + 4: "0–15 min", + }, + nights: { 0: "5–7", 1: "4", 2: "3", 3: "2", 4: "0–1" }, + quality: { + 0: "Muy mala", + 1: "Mala", + 2: "Regular", + 3: "Buena", + 4: "Muy buena", + }, + impact: { 0: "Muchísimo", 1: "Mucho", 2: "Algo", 3: "Un poco", 4: "Nada" }, + problemDuration: { + 0: "> 1 año", + 1: "7–12 meses", + 2: "3–6 meses", + 3: "1–2 meses", + 4: "No tengo ningún problema / < 1 mes", + }, + }, +}; + +export default wording; diff --git a/src/lib/mental-health/wording/sci/fr.ts b/src/lib/mental-health/wording/sci/fr.ts new file mode 100644 index 000000000..4dfa44ac4 --- /dev/null +++ b/src/lib/mental-health/wording/sci/fr.ts @@ -0,0 +1,67 @@ +/** + * SCI, French: not a validated translation. + * + * No validated translation of the SCI in this language could be found whose + * item text is openly published for reuse. This is a translation written for + * HealthLog, faithful to the English and on the same scale; the surfaces say + * so beside it and offer the English original. + * + * A validated French SCI exists (Bayard et al., J Sleep Res 2017;26:702-708), + * but its text is published only in the journal (© European Sleep Research + * Society) and in an accepted manuscript whose repository terms allow one + * copy for private study, not redistribution in software. + */ +import type { InstrumentWording } from "../types"; + +const wording: InstrumentWording = { + validated: false, + stems: { + night: "En pensant à une nuit typique du mois dernier …", + impact: + "En pensant au mois dernier, dans quelle mesure votre mauvais sommeil …", + finally: "Enfin …", + }, + items: [ + "… combien de temps vous faut-il pour vous endormir ?", + "… si vous vous réveillez ensuite pendant la nuit … combien de temps restez-vous éveillé(e) au total ? (additionnez tous les réveils)", + "… combien de nuits par semaine avez-vous un problème de sommeil ?", + "… comment évalueriez-vous la qualité de votre sommeil ?", + "… a-t-il affecté votre humeur, votre énergie ou vos relations ?", + "… a-t-il affecté votre concentration, votre productivité ou votre capacité à rester éveillé(e) ?", + "… vous a-t-il gêné(e) de manière générale ?", + "… depuis combien de temps avez-vous un problème de sommeil ?", + ], + anchors: { + duration: { + 0: "≥ 61 min", + 1: "46–60 min", + 2: "31–45 min", + 3: "16–30 min", + 4: "0–15 min", + }, + nights: { 0: "5–7", 1: "4", 2: "3", 3: "2", 4: "0–1" }, + quality: { + 0: "Très mauvaise", + 1: "Mauvaise", + 2: "Moyenne", + 3: "Bonne", + 4: "Très bonne", + }, + impact: { + 0: "Énormément", + 1: "Beaucoup", + 2: "Moyennement", + 3: "Un peu", + 4: "Pas du tout", + }, + problemDuration: { + 0: "> 1 an", + 1: "7–12 mois", + 2: "3–6 mois", + 3: "1–2 mois", + 4: "Je n’ai pas de problème / < 1 mois", + }, + }, +}; + +export default wording; diff --git a/src/lib/mental-health/wording/sci/it.ts b/src/lib/mental-health/wording/sci/it.ts new file mode 100644 index 000000000..ab0ed81fd --- /dev/null +++ b/src/lib/mental-health/wording/sci/it.ts @@ -0,0 +1,64 @@ +/** + * SCI, Italian: not a validated translation. + * + * No validated translation of the SCI in this language could be found whose + * item text is openly published for reuse. This is a translation written for + * HealthLog, faithful to the English and on the same scale; the surfaces say + * so beside it and offer the English original. + * + * A validated Italian SCI exists (Palagini et al., Int J Psychophysiol + * 2015;98:435-440); its item text is not openly published. + */ +import type { InstrumentWording } from "../types"; + +const wording: InstrumentWording = { + validated: false, + stems: { + night: "Pensando a una notte tipica dell’ultimo mese …", + impact: "Pensando all’ultimo mese, in che misura il sonno scarso …", + finally: "Infine …", + }, + items: [ + "… quanto tempo impiega ad addormentarsi?", + "… se poi si sveglia durante la notte … per quanto tempo resta sveglio/a in totale? (sommi tutti i risvegli)", + "… quante notti alla settimana ha un problema con il sonno?", + "… come valuterebbe la qualità del suo sonno?", + "… ha influito sul suo umore, sulla sua energia o sulle sue relazioni?", + "… ha influito sulla sua concentrazione, sulla sua produttività o sulla sua capacità di restare sveglio/a?", + "… le ha dato fastidio in generale?", + "… da quanto tempo ha un problema con il sonno?", + ], + anchors: { + duration: { + 0: "≥ 61 min", + 1: "46–60 min", + 2: "31–45 min", + 3: "16–30 min", + 4: "0–15 min", + }, + nights: { 0: "5–7", 1: "4", 2: "3", 3: "2", 4: "0–1" }, + quality: { + 0: "Molto scarsa", + 1: "Scarsa", + 2: "Nella media", + 3: "Buona", + 4: "Molto buona", + }, + impact: { + 0: "Moltissimo", + 1: "Molto", + 2: "Abbastanza", + 3: "Un poco", + 4: "Per niente", + }, + problemDuration: { + 0: "> 1 anno", + 1: "7–12 mesi", + 2: "3–6 mesi", + 3: "1–2 mesi", + 4: "Non ho problemi / < 1 mese", + }, + }, +}; + +export default wording; diff --git a/src/lib/mental-health/wording/sci/ko.ts b/src/lib/mental-health/wording/sci/ko.ts new file mode 100644 index 000000000..1fed6a900 --- /dev/null +++ b/src/lib/mental-health/wording/sci/ko.ts @@ -0,0 +1,66 @@ +/** + * SCI, Korean: not a validated translation. + * + * No validated translation of the SCI in this language could be found whose + * item text is openly published for reuse. This is a translation written for + * HealthLog, faithful to the English and on the same scale; the surfaces say + * so beside it and offer the English original. + * + * A Korean SCI was tested in a master's thesis (Song S-A, Sungshin Women's + * University 2024; CC BY-NC-ND). It is not a peer-reviewed validation and + * it changes the instrument: an agreement scale for items 5 to 7, a + * different item 7 and no closing stem, so it is not used. + */ +import type { InstrumentWording } from "../types"; + +const wording: InstrumentWording = { + validated: false, + stems: { + night: "지난 한 달 동안의 평소 밤을 생각해 볼 때 …", + impact: "지난 한 달을 생각해 볼 때, 잠을 잘 자지 못한 것이 …", + finally: "마지막으로 …", + }, + items: [ + "… 잠드는 데 얼마나 걸립니까?", + "… 그런 다음 밤중에 깨는 경우 … 모두 합해 얼마나 오래 깨어 있습니까? (깨어 있던 시간을 모두 더하십시오)", + "… 일주일에 며칠 밤 수면에 문제가 있습니까?", + "… 수면의 질을 어떻게 평가하시겠습니까?", + "… 기분, 기력 또는 대인 관계에 어느 정도 영향을 주었습니까?", + "… 집중력, 생산성 또는 깨어 있는 능력에 어느 정도 영향을 주었습니까?", + "… 전반적으로 어느 정도 괴로움을 주었습니까?", + "… 수면 문제가 얼마나 오래되었습니까?", + ], + anchors: { + duration: { + 0: "61분 이상", + 1: "46–60분", + 2: "31–45분", + 3: "16–30분", + 4: "0–15분", + }, + nights: { 0: "5–7", 1: "4", 2: "3", 3: "2", 4: "0–1" }, + quality: { + 0: "매우 나쁨", + 1: "나쁨", + 2: "보통", + 3: "좋음", + 4: "매우 좋음", + }, + impact: { + 0: "매우 많이", + 1: "많이", + 2: "어느 정도", + 3: "약간", + 4: "전혀 아니다", + }, + problemDuration: { + 0: "1년 초과", + 1: "7–12개월", + 2: "3–6개월", + 3: "1–2개월", + 4: "문제 없음 / 1개월 미만", + }, + }, +}; + +export default wording; diff --git a/src/lib/mental-health/wording/sci/nl.ts b/src/lib/mental-health/wording/sci/nl.ts new file mode 100644 index 000000000..535d13a9e --- /dev/null +++ b/src/lib/mental-health/wording/sci/nl.ts @@ -0,0 +1,62 @@ +/** + * SCI, Dutch: not a validated translation. + * + * No validated translation of the SCI in this language could be found whose + * item text is openly published for reuse. This is a translation written for + * HealthLog, faithful to the English and on the same scale; the surfaces say + * so beside it and offer the English original. + */ +import type { InstrumentWording } from "../types"; + +const wording: InstrumentWording = { + validated: false, + stems: { + night: "Als u denkt aan een gewone nacht in de afgelopen maand …", + impact: + "Als u denkt aan de afgelopen maand, in hoeverre heeft slecht slapen …", + finally: "Tot slot …", + }, + items: [ + "… hoe lang duurt het voordat u in slaap valt?", + "… als u daarna ’s nachts wakker wordt … hoe lang bent u dan in totaal wakker? (tel alle keren dat u wakker was bij elkaar op)", + "… hoeveel nachten per week hebt u een probleem met uw slaap?", + "… hoe zou u de kwaliteit van uw slaap beoordelen?", + "… invloed gehad op uw stemming, energie of relaties?", + "… invloed gehad op uw concentratie, productiviteit of vermogen om wakker te blijven?", + "… u in het algemeen dwarsgezeten?", + "… hoe lang hebt u al een probleem met uw slaap?", + ], + anchors: { + duration: { + 0: "≥ 61 min", + 1: "46–60 min", + 2: "31–45 min", + 3: "16–30 min", + 4: "0–15 min", + }, + nights: { 0: "5–7", 1: "4", 2: "3", 3: "2", 4: "0–1" }, + quality: { + 0: "Zeer slecht", + 1: "Slecht", + 2: "Gemiddeld", + 3: "Goed", + 4: "Zeer goed", + }, + impact: { + 0: "Heel veel", + 1: "Veel", + 2: "Enigszins", + 3: "Een beetje", + 4: "Helemaal niet", + }, + problemDuration: { + 0: "> 1 jaar", + 1: "7–12 maanden", + 2: "3–6 maanden", + 3: "1–2 maanden", + 4: "Ik heb geen probleem / < 1 maand", + }, + }, +}; + +export default wording; diff --git a/src/lib/mental-health/wording/sci/pl.ts b/src/lib/mental-health/wording/sci/pl.ts new file mode 100644 index 000000000..504ccf537 --- /dev/null +++ b/src/lib/mental-health/wording/sci/pl.ts @@ -0,0 +1,69 @@ +/** + * SCI, Polish: a validated translation, reproduced word for word. + * + * Validated psychometrically in an online panel (n = 1,282); the paper + * reports no diagnostic interview. As printed, with three changes of + * typography only: the items that open with three full stops open with + * the ellipsis the others use, the stray "<" left at the end of the first + * answer to item 8 (a remnant of the English "< 1 month" with no number + * after it) is dropped, and the slash in that answer is spaced. The + * fourth duration answer reads "45-60 min" in the published version + * (the English form has 46–60) and is kept. Item 6 renders "ability to + * stay awake" as excessive sleepiness, as the validated version does. + */ +import type { InstrumentWording } from "../types"; + +const wording: InstrumentWording = { + validated: true, + source: + 'Bidzan-Wiącek M, Cieślak M, Łockiewicz M, et al. Polish validation of the Sleep Condition Indicator (SCI-PL) for screening insomnia disorder based on DSM-5 criteria. Sci Rep 2026, doi:10.1038/s41598-026-71789-4, Supplementary Material 1, Appendix 1 ("Wskaźnik jakości snu (SCI)"), https://www.nature.com/articles/s41598-026-71789-4. Article licensed CC BY 4.0; the SCI itself © Sleepio Limited, free for non-commercial use (CC BY-NC), Espie et al., BMJ Open 2014;4:e004183.', + stems: { + night: "Biorąc pod uwagę typową noc w ciągu ostatniego miesiąca…", + impact: + "Biorąc pod uwagę tylko ostatni miesiąc, w jakim stopniu problemy ze snem…", + finally: "Ostatnie pytanie…", + }, + items: [ + "…ile czasu zajmuje Panu(-i) zaśnięcie?", + "…jeśli po zaśnięciu wybudza się Pan(i) w nocy, jak długo łącznie Pan(i) nie śpi? (prosimy zsumować czas wszystkich wybudzeń)", + "…przez ile nocy w tygodniu ma Pan(i) problemy ze snem?", + "…jak ocenił(a)by Pan(i) jakość swojego snu?", + "…wpłynęły na Pana(-i) nastrój, poziom energii lub relacje z innymi?", + "…wpłynęły na Pana(-i) koncentrację, produktywność lub spowodowały nadmierną senność?", + "…ogólnie Panu(-i) przeszkadzał?", + "…od jak dawna ma Pan(i) problemy ze snem?", + ], + anchors: { + duration: { + 0: "≥ 61 min", + 1: "45-60 min", + 2: "31-45 min", + 3: "16-30 min", + 4: "0-15 min", + }, + nights: { 0: "5-7", 1: "4", 2: "3", 3: "2", 4: "0-1" }, + quality: { + 0: "Bardzo źle", + 1: "Źle", + 2: "Średnio", + 3: "Dobrze", + 4: "Bardzo dobrze", + }, + impact: { + 0: "Bardzo mocno", + 1: "Mocno", + 2: "Lekko", + 3: "Trochę", + 4: "Wcale", + }, + problemDuration: { + 0: "Ponad rok", + 1: "7-12 miesięcy", + 2: "3-6 miesięcy", + 3: "1-2 miesiące", + 4: "Nie mam problemów / krócej niż miesiąc", + }, + }, +}; + +export default wording; diff --git a/src/lib/mental-health/wording/sci/pt-br.ts b/src/lib/mental-health/wording/sci/pt-br.ts new file mode 100644 index 000000000..353952ce3 --- /dev/null +++ b/src/lib/mental-health/wording/sci/pt-br.ts @@ -0,0 +1,65 @@ +/** + * SCI, Portuguese (Brazil): not a validated translation. + * + * No validated translation of the SCI in this language could be found whose + * item text is openly published for reuse. This is a translation written for + * HealthLog, faithful to the English and on the same scale; the surfaces say + * so beside it and offer the English original. + * + * The SCI is validated in European Portuguese (Marques et al., J Sleep Res + * 2024), not in Brazilian Portuguese, and that version's text is + * available only from its authors on request. + */ +import type { InstrumentWording } from "../types"; + +const wording: InstrumentWording = { + validated: false, + stems: { + night: "Pensando em uma noite típica do último mês …", + impact: "Pensando no último mês, em que medida o sono ruim …", + finally: "Por fim …", + }, + items: [ + "… quanto tempo você leva para pegar no sono?", + "… se depois você acorda durante a noite … quanto tempo fica acordado/a no total? (some todos os despertares)", + "… quantas noites por semana você tem problema com o sono?", + "… como você avaliaria a qualidade do seu sono?", + "… afetou seu humor, sua energia ou seus relacionamentos?", + "… afetou sua concentração, sua produtividade ou sua capacidade de se manter acordado/a?", + "… incomodou você de modo geral?", + "… há quanto tempo você tem problema com o sono?", + ], + anchors: { + duration: { + 0: "≥ 61 min", + 1: "46–60 min", + 2: "31–45 min", + 3: "16–30 min", + 4: "0–15 min", + }, + nights: { 0: "5–7", 1: "4", 2: "3", 3: "2", 4: "0–1" }, + quality: { + 0: "Muito ruim", + 1: "Ruim", + 2: "Regular", + 3: "Boa", + 4: "Muito boa", + }, + impact: { + 0: "Muitíssimo", + 1: "Muito", + 2: "Mais ou menos", + 3: "Um pouco", + 4: "Nada", + }, + problemDuration: { + 0: "> 1 ano", + 1: "7–12 meses", + 2: "3–6 meses", + 3: "1–2 meses", + 4: "Não tenho problema / < 1 mês", + }, + }, +}; + +export default wording; diff --git a/src/lib/mental-health/wording/types.ts b/src/lib/mental-health/wording/types.ts new file mode 100644 index 000000000..a54f207be --- /dev/null +++ b/src/lib/mental-health/wording/types.ts @@ -0,0 +1,76 @@ +/** + * The shape of one questionnaire's wording in one app language. + * + * Questionnaire text lives in code, not in `messages/*.json`: a translator + * working through the bundles must never see an item, let alone machine- + * translate one, because changing a word in a validated instrument changes + * what it measures. Every entry says what it is. A validated entry reproduces + * a published translation word for word and names it in `source`; an + * unvalidated entry is a translation written for HealthLog, faithful to the + * English and on the same scale, and the surfaces say so beside it. + */ + +import type { InstrumentId } from "../instruments"; + +/** Answer labels keyed by the score each one carries. */ +export type AnchorSet = Readonly>; + +/** The anchor group every item of a single-scale instrument uses. */ +export const SINGLE_ANCHOR_GROUP = "all"; + +interface WordingText { + /** + * The text the source form prints above its items, keyed by the stem id + * the instrument definition names per item (`stemIds`). + */ + stems: Readonly>; + /** The scored items in the order the instrument presents them. */ + items: readonly string[]; + /** + * Answer labels per anchor group: `all` for a single-scale instrument, the + * group names of `optionGroups` for one with item-specific anchors (SCI). + */ + anchors: Readonly>; + /** The PHQ-9's unscored follow-up question and its four answers. */ + functional?: { title: string; anchors: AnchorSet }; +} + +export interface ValidatedWording extends WordingText { + validated: true; + /** + * The published version reproduced: who made it, the document and where it + * was read from, and its terms of use. + */ + source: string; +} + +export interface UnvalidatedWording extends WordingText { + validated: false; +} + +export type InstrumentWording = ValidatedWording | UnvalidatedWording; + +/** Every questionnaire's wording in one language. */ +export type LocaleWording = Readonly>; + +/** + * Which text an administration was answered in, stored on the row: + * - `validated`: the published validated version for the reader's language + * (the English original for an English reader); + * - `unvalidated_translation`: HealthLog's own translation, for a language + * without a validated version; + * - `original_en`: the English original, chosen by a reader whose language + * has its own text (or whose language the app does not ship). + */ +export const ASSESSMENT_WORDINGS = [ + "validated", + "unvalidated_translation", + "original_en", +] as const; +export type AssessmentWording = (typeof ASSESSMENT_WORDINGS)[number]; + +export function isAssessmentWording( + value: string | null, +): value is AssessmentWording { + return (ASSESSMENT_WORDINGS as readonly (string | null)[]).includes(value); +} diff --git a/src/lib/mental-health/wording/who5/de.ts b/src/lib/mental-health/wording/who5/de.ts new file mode 100644 index 000000000..111021e60 --- /dev/null +++ b/src/lib/mental-health/wording/who5/de.ts @@ -0,0 +1,30 @@ +/** + * WHO-5, German: a validated translation, reproduced word for word. + */ +import type { InstrumentWording } from "../types"; + +const wording: InstrumentWording = { + validated: true, + source: + 'Psychiatric Research Unit (WHO Collaborating Centre in Mental Health, Mental Health Centre North Zealand) translation, republished by WHO beside WHO/UCN/MSD/MHE/2024.1 (licence CC BY-NC-SA 3.0 IGO). German, 1998 version ("WHO (Fünf) - Fragebogen zum Wohlbefinden"), read from https://cdn.who.int/media/docs/default-source/mental-health/five-well-being-index-(who-5)/who-5_german.pdf.', + stems: { period: "In den letzten zwei Wochen …" }, + items: [ + "… war ich froh und guter Laune", + "… habe ich mich ruhig und entspannt gefühlt", + "… habe ich mich energisch und aktiv gefühlt", + "… habe ich mich beim Aufwachen frisch und ausgeruht gefühlt", + "… war mein Alltag voller Dinge, die mich interessieren", + ], + anchors: { + all: { + 0: "Zu keinem Zeitpunkt", + 1: "Ab und zu", + 2: "Etwas weniger als die Hälfte der Zeit", + 3: "Etwas mehr als die Hälfte der Zeit", + 4: "Meistens", + 5: "Die ganze Zeit", + }, + }, +}; + +export default wording; diff --git a/src/lib/mental-health/wording/who5/en.ts b/src/lib/mental-health/wording/who5/en.ts new file mode 100644 index 000000000..047322584 --- /dev/null +++ b/src/lib/mental-health/wording/who5/en.ts @@ -0,0 +1,30 @@ +/** + * WHO-5, English: a validated translation, reproduced word for word. + */ +import type { InstrumentWording } from "../types"; + +const wording: InstrumentWording = { + validated: true, + source: + "World Health Organization. The World Health Organization-Five Well-Being Index (WHO-5). Geneva: World Health Organization; 2024 (WHO/UCN/MSD/MHE/2024.1), https://www.who.int/publications/m/item/WHO-UCN-MSD-MHE-2024.01. Licence: CC BY-NC-SA 3.0 IGO.", + stems: { period: "Over the last two weeks …" }, + items: [ + "I have felt cheerful and in good spirits", + "I have felt calm and relaxed", + "I have felt active and vigorous", + "I woke up feeling fresh and rested", + "My daily life has been filled with things that interest me", + ], + anchors: { + all: { + 0: "At no time", + 1: "Some of the time", + 2: "Less than half of the time", + 3: "More than half of the time", + 4: "Most of the time", + 5: "All of the time", + }, + }, +}; + +export default wording; diff --git a/src/lib/mental-health/wording/who5/es.ts b/src/lib/mental-health/wording/who5/es.ts new file mode 100644 index 000000000..3cce92e1b --- /dev/null +++ b/src/lib/mental-health/wording/who5/es.ts @@ -0,0 +1,33 @@ +/** + * WHO-5, Spanish: a validated translation, reproduced word for word. + * + * The sheet misprints item 4 as "descandado"; this text keeps the word it + * means, "descansado". Nothing else differs from the sheet. + */ +import type { InstrumentWording } from "../types"; + +const wording: InstrumentWording = { + validated: true, + source: + 'Psychiatric Research Unit (WHO Collaborating Centre in Mental Health, Mental Health Centre North Zealand) translation, republished by WHO beside WHO/UCN/MSD/MHE/2024.1 (licence CC BY-NC-SA 3.0 IGO). Spanish, 1998 version ("OMS (cinco) Indice de Bienestar"), read from https://cdn.who.int/media/docs/default-source/mental-health/oms-(cinco)-indice-de-bienestar-(oms-5).pdf.', + stems: { period: "Durante las últimas dos semanas:" }, + items: [ + "Me he sentido alegre y de buen humor", + "Me he sentido tranquilo y relajado", + "Me he sentido activo y enérgico", + "Me he despertado fresco y descansado", + "Mi vida cotidiana ha estado llena de cosas que me interesan", + ], + anchors: { + all: { + 0: "Nunca", + 1: "De vez en cuando", + 2: "Menos de la mitad del tiempo", + 3: "Más de la mitad del tiempo", + 4: "La mayor parte del tiempo", + 5: "Todo el tiempo", + }, + }, +}; + +export default wording; diff --git a/src/lib/mental-health/wording/who5/fr.ts b/src/lib/mental-health/wording/who5/fr.ts new file mode 100644 index 000000000..e6a23ab2e --- /dev/null +++ b/src/lib/mental-health/wording/who5/fr.ts @@ -0,0 +1,30 @@ +/** + * WHO-5, French: a validated translation, reproduced word for word. + */ +import type { InstrumentWording } from "../types"; + +const wording: InstrumentWording = { + validated: true, + source: + 'Psychiatric Research Unit (WHO Collaborating Centre in Mental Health, Mental Health Centre North Zealand) translation, republished by WHO beside WHO/UCN/MSD/MHE/2024.1 (licence CC BY-NC-SA 3.0 IGO). French, 1999 version ("Indice (en cinq points) de bien-être de l’OMS"), read from https://cdn.who.int/media/docs/default-source/mental-health/indice-(en-cinq-points)-de-bien--tre-de-l-oms.pdf.', + stems: { period: "Au cours des deux dernières semaines" }, + items: [ + "Je me suis senti(e) bien et de bonne humeur", + "Je me suis senti(e) calme et tranquille", + "Je me suis senti(e) plein(e) d'énergie et vigoureux(se)", + "Je me suis réveillé(e) en me sentant frais(che) et dispos(e)", + "Ma vie quotidienne a été remplie de choses intéressantes", + ], + anchors: { + all: { + 0: "Jamais", + 1: "De temps en temps", + 2: "Moins de la moitié du temps", + 3: "Plus de la moitié du temps", + 4: "La plupart du temps", + 5: "Tout le temps", + }, + }, +}; + +export default wording; diff --git a/src/lib/mental-health/wording/who5/it.ts b/src/lib/mental-health/wording/who5/it.ts new file mode 100644 index 000000000..dd8a36240 --- /dev/null +++ b/src/lib/mental-health/wording/who5/it.ts @@ -0,0 +1,30 @@ +/** + * WHO-5, Italian: a validated translation, reproduced word for word. + */ +import type { InstrumentWording } from "../types"; + +const wording: InstrumentWording = { + validated: true, + source: + 'Psychiatric Research Unit (WHO Collaborating Centre in Mental Health, Mental Health Centre North Zealand) translation, republished by WHO beside WHO/UCN/MSD/MHE/2024.1 (licence CC BY-NC-SA 3.0 IGO). Italian, 1998 version ("Questionario dell’OMS (in 5 domande) sullo stato di benessere"), read from https://cdn.who.int/media/docs/default-source/mental-health/five-well-being-index-(who-5)/who-5_italian.pdf.', + stems: { period: "Nelle ultime due settimane" }, + items: [ + "Mi sono sentito/a allegro/a e di buon umore", + "Mi sono sentito/a calmo/a e rilassato/a", + "Mi sono sentito/a attivo/a ed energico/a", + "Mi sono svegliato/a sentendomi fresco/a e riposato/a", + "La mia vita di tutti i giorni è stata piena di cose che mi interessano", + ], + anchors: { + all: { + 0: "Mai", + 1: "A volte", + 2: "Meno della metà del tempo", + 3: "Più della metà del tempo", + 4: "La maggior parte del tempo", + 5: "Sempre", + }, + }, +}; + +export default wording; diff --git a/src/lib/mental-health/wording/who5/ko.ts b/src/lib/mental-health/wording/who5/ko.ts new file mode 100644 index 000000000..3ab0fc682 --- /dev/null +++ b/src/lib/mental-health/wording/who5/ko.ts @@ -0,0 +1,36 @@ +/** + * WHO-5, Korean: a validated translation, reproduced word for word. + * + * Neither WHO nor the Psychiatric Research Unit distributes a Korean + * WHO-5; this is the validated Korean version from the literature. The + * stem is the table's column header, the items and answers are as + * printed less the full stop that closes each table cell, and the items + * keep the present tense the published version uses. + */ +import type { InstrumentWording } from "../types"; + +const wording: InstrumentWording = { + validated: true, + source: + "Kim HJ, Moon YS, Son BK, Lee SK, Rho HJ, Kim DH. The utility of Korean version of the WHO Five Well-Being Index in evaluating depressive symptoms and quality of life in the aged dwelling in community (한국판 WHO-5의 유용성). Journal of Korean Geriatric Psychiatry 2010;14(2):90-96, Appendix 1, p. 96; free full text at https://www.kci.go.kr/kciportal/ci/sereArticleSearch/ciSereArtiView.kci?sereArticleSearchBean.artiId=ART001514454. Forward and blind back-translation, validated against depression and quality-of-life measures. The article states no licence beyond its copyright line (© 2010 Korean Association for Geriatric Psychiatry) and no restriction on using the instrument; the WHO-5 itself is free to use (WHO, CC BY-NC-SA 3.0 IGO).", + stems: { period: "지난 2주 동안" }, + items: [ + "나는 즐겁고 기분이 좋다", + "나는 평온하고 느긋하다", + "나는 의욕적이고 활기차다", + "나는 자고 일어나면 상쾌하고 푹 쉬었다는 느낌이 든다", + "나의 일상생활은 흥미로운 일들로 가득하다", + ], + anchors: { + all: { + 0: "전혀 그렇지 않다", + 1: "가끔 그렇다", + 2: "절반 미만이 그렇다", + 3: "절반 이상이 그렇다", + 4: "거의 그렇다", + 5: "항상 그렇다", + }, + }, +}; + +export default wording; diff --git a/src/lib/mental-health/wording/who5/nl.ts b/src/lib/mental-health/wording/who5/nl.ts new file mode 100644 index 000000000..cc2654ac4 --- /dev/null +++ b/src/lib/mental-health/wording/who5/nl.ts @@ -0,0 +1,32 @@ +/** + * WHO-5, Dutch: a validated translation, reproduced word for word. + * + * The stem is the form's column header; items and answers as printed. + */ +import type { InstrumentWording } from "../types"; + +const wording: InstrumentWording = { + validated: true, + source: + 'Psychiatric Research Unit (WHO Collaborating Centre in Mental Health, Mental Health Centre North Zealand) translation, republished by WHO beside WHO/UCN/MSD/MHE/2024.1 (licence CC BY-NC-SA 3.0 IGO). Dutch, 1999 version ("WHO (vijf) Welzijnsindex"), read from https://cdn.who.int/media/docs/default-source/mental-health/five-well-being-index-(who-5)/who-5_dutch.pdf.', + stems: { period: "Gedurende de laatste twee weken" }, + items: [ + "Ik voelde me vrolijk en in een opperbeste stemming", + "Ik voelde me rustig en ontspannen", + "Ik voelde me actief en doelbewust", + "Ik voelde me fris en uitgerust wanneer ik wakker werd", + "Mijn dagelijkse leven was gevuld met dingen die me interesseren", + ], + anchors: { + all: { + 0: "Helemaal niet", + 1: "Soms", + 2: "Minder dan de helft van de tijd", + 3: "Meer dan de helft van de tijd", + 4: "Meestal", + 5: "Constant", + }, + }, +}; + +export default wording; diff --git a/src/lib/mental-health/wording/who5/pl.ts b/src/lib/mental-health/wording/who5/pl.ts new file mode 100644 index 000000000..a86aa3285 --- /dev/null +++ b/src/lib/mental-health/wording/who5/pl.ts @@ -0,0 +1,33 @@ +/** + * WHO-5, Polish: a validated translation, reproduced word for word. + * + * The Polish form prints each item in both its feminine and its masculine + * form, joined by a slash; both are kept. + */ +import type { InstrumentWording } from "../types"; + +const wording: InstrumentWording = { + validated: true, + source: + "Psychiatric Research Unit (WHO Collaborating Centre in Mental Health, Mental Health Centre North Zealand) translation, republished by WHO beside WHO/UCN/MSD/MHE/2024.1 (licence CC BY-NC-SA 3.0 IGO). Polish version, read from https://cdn.who.int/media/docs/default-source/mental-health/five-well-being-index-(who-5)/who-5_polish.pdf.", + stems: { period: "W ciągu ostatnich dwóch tygodni" }, + items: [ + "Czułam się wesoła/Czułem się wesoły i w dobrym nastroju", + "Czułam się spokojna i odprężona/Czułem się spokojny i odprężony", + "Czułam się aktywna i energiczna/Czułem się aktywny i energiczny", + "Budziłam się z uczuciem świeżości i wypoczęta/Budziłem się z uczuciem świeżości i wypoczęty", + "Moje życie codzienne było wypełnione interesującymi mnie sprawami", + ], + anchors: { + all: { + 0: "Nigdy", + 1: "Od czasu do czasu", + 2: "Mniej niż połowę czasu", + 3: "Więcej niż połowę czasu", + 4: "Prawie cały czas", + 5: "Cały czas", + }, + }, +}; + +export default wording; diff --git a/src/lib/mental-health/wording/who5/pt-br.ts b/src/lib/mental-health/wording/who5/pt-br.ts new file mode 100644 index 000000000..4990075f6 --- /dev/null +++ b/src/lib/mental-health/wording/who5/pt-br.ts @@ -0,0 +1,37 @@ +/** + * WHO-5, Portuguese (Brazil): not a validated translation. + * + * Neither WHO nor the Psychiatric Research Unit publishes a translation of + * the WHO-5 in this language. This is a translation written for HealthLog, + * faithful to the WHO 2024 English text and on the same scale; the surfaces + * say so beside it and offer the English original. + * + * A Brazilian Portuguese WHO-5 was validated by de Souza & Hidalgo (Eur + * Arch Psychiatry Clin Neurosci 2012); its item text is not + * openly published. WHO distributes a European Portuguese version only. + */ +import type { InstrumentWording } from "../types"; + +const wording: InstrumentWording = { + validated: false, + stems: { period: "Nas últimas duas semanas …" }, + items: [ + "Eu me senti alegre e de bom humor", + "Eu me senti calmo/a e relaxado/a", + "Eu me senti ativo/a e cheio/a de energia", + "Eu acordei me sentindo disposto/a e descansado/a", + "Meu dia a dia foi preenchido com coisas que me interessam", + ], + anchors: { + all: { + 0: "Em nenhum momento", + 1: "Algumas vezes", + 2: "Menos da metade do tempo", + 3: "Mais da metade do tempo", + 4: "A maior parte do tempo", + 5: "O tempo todo", + }, + }, +}; + +export default wording; diff --git a/src/lib/query-keys/mental-health.ts b/src/lib/query-keys/mental-health.ts index fe7382d7a..535a93e02 100644 --- a/src/lib/query-keys/mental-health.ts +++ b/src/lib/query-keys/mental-health.ts @@ -9,4 +9,7 @@ export const mentalHealthKeys = { /** One administration with its decrypted per-item answers. */ mentalHealthAssessmentDetail: (id: string) => ["mental-health-assessment-detail", id] as const, + /** Every questionnaire's text in one app language (a static chunk). */ + mentalHealthWording: (locale: string) => + ["mental-health-wording", locale] as const, };