mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
feat(mental-health): screener UI, locale-aware copy, and OpenAPI surface
Add the opt-in PHQ-9 / GAD-7 questionnaire surface at /insights/mental-wellbeing: instrument picker, 0–3 item scale, server-scored result with the validated severity band, the verbatim Pfizer/PHQ attribution, the screening-not-diagnosis disclaimer, and a calm crisis-resource card on a positive item-9. The surface deliberately does NOT mount the shared Coach-launch card — mental-health item content stays out of the AI. - i18n: full mentalHealth block plus the six new measurement-type labels and the longevity-marker catalogue names/descriptions across all six locales (de/en/es/fr/it/pl). Instrument item wording uses the validated German text and the canonical English elsewhere (re-wording invalidates the score). - OpenAPI: regenerate the contract for /api/mental-health/assessments (response excludes raw item answers by construction). - Exclude the new manual / instrument types from the HealthKit-counterpart guard.
This commit is contained in:
1 parent
7575275d7e
commit
c322a68d19
13 files changed
+1453
-22
No files matched your search
@@ -0,0 +1,360 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* v1.25 — opt-in mental-health screener surface (PHQ-9 / GAD-7).
|
||||
*
|
||||
* Beside mood tracking, never replacing it; framed as a screening self-check,
|
||||
* not a diagnosis. The per-item answers stay on the device until submit, the
|
||||
* server encrypts them, and only the total trend is persisted as a measurement.
|
||||
*
|
||||
* SAFETY: this surface is deliberately kept OFF the AI Coach — the shared
|
||||
* Coach-launch assessment card is intentionally NOT used here, so a depression /
|
||||
* 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.
|
||||
*/
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { useTranslations, useFormatters } 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 {
|
||||
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;
|
||||
|
||||
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);
|
||||
|
||||
const lower = (id: InstrumentId) => (id === "PHQ9" ? "phq9" : "gad7");
|
||||
|
||||
const { data: history } = useQuery({
|
||||
queryKey: queryKeys.mentalHealthAssessments(),
|
||||
queryFn: () =>
|
||||
apiGet<{ assessments: AssessmentRow[] }>(
|
||||
"/api/mental-health/assessments",
|
||||
),
|
||||
});
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (body: unknown) =>
|
||||
apiPost<CreateResponse>("/api/mental-health/assessments", body),
|
||||
onSuccess: (data) => {
|
||||
setResult(data);
|
||||
setPhase("result");
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.mentalHealthAssessments(),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
function begin(id: InstrumentId) {
|
||||
setInstrument(id);
|
||||
setItems(Array(INSTRUMENTS[id].itemCount).fill(-1));
|
||||
setFunctional(null);
|
||||
setResult(null);
|
||||
setPhase("form");
|
||||
}
|
||||
|
||||
const complete = items.length > 0 && items.every((v) => v >= 0);
|
||||
|
||||
function submit() {
|
||||
if (!complete) return;
|
||||
mutation.mutate({
|
||||
instrument,
|
||||
items,
|
||||
...(functional !== null ? { functionalDifficulty: functional } : {}),
|
||||
locale,
|
||||
});
|
||||
}
|
||||
|
||||
const itemCount = INSTRUMENTS[instrument].itemCount;
|
||||
const itemIndexes = useMemo(
|
||||
() => Array.from({ length: itemCount }, (_, i) => i),
|
||||
[itemCount],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex max-w-2xl flex-col gap-6">
|
||||
<header className="flex flex-col gap-2">
|
||||
<h1 className="text-2xl font-semibold">
|
||||
{t("mentalHealth.pageTitle")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("mentalHealth.pageDescription")}
|
||||
</p>
|
||||
<p className="text-muted-foreground border-border/60 bg-muted/40 rounded-md border p-3 text-xs">
|
||||
{t("mentalHealth.disclaimer")}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("mentalHealth.optInNote")}
|
||||
</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>
|
||||
)}
|
||||
|
||||
{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"
|
||||
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"
|
||||
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>
|
||||
)}
|
||||
|
||||
{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>{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 className="border-amber-400/50">
|
||||
<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>
|
||||
)}
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user