Show the answers behind a screening score

Refs A3-8. The per-item PHQ-9 / GAD-7 / WHO-5 / SCI answers have been
stored encrypted since the assessments shipped, and nothing ever read
them back — the clinical detail (item 9 included) was invisible. A new
by-id read decrypts them for whoever holds the mind read grant (the same
grant that already serves the mood diary's prose and the item-9 flag),
degrades to an honest items-unavailable state on any decrypt or shape
problem, and the history rows gain an expandable breakdown rendering the
official instrument texts the check-in flow already carries — nothing
retranslated, no alarm styling on item 9 beyond what the house already
does.

Rides along: the guard inventories learn the branch's new routes (frozen
counts moved with reasons), the reminder-engine test harnesses carry the
ledger delegate, and the custom-metric restore toast keys sit in their
right namespace in all six locales.
This commit is contained in:
Marc-André Bombeck committed 2026-08-14 09:46:48 +02:00
1 parent 651d37b494
commit 90bad17cc8
23 files changed
+1036 -34

No files matched your search

@@ -28,6 +28,8 @@ vi.mock("@/hooks/use-measurement-reminders", () => ({
update: { mutate: vi.fn(), isPending: false },
remove: { mutate: vi.fn(), isPending: false },
satisfy: { mutate: vi.fn(), isPending: false },
skip: { mutate: vi.fn(), isPending: false },
snooze: { mutate: vi.fn(), isPending: false },
}),
}));
@@ -0,0 +1,118 @@
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 { queryKeys } from "@/lib/query-keys";
import en from "../../../../messages/en.json";
/**
* The expandable per-item answer breakdown (the read side of
* `responsesEncrypted`). SSR-only convention (`renderToStaticMarkup`) — the
* detail query is seeded into the cache, the fetch never runs.
*
* Pins: the official item texts render from the SAME `mentalHealth.items.*`
* keys the check-in wizard uses (validated instrument copy, never re-worded),
* each answer shows its option label + value, PHQ-9 item 9 gets NO alarm
* styling, and the server's decrypt-failure degrade renders as the honest
* unavailable line rather than a blank.
*/
vi.mock("@/lib/api/api-fetch", () => ({
apiGet: () => new Promise(() => {}),
apiPost: () => new Promise(() => {}),
}));
import { AssessmentItemBreakdown } from "../assessment-item-breakdown";
import type { AssessmentDetail } from "../types";
const mh = en.mentalHealth;
function detailRow(overrides: Partial<AssessmentDetail>): AssessmentDetail {
return {
id: "mha_1",
instrument: "PHQ9",
locale: "en",
totalScore: 10,
severityBand: "moderate",
item9Flagged: true,
crisisShownAt: null,
takenAt: "2026-06-28T00:00:00.000Z",
items: [1, 1, 1, 1, 1, 1, 1, 1, 2],
functionalDifficulty: null,
itemsUnavailable: false,
...overrides,
};
}
function render(detail: AssessmentDetail): string {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
queryClient.setQueryData(queryKeys.mentalHealthAssessmentDetail(detail.id), {
assessment: detail,
});
return renderToStaticMarkup(
<QueryClientProvider client={queryClient}>
<I18nProvider initialLocale="en">
<AssessmentItemBreakdown
assessmentId={detail.id}
instrument={detail.instrument}
/>
</I18nProvider>
</QueryClientProvider>,
);
}
describe("AssessmentItemBreakdown", () => {
it("renders every official PHQ-9 item text with its answer label + value", () => {
const html = render(detailRow({}));
// All nine validated item texts, from the wizard's own keys.
for (let n = 1; n <= 9; n++) {
expect(html).toContain(
mh.items.phq9[String(n) as keyof typeof mh.items.phq9],
);
}
// Answer labels are the shared PHQ/GAD anchors.
expect(html).toContain(mh.options["1"]);
expect(html).toContain(mh.options["2"]);
});
it("gives PHQ-9 item 9 no alarm styling — it renders like every other item", () => {
const html = render(detailRow({}));
expect(html).toContain(mh.items.phq9["9"]);
expect(html).not.toContain("text-destructive");
});
it("renders the unscored functional follow-up when it was answered", () => {
const html = render(detailRow({ functionalDifficulty: 2 }));
expect(html).toContain(mh.functionalTitle);
expect(html).toContain(mh.functional["2"]);
});
it("omits the functional follow-up row when it was not answered", () => {
const html = render(detailRow({ functionalDifficulty: null }));
expect(html).not.toContain(mh.functionalTitle);
});
it("paints the WHO-5 recall stem once above its run of items", () => {
const html = render(
detailRow({
instrument: "WHO5",
items: [5, 4, 3, 2, 1],
item9Flagged: false,
}),
);
// The shared stem appears exactly once, not per item.
const stem = mh.stems.who5.period;
expect(html.split(stem).length - 1).toBe(1);
expect(html).toContain(mh.items.who5["1"]);
expect(html).toContain(mh.who5Options["5"]);
});
it("renders the honest unavailable line on the server's decrypt degrade", () => {
const html = render(detailRow({ items: null, itemsUnavailable: true }));
expect(html).toContain(mh.history.answersUnavailable);
expect(html).not.toContain(mh.items.phq9["1"]);
});
});
@@ -15,6 +15,11 @@
* 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.
*
* Each row also carries an opt-in answer breakdown: expanding it fetches the
* per-administration detail read, where the server decrypts the stored
* per-item answers (`AssessmentItemBreakdown`). Item 9 renders there like any
* other item — the flagged marker above stays the one crisis affordance.
*/
import { useMemo, useState } from "react";
import dynamic from "next/dynamic";
@@ -28,8 +33,10 @@ 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 { Activity, ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";
import { AssessmentItemBreakdown } from "./assessment-item-breakdown";
import { CrisisCard } from "./crisis-card";
import type { AssessmentRow, CrisisSet, InstrumentId } from "./types";
@@ -80,6 +87,11 @@ export function AssessmentHistory({
const { date: formatDate } = useFormatters();
// Which flagged row's crisis card is currently expanded (re-surfaced).
const [openCrisisRowId, setOpenCrisisRowId] = useState<string | null>(null);
// Which row's per-item answer breakdown is expanded (single-open, like the
// crisis card — the detail fetch runs only when someone asks).
const [openBreakdownRowId, setOpenBreakdownRowId] = useState<string | null>(
null,
);
const forInstrument = useMemo(
() =>
@@ -116,6 +128,7 @@ export function AssessmentHistory({
{forInstrument.map((row) => {
const flagged = row.item9Flagged;
const open = openCrisisRowId === row.id;
const breakdownOpen = openBreakdownRowId === 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">
@@ -146,9 +159,37 @@ export function AssessmentHistory({
<span className="font-medium tabular-nums">
{row.totalScore}
</span>
<Button
type="button"
variant="ghost"
size="sm"
className="h-auto p-0.5"
aria-expanded={breakdownOpen}
aria-label={t("mentalHealth.history.answersToggle")}
onClick={() =>
setOpenBreakdownRowId(breakdownOpen ? null : row.id)
}
data-slot="history-answers-toggle"
>
<ChevronDown
aria-hidden="true"
className={cn(
"text-muted-foreground size-4 transition-transform",
breakdownOpen && "rotate-180",
)}
/>
</Button>
</div>
</div>
{flagged && open && <CrisisCard crisis={crisisFromRow(row)} />}
{breakdownOpen && (
<div className="border-border/70 border-l pl-3">
<AssessmentItemBreakdown
assessmentId={row.id}
instrument={instrument}
/>
</div>
)}
</li>
);
})}
@@ -0,0 +1,136 @@
"use client";
/**
* Per-item answer breakdown for one recorded administration, expanded from a
* history row. Fetches the detail read (`GET …/assessments/[id]`), which is
* where the server decrypts `responsesEncrypted` — the answers never ride the
* list payload.
*
* Grammar: the detail surface's label/value rows — official item text muted
* (validated instrument copy via the SAME `mentalHealth.items.*` keys the
* check-in wizard renders; never re-worded here), the chosen answer label +
* its value in foreground. The WHO-5 / SCI recall stems paint once above each
* run of items, as on the source forms. PHQ-9 item 9 renders like every other
* item — no alarm colour; the row's existing "support shown" marker is the
* crisis affordance and is not duplicated here.
*/
import { useQuery } from "@tanstack/react-query";
import { Skeleton } from "@/components/ui/skeleton";
import { useTranslations } from "@/lib/i18n/context";
import { apiGet } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
import {
INSTRUMENTS,
optionLabelKey,
stemKey,
} from "@/lib/mental-health/instruments";
import type { AssessmentDetail, InstrumentId } from "./types";
export function AssessmentItemBreakdown({
assessmentId,
instrument,
}: {
assessmentId: string;
instrument: InstrumentId;
}) {
const { t } = useTranslations();
const def = INSTRUMENTS[instrument];
const { data, isPending, isError } = useQuery({
queryKey: queryKeys.mentalHealthAssessmentDetail(assessmentId),
queryFn: () =>
apiGet<{ assessment: AssessmentDetail }>(
`/api/mental-health/assessments/${assessmentId}`,
),
});
if (isPending) {
return (
<div
className="flex flex-col gap-2"
data-slot="assessment-item-breakdown"
aria-busy="true"
>
{Array.from({ length: def.itemCount }, (_, i) => (
<Skeleton key={i} className="h-4 w-full" />
))}
</div>
);
}
if (isError || !data) {
return (
<p
className="text-destructive text-sm"
role="alert"
data-slot="assessment-item-breakdown"
>
{t("mentalHealth.error")}
</p>
);
}
const detail = data.assessment;
if (detail.items === null) {
// The server's honest degrade: the blob exists but cannot be decrypted
// (key gap / corruption). The score above this breakdown still answers.
return (
<p
className="text-muted-foreground text-sm"
data-slot="assessment-item-breakdown"
>
{t("mentalHealth.history.answersUnavailable")}
</p>
);
}
return (
<div
className="flex flex-col gap-2 text-sm"
data-slot="assessment-item-breakdown"
>
{detail.items.map((value, index) => {
// Stem captions group runs of items exactly as the wizard presents
// them: paint the stem only where it differs from the previous item's.
const stem = stemKey(instrument, index);
const caption =
stem !== (index > 0 ? stemKey(instrument, index - 1) : null)
? stem
: null;
return (
<div key={index} className="flex flex-col gap-1">
{caption && (
<p className="text-muted-foreground pt-1 text-xs">
{t(`mentalHealth.stems.${caption}`)}
</p>
)}
<div className="flex items-baseline justify-between gap-3">
<span className="text-muted-foreground min-w-0 flex-1">
{t(`mentalHealth.items.${def.i18nKey}.${index + 1}`)}
</span>
<span className="text-foreground shrink-0 text-right">
{t(optionLabelKey(instrument, index, value))}
{" · "}
<span className="tabular-nums">{value}</span>
</span>
</div>
</div>
);
})}
{detail.functionalDifficulty !== null && (
<div className="flex items-baseline justify-between gap-3">
<span className="text-muted-foreground min-w-0 flex-1">
{t("mentalHealth.functionalTitle")}
</span>
{/* Unscored follow-up: the label alone, no value — it never counts
into the total and a number would imply it does. */}
<span className="text-foreground shrink-0 text-right">
{t(`mentalHealth.functional.${detail.functionalDifficulty}`)}
</span>
</div>
)}
</div>
);
}
+15 -2
View File
@@ -4,8 +4,9 @@
* 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).
* — totals + bands + flags only on the list. The per-item answers ride ONLY
* the per-administration detail read (`…/assessments/[id]`), which decrypts
* them for the history's expandable answer breakdown.
*/
import type { InstrumentId } from "@/lib/mental-health/instruments";
@@ -25,6 +26,18 @@ export interface AssessmentRow {
takenAt: string;
}
/**
* One administration with its decrypted answers (`GET …/assessments/[id]`).
* `items: null` + `itemsUnavailable` is the server's honest degrade for a row
* whose blob cannot be decrypted — the score fields still answer.
*/
export interface AssessmentDetail extends AssessmentRow {
items: number[] | null;
/** The unscored PHQ-9 functional follow-up, when it was answered. */
functionalDifficulty: number | null;
itemsUnavailable: boolean;
}
export interface CrisisSet {
emergencyNumber: string;
resources: { id: string; contacts: string[] }[];