mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 22:26:13 +00:00
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:
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>
|
||||
);
|
||||
}
|
||||
@@ -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[] }[];
|
||||
|
||||
Reference in new issue
Block a user