feat(mental-health): slim the check-in 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 and a small "3 / 9" caption stay for orientation; answering
the last question arms the submit directly and the next screen is the
result view. The optional functional-difficulty follow-up leaves the web
wizard with the review step (the API keeps accepting it). Unused wizard
keys removed from all six locale bundles.
This commit is contained in:
Marc-André Bombeck committed 2026-07-05 14:52:10 +02:00
1 parent 07f48d215c
commit f7920d81a6
11 files changed
+121 -343

No files matched your search

+1 -15
View File
@@ -7650,13 +7650,6 @@
"2": "An mehr als der Hälfte der Tage",
"3": "Beinahe jeden Tag"
},
"functionalTitle": "Wenn du eine oder mehrere Beschwerden angegeben hast: Wie sehr haben diese deine Arbeit, deinen Haushalt oder den Umgang mit anderen Menschen erschwert?",
"functional": {
"0": "Überhaupt nicht erschwert",
"1": "Etwas erschwert",
"2": "Sehr erschwert",
"3": "Extrem erschwert"
},
"result": {
"title": "Dein Ergebnis",
"totalLabel": "Gesamtwert",
@@ -7743,18 +7736,11 @@
"gad7": "Angst-Screening mit 7 Fragen. Beziehe dich auf die letzten zwei Wochen."
},
"progress": "{current} / {total}",
"questionAria": "Frage {current} von {total}",
"next": "Weiter",
"lastResult": "Letzter Test",
"lastScore": "Letztes Ergebnis",
"noResultYet": "Noch kein Check-in",
"backToList": "Übersicht",
"jumpFirst": "Zur ersten Frage springen",
"jumpLast": "Zur letzten Frage springen",
"review": {
"title": "Überblick",
"recap": "Du hast {answered} von {total} Fragen beantwortet."
}
"backToList": "Übersicht"
},
"records": {
"disclaimer": "Das sind deine eigenen Angaben, keine ärztliche Diagnose. HealthLog stellt keine Diagnose, behandelt nicht und deutet nichts.",
+1 -15
View File
@@ -7650,13 +7650,6 @@
"2": "More than half the days",
"3": "Nearly every day"
},
"functionalTitle": "If you checked off any problems, how difficult have these made it to do your work, take care of things at home, or get along with other people?",
"functional": {
"0": "Not difficult at all",
"1": "Somewhat difficult",
"2": "Very difficult",
"3": "Extremely difficult"
},
"result": {
"title": "Your result",
"totalLabel": "Total score",
@@ -7743,18 +7736,11 @@
"gad7": "7-question anxiety screen. Answer for the last two weeks."
},
"progress": "{current} / {total}",
"questionAria": "Question {current} of {total}",
"next": "Next",
"lastResult": "Last test",
"lastScore": "Last result",
"noResultYet": "No check-in yet",
"backToList": "Overview",
"jumpFirst": "Jump to first question",
"jumpLast": "Jump to last question",
"review": {
"title": "Review",
"recap": "You answered {answered} of {total} questions."
}
"backToList": "Overview"
},
"records": {
"disclaimer": "These are your own records, not a medical diagnosis. HealthLog does not diagnose, treat, or interpret them.",
+1 -15
View File
@@ -7650,13 +7650,6 @@
"2": "Más de la mitad de los días",
"3": "Casi todos los días"
},
"functionalTitle": "Si marcaste algún problema, ¿cuánta dificultad te han causado para trabajar, ocuparte de la casa o relacionarte con otras personas?",
"functional": {
"0": "Ninguna dificultad",
"1": "Algo de dificultad",
"2": "Mucha dificultad",
"3": "Dificultad extrema"
},
"result": {
"title": "Tu resultado",
"totalLabel": "Puntuación total",
@@ -7743,18 +7736,11 @@
"gad7": "Cribado de ansiedad de 7 preguntas. Responde pensando en las últimas dos semanas."
},
"progress": "{current} / {total}",
"questionAria": "Pregunta {current} de {total}",
"next": "Siguiente",
"lastResult": "Última prueba",
"lastScore": "Último resultado",
"noResultYet": "Aún sin check-in",
"backToList": "Vista general",
"jumpFirst": "Ir a la primera pregunta",
"jumpLast": "Ir a la última pregunta",
"review": {
"title": "Resumen",
"recap": "Has respondido {answered} de {total} preguntas."
}
"backToList": "Vista general"
},
"records": {
"disclaimer": "Son tus propios registros, no un diagnóstico médico. HealthLog no diagnostica, no trata ni los interpreta.",
+1 -15
View File
@@ -7650,13 +7650,6 @@
"2": "Plus de la moitié des jours",
"3": "Presque tous les jours"
},
"functionalTitle": "Si tu as coché des problèmes, à quel point t'ont-ils gêné(e) dans ton travail, à la maison ou dans tes relations avec les autres ?",
"functional": {
"0": "Pas du tout gênant",
"1": "Un peu gênant",
"2": "Très gênant",
"3": "Extrêmement gênant"
},
"result": {
"title": "Ton résultat",
"totalLabel": "Score total",
@@ -7743,18 +7736,11 @@
"gad7": "Dépistage de l'anxiété en 7 questions. Répondez en pensant aux deux dernières semaines."
},
"progress": "{current} / {total}",
"questionAria": "Question {current} sur {total}",
"next": "Suivant",
"lastResult": "Dernier test",
"lastScore": "Dernier résultat",
"noResultYet": "Pas encore de check-in",
"backToList": "Vue d'ensemble",
"jumpFirst": "Aller à la première question",
"jumpLast": "Aller à la dernière question",
"review": {
"title": "Récapitulatif",
"recap": "Vous avez répondu à {answered} questions sur {total}."
}
"backToList": "Vue d'ensemble"
},
"records": {
"disclaimer": "Ce sont vos propres informations, pas un diagnostic médical. HealthLog ne diagnostique pas, ne traite pas et ne les interprète pas.",
+1 -15
View File
@@ -7650,13 +7650,6 @@
"2": "Più della metà dei giorni",
"3": "Quasi ogni giorno"
},
"functionalTitle": "Se hai indicato dei problemi, quanto ti hanno reso difficile lavorare, occuparti della casa o andare d'accordo con gli altri?",
"functional": {
"0": "Per niente difficile",
"1": "Un po' difficile",
"2": "Molto difficile",
"3": "Estremamente difficile"
},
"result": {
"title": "Il tuo risultato",
"totalLabel": "Punteggio totale",
@@ -7743,18 +7736,11 @@
"gad7": "Screening dell'ansia in 7 domande. Rispondi pensando alle ultime due settimane."
},
"progress": "{current} / {total}",
"questionAria": "Domanda {current} di {total}",
"next": "Avanti",
"lastResult": "Ultimo test",
"lastScore": "Ultimo risultato",
"noResultYet": "Ancora nessun check-in",
"backToList": "Panoramica",
"jumpFirst": "Vai alla prima domanda",
"jumpLast": "Vai all'ultima domanda",
"review": {
"title": "Riepilogo",
"recap": "Hai risposto a {answered} domande su {total}."
}
"backToList": "Panoramica"
},
"records": {
"disclaimer": "Sono i tuoi dati, non una diagnosi medica. HealthLog non diagnostica, non cura e non li interpreta.",
+1 -15
View File
@@ -7650,13 +7650,6 @@
"2": "Więcej niż połowę dni",
"3": "Niemal codziennie"
},
"functionalTitle": "Jeśli zaznaczyłeś(-aś) jakiekolwiek problemy, na ile utrudniły Ci one pracę, prowadzenie domu lub kontakty z innymi ludźmi?",
"functional": {
"0": "Wcale nie utrudniły",
"1": "Trochę utrudniły",
"2": "Bardzo utrudniły",
"3": "Skrajnie utrudniły"
},
"result": {
"title": "Twój wynik",
"totalLabel": "Wynik łączny",
@@ -7743,18 +7736,11 @@
"gad7": "Przesiew w kierunku lęku, 7 pytań. Odpowiadaj, myśląc o ostatnich dwóch tygodniach."
},
"progress": "{current} / {total}",
"questionAria": "Pytanie {current} z {total}",
"next": "Dalej",
"lastResult": "Ostatni test",
"lastScore": "Ostatni wynik",
"noResultYet": "Brak check-inów",
"backToList": "Przegląd",
"jumpFirst": "Przejdź do pierwszego pytania",
"jumpLast": "Przejdź do ostatniego pytania",
"review": {
"title": "Podsumowanie",
"recap": "Odpowiedziano na {answered} z {total} pytań."
}
"backToList": "Przegląd"
},
"records": {
"disclaimer": "To Twoje własne wpisy, a nie diagnoza lekarska. HealthLog nie diagnozuje, nie leczy i ich nie interpretuje.",
@@ -2,23 +2,19 @@ import { describe, it, expect } from "vitest";
import {
UNANSWERED,
answeredCount,
buildStepList,
isComplete,
isReviewStep,
isLastStep,
nextStep,
prevStep,
reachableUntilIndex,
reviewStep,
} from "../check-in-nav";
/**
* The one-question-at-a-time check-in is LINEAR: the step list is
* `[1..itemCount, review]`, backward navigation is always allowed, and the
* forward ceiling never lets a user skip ahead onto an unanswered question.
* These are the contracts the wizard's advance / back / reachability 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 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).
*/
const PHQ9 = 9;
@@ -28,71 +24,35 @@ function blank(count: number): number[] {
return Array(count).fill(UNANSWERED);
}
describe("check-in step grammar", () => {
it("builds questions + a single review dot (itemCount + 1)", () => {
expect(buildStepList(PHQ9)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
expect(buildStepList(GAD7)).toEqual([1, 2, 3, 4, 5, 6, 7, 8]);
expect(reviewStep(PHQ9)).toBe(10);
expect(reviewStep(GAD7)).toBe(8);
});
it("advances forward on a question and into review from the last question", () => {
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);
// last question (9) → review (10)
expect(nextStep(9, PHQ9)).toBe(10);
// review stays put (submit, not advance)
expect(nextStep(10, PHQ9)).toBe(10);
// 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);
});
it("steps back, clamped at the first question", () => {
expect(prevStep(10)).toBe(9);
expect(prevStep(9)).toBe(8);
expect(prevStep(2)).toBe(1);
expect(prevStep(1)).toBe(1);
});
it("identifies the review step", () => {
expect(isReviewStep(9, PHQ9)).toBe(false);
expect(isReviewStep(10, PHQ9)).toBe(true);
expect(isReviewStep(7, GAD7)).toBe(false);
expect(isReviewStep(8, GAD7)).toBe(true);
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("reachability ceiling (never skip ahead to an unanswered question)", () => {
it("with nothing answered, only the first question is reachable", () => {
expect(reachableUntilIndex(blank(PHQ9))).toBe(0);
});
it("the frontier follows the first unanswered item", () => {
const items = blank(PHQ9);
items[0] = 2; // Q1 answered → can reach Q2 (index 1)
expect(reachableUntilIndex(items)).toBe(1);
items[1] = 0;
items[2] = 3; // Q3 answered too → frontier at the first gap (index 3)
expect(reachableUntilIndex(items)).toBe(3);
});
it("once every item is answered the review slot (last index) is reachable", () => {
const items = blank(GAD7).map(() => 1);
// step list length is itemCount + 1; review index == itemCount
expect(reachableUntilIndex(items)).toBe(GAD7);
expect(buildStepList(GAD7).length - 1).toBe(GAD7);
});
});
describe("completeness + recap", () => {
describe("completeness (gates the submit on the last question)", () => {
it("isComplete requires every item answered", () => {
expect(isComplete(blank(PHQ9))).toBe(false);
const partial = blank(PHQ9).map((_, i) => (i < 5 ? 1 : UNANSWERED));
expect(isComplete(partial)).toBe(false);
expect(isComplete(blank(PHQ9).map(() => 0))).toBe(true);
});
it("answeredCount counts only real (0–3) answers", () => {
const items = blank(PHQ9);
items[0] = 0;
items[1] = 3;
expect(answeredCount(items)).toBe(2);
expect(isComplete(blank(GAD7).map(() => 3))).toBe(true);
});
});
@@ -75,7 +75,7 @@ describe("landing chrome (v1.25.5 — disclaimer + heading removed)", () => {
});
});
describe("check-in wizard scaffolding", () => {
describe("check-in wizard scaffolding (v1.27.6 — questions only)", () => {
const html = withProviders(
<CheckInWizard
instrument="PHQ9"
@@ -86,8 +86,13 @@ describe("check-in wizard scaffolding", () => {
/>,
);
it("reuses the medication wizard stepper", () => {
expect(html).toContain('data-slot="wizard-stepper"');
it("no longer renders the question-overview strip", () => {
expect(html).not.toContain('data-slot="wizard-stepper"');
});
it("no longer renders the review recap or the functional follow-up", () => {
expect(html).not.toContain("You have answered");
expect(html).not.toContain('data-slot="check-in-submit"');
});
it("opens on the first question with the Back/Next footer", () => {
+14 -43
View File
@@ -2,61 +2,32 @@
* v1.25.3 — 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 step list is a fixed `[1..itemCount, review]` and
* the reachability ceiling is just "the question you are currently working on"
* — you can always step backward to change an answer, never skip ahead onto an
* unanswered question. Keeping the navigation here (mirroring the medication
* wizard's `wizard-payload.ts`) makes the advance/back/reachability contract
* 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.
*
* Step numbers are 1-based and run `1..itemCount` for the questions; the review
* step is `itemCount + 1`. The optional functional-difficulty control folds
* into the review step (it is unscored), so the dot count stays at
* `itemCount + 1` rather than gaining its own dot.
* 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`.
*/
export const UNANSWERED = -1;
/** The 1-based review step number for an instrument of `itemCount` items. */
export function reviewStep(itemCount: number): number {
return itemCount + 1;
}
/** Ordered raw step numbers fed straight to `WizardStepper`: questions + review. */
export function buildStepList(itemCount: number): number[] {
return Array.from({ length: itemCount + 1 }, (_, i) => i + 1);
}
/** Index (into the step list) of the highest forward-reachable slot. */
export function reachableUntilIndex(items: readonly number[]): number {
const firstUnanswered = items.findIndex((v) => v < 0);
// All answered → the review slot (last index) is reachable.
if (firstUnanswered === -1) return items.length;
// Otherwise the frontier is the question currently being answered; its index
// in the step list equals its 0-based item index.
return firstUnanswered;
}
/** Every item answered (0–3) — gates the review step + submit. */
/** Every item answered (0–3) — gates the submit on the last question. */
export function isComplete(items: readonly number[]): boolean {
return items.length > 0 && items.every((v) => v >= 0);
}
/** How many items carry a real answer (for the review recap). */
export function answeredCount(items: readonly number[]): number {
return items.filter((v) => v >= 0).length;
}
/**
* The next step after `step`. A question advances to the next question, the
* last question advances to the review step, and the review step stays put
* (submit, not advance).
* 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 {
const review = reviewStep(itemCount);
if (step >= review) return review;
if (step >= itemCount) return itemCount;
return step + 1;
}
@@ -65,7 +36,7 @@ export function prevStep(step: number): number {
return step > 1 ? step - 1 : 1;
}
/** Whether `step` is the review step for this instrument. */
export function isReviewStep(step: number, itemCount: number): boolean {
return step >= reviewStep(itemCount);
/** Whether `step` is the last question for this instrument. */
export function isLastStep(step: number, itemCount: number): boolean {
return step >= itemCount;
}
+68 -145
View File
@@ -1,44 +1,39 @@
"use client";
/**
* v1.25.3 — one-question-at-a-time check-in.
* 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.
*
* Replaces the all-questions-at-once `<ol>` wall with a linear stepper that
* reuses the medication wizard's presentational `WizardStepper` (the dot row +
* "Question 3 of 9" caption) and its Back/Next footer grammar — rendered INLINE
* on the page (not a modal), because here the page *is* the surface. The step
* navigation lives in `check-in-nav.ts` (pure, linear: backward always allowed,
* never skip ahead to an unanswered question).
* 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.
*
* Header: instrument title + ONE standardized explanation line, NO disclaimer
* (§2 — the "voluntary self-test, not a diagnosis" disclaimer renders only on
* the landing + behind the InfoHint, never while taking the test). The optional
* functional-difficulty control folds into the review step.
* 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).
*/
import { useEffect, useRef, useState } from "react";
import { ArrowLeft } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { WizardStepper } from "@/components/medications/wizard/wizard-stepper";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import { INSTRUMENTS } from "@/lib/mental-health/instruments";
import {
UNANSWERED,
answeredCount,
buildStepList,
isComplete,
isReviewStep,
isLastStep,
nextStep,
prevStep,
reachableUntilIndex,
UNANSWERED,
} from "./check-in-nav";
import type { InstrumentId } from "./types";
const SCALE = [0, 1, 2, 3] as const;
const FUNCTIONAL = [0, 1, 2, 3] as const;
function lower(id: InstrumentId): "phq9" | "gad7" {
return id === "PHQ9" ? "phq9" : "gad7";
@@ -52,8 +47,8 @@ export function CheckInWizard({
isError,
}: {
instrument: InstrumentId;
/** Submit the completed check-in. `functional` is null when unanswered. */
onSubmit: (items: number[], functional: number | null) => void;
/** Submit the completed check-in. */
onSubmit: (items: number[]) => void;
/** Return to the landing (abandons the in-progress check-in). */
onBack: () => void;
isPending: boolean;
@@ -67,17 +62,14 @@ export function CheckInWizard({
const [items, setItems] = useState<number[]>(() =>
Array(itemCount).fill(UNANSWERED),
);
const [functional, setFunctional] = useState<number | null>(null);
const stepList = buildStepList(itemCount);
const onReview = isReviewStep(step, itemCount);
const questionIndex = step - 1; // 0-based item index for a question step
const onLast = isLastStep(step, itemCount);
const questionIndex = step - 1; // 0-based item index for the current question
const complete = isComplete(items);
const reachableUntil = reachableUntilIndex(items);
// a11y: announce each step by moving focus to its heading (the question text
// / the review title). Mirrors the medication dialog's per-step title focus
// so a keyboard / screen-reader user hears the new question after an
// 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(() => {
@@ -90,15 +82,12 @@ export function CheckInWizard({
next[questionIndex] = value;
return next;
});
// Forward-on-select: advance to the next question, or onto the review step
// from the last question. The footer Next is the redundant keyboard path.
setStep((s) => nextStep(s, itemCount));
// 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, itemCount));
}
const srLabel = onReview
? t("mentalHealth.review.title")
: t("mentalHealth.questionAria", { current: step, total: itemCount });
return (
<section
className="flex flex-col gap-5"
@@ -118,7 +107,7 @@ export function CheckInWizard({
</Button>
{/* In-test header — instrument title + ONE standardized explanation line.
No disclaimer here (§2); the (i) tooltip on the landing carries it. */}
No disclaimer here (§2); the landing carries it. */}
<header className="flex flex-col gap-1">
<h1 className="text-2xl font-semibold">
{t(`mentalHealth.instrument.${key}`)}
@@ -129,114 +118,48 @@ export function CheckInWizard({
</header>
<Card className="gap-0 p-0">
<div className="border-border/70 space-y-1.5 border-b p-4 sm:p-6">
<WizardStepper
steps={stepList}
current={step}
reachableUntil={reachableUntil}
labels={Object.fromEntries([
...Array.from({ length: itemCount }, (_, i) => [
i + 1,
String(i + 1),
]),
[itemCount + 1, t("mentalHealth.review.title")],
])}
onJump={(target) => setStep(target)}
onFirst={() => setStep(1)}
onLast={() => setStep(itemCount + 1)}
firstEnabled={step > 1}
lastEnabled={complete && !onReview}
firstLabel={t("mentalHealth.jumpFirst")}
lastLabel={t("mentalHealth.jumpLast")}
srLabel={srLabel}
/>
</div>
<CardContent className="flex flex-col gap-5 p-4 sm:p-6">
{!onReview ? (
<div className="flex flex-col gap-4">
<p className="text-muted-foreground text-xs">
{t("mentalHealth.progress", {
current: step,
total: itemCount,
})}
</p>
<h2
ref={headingRef}
tabIndex={-1}
className="scroll-mt-4 text-base font-medium outline-none"
>
{t(`mentalHealth.items.${key}.${step}`)}
</h2>
<div
className="flex flex-col gap-2"
role="group"
aria-label={t(`mentalHealth.items.${key}.${step}`)}
>
{SCALE.map((v) => (
<Button
key={v}
type="button"
// 44px touch-target floor (WCAG 2.5.5).
className="min-h-11 justify-start"
variant={items[questionIndex] === v ? "default" : "outline"}
aria-pressed={items[questionIndex] === v}
onClick={() => selectAnswer(v)}
>
{t(`mentalHealth.options.${v}`)}
</Button>
))}
</div>
</div>
) : (
<div className="flex flex-col gap-5">
<h2
ref={headingRef}
tabIndex={-1}
className="scroll-mt-4 text-base font-medium outline-none"
>
{t("mentalHealth.review.title")}
</h2>
<p className="text-muted-foreground text-sm">
{t("mentalHealth.review.recap", {
answered: answeredCount(items),
total: itemCount,
})}
</p>
{/* The optional, unscored functional-difficulty follow-up folds
into the review step rather than its own dot (§1). */}
<div className="border-border/60 flex flex-col gap-2 border-t pt-4">
<span className="text-sm">
{t("mentalHealth.functionalTitle")}
</span>
<div
className="flex flex-col gap-2"
role="group"
aria-label={t("mentalHealth.functionalTitle")}
<div className="flex flex-col gap-4">
<p className="text-muted-foreground text-xs">
{t("mentalHealth.progress", {
current: step,
total: itemCount,
})}
</p>
<h2
ref={headingRef}
tabIndex={-1}
className="scroll-mt-4 text-base font-medium outline-none"
>
{t(`mentalHealth.items.${key}.${step}`)}
</h2>
<div
className="flex flex-col gap-2"
role="group"
aria-label={t(`mentalHealth.items.${key}.${step}`)}
>
{SCALE.map((v) => (
<Button
key={v}
type="button"
// 44px touch-target floor (WCAG 2.5.5).
className="min-h-11 justify-start"
variant={items[questionIndex] === v ? "default" : "outline"}
aria-pressed={items[questionIndex] === v}
onClick={() => selectAnswer(v)}
disabled={isPending}
>
{FUNCTIONAL.map((v) => (
<Button
key={v}
type="button"
className="min-h-11 justify-start"
variant={functional === v ? "default" : "outline"}
aria-pressed={functional === v}
onClick={() => setFunctional(v)}
>
{t(`mentalHealth.functional.${v}`)}
</Button>
))}
</div>
</div>
{isError && (
<p className="text-destructive text-sm">
{t("mentalHealth.error")}
</p>
)}
{t(`mentalHealth.options.${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 sm:p-6">
@@ -250,7 +173,7 @@ export function CheckInWizard({
>
{t("mentalHealth.back")}
</Button>
{!onReview ? (
{!onLast ? (
<Button
type="button"
className="h-11"
@@ -263,8 +186,8 @@ export function CheckInWizard({
) : (
<Button
type="button"
className={cn("h-11")}
onClick={() => onSubmit(items, functional)}
className="h-11"
onClick={() => onSubmit(items)}
disabled={!complete || isPending}
aria-busy={isPending || undefined}
data-slot="check-in-submit"
@@ -93,11 +93,14 @@ export function MentalWellbeing() {
setPhase("choose");
}
function submit(items: number[], functional: number | null) {
// v1.27.6 — the review step (and with it the optional functional-difficulty
// follow-up) left the web wizard; the check-in submits the scored items
// directly after the last question. The API keeps accepting
// `functionalDifficulty` for clients that still capture it.
function submit(items: number[]) {
mutation.mutate({
instrument,
items,
...(functional !== null ? { functionalDifficulty: functional } : {}),
locale,
});
}