mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
Merge v1.25.3: v1253-mental-wellbeing
This commit is contained in:
commit
78fec1ceff
17 files changed
+1532
-320
No files matched your search
+27
-2
@@ -7565,7 +7565,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",
|
||||
@@ -7615,7 +7614,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",
|
||||
@@ -7667,6 +7673,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": {
|
||||
|
||||
+27
-2
@@ -7565,7 +7565,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",
|
||||
@@ -7615,7 +7614,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",
|
||||
@@ -7667,6 +7673,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": {
|
||||
|
||||
+27
-2
@@ -7565,7 +7565,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",
|
||||
@@ -7615,7 +7614,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",
|
||||
@@ -7667,6 +7673,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": {
|
||||
|
||||
+27
-2
@@ -7565,7 +7565,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é",
|
||||
@@ -7615,7 +7614,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)",
|
||||
@@ -7667,6 +7673,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": {
|
||||
|
||||
+27
-2
@@ -7565,7 +7565,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",
|
||||
@@ -7615,7 +7614,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",
|
||||
@@ -7667,6 +7673,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": {
|
||||
|
||||
+27
-2
@@ -7565,7 +7565,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",
|
||||
@@ -7615,7 +7614,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)",
|
||||
@@ -7667,6 +7673,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": {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<I18nProvider initialLocale="en">{node}</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
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(<MentalWellbeing />);
|
||||
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(
|
||||
<CheckInWizard
|
||||
instrument="PHQ9"
|
||||
onSubmit={() => {}}
|
||||
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(
|
||||
<CheckInWizard
|
||||
instrument="PHQ9"
|
||||
onSubmit={() => {}}
|
||||
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(
|
||||
<AssessmentResult
|
||||
result={result({
|
||||
emergencyNumber: "112",
|
||||
resources: [{ id: "findahelpline", contacts: ["findahelpline.com"] }],
|
||||
})}
|
||||
onTakeAnother={() => {}}
|
||||
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(
|
||||
<AssessmentResult
|
||||
result={result(null)}
|
||||
onTakeAnother={() => {}}
|
||||
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(<AssessmentHistory rows={rows} />);
|
||||
|
||||
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(<AssessmentHistory rows={[]} />);
|
||||
expect(empty).toContain(mh.history.empty);
|
||||
expect(empty).not.toContain('data-slot="history-list"');
|
||||
});
|
||||
});
|
||||
@@ -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<ChartPoint[]>(() => {
|
||||
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 (
|
||||
<p className="text-muted-foreground py-10 text-center text-sm">
|
||||
{t("mentalHealth.history.empty")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<ResponsiveContainer width="100%" height={CHART_HEIGHT_PX}>
|
||||
<ComposedChart
|
||||
data={points}
|
||||
margin={{ top: 8, right: 12, bottom: 4, left: 0 }}
|
||||
>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
stroke="var(--border)"
|
||||
vertical={false}
|
||||
/>
|
||||
{def.bands.map((band, i) => (
|
||||
<ReferenceArea
|
||||
key={band.key}
|
||||
y1={band.min}
|
||||
// Extend the top band to the instrument max so the last slice fills.
|
||||
y2={i === bandCount - 1 ? def.maxScore : band.max + 1}
|
||||
fill={primary}
|
||||
// 0.04 → ~0.16 across the bands: muted, monochrome, never alarm.
|
||||
fillOpacity={0.04 + (i / Math.max(1, bandCount - 1)) * 0.12}
|
||||
stroke="none"
|
||||
ifOverflow="extendDomain"
|
||||
/>
|
||||
))}
|
||||
<XAxis
|
||||
dataKey="timestamp"
|
||||
type="number"
|
||||
scale="time"
|
||||
domain={["dataMin", "dataMax"]}
|
||||
tickFormatter={(ts: number) => formatDateShort(new Date(ts))}
|
||||
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
|
||||
stroke="var(--border)"
|
||||
minTickGap={32}
|
||||
/>
|
||||
<YAxis
|
||||
domain={[0, def.maxScore]}
|
||||
allowDecimals={false}
|
||||
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
|
||||
stroke="var(--border)"
|
||||
width={32}
|
||||
/>
|
||||
<Tooltip
|
||||
content={(props) => {
|
||||
const active = props.active ?? false;
|
||||
const payload = props.payload as
|
||||
ReadonlyArray<{ payload?: ChartPoint }> | undefined;
|
||||
const point = payload?.[0]?.payload;
|
||||
if (!active || !point) {
|
||||
return <RichChartTooltip active={false} rows={[]} />;
|
||||
}
|
||||
const rowsOut: RichTooltipRow[] = [
|
||||
{
|
||||
name: t("mentalHealth.history.totalLabel"),
|
||||
value: String(point.value),
|
||||
color: primary,
|
||||
},
|
||||
];
|
||||
return (
|
||||
<RichChartTooltip
|
||||
active
|
||||
label={formatDateShort(new Date(point.timestamp))}
|
||||
rows={rowsOut}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="value"
|
||||
stroke={primary}
|
||||
strokeWidth={2}
|
||||
dot={{ r: 3, fill: primary }}
|
||||
activeDot={{ r: 5 }}
|
||||
isAnimationActive={animate}
|
||||
/>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
@@ -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 `<ChartSkeleton>` shell holds the layout.
|
||||
const AssessmentHistoryChartLazy = dynamic(
|
||||
() =>
|
||||
importWithRetry(() => import("./assessment-history-chart")).then((mod) => ({
|
||||
default: mod.AssessmentHistoryChart,
|
||||
})),
|
||||
{ ssr: false, loading: () => <ChartSkeleton /> },
|
||||
);
|
||||
function AssessmentHistoryChart(
|
||||
props: ComponentProps<typeof AssessmentHistoryChartLazy>,
|
||||
) {
|
||||
return (
|
||||
<ChartErrorBoundary>
|
||||
<AssessmentHistoryChartLazy {...props} />
|
||||
</ChartErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
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<InstrumentId>("PHQ9");
|
||||
// Which flagged row's crisis card is currently expanded (re-surfaced).
|
||||
const [openCrisisRowId, setOpenCrisisRowId] = useState<string | null>(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 (
|
||||
<Card data-slot="mental-health-history">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">
|
||||
{t("mentalHealth.history.title")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
{isEmpty ? (
|
||||
<EmptyState
|
||||
icon={<Activity className="size-6" />}
|
||||
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. */}
|
||||
<div
|
||||
className="flex gap-1"
|
||||
role="tablist"
|
||||
aria-label={t("mentalHealth.history.chartTitle")}
|
||||
>
|
||||
{(["PHQ9", "GAD7"] as InstrumentId[]).map((id) => (
|
||||
<Button
|
||||
key={id}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={tab === id}
|
||||
size="sm"
|
||||
variant={tab === id ? "secondary" : "ghost"}
|
||||
className="h-8 px-3 text-xs"
|
||||
onClick={() => {
|
||||
setTab(id);
|
||||
setOpenCrisisRowId(null);
|
||||
}}
|
||||
>
|
||||
{t(`mentalHealth.history.tab.${lower(id)}`)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<AssessmentHistoryChart instrument={tab} rows={forTab} />
|
||||
|
||||
{forTab.length === 0 ? (
|
||||
<p className="text-muted-foreground py-4 text-center text-sm">
|
||||
{t("mentalHealth.history.empty")}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-1.5" data-slot="history-list">
|
||||
{forTab.map((row) => {
|
||||
const flagged = row.item9Flagged;
|
||||
const open = openCrisisRowId === row.id;
|
||||
return (
|
||||
<li key={row.id} className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center justify-between gap-3 text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
{formatDate(row.takenAt)}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{flagged && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-auto p-0"
|
||||
aria-expanded={open}
|
||||
onClick={() =>
|
||||
setOpenCrisisRowId(open ? null : row.id)
|
||||
}
|
||||
data-slot="history-flagged-marker"
|
||||
>
|
||||
<Badge variant="outline">
|
||||
{t("mentalHealth.history.flaggedBadge")}
|
||||
</Badge>
|
||||
</Button>
|
||||
)}
|
||||
<Badge variant="secondary">
|
||||
{t(
|
||||
`mentalHealth.band.${row.instrument}.${row.severityBand}`,
|
||||
)}
|
||||
</Badge>
|
||||
<span className="font-medium tabular-nums">
|
||||
{row.totalScore}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{flagged && open && (
|
||||
<CrisisCard crisis={crisisFromRow(row)} />
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -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 <body>.
|
||||
const headingRef = useRef<HTMLHeadingElement | null>(null);
|
||||
useEffect(() => {
|
||||
headingRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<section
|
||||
className="flex flex-col gap-4"
|
||||
data-slot="mental-health-result"
|
||||
aria-live="polite"
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="-ml-2 w-fit"
|
||||
onClick={onBack}
|
||||
data-slot="result-back-to-landing"
|
||||
>
|
||||
<ArrowLeft className="size-4" aria-hidden="true" />
|
||||
{t("mentalHealth.backToList")}
|
||||
</Button>
|
||||
|
||||
<header className="flex flex-col gap-1">
|
||||
<h1
|
||||
ref={headingRef}
|
||||
tabIndex={-1}
|
||||
className="scroll-mt-4 text-2xl font-semibold outline-none"
|
||||
>
|
||||
{t(`mentalHealth.instrument.${key}`)}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t(`mentalHealth.instrumentDescription.${key}`)}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<p className="text-muted-foreground text-xs" role="status">
|
||||
{t("mentalHealth.saved")}
|
||||
</p>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("mentalHealth.result.title")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3 text-sm">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<span className="text-muted-foreground">
|
||||
{t("mentalHealth.result.totalLabel")}
|
||||
</span>
|
||||
<span className="text-2xl font-semibold">
|
||||
{result.assessment.totalScore}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between">
|
||||
<span className="text-muted-foreground">
|
||||
{t("mentalHealth.result.bandLabel")}
|
||||
</span>
|
||||
<span className="font-medium">
|
||||
{t(
|
||||
`mentalHealth.band.${result.assessment.instrument}.${result.assessment.severityBand}`,
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{result.assessment.totalScore >= result.actionThreshold && (
|
||||
<p className="text-muted-foreground bg-muted/40 rounded-md p-3 text-xs">
|
||||
{t("mentalHealth.considerProfessional")}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{result.crisis && <CrisisCard crisis={result.crisis} />}
|
||||
|
||||
<p className="text-muted-foreground text-[11px] leading-snug">
|
||||
<span className="font-medium">
|
||||
{t("mentalHealth.attributionLabel")}:
|
||||
</span>{" "}
|
||||
{PHQ_GAD_ATTRIBUTION}
|
||||
</p>
|
||||
|
||||
<Button variant="outline" onClick={onTakeAnother}>
|
||||
{t("mentalHealth.result.takeAnother")}
|
||||
</Button>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* v1.25.3 — one-question-at-a-time check-in.
|
||||
*
|
||||
* 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).
|
||||
*
|
||||
* 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<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 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<HTMLHeadingElement | null>(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 (
|
||||
<section
|
||||
className="flex flex-col gap-5"
|
||||
data-slot="mental-health-check-in"
|
||||
aria-busy={isPending || undefined}
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="-ml-2 w-fit"
|
||||
onClick={onBack}
|
||||
data-slot="check-in-back-to-landing"
|
||||
>
|
||||
<ArrowLeft className="size-4" aria-hidden="true" />
|
||||
{t("mentalHealth.backToList")}
|
||||
</Button>
|
||||
|
||||
{/* In-test header — instrument title + ONE standardized explanation line.
|
||||
No disclaimer here (§2); the (i) tooltip on the landing carries it. */}
|
||||
<header className="flex flex-col gap-1">
|
||||
<h1 className="text-2xl font-semibold">
|
||||
{t(`mentalHealth.instrument.${key}`)}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t(`mentalHealth.instrumentDescription.${key}`)}
|
||||
</p>
|
||||
</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")}
|
||||
>
|
||||
{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>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
<div className="border-border/70 flex items-center justify-between gap-2 border-t p-4 sm:p-6">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="h-11"
|
||||
onClick={() => setStep((s) => prevStep(s))}
|
||||
disabled={step === 1 || isPending}
|
||||
data-slot="check-in-back"
|
||||
>
|
||||
{t("mentalHealth.back")}
|
||||
</Button>
|
||||
{!onReview ? (
|
||||
<Button
|
||||
type="button"
|
||||
className="h-11"
|
||||
onClick={() => setStep((s) => nextStep(s, itemCount))}
|
||||
disabled={items[questionIndex] < 0}
|
||||
data-slot="check-in-next"
|
||||
>
|
||||
{t("mentalHealth.next")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
className={cn("h-11")}
|
||||
onClick={() => onSubmit(items, functional)}
|
||||
disabled={!complete || isPending}
|
||||
aria-busy={isPending || undefined}
|
||||
data-slot="check-in-submit"
|
||||
>
|
||||
{isPending
|
||||
? t("mentalHealth.submitting")
|
||||
: t("mentalHealth.submit")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<Card
|
||||
className="border-destructive/40"
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
data-slot="mental-health-crisis-card"
|
||||
>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">
|
||||
{t("mentalHealth.crisis.title")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3 text-sm">
|
||||
<p>{t("mentalHealth.crisis.intro")}</p>
|
||||
<p className="font-medium">
|
||||
{t("mentalHealth.crisis.ifDanger", {
|
||||
emergency: crisis.emergencyNumber,
|
||||
})}
|
||||
</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-muted-foreground text-xs font-medium">
|
||||
{t("mentalHealth.crisis.resourcesTitle")}
|
||||
</span>
|
||||
<ul className="flex flex-col gap-2">
|
||||
{crisis.resources.map((r) => (
|
||||
<li key={r.id} className="flex flex-col">
|
||||
<span className="font-medium">
|
||||
{t(`mentalHealth.crisisResource.${r.id}.name`)}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
{r.contacts.join(" · ")}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<Card className="h-full gap-3" data-slot="instrument-card">
|
||||
<CardContent className="flex h-full flex-col gap-4 p-4">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="font-medium">
|
||||
{t(`mentalHealth.instrument.${key}`)}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{t(`mentalHealth.instrumentSub.${key}`)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="text-muted-foreground min-h-5 text-xs">
|
||||
{last ? (
|
||||
<span className="flex flex-wrap items-center gap-1.5">
|
||||
<span>{t("mentalHealth.lastResult")}</span>
|
||||
<Badge variant="secondary">
|
||||
{t(`mentalHealth.band.${instrument}.${last.severityBand}`)}
|
||||
</Badge>
|
||||
<span>{formatDate(last.takenAt)}</span>
|
||||
</span>
|
||||
) : (
|
||||
<span>{t("mentalHealth.noResultYet")}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-auto pt-0">
|
||||
<Button type="button" className="min-h-11 w-full" onClick={onStart}>
|
||||
{t("mentalHealth.start")}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -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<Phase>("choose");
|
||||
const [instrument, setInstrument] = useState<InstrumentId>("PHQ9");
|
||||
const [items, setItems] = useState<number[]>([]);
|
||||
const [functional, setFunctional] = useState<number | null>(null);
|
||||
const [result, setResult] = useState<CreateResponse | null>(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 `<body>` and land on the new content.
|
||||
const resultHeadingRef = useRef<HTMLDivElement | null>(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<InstrumentId, AssessmentRow>();
|
||||
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 (
|
||||
<div className="mx-auto flex max-w-3xl flex-col gap-6">
|
||||
<header className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<h1 className="text-2xl font-semibold">
|
||||
{t("mentalHealth.pageTitle")}
|
||||
</h1>
|
||||
<InfoHint label={t("mentalHealth.infoTooltip")} />
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("mentalHealth.pageDescription")}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{phase === "choose" && (
|
||||
<section
|
||||
className="flex flex-col gap-3"
|
||||
aria-label={t("mentalHealth.choosePrompt")}
|
||||
>
|
||||
<h2 className="text-sm font-medium">
|
||||
{t("mentalHealth.choosePrompt")}
|
||||
</h2>
|
||||
{(["PHQ9", "GAD7"] as InstrumentId[]).map((id) => (
|
||||
<Card key={id}>
|
||||
<CardContent className="flex items-center justify-between gap-4 p-4">
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">
|
||||
{t(`mentalHealth.instrument.${lower(id)}`)}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{t(`mentalHealth.instrumentSub.${lower(id)}`)}
|
||||
</span>
|
||||
</div>
|
||||
<Button onClick={() => begin(id)}>
|
||||
{t("mentalHealth.start")}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</section>
|
||||
<>
|
||||
<header className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<h1 className="text-2xl font-semibold">
|
||||
{t("mentalHealth.pageTitle")}
|
||||
</h1>
|
||||
<InfoHint label={t("mentalHealth.infoTooltip")} />
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("mentalHealth.pageDescription")}
|
||||
</p>
|
||||
{/* The ONLY place the disclaimer renders as body text (§2). */}
|
||||
<p
|
||||
className="text-muted-foreground text-xs"
|
||||
data-slot="mental-health-disclaimer"
|
||||
>
|
||||
{t("mentalHealth.landing.disclaimer")}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section aria-label={t("mentalHealth.choosePrompt")}>
|
||||
<h2 className="sr-only">{t("mentalHealth.choosePrompt")}</h2>
|
||||
<ul className="grid list-none gap-4 p-0 sm:grid-cols-2">
|
||||
{(["PHQ9", "GAD7"] as InstrumentId[]).map((id) => (
|
||||
<li key={id} className="contents">
|
||||
<InstrumentCard
|
||||
instrument={id}
|
||||
last={lastByInstrument.get(id)}
|
||||
onStart={() => begin(id)}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<AssessmentHistory rows={history?.assessments ?? []} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{phase === "form" && (
|
||||
<section className="flex flex-col gap-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setPhase("choose")}
|
||||
>
|
||||
{t("mentalHealth.back")}
|
||||
</Button>
|
||||
<span className="text-sm font-medium">
|
||||
{t(`mentalHealth.instrument.${lower(instrument)}`)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm">{t("mentalHealth.stem")}</p>
|
||||
|
||||
<ol className="flex flex-col gap-5">
|
||||
{itemIndexes.map((i) => (
|
||||
<li key={i} className="flex flex-col gap-2">
|
||||
<span className="text-sm">
|
||||
{i + 1}.{" "}
|
||||
{t(`mentalHealth.items.${lower(instrument)}.${i + 1}`)}
|
||||
</span>
|
||||
<div
|
||||
className="flex flex-wrap gap-2"
|
||||
role="group"
|
||||
aria-label={t(
|
||||
`mentalHealth.items.${lower(instrument)}.${i + 1}`,
|
||||
)}
|
||||
>
|
||||
{SCALE.map((v) => (
|
||||
<Button
|
||||
key={v}
|
||||
type="button"
|
||||
size="sm"
|
||||
// 44px touch-target floor (WCAG 2.5.5) — these are the
|
||||
// questionnaire option controls a full PHQ-9 taps 36 times.
|
||||
className="min-h-11"
|
||||
variant={items[i] === v ? "default" : "outline"}
|
||||
aria-pressed={items[i] === v}
|
||||
onClick={() =>
|
||||
setItems((prev) => {
|
||||
const next = [...prev];
|
||||
next[i] = v;
|
||||
return next;
|
||||
})
|
||||
}
|
||||
>
|
||||
{t(`mentalHealth.options.${v}`)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
<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-wrap gap-2"
|
||||
role="group"
|
||||
aria-label={t("mentalHealth.functionalTitle")}
|
||||
>
|
||||
{FUNCTIONAL.map((v) => (
|
||||
<Button
|
||||
key={v}
|
||||
type="button"
|
||||
size="sm"
|
||||
// 44px touch-target floor (WCAG 2.5.5), matching the scale.
|
||||
className="min-h-11"
|
||||
variant={functional === v ? "default" : "outline"}
|
||||
aria-pressed={functional === v}
|
||||
onClick={() => setFunctional(v)}
|
||||
>
|
||||
{t(`mentalHealth.functional.${v}`)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mutation.isError && (
|
||||
<p className="text-destructive text-sm">
|
||||
{t("mentalHealth.error")}
|
||||
</p>
|
||||
)}
|
||||
<Button onClick={submit} disabled={!complete || mutation.isPending}>
|
||||
{mutation.isPending
|
||||
? t("mentalHealth.submitting")
|
||||
: t("mentalHealth.submit")}
|
||||
</Button>
|
||||
</section>
|
||||
<CheckInWizard
|
||||
instrument={instrument}
|
||||
onSubmit={submit}
|
||||
onBack={backToLanding}
|
||||
isPending={mutation.isPending}
|
||||
isError={mutation.isError}
|
||||
/>
|
||||
)}
|
||||
|
||||
{phase === "result" && result && (
|
||||
<section className="flex flex-col gap-4">
|
||||
<p className="text-muted-foreground text-xs" role="status">
|
||||
{t("mentalHealth.saved")}
|
||||
</p>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle
|
||||
ref={resultHeadingRef}
|
||||
tabIndex={-1}
|
||||
className="scroll-mt-4 outline-none"
|
||||
>
|
||||
{t("mentalHealth.result.title")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3 text-sm">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<span className="text-muted-foreground">
|
||||
{t("mentalHealth.result.totalLabel")}
|
||||
</span>
|
||||
<span className="text-2xl font-semibold">
|
||||
{result.assessment.totalScore}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between">
|
||||
<span className="text-muted-foreground">
|
||||
{t("mentalHealth.result.bandLabel")}
|
||||
</span>
|
||||
<span className="font-medium">
|
||||
{t(
|
||||
`mentalHealth.band.${result.assessment.instrument}.${result.assessment.severityBand}`,
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{result.assessment.totalScore >= result.actionThreshold && (
|
||||
<p className="text-muted-foreground bg-muted/40 rounded-md p-3 text-xs">
|
||||
{t("mentalHealth.considerProfessional")}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{result.crisis && (
|
||||
<Card
|
||||
// v1.25.1 — calm destructive-subtle border instead of the amber
|
||||
// tint v1.25 retired; the role/aria-live carry the urgency, the
|
||||
// tone stays consistent with the rest of the app.
|
||||
className="border-destructive/40"
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">
|
||||
{t("mentalHealth.crisis.title")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3 text-sm">
|
||||
<p>{t("mentalHealth.crisis.intro")}</p>
|
||||
<p className="font-medium">
|
||||
{t("mentalHealth.crisis.ifDanger", {
|
||||
emergency: result.crisis.emergencyNumber,
|
||||
})}
|
||||
</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-muted-foreground text-xs font-medium">
|
||||
{t("mentalHealth.crisis.resourcesTitle")}
|
||||
</span>
|
||||
<ul className="flex flex-col gap-2">
|
||||
{result.crisis.resources.map((r) => (
|
||||
<li key={r.id} className="flex flex-col">
|
||||
<span className="font-medium">
|
||||
{t(`mentalHealth.crisisResource.${r.id}.name`)}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
{r.contacts.join(" · ")}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<p className="text-muted-foreground text-[11px] leading-snug">
|
||||
<span className="font-medium">
|
||||
{t("mentalHealth.attributionLabel")}:
|
||||
</span>{" "}
|
||||
{PHQ_GAD_ATTRIBUTION}
|
||||
</p>
|
||||
|
||||
<Button variant="outline" onClick={() => setPhase("choose")}>
|
||||
{t("mentalHealth.result.takeAnother")}
|
||||
</Button>
|
||||
</section>
|
||||
<AssessmentResult
|
||||
result={result}
|
||||
onTakeAnother={backToLanding}
|
||||
onBack={backToLanding}
|
||||
/>
|
||||
)}
|
||||
|
||||
<section className="border-border/60 flex flex-col gap-2 border-t pt-4">
|
||||
<h2 className="text-sm font-medium">
|
||||
{t("mentalHealth.history.title")}
|
||||
</h2>
|
||||
{!history || history.assessments.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("mentalHealth.history.empty")}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-1">
|
||||
{history.assessments.map((row) => (
|
||||
<li
|
||||
key={row.id}
|
||||
className="flex items-center justify-between text-sm"
|
||||
>
|
||||
<span>
|
||||
{t(`mentalHealth.instrument.${lower(row.instrument)}`)}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
{t(`mentalHealth.band.${row.instrument}.${row.severityBand}`)}{" "}
|
||||
· {formatDate(row.takenAt)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user