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

@@ -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}
/>