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

@@ -1,7 +1,7 @@
{
"$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
"count": 411,
"total": 148369,
"total": 148357,
"entries": {
"app/achievements/page.tsx :: AchievementsPage": 206,
"app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 314,
@@ -210,8 +210,8 @@
"components/medications/wizard/medication-wizard-dialog.tsx :: WizardDialogShell": 516,
"components/medications/wizard/steps/step3-dose.tsx :: Step3Dose": 222,
"components/medications/wizard/steps/step8-summary.tsx :: Step8Summary": 230,
"components/mental-health/check-in-wizard.tsx :: CheckInWizard": 214,
"components/mental-health/mental-wellbeing.tsx :: MentalWellbeing": 210,
"components/mental-health/check-in-wizard.tsx :: CheckInWizard": 205,
"components/mental-health/mental-wellbeing.tsx :: MentalWellbeing": 209,
"components/mood/manage/tag-groups-card.tsx :: TagGroupsCard": 256,
"components/mood/manage/tag-manager-card.tsx :: TagManagerCard": 270,
"components/mood/mood-form.tsx :: MoodForm": 513,
@@ -1,7 +1,7 @@
{
"$comment": "Comment lines that open with a release version, per file under src/. Maintained by src/__tests__/version-stamp-ratchet.test.ts.",
"count": 2957,
"total": 10516,
"count": 2954,
"total": 10508,
"entries": {
"__tests__/account-payload-consumer-guard.test.ts": 2,
"__tests__/acting-account-boundary-guard.test.ts": 7,
@@ -1245,10 +1245,7 @@
"components/mental-health/__tests__/mental-wellbeing.test.tsx": 3,
"components/mental-health/assessment-history-chart.tsx": 4,
"components/mental-health/assessment-history.tsx": 2,
"components/mental-health/assessment-result.tsx": 2,
"components/mental-health/assessment-trend-note.tsx": 1,
"components/mental-health/check-in-nav.ts": 2,
"components/mental-health/check-in-wizard.tsx": 4,
"components/mental-health/crisis-card.tsx": 1,
"components/mental-health/instrument-card.tsx": 5,
"components/mental-health/instrument-detail.tsx": 3,
@@ -2,19 +2,19 @@ import { describe, it, expect } from "vitest";
import {
UNANSWERED,
checkInSteps,
firstUnanswered,
isComplete,
isLastStep,
nextStep,
prevStep,
} from "../check-in-nav";
/**
* The one-question-at-a-time check-in is LINEAR: the steps are exactly the
* questions `1..itemCount`, backward navigation is always allowed, and the
* last question arms the submit directly (v1.27.6 — no review/summary step).
* These are the contracts the wizard's advance / back behaviour rests on
* (the SSR-only test convention can't drive the clicks, so the pure grammar
* is pinned here — the medication wizard's `wizard-payload` precedent).
* The check-in is LINEAR and the same for every instrument: the items, the
* PHQ-9's optional follow-up, then a review step that is the only place that
* submits. These are the contracts the wizard's advance / back behaviour
* rests on (the SSR-only component tests cannot drive clicks; the e2e
* journey drives them end to end).
*/
const PHQ9 = 9;
@@ -24,30 +24,37 @@ function blank(count: number): number[] {
return Array(count).fill(UNANSWERED);
}
describe("check-in step grammar (v1.27.6 — questions only, no review step)", () => {
it("advances forward and stays put on the last question (submit, not advance)", () => {
expect(nextStep(1, PHQ9)).toBe(2);
expect(nextStep(8, PHQ9)).toBe(9);
// The last question is the end of the line — no review step behind it.
expect(nextStep(9, PHQ9)).toBe(9);
expect(nextStep(7, GAD7)).toBe(7);
describe("check-in step layout", () => {
it("puts the review step after the last question of every instrument", () => {
expect(checkInSteps(PHQ9, true)).toEqual({
questions: 10,
functionalStep: 10,
review: 11,
});
expect(checkInSteps(GAD7, false)).toEqual({
questions: 7,
functionalStep: null,
review: 8,
});
expect(checkInSteps(5, false).review).toBe(6);
expect(checkInSteps(8, false).review).toBe(9);
});
it("advances forward and stops at the review step", () => {
const { review } = checkInSteps(PHQ9, true);
expect(nextStep(1, review)).toBe(2);
expect(nextStep(10, review)).toBe(11);
expect(nextStep(11, review)).toBe(11);
});
it("steps back, clamped at the first question", () => {
expect(prevStep(9)).toBe(8);
expect(prevStep(11)).toBe(10);
expect(prevStep(2)).toBe(1);
expect(prevStep(1)).toBe(1);
});
it("identifies the last question per instrument", () => {
expect(isLastStep(8, PHQ9)).toBe(false);
expect(isLastStep(9, PHQ9)).toBe(true);
expect(isLastStep(6, GAD7)).toBe(false);
expect(isLastStep(7, GAD7)).toBe(true);
});
});
describe("completeness (gates the submit on the last question)", () => {
describe("completeness (gates the review and the submit)", () => {
it("isComplete requires every item answered", () => {
expect(isComplete(blank(PHQ9))).toBe(false);
const partial = blank(PHQ9).map((_, i) => (i < 5 ? 1 : UNANSWERED));
@@ -55,4 +62,10 @@ describe("completeness (gates the submit on the last question)", () => {
expect(isComplete(blank(PHQ9).map(() => 0))).toBe(true);
expect(isComplete(blank(GAD7).map(() => 3))).toBe(true);
});
it("names the first unanswered item's step", () => {
expect(firstUnanswered(blank(GAD7))).toBe(1);
expect(firstUnanswered([0, 1, UNANSWERED, 2])).toBe(3);
expect(firstUnanswered([0, 1, 2, 3])).toBeNull();
});
});
@@ -257,7 +257,7 @@ describe("WHO-5 / SCI on the same infrastructure (v1.27.9)", () => {
// English items are the validated wording for en — no translation note.
expect(html).not.toContain('data-slot="check-in-validated-note"');
// No functional follow-up outside the PHQ-9: 5 steps total.
expect(html).toContain("1 / 5");
expect(html).toContain("Question 1 of 5");
});
it("SCI wizard opens on item 1 with the item-specific duration anchors and section stem", () => {
@@ -276,7 +276,7 @@ describe("WHO-5 / SCI on the same infrastructure (v1.27.9)", () => {
for (const v of ["4", "3", "2", "1", "0"] as const) {
expect(html).toContain(mh.sciOptions.duration[v]);
}
expect(html).toContain("1 / 8");
expect(html).toContain("Question 1 of 8");
});
it("shows the gentle PHQ-9 pointer on a WHO-5 total of 50 or below", () => {
@@ -1,50 +1,58 @@
"use client";
/**
* v1.25.3 — the contained result block: instrument header → result card →
* crisis card (on a positive PHQ-9 item 9) → required attribution → "take
* another". Content is unchanged from the v1.25 monolith; it is re-housed as
* one stack inside the page spine instead of loose top-level sections.
* The result of one check-in, the same layout for every instrument.
*
* v1.27.9 — direction-aware follow-up hints from the registry: PHQ-9/GAD-7
* keep the ≥10 professional-help suggestion, a WHO-5 total of 50 or below
* gets a gentle pointer to the PHQ-9 check-in (per the WHO scoring comment),
* and an SCI total of 16 or below gets the paper's neutral band wording.
* The attribution footer now comes from the instrument definition.
* Order: the crisis card first when PHQ-9 item 9 was answered above zero
* (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
* score crosses the instrument's threshold, a routine note otherwise. The
* attribution and "take another" close the view.
*
* SAFETY: the crisis set rides the POST response (`crisis`) on any non-zero
* item 9 and renders immediately; this surface never invites an AI Coach
* conversation about item content.
* Every number and verdict is the server's (the create response and the
* history read's `trends`); nothing is re-scored here. SAFETY: this surface
* never invites an AI Coach conversation about item content.
*/
import { useEffect, useRef } from "react";
import { ArrowLeft } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { useTranslations } from "@/lib/i18n/context";
import { PageHeader } from "@/components/ui/page-header";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { INSTRUMENTS, needsFollowUp } from "@/lib/mental-health/instruments";
import { AssessmentTrendNote } from "./assessment-trend-note";
import { CrisisCard } from "./crisis-card";
import type { CreateResponse } from "./types";
import { PageHeader } from "@/components/ui/page-header";
import type { AssessmentTrendDto, CreateResponse } from "./types";
export function AssessmentResult({
result,
trend,
onTakeAnother,
onBack,
}: {
result: CreateResponse;
/** This instrument's trend, once the history read includes this result. */
trend?: AssessmentTrendDto;
onTakeAnother: () => void;
onBack: () => void;
}) {
const { t } = useTranslations();
const instrument = result.assessment.instrument;
const { integer } = useFormatters();
const { assessment } = result;
const instrument = assessment.instrument;
const def = INSTRUMENTS[instrument];
const key = def.i18nKey;
const band = assessment.severityBand;
// The trend counts only once it ends on this very result.
const ownTrend =
trend && trend.latest.takenAt === assessment.takenAt ? trend : undefined;
// a11y: on submit the wizard unmounts and this view mounts; move focus to the
// result heading so keyboard / screen-reader users land on the new content
// rather than being dropped to <body>.
// result heading so keyboard / screen-reader users land on the new content.
const headingRef = useRef<HTMLHeadingElement | null>(null);
useEffect(() => {
headingRef.current?.focus();
@@ -54,6 +62,8 @@ export function AssessmentResult({
<section
className="flex flex-col gap-4"
data-slot="mental-health-result"
data-instrument={instrument}
data-band={band}
aria-live="polite"
>
<Button
@@ -79,46 +89,61 @@ export function AssessmentResult({
{t("mentalHealth.saved")}
</p>
{result.crisis && <CrisisCard crisis={result.crisis} />}
<Card>
<CardHeader>
<CardTitle>{t("mentalHealth.result.title")}</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-3 text-sm">
<div className="flex items-baseline justify-between">
<div className="flex items-baseline justify-between gap-3">
<span className="text-muted-foreground">
{t("mentalHealth.result.totalLabel")}
</span>
<span className="text-2xl font-semibold">
{result.assessment.totalScore}
<span data-slot="result-score">
<span className="text-2xl font-semibold tabular-nums">
{integer(assessment.totalScore)}
</span>{" "}
<span className="text-muted-foreground text-xs">
{t("mentalHealth.result.scoreOf", {
max: integer(def.maxScore),
})}
</span>
</span>
</div>
<div className="flex items-baseline justify-between">
<div className="flex items-baseline justify-between gap-3">
<span className="text-muted-foreground">
{t("mentalHealth.result.bandLabel")}
</span>
<span className="font-medium">
{t(
`mentalHealth.band.${result.assessment.instrument}.${result.assessment.severityBand}`,
)}
<span className="font-medium" data-slot="result-band">
{t(`mentalHealth.band.${instrument}.${band}`)}
</span>
</div>
{/* Direction-aware follow-up hint: PHQ-9/GAD-7 point up (≥ 10),
WHO-5 (≤ 50 → gentle PHQ-9 pointer) and SCI (≤ 16 → the paper's
neutral band wording) point down. Soft suggestions, never a
diagnosis. */}
{needsFollowUp(instrument, result.assessment.totalScore) && (
<p data-slot="result-meaning">
{t(`mentalHealth.bandMeaning.${instrument}.${band}`)}
</p>
{ownTrend && ownTrend.previous ? (
<AssessmentTrendNote trend={ownTrend} />
) : ownTrend ? (
<p className="text-muted-foreground" data-slot="result-first">
{t("mentalHealth.result.firstCheckIn")}
</p>
) : null}
{needsFollowUp(instrument, assessment.totalScore) ? (
<p
className="text-muted-foreground bg-muted/40 rounded-md p-3 text-xs"
className="bg-muted/40 rounded-md p-3"
data-slot="result-follow-up-hint"
>
{t(`mentalHealth.followUpHint.${key}`)}
</p>
) : (
<p className="text-muted-foreground" data-slot="result-next-step">
{t("mentalHealth.result.nextStep")}
</p>
)}
</CardContent>
</Card>
{result.crisis && <CrisisCard crisis={result.crisis} />}
<p className="text-muted-foreground text-xs leading-snug">
<span className="font-medium">
{t("mentalHealth.attributionLabel")}:
+36 -22
View File
@@ -1,33 +1,27 @@
/**
* v1.25.3 — pure step grammar for the one-question-at-a-time check-in.
* Pure step grammar for the one-question-at-a-time check-in.
*
* The PHQ-9 / GAD-7 screeners are LINEAR (no mode/cadence branching like the
* medication wizard), so the steps are exactly the questions `1..itemCount`:
* you can always step backward to change an answer, and never skip ahead past
* an unanswered question. Keeping the navigation here (mirroring the
* medication wizard's `wizard-payload.ts`) makes the advance/back contract
* unit-testable without driving a DOM.
* Every instrument runs the same sequence: its items `1..itemCount`, the
* PHQ-9's optional follow-up question as one more step, then a review step
* that lists every answer before anything is sent. The review step came
* back at a maintainer's request: a screener that submits on the last tap
* gives no chance to see what is about to be recorded. Backward navigation
* is always allowed and nothing skips past an unanswered item. Keeping the
* grammar here makes it unit-testable without driving a DOM.
*
* v1.27.6 — the review/summary step is gone: answering the last question
* arms the submit directly (no "9 of 9 answered" interstitial demanding one
* more decision). Step numbers are 1-based and run `1..itemCount`.
*
* An unanswered item carries the sentinel `-1`.
* Step numbers are 1-based. An unanswered item carries the sentinel `-1`.
*/
export const UNANSWERED = -1;
/** Every item answered (0–3) — gates the submit on the last question. */
/** Every item answered — gates the review step and the submit. */
export function isComplete(items: readonly number[]): boolean {
return items.length > 0 && items.every((v) => v >= 0);
}
/**
* The next step after `step`: a question advances to the next question; the
* last question stays put (submit, not advance).
*/
export function nextStep(step: number, itemCount: number): number {
if (step >= itemCount) return itemCount;
/** The step after `step`, clamped at the last one. */
export function nextStep(step: number, totalSteps: number): number {
if (step >= totalSteps) return totalSteps;
return step + 1;
}
@@ -36,7 +30,27 @@ export function prevStep(step: number): number {
return step > 1 ? step - 1 : 1;
}
/** Whether `step` is the last question for this instrument. */
export function isLastStep(step: number, itemCount: number): boolean {
return step >= itemCount;
/**
* The step layout of one instrument: how many questions it asks (its items
* plus the optional follow-up) and the review step after them.
*/
export function checkInSteps(
itemCount: number,
hasFunctional: boolean,
): { questions: number; functionalStep: number | null; review: number } {
const questions = itemCount + (hasFunctional ? 1 : 0);
return {
questions,
functionalStep: hasFunctional ? itemCount + 1 : null,
review: questions + 1,
};
}
/**
* The first unanswered item's step, or null when every item is answered.
* The review step is only reachable once this is null.
*/
export function firstUnanswered(items: readonly number[]): number | null {
const index = items.findIndex((v) => v < 0);
return index === -1 ? null : index + 1;
}
+415 -136
View File
@@ -1,34 +1,39 @@
"use client";
/**
* v1.25.3 — one-question-at-a-time check-in, rendered INLINE on the page
* (not a modal), because here the page *is* the surface.
* The one-question-at-a-time check-in, the same pattern for every instrument.
*
* v1.27.6 — slimmed to questions only. The question-overview strip (the
* dot/jump row borrowed from the medication wizard) and the "N of N
* answered" review interstitial are gone: a screening is a quiet, linear
* moment, not a form to audit. The Back button stays; a small "3 / 9"
* caption keeps orientation without turning progress into a scoreboard.
* Answering the last question arms the submit directly — the next thing
* the user sees is the dignified result view.
* Each step shows the instrument's own question stem ("Over the last 2
* weeks, how often have you been bothered by…", the WHO-5 and SCI section
* stems) above one item, the item's exact answer options as a radio group,
* the progress through the scored items, and Back. The PHQ-9's follow-up
* 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.
*
* v1.27.9 — the wizard drives from the instrument registry instead of a
* hard-wired 0–3 scale: option values/order, option-label scheme (shared
* anchors vs the SCI's per-item anchor groups), and the recall-stem caption
* (the WHO-5 / SCI items are fragments under a shared stem on the source
* forms) all come from `INSTRUMENTS`. Locales without a validated item
* translation (SCI outside English) get an honest chrome note; the items
* themselves stay in the validated source language.
* 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.
*
* Header: instrument title + ONE standardized explanation line, NO
* disclaimer (§2 — the "voluntary self-test, not a diagnosis" disclaimer
* renders only on the landing, never while taking the test).
* Keyboard: Tab reaches the selected answer (or the first), the arrow keys
* move between answers, Space or Enter chooses, and the number keys choose
* the answer in that position. Choosing advances; on the review step every
* answer can be changed and the change returns to the review.
*/
import { useEffect, useRef, useState } from "react";
import {
useEffect,
useRef,
useState,
type KeyboardEvent,
type RefObject,
} from "react";
import { ArrowLeft } from "lucide-react";
import { Button } from "@/components/ui/button";
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 { useTranslations } from "@/lib/i18n/context";
import {
INSTRUMENTS,
@@ -45,17 +50,19 @@ import {
} from "@/lib/mental-health/instrument-wording";
import {
UNANSWERED,
checkInSteps,
firstUnanswered,
isComplete,
isLastStep,
nextStep,
prevStep,
UNANSWERED,
} from "./check-in-nav";
import type { InstrumentId } from "./types";
import { PageHeader } from "@/components/ui/page-header";
const FUNCTIONAL_SCALE = [0, 1, 2, 3] as const;
type Wording = (key: string) => string;
export function CheckInWizard({
instrument,
onSubmit,
@@ -73,66 +80,65 @@ export function CheckInWizard({
}) {
const { t, locale } = useTranslations();
const def = INSTRUMENTS[instrument];
const wording = (k: string) => instrumentText(instrument, k, locale, t);
const itemCount = def.itemCount;
// v1.27.8 — the PHQ-9 asks its validated functional-impairment
// follow-up as a regular LAST question (10 of 10) instead of a
// separate review-step widget. It is OPTIONAL per the instrument
// (answering is not required to submit) and never scores into the
// total — the value rides the existing `functionalDifficulty` API
// field. The other instruments have no such item.
const hasFunctionalStep = hasFunctionalItem(instrument);
const totalSteps = itemCount + (hasFunctionalStep ? 1 : 0);
const wording: Wording = (k) => instrumentText(instrument, k, locale, t);
const steps = checkInSteps(def.itemCount, hasFunctionalItem(instrument));
const [step, setStep] = useState(1);
const [items, setItems] = useState<number[]>(() =>
Array(itemCount).fill(UNANSWERED),
Array(def.itemCount).fill(UNANSWERED),
);
const [functional, setFunctional] = useState<number>(UNANSWERED);
// Set when a step was opened from the review: choosing returns there.
const [fromReview, setFromReview] = useState(false);
const onFunctionalStep = hasFunctionalStep && step === itemCount + 1;
const onLast = isLastStep(step, totalSteps);
const questionIndex = step - 1; // 0-based item index for the current question
const complete = isComplete(items);
const questionLabel = onFunctionalStep
? wording(FUNCTIONAL_TITLE_KEY)
: wording(itemKey(instrument, questionIndex));
// Recall-stem caption above the question (WHO-5 / SCI fragments).
const stem = onFunctionalStep ? null : stemKey(instrument, questionIndex);
const scale = onFunctionalStep ? FUNCTIONAL_SCALE : def.optionOrder;
const onReview = step === steps.review;
const onFunctional = step === steps.functionalStep;
const questionIndex = step - 1;
// a11y: announce each step by moving focus to its heading (the question
// text). Mirrors the medication dialog's per-step title focus so a
// keyboard / screen-reader user hears the new question after an
// auto-advance, not silence.
const headingRef = useRef<HTMLHeadingElement | null>(null);
useEffect(() => {
headingRef.current?.focus({ preventScroll: false });
}, [step]);
function selectAnswer(value: number) {
if (onFunctionalStep) {
// Optional follow-up: selecting never advances (it IS the last
// step) and tapping the selected option again clears it — the
// answer stays skippable right up to submit.
function advance() {
if (fromReview && isComplete(items)) {
setFromReview(false);
setStep(steps.review);
return;
}
setStep((s) => nextStep(s, steps.review));
}
function choose(value: number) {
if (onFunctional) {
// Optional and unscored: choosing the chosen answer again clears it.
setFunctional((prev) => (prev === value ? UNANSWERED : value));
return;
}
setItems((prev) => {
const next = [...prev];
next[questionIndex] = value;
return next;
});
// Forward-on-select: advance to the next question. On the last question
// the selection stays visible and the footer submit arms — submitting is
// a deliberate second tap, never a side effect of answering.
if (!onLast) setStep((s) => nextStep(s, totalSteps));
const next = [...items];
next[questionIndex] = value;
setItems(next);
if (fromReview && isComplete(next)) {
setFromReview(false);
setStep(steps.review);
return;
}
setStep((s) => nextStep(s, steps.review));
}
function edit(target: number) {
setFromReview(true);
setStep(target);
}
const canReview = firstUnanswered(items) === null;
const answered = items.filter((v) => v >= 0).length;
return (
<section
className="flex flex-col gap-4"
data-slot="mental-health-check-in"
data-instrument={instrument}
aria-busy={isPending || undefined}
>
<Button
@@ -147,15 +153,11 @@ export function CheckInWizard({
{t("mentalHealth.backToList")}
</Button>
{/* In-test header — instrument title + ONE standardized explanation line.
No disclaimer here (§2); the landing carries it. */}
<header className="flex flex-col gap-1">
<PageHeader
title={t(`mentalHealth.instrument.${def.i18nKey}`)}
description={t(`mentalHealth.instrumentDescription.${def.i18nKey}`)}
/>
{/* Honest translation note: the instrument is validated in its source
language; the surrounding chrome stays localized. */}
{!hasValidatedItems(instrument, locale) && (
<p
className="text-muted-foreground text-xs"
@@ -168,64 +170,51 @@ export function CheckInWizard({
<Card className="gap-0 p-0">
<CardContent className="flex flex-col gap-4 p-4 md:p-6">
<div className="flex flex-col gap-4">
<p className="text-muted-foreground text-xs">
{t("mentalHealth.progress", {
current: step,
total: totalSteps,
})}
<CheckInProgress
answered={onReview ? def.itemCount : answered}
current={Math.min(step, def.itemCount)}
total={def.itemCount}
label={
onReview
? t("mentalHealth.wizard.allAnswered", {
total: def.itemCount,
})
: onFunctional
? t("mentalHealth.wizard.followUpLabel")
: t("mentalHealth.wizard.progressLabel", {
current: step,
total: def.itemCount,
})
}
/>
{onReview ? (
<ReviewStep
instrument={instrument}
items={items}
functional={functional}
functionalStep={steps.functionalStep}
wording={wording}
headingRef={headingRef}
onEdit={edit}
/>
) : (
<QuestionStep
key={step}
instrument={instrument}
step={step}
onFunctional={onFunctional}
selected={onFunctional ? functional : items[questionIndex]}
wording={wording}
headingRef={headingRef}
disabled={isPending}
onChoose={choose}
/>
)}
{isError && (
<p className="text-destructive text-sm" role="alert">
{t("mentalHealth.error")}
</p>
{stem && (
<p className="text-sm" data-slot="check-in-stem">
{wording(`mentalHealth.stems.${stem}`)}
</p>
)}
<h2
ref={headingRef}
tabIndex={-1}
className="scroll-mt-4 text-base font-medium outline-none"
>
{questionLabel}
</h2>
{onFunctionalStep && (
<p className="text-muted-foreground text-xs">
{t("mentalHealth.functionalOptionalHint")}
</p>
)}
<div
className="flex flex-col gap-2"
role="group"
aria-label={questionLabel}
>
{scale.map((v) => {
const selected = onFunctionalStep
? functional === v
: items[questionIndex] === v;
return (
<Button
key={v}
type="button"
// 44px touch-target floor (WCAG 2.5.5).
className="min-h-11 justify-start"
variant={selected ? "default" : "outline"}
aria-pressed={selected}
onClick={() => selectAnswer(v)}
disabled={isPending}
>
{onFunctionalStep
? wording(functionalOptionKey(v))
: wording(optionLabelKey(instrument, questionIndex, v))}
</Button>
);
})}
</div>
{isError && (
<p className="text-destructive text-sm" role="alert">
{t("mentalHealth.error")}
</p>
)}
</div>
)}
</CardContent>
<div className="border-border/70 flex items-center justify-between gap-2 border-t p-4 md:p-6">
@@ -233,30 +222,23 @@ export function CheckInWizard({
type="button"
variant="ghost"
className="h-11"
onClick={() => setStep((s) => prevStep(s))}
onClick={() => {
setFromReview(false);
setStep((s) => prevStep(s));
}}
disabled={step === 1 || isPending}
data-slot="check-in-back"
>
{t("mentalHealth.back")}
</Button>
{!onLast ? (
<Button
type="button"
className="h-11"
onClick={() => setStep((s) => nextStep(s, totalSteps))}
disabled={items[questionIndex] < 0}
data-slot="check-in-next"
>
{t("mentalHealth.next")}
</Button>
) : (
{onReview ? (
<Button
type="button"
className="h-11"
onClick={() =>
onSubmit(items, functional >= 0 ? functional : undefined)
}
disabled={!complete || isPending}
disabled={!isComplete(items) || isPending}
aria-busy={isPending || undefined}
data-slot="check-in-submit"
>
@@ -264,9 +246,306 @@ export function CheckInWizard({
? t("mentalHealth.submitting")
: t("mentalHealth.submit")}
</Button>
) : (
<Button
type="button"
className="h-11"
onClick={advance}
disabled={
onFunctional
? !canReview
: items[questionIndex] < 0 || isPending
}
data-slot="check-in-next"
>
{step >= steps.questions
? t("mentalHealth.review.open")
: t("mentalHealth.next")}
</Button>
)}
</div>
</Card>
</section>
);
}
function CheckInProgress({
answered,
current,
total,
label,
}: {
answered: number;
current: number;
total: number;
label: string;
}) {
return (
<div className="flex flex-col gap-2" data-slot="check-in-progress">
<p
className="text-muted-foreground text-xs"
data-current={current}
data-total={total}
>
{label}
</p>
<Progress
value={Math.round((answered / total) * 100)}
aria-label={label}
className="h-1.5"
/>
</div>
);
}
function QuestionStep({
instrument,
step,
onFunctional,
selected,
wording,
headingRef,
disabled,
onChoose,
}: {
instrument: InstrumentId;
step: number;
onFunctional: boolean;
selected: number;
wording: Wording;
headingRef: RefObject<HTMLHeadingElement | null>;
disabled: boolean;
onChoose: (value: number) => void;
}) {
const { t } = useTranslations();
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 options = scale.map((value) => ({
value,
label: onFunctional
? wording(functionalOptionKey(value))
: wording(optionLabelKey(instrument, index, value)),
}));
const questionId = `check-in-question-${instrument}-${step}`;
return (
<div
className="flex flex-col gap-4"
data-slot="check-in-step"
data-step={step}
data-functional={onFunctional || undefined}
>
{stem && (
<p className="text-sm" data-slot="check-in-stem">
{wording(`mentalHealth.stems.${stem}`)}
</p>
)}
<h2
ref={headingRef}
id={questionId}
tabIndex={-1}
className="scroll-mt-4 text-base font-medium outline-none"
data-slot="check-in-question"
>
{question}
</h2>
{onFunctional && (
<p className="text-muted-foreground text-xs">
{t("mentalHealth.functionalOptionalHint")}
</p>
)}
<AnswerGroup
labelledBy={questionId}
options={options}
selected={selected}
disabled={disabled}
onChoose={onChoose}
/>
<p className="text-muted-foreground hidden text-xs md:block">
{t("mentalHealth.wizard.keyboardHint", { count: options.length })}
</p>
</div>
);
}
/**
* The answer control every instrument shares: a radio group of full-width
* options in the order the source form prints them.
*/
function AnswerGroup({
labelledBy,
options,
selected,
disabled,
onChoose,
}: {
labelledBy: string;
options: ReadonlyArray<{ value: number; label: string }>;
selected: number;
disabled: boolean;
onChoose: (value: number) => void;
}) {
const refs = useRef<Array<HTMLButtonElement | null>>([]);
const selectedIndex = options.findIndex((o) => o.value === selected);
const tabIndexAt = selectedIndex >= 0 ? selectedIndex : 0;
function onKeyDown(event: KeyboardEvent<HTMLDivElement>) {
const focused = refs.current.findIndex(
(el) => el === document.activeElement,
);
const from = focused >= 0 ? focused : tabIndexAt;
const last = options.length - 1;
const move: Record<string, number> = {
ArrowDown: from === last ? 0 : from + 1,
ArrowRight: from === last ? 0 : from + 1,
ArrowUp: from === 0 ? last : from - 1,
ArrowLeft: from === 0 ? last : from - 1,
Home: 0,
End: last,
};
if (event.key in move) {
event.preventDefault();
refs.current[move[event.key]]?.focus();
return;
}
const position = Number.parseInt(event.key, 10);
if (position >= 1 && position <= options.length && !disabled) {
event.preventDefault();
onChoose(options[position - 1].value);
}
}
return (
<div
role="radiogroup"
aria-labelledby={labelledBy}
className="flex flex-col gap-2"
data-slot="check-in-options"
onKeyDown={onKeyDown}
>
{options.map((option, i) => {
const checked = option.value === selected;
return (
<Button
key={option.value}
ref={(el) => {
refs.current[i] = el;
}}
type="button"
role="radio"
aria-checked={checked}
tabIndex={i === tabIndexAt ? 0 : -1}
// 44px touch-target floor (WCAG 2.5.5).
className="h-auto min-h-11 justify-start py-2 text-left whitespace-normal"
variant={checked ? "default" : "outline"}
onClick={() => onChoose(option.value)}
disabled={disabled}
data-slot="check-in-option"
data-value={option.value}
data-selected={checked || undefined}
>
{option.label}
</Button>
);
})}
</div>
);
}
function ReviewStep({
instrument,
items,
functional,
functionalStep,
wording,
headingRef,
onEdit,
}: {
instrument: InstrumentId;
items: readonly number[];
functional: number;
functionalStep: number | null;
wording: Wording;
headingRef: RefObject<HTMLHeadingElement | null>;
onEdit: (step: number) => void;
}) {
const { t } = useTranslations();
const rows = items.map((value, index) => ({
step: index + 1,
question: wording(itemKey(instrument, index)),
answer: wording(optionLabelKey(instrument, index, value)),
value: value as number | null,
stem: stemKey(instrument, index),
}));
if (functionalStep !== null) {
rows.push({
step: functionalStep,
question: wording(FUNCTIONAL_TITLE_KEY),
answer:
functional >= 0
? wording(functionalOptionKey(functional))
: t("mentalHealth.review.notAnswered"),
value: null,
stem: null,
});
}
return (
<div className="flex flex-col gap-3" data-slot="check-in-review">
<h2
ref={headingRef}
tabIndex={-1}
className="text-base font-medium outline-none"
>
{t("mentalHealth.review.title")}
</h2>
<p className="text-sm">{t("mentalHealth.review.description")}</p>
<ol className="flex flex-col gap-3">
{rows.map((row, i) => (
<li
key={row.step}
className="flex flex-col gap-1 text-sm"
data-slot="check-in-review-row"
data-step={row.step}
data-value={row.value ?? undefined}
>
{row.stem && row.stem !== rows[i - 1]?.stem && (
<p className="text-muted-foreground text-xs">
{wording(`mentalHealth.stems.${row.stem}`)}
</p>
)}
<div className="flex items-start justify-between gap-3">
<span className="min-w-0 flex-1">{row.question}</span>
<InlineAction
tone="primary"
size="sm"
className="shrink-0"
onClick={() => onEdit(row.step)}
aria-label={t("mentalHealth.review.changeLabel", {
number: row.step,
})}
data-review-edit={row.step}
>
{t("mentalHealth.review.change")}
</InlineAction>
</div>
<span className="text-foreground font-medium">
{row.answer}
{row.value !== null && (
<span className="text-muted-foreground font-normal tabular-nums">
{" "}
({row.value})
</span>
)}
</span>
</li>
))}
</ol>
</div>
);
}
@@ -89,6 +89,7 @@ export function InstrumentCard({
<Card
className="relative h-full gap-3 md:gap-3"
data-slot="instrument-card"
data-instrument={instrument}
>
{/* Source / licence — a discreet info control at the top-right of the
header, at the heading's height. A sibling of the detail-open
@@ -187,7 +188,12 @@ export function InstrumentCard({
{canManageMind && (
<CardContent className="flex h-full flex-col">
<div className="mt-auto pt-0">
<Button type="button" className="min-h-11 w-full" onClick={onStart}>
<Button
type="button"
className="min-h-11 w-full"
onClick={onStart}
data-slot="instrument-card-start"
>
{t("mentalHealth.start")}
</Button>
</div>
@@ -203,11 +203,7 @@ export function MentalWellbeing() {
)}
</section>
{/* The pointer to mood history that used to sit here is gone. The
cross-link on `/mood` still points at this page, which is the
direction that earns its keep — someone logging a mood may not
know the screeners exist, while someone who came here for a
screener already knows where their mood diary is. */}
{/* No mood pointer here: `/mood` links to this page instead. */}
{/* Per-instrument detail, opened from a card body: last result,
trend chart, dated history, Start — the Verlauf lives here,
@@ -255,6 +251,9 @@ export function MentalWellbeing() {
{phase === "result" && result && (
<AssessmentResult
result={result}
trend={history?.trends?.find(
(trend) => trend.instrument === result.assessment.instrument,
)}
onTakeAnother={backToLanding}
onBack={backToLanding}
/>
@@ -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;
}
/**