feat(mental-health): one check-in pattern for every questionnaire, worded as published

Every instrument now runs the same wizard: the form's own question stem
above each item (the PHQ-9 and GAD-7 stems were missing), the item's
exact answer options as one radio group in source order, progress, Back,
number-key answers, and a review step that lists every answer, lets any
of them be changed and is the only step that submits. The result leads
with the crisis card when it applies, then shows the score on the
instrument's scale, the band, what the band means, the change since the
last check-in against the meaningful-change threshold, and the next
step. Scoring is untouched.

The wording was read against the published versions. German follows the
PHQ-D and Löwe's GAD-7 verbatim, including the follow-up question in its
published formal address. Spanish, Italian and Polish carry the official
PHQ-9 and GAD-7 translations, French the official GAD-7, and the GAD-7
gets its own answer set because the Italian forms word it differently.
Where a version could not be matched to a published form (the French
PHQ-9, every Korean form) the language now shows the English original.
Each locale list names its sources, and spot checks pin the corrected
wording.
This commit is contained in:
Marc-André Bombeck committed 2026-10-22 19:28:29 +02:00
1 parent 49a999ab94
commit 038f8b10cd
24 files changed
+1398 -435

No files matched your search

@@ -122,6 +122,62 @@ describe("instrument wording", () => {
});
});
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<string, Record<string, string>> = {
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",
},
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);
}
});
it("keeps the German follow-up question in the published formal address", () => {
const title = bundle("de").get("mentalHealth.functionalTitle") ?? "";
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.
@@ -166,6 +166,7 @@ describe("mental-health instruments", () => {
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(
@@ -185,29 +186,37 @@ describe("mental-health instruments", () => {
);
});
it("maps recall stems (WHO-5 one stem; SCI three sections; PHQ/GAD none)", () => {
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)).toBeNull();
expect(stemKey("GAD7", 0)).toBeNull();
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 (SCI = English only)", () => {
for (const locale of ["de", "en", "es", "fr", "it", "pl", "ko"]) {
expect(hasValidatedItems("WHO5", locale)).toBe(true);
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);
}
// The Dutch wording has not been read against the published forms yet.
expect(hasValidatedItems("PHQ9", "nl")).toBe(false);
expect(hasValidatedItems("GAD7", "nl")).toBe(false);
expect(hasValidatedItems("WHO5", "nl")).toBe(false);
// Nor has the Brazilian Portuguese one.
expect(hasValidatedItems("PHQ9", "pt-BR")).toBe(false);
expect(hasValidatedItems("GAD7", "pt-BR")).toBe(false);
expect(hasValidatedItems("WHO5", "pt-BR")).toBe(false);
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);
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(hasValidatedItems("SCI", "en")).toBe(true);
expect(hasValidatedItems("SCI", "en-GB")).toBe(true);
expect(hasValidatedItems("SCI", "de")).toBe(false);
@@ -72,6 +72,10 @@ export const SOURCE_WORDING: Readonly<Record<string, string>> = {
"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",
@@ -110,6 +114,10 @@ export const SOURCE_WORDING: Readonly<Record<string, string>> = {
"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 …",
+74 -31
View File
@@ -11,9 +11,8 @@
* - 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". Six app locales ship the WHO/PRU translations
* verbatim; Korean, which neither distribution carries, ships the
* published Kim 2010 translation instead.
* further assessment". Five other app languages ship the PRU
* translations WHO republishes, verbatim (sources at `WHO5_LOCALES`).
* - 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
@@ -62,12 +61,12 @@ export interface InstrumentDefinition {
optionNamespace: string;
optionGroups?: readonly string[];
/**
* Recall-stem caption key per item under `mentalHealth.stems.` — the WHO-5
* and SCI items are fragments under a shared stem on the source forms, so
* the wizard paints the applicable stem above the question. PHQ/GAD keep
* their frame in the instrument description line (shipped contract).
* 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.
*/
stemKeys?: readonly string[];
stemKeys: readonly string[];
/** Multiplier from item-sum to the reported total (WHO-5: × 4). */
scoreMultiplier: number;
/** Inclusive REPORTED total range (WHO-5: 0–100 percentage). */
@@ -101,15 +100,58 @@ export interface InstrumentDefinition {
attribution: string;
}
/**
* The app languages whose PHQ-9 / GAD-7 / WHO-5 item wording is bundled from
* a published validated version. `nl` and `pt-BR` are absent on purpose: no
* published Dutch or Brazilian Portuguese form has been read and bundled yet,
* so those readers get the English items, answers and stems plus the honest
* note, and their bundles carry none of that wording. Adding a language here
* means bundling its validated form verbatim in the same change.
/*
* 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.
*/
const ALL_APP_LOCALES = ["de", "en", "es", "fr", "it", "pl", "ko"] as const;
/**
* 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_<language> for <country>.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_<language> for <country>.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_<language>.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
@@ -139,10 +181,13 @@ const PHQ9: InstrumentDefinition = {
{ key: "modSevere", min: 15, max: 19 },
{ key: "severe", min: 20, max: 27 },
],
// 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"),
actionThreshold: 10,
// Item 9 ("Thoughts that you would be better off dead…") — the safety field.
safetyItemIndex: 8,
validatedItemLocales: ALL_APP_LOCALES,
validatedItemLocales: PHQ9_LOCALES,
attribution: PHQ_GAD_ATTRIBUTION,
};
@@ -152,7 +197,10 @@ const GAD7: InstrumentDefinition = {
itemCount: 7,
itemMax: 3,
optionOrder: [0, 1, 2, 3],
optionNamespace: "mentalHealth.options",
// 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"),
scoreMultiplier: 1,
minScore: 0,
maxScore: 21,
@@ -166,7 +214,7 @@ const GAD7: InstrumentDefinition = {
],
actionThreshold: 10,
safetyItemIndex: null,
validatedItemLocales: ALL_APP_LOCALES,
validatedItemLocales: GAD7_LOCALES,
attribution: PHQ_GAD_ATTRIBUTION,
};
@@ -198,18 +246,11 @@ const WHO5: InstrumentDefinition = {
],
actionThreshold: 50,
safetyItemIndex: null,
// WHO publishes official de/en/es/fr/it/pl versions — all bundled verbatim.
// There is no Korean one, so `ko` carries the published forward/back-
// translation from Kim 2010 instead, which is CC BY-NC and so
// redistributable. Its validation sample was community-dwelling elderly,
// not general adults — narrower than the other six arms.
validatedItemLocales: ALL_APP_LOCALES,
validatedItemLocales: WHO5_LOCALES,
attribution:
"World Health Organization. The World Health Organization-Five Well-Being " +
"Index (WHO-5). Geneva: World Health Organization; 2024. Licence: " +
"CC BY-NC-SA 3.0 IGO. WHO does not endorse this application. Korean " +
"translation: Kim HJ, Moon YS, Son BK, Lee SK, Rho HJ, Kim DH. J Korean " +
"Geriatr Psychiatry 2010;14(2):90-96 (CC BY-NC 3.0).",
"CC BY-NC-SA 3.0 IGO. WHO does not endorse this application.",
};
/**
@@ -260,8 +301,10 @@ const SCI: InstrumentDefinition = {
safetyItemIndex: null,
// Validated in English (the CC BY-NC BMJ Open publication). French/Italian
// validations exist in the literature but their item text is not openly
// redistributable, so every non-EN locale presents the English items with
// an honest note — never a self-made translation.
// 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 " +
@@ -341,7 +384,7 @@ export function optionLabelKey(
/** 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;
return INSTRUMENTS[id].stemKeys[itemIndex] ?? null;
}
/**