mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +00:00
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:
1 parent
07f48d215c
commit
f7920d81a6
11 files changed
+121
-343
No files matched your search
+1
-15
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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", () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user