From d4272b13c4afa036f1a32a9a2828f193885e7d3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 29 Jun 2026 15:09:28 +0200 Subject: [PATCH] feat(mental-health): one-question check-in + card layout + trend history MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rework the PHQ-9 / GAD-7 screener surface to the card + detail-page grammar the rest of the app uses, and replace the all-questions-at-once form with a one-question-at-a-time check-in. Presentational + client-flow only — the API, encryption at rest, the opt-in module gate, and the item-9 crisis-safety behaviour are untouched, and the surface stays off the AI Coach by construction. - Split the single 387-line component into a focused folder: an orchestrator + landing, instrument card, check-in wizard, result block, history card + its lazy chart, a shared crisis card, shared types, and a pure step-grammar module. - Check-in reuses the medication wizard's stepper + Back/Next footer grammar, rendered inline on the page: one question per step, auto-advance on select, backward navigation to change an answer, never skip ahead to an unanswered question, and a review step that folds in the optional functional-difficulty follow-up before submit. Progress is announced and the steps are keyboard navigable. - Header carries the instrument title + one standardized explanation line; the "voluntary self-test, not a diagnosis" disclaimer renders only on the landing and behind the info tooltip, never while taking the test. - Landing follows the Vorsorge / medication card layout: neutral instrument cards with a last-result line + Start, and a history card. - History gains a lazy Recharts trend chart with muted severity-band reference areas (never an alarm colour) and a PHQ-9 / GAD-7 toggle, plus a dated list with band badges. A flagged row keeps a discreet "support shown" marker that re-surfaces the static crisis-resource card without decrypting item content. - Add the new mentalHealth.* keys across all six locales; drop the orphaned stem key the redesign no longer renders. New tests cover the step grammar (advance / back / reachability), the disclaimer staying hidden during the test, the item-9 crisis surfacing, and the history rendering. --- messages/de.json | 29 +- messages/en.json | 29 +- messages/es.json | 29 +- messages/fr.json | 29 +- messages/it.json | 29 +- messages/pl.json | 29 +- .../__tests__/check-in-nav.test.ts | 98 +++++ .../__tests__/mental-wellbeing.test.tsx | 190 +++++++++ .../assessment-history-chart.tsx | 157 +++++++ .../mental-health/assessment-history.tsx | 191 +++++++++ .../mental-health/assessment-result.tsx | 129 ++++++ src/components/mental-health/check-in-nav.ts | 71 ++++ .../mental-health/check-in-wizard.tsx | 281 +++++++++++++ src/components/mental-health/crisis-card.tsx | 61 +++ .../mental-health/instrument-card.tsx | 71 ++++ .../mental-health/mental-wellbeing.tsx | 392 ++++-------------- src/components/mental-health/types.ts | 37 ++ 17 files changed, 1532 insertions(+), 320 deletions(-) create mode 100644 src/components/mental-health/__tests__/check-in-nav.test.ts create mode 100644 src/components/mental-health/__tests__/mental-wellbeing.test.tsx create mode 100644 src/components/mental-health/assessment-history-chart.tsx create mode 100644 src/components/mental-health/assessment-history.tsx create mode 100644 src/components/mental-health/assessment-result.tsx create mode 100644 src/components/mental-health/check-in-nav.ts create mode 100644 src/components/mental-health/check-in-wizard.tsx create mode 100644 src/components/mental-health/crisis-card.tsx create mode 100644 src/components/mental-health/instrument-card.tsx create mode 100644 src/components/mental-health/types.ts diff --git a/messages/de.json b/messages/de.json index c97fb115e..189b80a1b 100644 --- a/messages/de.json +++ b/messages/de.json @@ -7569,7 +7569,6 @@ "choosePrompt": "Check-in auswählen", "start": "Starten", "back": "Zurück", - "stem": "Wie oft fühltest du dich im Verlauf der letzten 2 Wochen durch die folgenden Beschwerden beeinträchtigt?", "submit": "Ergebnis ansehen", "submitting": "Wird gespeichert…", "saved": "Gespeichert", @@ -7619,7 +7618,14 @@ }, "history": { "title": "Dein Verlauf", - "empty": "Noch keine Check-ins. Dein Verlauf erscheint hier, sobald du einen abgeschlossen hast." + "empty": "Noch keine Check-ins. Dein Verlauf erscheint hier, sobald du einen abgeschlossen hast.", + "chartTitle": "Punkteverlauf", + "flaggedBadge": "Hilfe angezeigt", + "totalLabel": "Punkte", + "tab": { + "phq9": "PHQ-9", + "gad7": "GAD-7" + } }, "crisis": { "title": "Du musst das nicht allein durchstehen", @@ -7671,6 +7677,25 @@ "crisisTextLine": { "name": "Crisis Text Line" } + }, + "instrumentDescription": { + "phq9": "Depressions-Screening mit 9 Fragen. Beziehe dich auf die letzten zwei Wochen.", + "gad7": "Angst-Screening mit 7 Fragen. Beziehe dich auf die letzten zwei Wochen." + }, + "progress": "{current} / {total}", + "questionAria": "Frage {current} von {total}", + "next": "Weiter", + "lastResult": "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." + }, + "landing": { + "disclaimer": "Dies sind freiwillige Selbsttests, keine Diagnose. Sie unterstützen das Gespräch mit einer Fachperson, ersetzen es aber nicht." } }, "records": { diff --git a/messages/en.json b/messages/en.json index 3f2844f28..2aef6e48a 100644 --- a/messages/en.json +++ b/messages/en.json @@ -7569,7 +7569,6 @@ "choosePrompt": "Choose a check-in", "start": "Start", "back": "Back", - "stem": "Over the last 2 weeks, how often have you been bothered by the following problems?", "submit": "See my result", "submitting": "Saving…", "saved": "Saved", @@ -7619,7 +7618,14 @@ }, "history": { "title": "Your history", - "empty": "No check-ins yet. Your trend will appear here once you complete one." + "empty": "No check-ins yet. Your trend will appear here once you complete one.", + "chartTitle": "Score trend", + "flaggedBadge": "Support shown", + "totalLabel": "Score", + "tab": { + "phq9": "PHQ-9", + "gad7": "GAD-7" + } }, "crisis": { "title": "You don't have to face this alone", @@ -7671,6 +7677,25 @@ "crisisTextLine": { "name": "Crisis Text Line" } + }, + "instrumentDescription": { + "phq9": "9-question depression screen. Answer for the last two weeks.", + "gad7": "7-question anxiety screen. Answer for the last two weeks." + }, + "progress": "{current} / {total}", + "questionAria": "Question {current} of {total}", + "next": "Next", + "lastResult": "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." + }, + "landing": { + "disclaimer": "These are voluntary self-tests, not a diagnosis. They support a conversation with a professional; they don't replace one." } }, "records": { diff --git a/messages/es.json b/messages/es.json index 98aee4b3d..c29d73b90 100644 --- a/messages/es.json +++ b/messages/es.json @@ -7569,7 +7569,6 @@ "choosePrompt": "Elige una revisión", "start": "Empezar", "back": "Atrás", - "stem": "Durante las últimas 2 semanas, ¿con qué frecuencia te han molestado los siguientes problemas?", "submit": "Ver mi resultado", "submitting": "Guardando…", "saved": "Guardado", @@ -7619,7 +7618,14 @@ }, "history": { "title": "Tu historial", - "empty": "Aún no hay revisiones. Tu tendencia aparecerá aquí cuando completes una." + "empty": "Aún no hay revisiones. Tu tendencia aparecerá aquí cuando completes una.", + "chartTitle": "Evolución de la puntuación", + "flaggedBadge": "Apoyo mostrado", + "totalLabel": "Puntuación", + "tab": { + "phq9": "PHQ-9", + "gad7": "GAD-7" + } }, "crisis": { "title": "No tienes que afrontar esto a solas", @@ -7671,6 +7677,25 @@ "crisisTextLine": { "name": "Crisis Text Line" } + }, + "instrumentDescription": { + "phq9": "Cribado de depresión de 9 preguntas. Responde pensando en las últimas dos semanas.", + "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": "Ú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." + }, + "landing": { + "disclaimer": "Son autoevaluaciones voluntarias, no un diagnóstico. Sirven de apoyo para hablar con un profesional, no lo sustituyen." } }, "records": { diff --git a/messages/fr.json b/messages/fr.json index 80c0be5fe..70725ed59 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -7569,7 +7569,6 @@ "choosePrompt": "Choisis un bilan", "start": "Commencer", "back": "Retour", - "stem": "Au cours des 2 dernières semaines, à quelle fréquence as-tu été gêné(e) par les problèmes suivants ?", "submit": "Voir mon résultat", "submitting": "Enregistrement…", "saved": "Enregistré", @@ -7619,7 +7618,14 @@ }, "history": { "title": "Ton historique", - "empty": "Aucun bilan pour le moment. Ta tendance apparaîtra ici dès que tu en auras terminé un." + "empty": "Aucun bilan pour le moment. Ta tendance apparaîtra ici dès que tu en auras terminé un.", + "chartTitle": "Évolution du score", + "flaggedBadge": "Aide affichée", + "totalLabel": "Score", + "tab": { + "phq9": "PHQ-9", + "gad7": "GAD-7" + } }, "crisis": { "title": "Tu n'as pas à affronter cela seul(e)", @@ -7671,6 +7677,25 @@ "crisisTextLine": { "name": "Crisis Text Line" } + }, + "instrumentDescription": { + "phq9": "Dépistage de la dépression en 9 questions. Répondez en pensant aux deux dernières semaines.", + "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 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}." + }, + "landing": { + "disclaimer": "Ce sont des auto-évaluations volontaires, pas un diagnostic. Elles aident à préparer un échange avec un professionnel, sans le remplacer." } }, "records": { diff --git a/messages/it.json b/messages/it.json index 03a3fa8ed..786ce24dc 100644 --- a/messages/it.json +++ b/messages/it.json @@ -7569,7 +7569,6 @@ "choosePrompt": "Scegli un check-in", "start": "Inizia", "back": "Indietro", - "stem": "Nelle ultime 2 settimane, con quale frequenza ti hanno disturbato i seguenti problemi?", "submit": "Vedi il mio risultato", "submitting": "Salvataggio…", "saved": "Salvato", @@ -7619,7 +7618,14 @@ }, "history": { "title": "La tua cronologia", - "empty": "Ancora nessun check-in. Il tuo andamento apparirà qui dopo che ne avrai completato uno." + "empty": "Ancora nessun check-in. Il tuo andamento apparirà qui dopo che ne avrai completato uno.", + "chartTitle": "Andamento del punteggio", + "flaggedBadge": "Supporto mostrato", + "totalLabel": "Punteggio", + "tab": { + "phq9": "PHQ-9", + "gad7": "GAD-7" + } }, "crisis": { "title": "Non devi affrontare tutto questo da solo/a", @@ -7671,6 +7677,25 @@ "crisisTextLine": { "name": "Crisis Text Line" } + }, + "instrumentDescription": { + "phq9": "Screening della depressione in 9 domande. Rispondi pensando alle ultime due settimane.", + "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 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}." + }, + "landing": { + "disclaimer": "Sono autovalutazioni volontarie, non una diagnosi. Sostengono il confronto con un professionista, non lo sostituiscono." } }, "records": { diff --git a/messages/pl.json b/messages/pl.json index 4b2ba446c..2c4512556 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -7569,7 +7569,6 @@ "choosePrompt": "Wybierz sprawdzenie", "start": "Rozpocznij", "back": "Wstecz", - "stem": "Jak często w ciągu ostatnich 2 tygodni dokuczały Ci następujące problemy?", "submit": "Zobacz mój wynik", "submitting": "Zapisywanie…", "saved": "Zapisano", @@ -7619,7 +7618,14 @@ }, "history": { "title": "Twoja historia", - "empty": "Brak sprawdzeń. Twój trend pojawi się tutaj po ukończeniu pierwszego." + "empty": "Brak sprawdzeń. Twój trend pojawi się tutaj po ukończeniu pierwszego.", + "chartTitle": "Przebieg wyniku", + "flaggedBadge": "Pokazano wsparcie", + "totalLabel": "Wynik", + "tab": { + "phq9": "PHQ-9", + "gad7": "GAD-7" + } }, "crisis": { "title": "Nie musisz mierzyć się z tym sam(a)", @@ -7671,6 +7677,25 @@ "crisisTextLine": { "name": "Crisis Text Line" } + }, + "instrumentDescription": { + "phq9": "Przesiew w kierunku depresji, 9 pytań. Odpowiadaj, myśląc o ostatnich dwóch tygodniach.", + "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 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ń." + }, + "landing": { + "disclaimer": "To dobrowolne testy przesiewowe, a nie diagnoza. Wspierają rozmowę ze specjalistą, ale jej nie zastępują." } }, "records": { diff --git a/src/components/mental-health/__tests__/check-in-nav.test.ts b/src/components/mental-health/__tests__/check-in-nav.test.ts new file mode 100644 index 000000000..9e0d4cdd1 --- /dev/null +++ b/src/components/mental-health/__tests__/check-in-nav.test.ts @@ -0,0 +1,98 @@ +import { describe, it, expect } from "vitest"; + +import { + UNANSWERED, + answeredCount, + buildStepList, + isComplete, + isReviewStep, + 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). + */ + +const PHQ9 = 9; +const GAD7 = 7; + +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", () => { + 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); + }); + + it("steps back, clamped at the first question", () => { + expect(prevStep(10)).toBe(9); + 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); + }); +}); + +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", () => { + 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); + }); +}); diff --git a/src/components/mental-health/__tests__/mental-wellbeing.test.tsx b/src/components/mental-health/__tests__/mental-wellbeing.test.tsx new file mode 100644 index 000000000..64069472e --- /dev/null +++ b/src/components/mental-health/__tests__/mental-wellbeing.test.tsx @@ -0,0 +1,190 @@ +import { describe, it, expect, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +import { I18nProvider } from "@/lib/i18n/context"; +import en from "../../../../messages/en.json"; + +/** + * Mental-wellbeing surface redesign (v1.25.3). The SSR-only convention + * (`renderToStaticMarkup`, `environment: "node"`, no `@testing-library/react`) + * means clicks can't be driven here — the wizard's advance/back grammar is + * pinned in `check-in-nav.test.ts`. These tests pin the SSR-observable + * contracts: the disclaimer renders ONLY on the landing (never while testing), + * a positive item-9 surfaces the crisis card, and the history paints its chart + * shell + dated list with severity + "support shown" badges. + */ + +vi.mock("@/lib/api/api-fetch", () => ({ + apiGet: () => new Promise(() => {}), + apiPost: () => new Promise(() => {}), +})); + +import { MentalWellbeing } from "../mental-wellbeing"; +import { CheckInWizard } from "../check-in-wizard"; +import { AssessmentResult } from "../assessment-result"; +import { AssessmentHistory } from "../assessment-history"; +import type { AssessmentRow, CreateResponse } from "../types"; + +const mh = en.mentalHealth; + +function withProviders(node: React.ReactNode): string { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return renderToStaticMarkup( + + {node} + , + ); +} + +describe("disclaimer placement (§2 — never while testing)", () => { + // `renderToStaticMarkup` HTML-escapes apostrophes (don't → don't), so + // assert on an apostrophe-free, distinctive slice of the disclaimer copy. + const DISCLAIMER_FRAGMENT = "voluntary self-tests, not a diagnosis"; + + it("renders the disclaimer on the landing", () => { + const html = withProviders(); + expect(html).toContain('data-slot="mental-health-disclaimer"'); + expect(html).toContain(DISCLAIMER_FRAGMENT); + }); + + it("does NOT render the disclaimer inside the check-in wizard", () => { + const html = withProviders( + {}} + onBack={() => {}} + isPending={false} + isError={false} + />, + ); + expect(html).not.toContain('data-slot="mental-health-disclaimer"'); + expect(html).not.toContain(DISCLAIMER_FRAGMENT); + // …but the standardized instrument explanation IS shown. + expect(html).toContain(mh.instrumentDescription.phq9); + }); +}); + +describe("check-in wizard scaffolding", () => { + const html = withProviders( + {}} + onBack={() => {}} + isPending={false} + isError={false} + />, + ); + + it("reuses the medication wizard stepper", () => { + expect(html).toContain('data-slot="wizard-stepper"'); + }); + + it("opens on the first question with the Back/Next footer", () => { + expect(html).toContain(mh.items.phq9["1"]); + expect(html).toContain('data-slot="check-in-back"'); + expect(html).toContain('data-slot="check-in-next"'); + // Back is disabled on the first step. + expect(html).toMatch( + /data-slot="check-in-back"[^>]*disabled|disabled[^>]*data-slot="check-in-back"/, + ); + }); +}); + +describe("item-9 crisis surfacing on the result", () => { + function result(crisis: CreateResponse["crisis"]): CreateResponse { + return { + assessment: { + id: "a1", + instrument: "PHQ9", + locale: "en", + totalScore: 18, + severityBand: "modSevere", + item9Flagged: crisis !== null, + crisisShownAt: crisis !== null ? "2026-06-20T00:00:00.000Z" : null, + takenAt: "2026-06-20T00:00:00.000Z", + }, + actionThreshold: 10, + crisis, + }; + } + + it("renders the crisis card when item 9 is flagged", () => { + const html = withProviders( + {}} + onBack={() => {}} + />, + ); + expect(html).toContain('data-slot="mental-health-crisis-card"'); + // crisis.title carries an apostrophe (escaped by SSR) — assert a slice. + expect(html).toContain("have to face this alone"); + }); + + it("omits the crisis card when item 9 is not flagged", () => { + const html = withProviders( + {}} + onBack={() => {}} + />, + ); + expect(html).not.toContain('data-slot="mental-health-crisis-card"'); + // The result still paints. + expect(html).toContain(mh.result.title); + }); +}); + +describe("history rendering", () => { + const rows: AssessmentRow[] = [ + { + id: "h1", + instrument: "PHQ9", + locale: "en", + totalScore: 16, + severityBand: "modSevere", + item9Flagged: true, + crisisShownAt: "2026-06-20T00:00:00.000Z", + takenAt: "2026-06-20T00:00:00.000Z", + }, + { + id: "h2", + instrument: "PHQ9", + locale: "en", + totalScore: 4, + severityBand: "minimal", + item9Flagged: false, + crisisShownAt: null, + takenAt: "2026-05-20T00:00:00.000Z", + }, + ]; + + const html = withProviders(); + + it("paints the lazy chart shell + the dated list", () => { + expect(html).toContain('data-slot="skeleton"'); + expect(html).toContain('data-slot="history-list"'); + }); + + it("shows the PHQ-9 / GAD-7 toggle and a severity badge", () => { + expect(html).toContain('role="tablist"'); + expect(html).toContain(mh.band.PHQ9.modSevere); + }); + + it("marks a flagged row with the discreet support marker", () => { + expect(html).toContain('data-slot="history-flagged-marker"'); + expect(html).toContain(mh.history.flaggedBadge); + }); + + it("renders the empty state with no rows", () => { + const empty = withProviders(); + expect(empty).toContain(mh.history.empty); + expect(empty).not.toContain('data-slot="history-list"'); + }); +}); diff --git a/src/components/mental-health/assessment-history-chart.tsx b/src/components/mental-health/assessment-history-chart.tsx new file mode 100644 index 000000000..d986365b3 --- /dev/null +++ b/src/components/mental-health/assessment-history-chart.tsx @@ -0,0 +1,157 @@ +"use client"; + +/** + * v1.25.3 — per-instrument screener trend chart, in the dashboard's visual + * language (mirrors `labs/lab-biomarker-chart.tsx`). A single line of the total + * score over `takenAt`, with the instrument's severity bands painted as stacked + * MUTED `ReferenceArea`s (increasing opacity toward the top, all in the + * primary/muted family) so the reader sees "where this score sits" WITHOUT a + * danger palette — never an alarm colour (the no-alarming-colour rule the labs + * chart documents). + * + * One instrument at a time: PHQ-9 (0–27) and GAD-7 (0–21) have different ranges + * and bands, so the parent toggles which series + band set this paints. + */ +import { useMemo } from "react"; +import { + CartesianGrid, + ComposedChart, + Line, + ReferenceArea, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; + +import { RichChartTooltip, type RichTooltipRow } from "../charts/chart-tooltip"; +import { CHART_HEIGHT_PX } from "@/lib/charts/constants"; +import { prefersReducedMotion } from "@/lib/charts/reduced-motion"; +import { formatDateShort } from "@/lib/format"; +import { useTranslations } from "@/lib/i18n/context"; +import { INSTRUMENTS } from "@/lib/mental-health/instruments"; + +import type { AssessmentRow, InstrumentId } from "./types"; + +interface ChartPoint { + timestamp: number; + value: number; +} + +export function AssessmentHistoryChart({ + instrument, + rows, +}: { + instrument: InstrumentId; + /** Rows for THIS instrument, any order (sorted internally, ascending time). */ + rows: AssessmentRow[]; +}) { + const { t } = useTranslations(); + const def = INSTRUMENTS[instrument]; + + const points = useMemo(() => { + return [...rows] + .sort( + (a, b) => new Date(a.takenAt).getTime() - new Date(b.takenAt).getTime(), + ) + .map((r) => ({ + timestamp: new Date(r.takenAt).getTime(), + value: r.totalScore, + })); + }, [rows]); + + const animate = !prefersReducedMotion(); + const primary = "var(--primary)"; + + if (points.length === 0) { + return ( +

+ {t("mentalHealth.history.empty")} +

+ ); + } + + // Severity bands as stacked muted areas — opacity climbs with severity so the + // higher bands read "heavier" without ever painting red. The whole y-range is + // the instrument's 0..maxScore so the bands tile the plot. + const bandCount = def.bands.length; + + return ( + + + + {def.bands.map((band, i) => ( + + ))} + formatDateShort(new Date(ts))} + tick={{ fontSize: 11, fill: "var(--muted-foreground)" }} + stroke="var(--border)" + minTickGap={32} + /> + + { + const active = props.active ?? false; + const payload = props.payload as + ReadonlyArray<{ payload?: ChartPoint }> | undefined; + const point = payload?.[0]?.payload; + if (!active || !point) { + return ; + } + const rowsOut: RichTooltipRow[] = [ + { + name: t("mentalHealth.history.totalLabel"), + value: String(point.value), + color: primary, + }, + ]; + return ( + + ); + }} + /> + + + + ); +} diff --git a/src/components/mental-health/assessment-history.tsx b/src/components/mental-health/assessment-history.tsx new file mode 100644 index 000000000..ca3a8458e --- /dev/null +++ b/src/components/mental-health/assessment-history.tsx @@ -0,0 +1,191 @@ +"use client"; + +/** + * v1.25.3 — the history card: a per-instrument trend chart over a band-shaded + * plot, then the dated list with severity badges. Mirrors the labs detail spine + * (chart → list) and lazy-loads Recharts through `next/dynamic` so it stays off + * the surface's first-load JS, with a `ChartSkeleton` shell. + * + * PHQ-9 / GAD-7 toggle: the two instruments have different score ranges + bands, + * so the chart paints one at a time. + * + * CRISIS-SAFETY: a flagged PHQ-9 row carries a discreet "support shown" marker; + * tapping it re-surfaces the STATIC crisis-resource card derived from the row's + * stored locale (`crisisResourcesForLocale`) — it NEVER decrypts or reveals the + * item-9 answer, and surfacing it triggers no third-party alert. No new data + * leaves the safety boundary. + */ +import { useMemo, useState } from "react"; +import dynamic from "next/dynamic"; +import type { ComponentProps } from "react"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { EmptyState } from "@/components/ui/empty-state"; +import { ChartErrorBoundary } from "@/components/charts/chart-error-state"; +import { ChartSkeleton } from "@/components/charts/chart-skeleton"; +import { importWithRetry } from "@/lib/retry-import"; +import { useFormatters, useTranslations } from "@/lib/i18n/context"; +import { crisisResourcesForLocale } from "@/lib/mental-health/crisis-resources"; +import { Activity } from "lucide-react"; + +import { CrisisCard } from "./crisis-card"; +import type { AssessmentRow, CrisisSet, InstrumentId } from "./types"; + +// Defer the recharts trend chart so recharts stays off the surface's first-load +// JS (the labs detail precedent). The `` shell holds the layout. +const AssessmentHistoryChartLazy = dynamic( + () => + importWithRetry(() => import("./assessment-history-chart")).then((mod) => ({ + default: mod.AssessmentHistoryChart, + })), + { ssr: false, loading: () => }, +); +function AssessmentHistoryChart( + props: ComponentProps, +) { + return ( + + + + ); +} + +function lower(id: InstrumentId): "phq9" | "gad7" { + return id === "PHQ9" ? "phq9" : "gad7"; +} + +/** Re-derive the STATIC crisis set from a row's locale — no decryption. */ +function crisisFromRow(row: AssessmentRow): CrisisSet { + const set = crisisResourcesForLocale(row.locale); + return { + emergencyNumber: set.emergencyNumber, + resources: set.resources.map((r) => ({ + id: r.id, + contacts: [...r.contacts], + })), + }; +} + +export function AssessmentHistory({ rows }: { rows: AssessmentRow[] }) { + const { t } = useTranslations(); + const { date: formatDate } = useFormatters(); + const [tab, setTab] = useState("PHQ9"); + // Which flagged row's crisis card is currently expanded (re-surfaced). + const [openCrisisRowId, setOpenCrisisRowId] = useState(null); + + const forTab = useMemo( + () => + rows + .filter((r) => r.instrument === tab) + .sort( + (a, b) => + new Date(b.takenAt).getTime() - new Date(a.takenAt).getTime(), + ), + [rows, tab], + ); + + const isEmpty = rows.length === 0; + + return ( + + + + {t("mentalHealth.history.title")} + + + + {isEmpty ? ( + } + title={t("mentalHealth.history.title")} + description={t("mentalHealth.history.empty")} + /> + ) : ( + <> + {/* PHQ-9 / GAD-7 toggle — the two ranges + band sets can't share an + axis, so the chart + list paint one instrument at a time. */} +
+ {(["PHQ9", "GAD7"] as InstrumentId[]).map((id) => ( + + ))} +
+ + + + {forTab.length === 0 ? ( +

+ {t("mentalHealth.history.empty")} +

+ ) : ( +
    + {forTab.map((row) => { + const flagged = row.item9Flagged; + const open = openCrisisRowId === row.id; + return ( +
  • +
    + + {formatDate(row.takenAt)} + +
    + {flagged && ( + + )} + + {t( + `mentalHealth.band.${row.instrument}.${row.severityBand}`, + )} + + + {row.totalScore} + +
    +
    + {flagged && open && ( + + )} +
  • + ); + })} +
+ )} + + )} +
+
+ ); +} diff --git a/src/components/mental-health/assessment-result.tsx b/src/components/mental-health/assessment-result.tsx new file mode 100644 index 000000000..67cb1b6cb --- /dev/null +++ b/src/components/mental-health/assessment-result.tsx @@ -0,0 +1,129 @@ +"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. + * + * 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. + */ +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 { PHQ_GAD_ATTRIBUTION } from "@/lib/mental-health/instruments"; + +import { CrisisCard } from "./crisis-card"; +import type { CreateResponse, InstrumentId } from "./types"; + +function lower(id: InstrumentId): "phq9" | "gad7" { + return id === "PHQ9" ? "phq9" : "gad7"; +} + +export function AssessmentResult({ + result, + onTakeAnother, + onBack, +}: { + result: CreateResponse; + onTakeAnother: () => void; + onBack: () => void; +}) { + const { t } = useTranslations(); + const instrument = result.assessment.instrument; + const key = lower(instrument); + + // 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 . + const headingRef = useRef(null); + useEffect(() => { + headingRef.current?.focus(); + }, []); + + return ( +
+ + +
+

+ {t(`mentalHealth.instrument.${key}`)} +

+

+ {t(`mentalHealth.instrumentDescription.${key}`)} +

+
+ +

+ {t("mentalHealth.saved")} +

+ + + + {t("mentalHealth.result.title")} + + +
+ + {t("mentalHealth.result.totalLabel")} + + + {result.assessment.totalScore} + +
+
+ + {t("mentalHealth.result.bandLabel")} + + + {t( + `mentalHealth.band.${result.assessment.instrument}.${result.assessment.severityBand}`, + )} + +
+ {result.assessment.totalScore >= result.actionThreshold && ( +

+ {t("mentalHealth.considerProfessional")} +

+ )} +
+
+ + {result.crisis && } + +

+ + {t("mentalHealth.attributionLabel")}: + {" "} + {PHQ_GAD_ATTRIBUTION} +

+ + +
+ ); +} diff --git a/src/components/mental-health/check-in-nav.ts b/src/components/mental-health/check-in-nav.ts new file mode 100644 index 000000000..125d04a6b --- /dev/null +++ b/src/components/mental-health/check-in-nav.ts @@ -0,0 +1,71 @@ +/** + * 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 + * 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. + * + * 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. */ +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). + */ +export function nextStep(step: number, itemCount: number): number { + const review = reviewStep(itemCount); + if (step >= review) return review; + return step + 1; +} + +/** The previous step, clamped at the first question. */ +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); +} diff --git a/src/components/mental-health/check-in-wizard.tsx b/src/components/mental-health/check-in-wizard.tsx new file mode 100644 index 000000000..8d337b859 --- /dev/null +++ b/src/components/mental-health/check-in-wizard.tsx @@ -0,0 +1,281 @@ +"use client"; + +/** + * v1.25.3 — one-question-at-a-time check-in. + * + * Replaces the all-questions-at-once `
    ` 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). + * + * 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. + */ +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, + nextStep, + prevStep, + reachableUntilIndex, +} 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"; +} + +export function CheckInWizard({ + instrument, + onSubmit, + onBack, + isPending, + isError, +}: { + instrument: InstrumentId; + /** Submit the completed check-in. `functional` is null when unanswered. */ + onSubmit: (items: number[], functional: number | null) => void; + /** Return to the landing (abandons the in-progress check-in). */ + onBack: () => void; + isPending: boolean; + isError: boolean; +}) { + const { t } = useTranslations(); + const key = lower(instrument); + const itemCount = INSTRUMENTS[instrument].itemCount; + + const [step, setStep] = useState(1); + const [items, setItems] = useState(() => + Array(itemCount).fill(UNANSWERED), + ); + const [functional, setFunctional] = useState(null); + + const stepList = buildStepList(itemCount); + const onReview = isReviewStep(step, itemCount); + const questionIndex = step - 1; // 0-based item index for a question step + 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 + // auto-advance, not silence. + const headingRef = useRef(null); + useEffect(() => { + headingRef.current?.focus({ preventScroll: false }); + }, [step]); + + function selectAnswer(value: number) { + setItems((prev) => { + const next = [...prev]; + 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)); + } + + const srLabel = onReview + ? t("mentalHealth.review.title") + : t("mentalHealth.questionAria", { current: step, total: itemCount }); + + return ( +
    + + + {/* In-test header — instrument title + ONE standardized explanation line. + No disclaimer here (§2); the (i) tooltip on the landing carries it. */} +
    +

    + {t(`mentalHealth.instrument.${key}`)} +

    +

    + {t(`mentalHealth.instrumentDescription.${key}`)} +

    +
    + + +
    + [ + 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} + /> +
    + + + {!onReview ? ( +
    +

    + {t("mentalHealth.progress", { + current: step, + total: itemCount, + })} +

    +

    + {t(`mentalHealth.items.${key}.${step}`)} +

    +
    + {SCALE.map((v) => ( + + ))} +
    +
    + ) : ( +
    +

    + {t("mentalHealth.review.title")} +

    +

    + {t("mentalHealth.review.recap", { + answered: answeredCount(items), + total: itemCount, + })} +

    + + {/* The optional, unscored functional-difficulty follow-up folds + into the review step rather than its own dot (§1). */} +
    + + {t("mentalHealth.functionalTitle")} + +
    + {FUNCTIONAL.map((v) => ( + + ))} +
    +
    + + {isError && ( +

    + {t("mentalHealth.error")} +

    + )} +
    + )} +
    + +
    + + {!onReview ? ( + + ) : ( + + )} +
    +
    +
    + ); +} diff --git a/src/components/mental-health/crisis-card.tsx b/src/components/mental-health/crisis-card.tsx new file mode 100644 index 000000000..d10aaee09 --- /dev/null +++ b/src/components/mental-health/crisis-card.tsx @@ -0,0 +1,61 @@ +"use client"; + +/** + * v1.25.3 — the calm, non-alarmist crisis-resource card. + * + * Shared between the fresh-result view (the POST response carries the crisis + * set on any non-zero PHQ-9 item 9) and the history list, where tapping a + * flagged row re-surfaces this card from the STATIC, locale-derived resource + * config (`crisisResourcesForLocale`) — it NEVER decrypts or reveals the + * item-9 answer, and showing it never triggers any third-party alert. + * + * Tone follows the v1.25.1 decision: a calm `border-destructive/40` border, + * never an amber/red fill; the `role="alert"` + `aria-live` carry the urgency. + */ +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { useTranslations } from "@/lib/i18n/context"; + +import type { CrisisSet } from "./types"; + +export function CrisisCard({ crisis }: { crisis: CrisisSet }) { + const { t } = useTranslations(); + return ( + + + + {t("mentalHealth.crisis.title")} + + + +

    {t("mentalHealth.crisis.intro")}

    +

    + {t("mentalHealth.crisis.ifDanger", { + emergency: crisis.emergencyNumber, + })} +

    +
    + + {t("mentalHealth.crisis.resourcesTitle")} + +
      + {crisis.resources.map((r) => ( +
    • + + {t(`mentalHealth.crisisResource.${r.id}.name`)} + + + {r.contacts.join(" · ")} + +
    • + ))} +
    +
    +
    +
    + ); +} diff --git a/src/components/mental-health/instrument-card.tsx b/src/components/mental-health/instrument-card.tsx new file mode 100644 index 000000000..a7664446c --- /dev/null +++ b/src/components/mental-health/instrument-card.tsx @@ -0,0 +1,71 @@ +"use client"; + +/** + * v1.25.3 — one neutral card per instrument on the landing. + * + * Mirrors the Vorsorge / medication card grammar: a header (title + sub), + * a discreet "last result" line when history exists (band badge + relative + * date), and a single bottom-pinned Start action. The surface stays NEUTRAL + * regardless of the last band — no severity tint (house rule: status reads + * through a discreet badge only). + */ +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { useFormatters, useTranslations } from "@/lib/i18n/context"; + +import type { AssessmentRow, InstrumentId } from "./types"; + +function lower(id: InstrumentId): "phq9" | "gad7" { + return id === "PHQ9" ? "phq9" : "gad7"; +} + +export function InstrumentCard({ + instrument, + last, + onStart, +}: { + instrument: InstrumentId; + /** Most-recent assessment for this instrument, or undefined when none yet. */ + last: AssessmentRow | undefined; + onStart: () => void; +}) { + const { t } = useTranslations(); + const { date: formatDate } = useFormatters(); + const key = lower(instrument); + + return ( + + +
    + + {t(`mentalHealth.instrument.${key}`)} + + + {t(`mentalHealth.instrumentSub.${key}`)} + +
    + +
    + {last ? ( + + {t("mentalHealth.lastResult")} + + {t(`mentalHealth.band.${instrument}.${last.severityBand}`)} + + {formatDate(last.takenAt)} + + ) : ( + {t("mentalHealth.noResultYet")} + )} +
    + +
    + +
    +
    +
    + ); +} diff --git a/src/components/mental-health/mental-wellbeing.tsx b/src/components/mental-health/mental-wellbeing.tsx index 0cd7b89da..14978714b 100644 --- a/src/components/mental-health/mental-wellbeing.tsx +++ b/src/components/mental-health/mental-wellbeing.tsx @@ -12,70 +12,41 @@ * anxiety screen never invites an AI conversation about item content. On a * positive PHQ-9 item-9 the server returns a calm, locale-aware crisis-resource * set which is rendered immediately below the result. + * + * v1.25.3 — this is now the ORCHESTRATOR + landing only; the 387-line monolith + * split into focused components (`instrument-card`, `check-in-wizard`, + * `assessment-result`, `assessment-history` + its lazy chart, `crisis-card`). + * The landing follows the card grammar of Vorsorge / Medications: instrument + * cards (last-result line + Start) + a history card. The disclaimer renders + * ONLY here (a muted caption) and behind the InfoHint — never while testing. */ -import { useEffect, useMemo, useRef, useState } from "react"; +import { useMemo, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { useTranslations, useFormatters } from "@/lib/i18n/context"; +import { useTranslations } from "@/lib/i18n/context"; import { apiGet, apiPost } from "@/lib/api/api-fetch"; import { queryKeys } from "@/lib/query-keys"; -import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { InfoHint } from "@/components/ui/info-hint"; -import { - INSTRUMENTS, - PHQ_GAD_ATTRIBUTION, - type InstrumentId, -} from "@/lib/mental-health/instruments"; -type Phase = "choose" | "form" | "result"; - -interface AssessmentRow { - id: string; - instrument: InstrumentId; - totalScore: number; - severityBand: string; - item9Flagged: boolean; - takenAt: string; -} - -interface CrisisSet { - emergencyNumber: string; - resources: { id: string; contacts: string[] }[]; -} - -interface CreateResponse { - assessment: AssessmentRow; - actionThreshold: number; - crisis: CrisisSet | null; -} - -const SCALE = [0, 1, 2, 3] as const; -const FUNCTIONAL = [0, 1, 2, 3] as const; +import { AssessmentHistory } from "./assessment-history"; +import { AssessmentResult } from "./assessment-result"; +import { CheckInWizard } from "./check-in-wizard"; +import { InstrumentCard } from "./instrument-card"; +import type { + AssessmentRow, + CreateResponse, + InstrumentId, + Phase, +} from "./types"; export function MentalWellbeing() { const { t, locale } = useTranslations(); - const { date: formatDate } = useFormatters(); const queryClient = useQueryClient(); const [phase, setPhase] = useState("choose"); const [instrument, setInstrument] = useState("PHQ9"); - const [items, setItems] = useState([]); - const [functional, setFunctional] = useState(null); const [result, setResult] = useState(null); - // a11y: on submit the form (incl. the submit button) unmounts and `phase` - // flips to "result". Move focus to the result heading so keyboard / screen- - // reader users are not dropped to `` and land on the new content. - const resultHeadingRef = useRef(null); - useEffect(() => { - if (phase === "result") { - resultHeadingRef.current?.focus(); - } - }, [phase]); - - const lower = (id: InstrumentId) => (id === "PHQ9" ? "phq9" : "gad7"); - const { data: history } = useQuery({ queryKey: queryKeys.mentalHealthAssessments(), queryFn: () => @@ -96,18 +67,29 @@ export function MentalWellbeing() { }, }); + // Most-recent assessment per instrument for the landing's "last result" line. + // The history GET returns rows newest-first, so the first match wins. + const lastByInstrument = useMemo(() => { + const map = new Map(); + for (const row of history?.assessments ?? []) { + if (!map.has(row.instrument)) map.set(row.instrument, row); + } + return map; + }, [history]); + function begin(id: InstrumentId) { setInstrument(id); - setItems(Array(INSTRUMENTS[id].itemCount).fill(-1)); - setFunctional(null); setResult(null); + mutation.reset(); setPhase("form"); } - const complete = items.length > 0 && items.every((v) => v >= 0); + function backToLanding() { + mutation.reset(); + setPhase("choose"); + } - function submit() { - if (!complete) return; + function submit(items: number[], functional: number | null) { mutation.mutate({ instrument, items, @@ -116,271 +98,65 @@ export function MentalWellbeing() { }); } - const itemCount = INSTRUMENTS[instrument].itemCount; - const itemIndexes = useMemo( - () => Array.from({ length: itemCount }, (_, i) => i), - [itemCount], - ); - return (
    -
    -
    -

    - {t("mentalHealth.pageTitle")} -

    - -
    -

    - {t("mentalHealth.pageDescription")} -

    -
    - {phase === "choose" && ( -
    -

    - {t("mentalHealth.choosePrompt")} -

    - {(["PHQ9", "GAD7"] as InstrumentId[]).map((id) => ( - - -
    - - {t(`mentalHealth.instrument.${lower(id)}`)} - - - {t(`mentalHealth.instrumentSub.${lower(id)}`)} - -
    - -
    -
    - ))} -
    + <> +
    +
    +

    + {t("mentalHealth.pageTitle")} +

    + +
    +

    + {t("mentalHealth.pageDescription")} +

    + {/* The ONLY place the disclaimer renders as body text (§2). */} +

    + {t("mentalHealth.landing.disclaimer")} +

    +
    + +
    +

    {t("mentalHealth.choosePrompt")}

    +
      + {(["PHQ9", "GAD7"] as InstrumentId[]).map((id) => ( +
    • + begin(id)} + /> +
    • + ))} +
    +
    + + + )} {phase === "form" && ( -
    -
    - - - {t(`mentalHealth.instrument.${lower(instrument)}`)} - -
    -

    {t("mentalHealth.stem")}

    - -
      - {itemIndexes.map((i) => ( -
    1. - - {i + 1}.{" "} - {t(`mentalHealth.items.${lower(instrument)}.${i + 1}`)} - -
      - {SCALE.map((v) => ( - - ))} -
      -
    2. - ))} -
    - -
    - {t("mentalHealth.functionalTitle")} -
    - {FUNCTIONAL.map((v) => ( - - ))} -
    -
    - - {mutation.isError && ( -

    - {t("mentalHealth.error")} -

    - )} - -
    + )} {phase === "result" && result && ( -
    -

    - {t("mentalHealth.saved")} -

    - - - - {t("mentalHealth.result.title")} - - - -
    - - {t("mentalHealth.result.totalLabel")} - - - {result.assessment.totalScore} - -
    -
    - - {t("mentalHealth.result.bandLabel")} - - - {t( - `mentalHealth.band.${result.assessment.instrument}.${result.assessment.severityBand}`, - )} - -
    - {result.assessment.totalScore >= result.actionThreshold && ( -

    - {t("mentalHealth.considerProfessional")} -

    - )} -
    -
    - - {result.crisis && ( - - - - {t("mentalHealth.crisis.title")} - - - -

    {t("mentalHealth.crisis.intro")}

    -

    - {t("mentalHealth.crisis.ifDanger", { - emergency: result.crisis.emergencyNumber, - })} -

    -
    - - {t("mentalHealth.crisis.resourcesTitle")} - -
      - {result.crisis.resources.map((r) => ( -
    • - - {t(`mentalHealth.crisisResource.${r.id}.name`)} - - - {r.contacts.join(" · ")} - -
    • - ))} -
    -
    -
    -
    - )} - -

    - - {t("mentalHealth.attributionLabel")}: - {" "} - {PHQ_GAD_ATTRIBUTION} -

    - - -
    + )} - -
    -

    - {t("mentalHealth.history.title")} -

    - {!history || history.assessments.length === 0 ? ( -

    - {t("mentalHealth.history.empty")} -

    - ) : ( -
      - {history.assessments.map((row) => ( -
    • - - {t(`mentalHealth.instrument.${lower(row.instrument)}`)} - - - {t(`mentalHealth.band.${row.instrument}.${row.severityBand}`)}{" "} - · {formatDate(row.takenAt)} - -
    • - ))} -
    - )} -
    ); } diff --git a/src/components/mental-health/types.ts b/src/components/mental-health/types.ts new file mode 100644 index 000000000..7b66ca1ff --- /dev/null +++ b/src/components/mental-health/types.ts @@ -0,0 +1,37 @@ +/** + * v1.25.3 — shared types for the opt-in mental-health screener surface. + * + * The 387-line monolith split into a folder of focused components; these are + * the seams they plug into. The wire shapes mirror the assessment route's + * `shapeRow` / create response (`src/app/api/mental-health/assessments/route.ts`) + * — totals + bands + flags only, NEVER item content (the item answers stay + * encrypted at rest and never ride this surface). + */ +import type { InstrumentId } from "@/lib/mental-health/instruments"; + +export type { InstrumentId }; + +export type Phase = "choose" | "form" | "result"; + +export interface AssessmentRow { + id: string; + instrument: InstrumentId; + /** Locale of the validated wording presented — re-derives crisis resources. */ + locale: string; + totalScore: number; + severityBand: string; + item9Flagged: boolean; + crisisShownAt: string | null; + takenAt: string; +} + +export interface CrisisSet { + emergencyNumber: string; + resources: { id: string; contacts: string[] }[]; +} + +export interface CreateResponse { + assessment: AssessmentRow; + actionThreshold: number; + crisis: CrisisSet | null; +}