mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
Merge branch 'feat/w-g-screenings' into release/v1.27.10
This commit is contained in:
commit
6e59fe5ce0
40 files changed
+2180
-352
No files matched your search
@@ -155,6 +155,39 @@ const LEGIT_IDENTICAL = new Set<string>([
|
||||
"mentalHealth.items.phq9.7",
|
||||
"mentalHealth.items.phq9.8",
|
||||
"mentalHealth.items.phq9.9",
|
||||
// v1.27.9 — the Sleep Condition Indicator is validated in English only
|
||||
// (Espie 2014, CC BY-NC). Item text, section stems and response anchors
|
||||
// ship VERBATIM in the source language in every locale — a self-made
|
||||
// translation would invalidate the instrument — and the UI shows a
|
||||
// localized "validated in English" note instead. Identical-to-EN is the
|
||||
// intended state here, not a leak.
|
||||
"mentalHealth.items.sci.1",
|
||||
"mentalHealth.items.sci.2",
|
||||
"mentalHealth.items.sci.3",
|
||||
"mentalHealth.items.sci.4",
|
||||
"mentalHealth.items.sci.5",
|
||||
"mentalHealth.items.sci.6",
|
||||
"mentalHealth.items.sci.7",
|
||||
"mentalHealth.items.sci.8",
|
||||
"mentalHealth.stems.sci.night",
|
||||
"mentalHealth.stems.sci.impact",
|
||||
"mentalHealth.stems.sci.finally",
|
||||
"mentalHealth.sciOptions.duration.0",
|
||||
"mentalHealth.sciOptions.quality.4",
|
||||
"mentalHealth.sciOptions.quality.3",
|
||||
"mentalHealth.sciOptions.quality.2",
|
||||
"mentalHealth.sciOptions.quality.1",
|
||||
"mentalHealth.sciOptions.quality.0",
|
||||
"mentalHealth.sciOptions.impact.4",
|
||||
"mentalHealth.sciOptions.impact.3",
|
||||
"mentalHealth.sciOptions.impact.2",
|
||||
"mentalHealth.sciOptions.impact.1",
|
||||
"mentalHealth.sciOptions.impact.0",
|
||||
"mentalHealth.sciOptions.problemDuration.4",
|
||||
"mentalHealth.sciOptions.problemDuration.3",
|
||||
"mentalHealth.sciOptions.problemDuration.2",
|
||||
"mentalHealth.sciOptions.problemDuration.1",
|
||||
"mentalHealth.sciOptions.problemDuration.0",
|
||||
]);
|
||||
|
||||
describe("i18n English-leak guard", () => {
|
||||
|
||||
@@ -284,6 +284,98 @@ describe("POST /api/mental-health/assessments", () => {
|
||||
expect(createArg.data.externalId).toBe("outbox-aa01");
|
||||
});
|
||||
|
||||
it("scores a WHO-5 check-in as raw-sum × 4 and projects a WHO5_SCORE measurement", async () => {
|
||||
// Raw 3+3+2+2+2 = 12 → reported 48 (below the WHO "below 50" cut → low).
|
||||
vi.mocked(prisma.mentalHealthAssessment.create).mockResolvedValue({
|
||||
id: "mha_who5",
|
||||
instrument: "WHO5",
|
||||
locale: "en",
|
||||
version: "standard",
|
||||
totalScore: 48,
|
||||
severityBand: "low",
|
||||
item9Flagged: false,
|
||||
crisisShownAt: null,
|
||||
takenAt: new Date("2026-06-28T00:00:00.000Z"),
|
||||
createdAt: new Date("2026-06-28T00:00:00.000Z"),
|
||||
} as never);
|
||||
|
||||
const res = await callPost(
|
||||
makeReq({ instrument: "WHO5", items: [3, 3, 2, 2, 2] }),
|
||||
);
|
||||
expect(res.status).toBe(201);
|
||||
|
||||
const createArg = vi.mocked(prisma.mentalHealthAssessment.create).mock
|
||||
.calls[0][0] as {
|
||||
data: { totalScore: number; severityBand: string; item9Flagged: boolean };
|
||||
};
|
||||
expect(createArg.data.totalScore).toBe(48);
|
||||
expect(createArg.data.severityBand).toBe("low");
|
||||
// Positively-worded instrument, no safety item — never flagged.
|
||||
expect(createArg.data.item9Flagged).toBe(false);
|
||||
|
||||
const measArg = vi.mocked(prisma.measurement.create).mock.calls[0][0] as {
|
||||
data: { type: string; value: number; source: string };
|
||||
};
|
||||
expect(measArg.data.type).toBe("WHO5_SCORE");
|
||||
expect(measArg.data.value).toBe(48);
|
||||
expect(measArg.data.source).toBe("COMPUTED");
|
||||
|
||||
const body = (await res.json()) as {
|
||||
data: { crisis: unknown; actionThreshold: number };
|
||||
};
|
||||
// No crisis routing on the WHO-5 — the ≤50 pointer is client-side copy.
|
||||
expect(body.data.crisis).toBeNull();
|
||||
expect(body.data.actionThreshold).toBe(50);
|
||||
});
|
||||
|
||||
it("accepts an SCI check-in (0–4 anchors) and projects an SCI_SCORE measurement", async () => {
|
||||
vi.mocked(prisma.mentalHealthAssessment.create).mockResolvedValue({
|
||||
id: "mha_sci",
|
||||
instrument: "SCI",
|
||||
locale: "en",
|
||||
version: "standard",
|
||||
totalScore: 16,
|
||||
severityBand: "belowThreshold",
|
||||
item9Flagged: false,
|
||||
crisisShownAt: null,
|
||||
takenAt: new Date("2026-06-28T00:00:00.000Z"),
|
||||
createdAt: new Date("2026-06-28T00:00:00.000Z"),
|
||||
} as never);
|
||||
|
||||
// Total 4+3+2+1+0+4+1+1 = 16 → the paper's ≤16 probable-insomnia range.
|
||||
const res = await callPost(
|
||||
makeReq({ instrument: "SCI", items: [4, 3, 2, 1, 0, 4, 1, 1] }),
|
||||
);
|
||||
expect(res.status).toBe(201);
|
||||
|
||||
const createArg = vi.mocked(prisma.mentalHealthAssessment.create).mock
|
||||
.calls[0][0] as { data: { totalScore: number; severityBand: string } };
|
||||
expect(createArg.data.totalScore).toBe(16);
|
||||
expect(createArg.data.severityBand).toBe("belowThreshold");
|
||||
|
||||
const measArg = vi.mocked(prisma.measurement.create).mock.calls[0][0] as {
|
||||
data: { type: string; value: number; source: string };
|
||||
};
|
||||
expect(measArg.data.type).toBe("SCI_SCORE");
|
||||
expect(measArg.data.value).toBe(16);
|
||||
expect(measArg.data.source).toBe("COMPUTED");
|
||||
});
|
||||
|
||||
it("rejects an out-of-scale answer for the instrument with 422 (SCI max 4, PHQ-9 max 3)", async () => {
|
||||
// 5 is a legal WHO-5 answer but out of scale for the SCI…
|
||||
const sciRes = await callPost(
|
||||
makeReq({ instrument: "SCI", items: [5, 3, 2, 1, 0, 4, 1, 1] }),
|
||||
);
|
||||
expect(sciRes.status).toBe(422);
|
||||
// …and 4 is out of scale for the PHQ-9.
|
||||
const phqRes = await callPost(
|
||||
makeReq({ instrument: "PHQ9", items: [4, 0, 0, 0, 0, 0, 0, 0, 0] }),
|
||||
);
|
||||
expect(phqRes.status).toBe(422);
|
||||
expect(prisma.mentalHealthAssessment.create).not.toHaveBeenCalled();
|
||||
expect(prisma.measurement.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a wrong item count with 422 and never writes", async () => {
|
||||
const res = await callPost(
|
||||
makeReq({ instrument: "PHQ9", items: [1, 2, 3] }), // 3 ≠ 9
|
||||
|
||||
@@ -229,8 +229,9 @@ async function postAssessment(request: NextRequest): Promise<Response> {
|
||||
if (existing) return respondExisting(existing);
|
||||
}
|
||||
|
||||
// Server-authoritative scoring (the client never computes these).
|
||||
const total = scoreTotal(items);
|
||||
// Server-authoritative scoring (the client never computes these). The
|
||||
// total is the REPORTED score — the WHO-5 projects raw-sum × 4 (0–100).
|
||||
const total = scoreTotal(id, items);
|
||||
const band = severityBand(id, total);
|
||||
const flagged = isSafetyFlagged(id, items);
|
||||
const presentedLocale = locale ?? user.locale ?? "en";
|
||||
|
||||
@@ -116,11 +116,11 @@ const TYPE_GROUPS = [
|
||||
],
|
||||
},
|
||||
// v1.27.6 — the mental-wellbeing screenings become plannable like any
|
||||
// other Vorsorge item. Auto-resolve rides the server-written PHQ9_SCORE /
|
||||
// GAD7_SCORE row a completed check-in produces.
|
||||
// other Vorsorge item. Auto-resolve rides the server-written *_SCORE row a
|
||||
// completed check-in produces (v1.27.9 adds WHO-5 + SCI).
|
||||
{
|
||||
group: "mentalWellbeing",
|
||||
types: ["PHQ9_SCORE", "GAD7_SCORE"],
|
||||
types: ["PHQ9_SCORE", "GAD7_SCORE", "WHO5_SCORE", "SCI_SCORE"],
|
||||
},
|
||||
] as const;
|
||||
|
||||
|
||||
@@ -125,6 +125,9 @@ export const MEASUREMENT_TYPE_LABEL_KEYS: Record<string, string> = {
|
||||
// ── v1.25 — clinical-signals wave ──
|
||||
PHQ9_SCORE: "measurements.typePhq9Score",
|
||||
GAD7_SCORE: "measurements.typeGad7Score",
|
||||
// ── v1.27.9 — screening scores ──
|
||||
WHO5_SCORE: "measurements.typeWho5Score",
|
||||
SCI_SCORE: "measurements.typeSciScore",
|
||||
GRIP_STRENGTH: "measurements.typeGripStrength",
|
||||
PAIN_NRS: "measurements.typePainNrs",
|
||||
WAIST_CIRCUMFERENCE: "measurements.typeWaistCircumference",
|
||||
@@ -257,6 +260,9 @@ export const MEASUREMENT_TYPE_ICONS: Record<string, LucideIcon> = {
|
||||
// Activity the pain trace; Scale the anthropometric waist pair.
|
||||
PHQ9_SCORE: Gauge,
|
||||
GAD7_SCORE: Gauge,
|
||||
// ── v1.27.9 — screening scores ride the same composite-dial icon ──
|
||||
WHO5_SCORE: Gauge,
|
||||
SCI_SCORE: Gauge,
|
||||
GRIP_STRENGTH: Dumbbell,
|
||||
PAIN_NRS: Activity,
|
||||
WAIST_CIRCUMFERENCE: Scale,
|
||||
@@ -380,6 +386,9 @@ export const MEASUREMENT_TYPE_COLORS: Record<string, string> = {
|
||||
// chart-3 (cardio/alert family) for pain; chart-4 (body family) for waist.
|
||||
PHQ9_SCORE: "bg-chart-1/20 text-chart-1",
|
||||
GAD7_SCORE: "bg-chart-1/20 text-chart-1",
|
||||
// ── v1.27.9 — screening scores join the composite/score colour family ──
|
||||
WHO5_SCORE: "bg-chart-1/20 text-chart-1",
|
||||
SCI_SCORE: "bg-chart-1/20 text-chart-1",
|
||||
GRIP_STRENGTH: "bg-chart-1/20 text-chart-1",
|
||||
PAIN_NRS: "bg-chart-3/20 text-chart-3",
|
||||
WAIST_CIRCUMFERENCE: "bg-chart-4/20 text-chart-4",
|
||||
|
||||
@@ -11,9 +11,9 @@ import en from "../../../../messages/en.json";
|
||||
* 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
|
||||
* dated list with severity + "support shown" badges — and NO trend chart
|
||||
* (v1.27.6: the score curve lives in Insights / Measurements, not here).
|
||||
* a positive item-9 surfaces the crisis card, and (v1.27.9) the landing is
|
||||
* intro + instrument cards only — the dated history + trend chart live in the
|
||||
* per-instrument detail a card click opens, pinned to one instrument.
|
||||
*/
|
||||
|
||||
vi.mock("@/lib/api/api-fetch", () => ({
|
||||
@@ -26,6 +26,7 @@ import { CheckInWizard } from "../check-in-wizard";
|
||||
import { AssessmentResult } from "../assessment-result";
|
||||
import { AssessmentHistory } from "../assessment-history";
|
||||
import { InstrumentCard } from "../instrument-card";
|
||||
import { InstrumentDetail } from "../instrument-detail";
|
||||
import type { AssessmentRow, CreateResponse } from "../types";
|
||||
|
||||
const mh = en.mentalHealth;
|
||||
@@ -178,18 +179,17 @@ describe("history rendering", () => {
|
||||
},
|
||||
];
|
||||
|
||||
const html = withProviders(<AssessmentHistory rows={rows} />);
|
||||
const html = withProviders(
|
||||
<AssessmentHistory rows={rows} instrument="PHQ9" />,
|
||||
);
|
||||
|
||||
it("paints the dated list WITHOUT a trend chart shell", () => {
|
||||
it("paints the pinned dated list with the trend-chart shell, no instrument toggle", () => {
|
||||
// v1.27.9 — the history is pinned to one instrument inside the detail
|
||||
// surface: no PHQ-9/GAD-7 tablist, and the lazy chart's skeleton shell
|
||||
// holds the layout in SSR (recharts itself stays off first-load JS).
|
||||
expect(html).toContain('data-slot="history-list"');
|
||||
// v1.27.6 — no chart on this surface: no lazy-chart skeleton, no
|
||||
// recharts container.
|
||||
expect(html).not.toContain('data-slot="skeleton"');
|
||||
expect(html).not.toContain("recharts");
|
||||
});
|
||||
|
||||
it("shows the PHQ-9 / GAD-7 toggle and a severity badge", () => {
|
||||
expect(html).toContain('role="tablist"');
|
||||
expect(html).toContain('data-pinned="PHQ9"');
|
||||
expect(html).not.toContain('role="tablist"');
|
||||
expect(html).toContain(mh.band.PHQ9.modSevere);
|
||||
});
|
||||
|
||||
@@ -198,14 +198,175 @@ describe("history rendering", () => {
|
||||
expect(html).toContain(mh.history.flaggedBadge);
|
||||
});
|
||||
|
||||
it("renders the empty state with no rows", () => {
|
||||
const empty = withProviders(<AssessmentHistory rows={[]} />);
|
||||
it("renders the empty state with no rows for the pinned instrument", () => {
|
||||
const empty = withProviders(
|
||||
<AssessmentHistory rows={rows} instrument="SCI" />,
|
||||
);
|
||||
expect(empty).toContain(mh.history.empty);
|
||||
expect(empty).not.toContain('data-slot="history-list"');
|
||||
});
|
||||
|
||||
it("stays OFF the landing entirely (intro + cards only)", () => {
|
||||
const landing = withProviders(<MentalWellbeing />);
|
||||
expect(landing).not.toContain('data-slot="mental-health-history"');
|
||||
expect(landing).not.toContain('data-slot="history-list"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("instrument card (v1.27.6 — med-/Vorsorge card anatomy)", () => {
|
||||
describe("WHO-5 / SCI on the same infrastructure (v1.27.9)", () => {
|
||||
it("renders all four instrument cards on the landing", () => {
|
||||
const html = withProviders(<MentalWellbeing />);
|
||||
expect(html).toContain(mh.instrument.phq9);
|
||||
expect(html).toContain(mh.instrument.gad7);
|
||||
expect(html).toContain(mh.instrument.who5);
|
||||
expect(html).toContain(mh.instrument.sci);
|
||||
// The rhythm hint states both recall windows honestly.
|
||||
expect(html).toContain(mh.pageRhythmHint);
|
||||
});
|
||||
|
||||
it("WHO-5 wizard opens on item 1 with the six-point scale in source order and the recall stem", () => {
|
||||
const html = withProviders(
|
||||
<CheckInWizard
|
||||
instrument="WHO5"
|
||||
onSubmit={() => {}}
|
||||
onBack={() => {}}
|
||||
isPending={false}
|
||||
isError={false}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain(mh.items.who5["1"]);
|
||||
expect(html).toContain('data-slot="check-in-stem"');
|
||||
expect(html).toContain(mh.stems.who5.period);
|
||||
// Six anchors, 5 → 0 (the WHO form leads with "All of the time").
|
||||
for (const v of ["5", "4", "3", "2", "1", "0"] as const) {
|
||||
expect(html).toContain(mh.who5Options[v]);
|
||||
}
|
||||
// English items are the validated wording for en — no translation note.
|
||||
expect(html).not.toContain('data-slot="check-in-validated-note"');
|
||||
// No functional follow-up outside the PHQ-9: 5 steps total.
|
||||
expect(html).toContain("1 / 5");
|
||||
});
|
||||
|
||||
it("SCI wizard opens on item 1 with the item-specific duration anchors and section stem", () => {
|
||||
const html = withProviders(
|
||||
<CheckInWizard
|
||||
instrument="SCI"
|
||||
onSubmit={() => {}}
|
||||
onBack={() => {}}
|
||||
isPending={false}
|
||||
isError={false}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain(mh.items.sci["1"]);
|
||||
expect(html).toContain(mh.stems.sci.night);
|
||||
// Item 1 carries the sleep-latency time ranges, 4 → 0.
|
||||
for (const v of ["4", "3", "2", "1", "0"] as const) {
|
||||
expect(html).toContain(mh.sciOptions.duration[v]);
|
||||
}
|
||||
expect(html).toContain("1 / 8");
|
||||
});
|
||||
|
||||
it("shows the gentle PHQ-9 pointer on a WHO-5 total of 50 or below", () => {
|
||||
const result: CreateResponse = {
|
||||
assessment: {
|
||||
id: "w1",
|
||||
instrument: "WHO5",
|
||||
locale: "en",
|
||||
totalScore: 48,
|
||||
severityBand: "low",
|
||||
item9Flagged: false,
|
||||
crisisShownAt: null,
|
||||
takenAt: "2026-06-20T00:00:00.000Z",
|
||||
},
|
||||
actionThreshold: 50,
|
||||
crisis: null,
|
||||
};
|
||||
const html = withProviders(
|
||||
<AssessmentResult
|
||||
result={result}
|
||||
onTakeAnother={() => {}}
|
||||
onBack={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-slot="result-follow-up-hint"');
|
||||
expect(html).toContain(mh.followUpHint.who5);
|
||||
// Required WHO attribution rides the result view.
|
||||
expect(html).toContain("CC BY-NC-SA 3.0 IGO");
|
||||
expect(html).not.toContain('data-slot="mental-health-crisis-card"');
|
||||
});
|
||||
|
||||
it("suppresses the hint on a good WHO-5 total (direction is inverted vs PHQ-9)", () => {
|
||||
const result: CreateResponse = {
|
||||
assessment: {
|
||||
id: "w2",
|
||||
instrument: "WHO5",
|
||||
locale: "en",
|
||||
totalScore: 80,
|
||||
severityBand: "good",
|
||||
item9Flagged: false,
|
||||
crisisShownAt: null,
|
||||
takenAt: "2026-06-20T00:00:00.000Z",
|
||||
},
|
||||
actionThreshold: 50,
|
||||
crisis: null,
|
||||
};
|
||||
const html = withProviders(
|
||||
<AssessmentResult
|
||||
result={result}
|
||||
onTakeAnother={() => {}}
|
||||
onBack={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(html).not.toContain('data-slot="result-follow-up-hint"');
|
||||
});
|
||||
|
||||
it("shows the neutral SCI band wording at 16 or below, with the Sleepio/BMJ attribution", () => {
|
||||
const result: CreateResponse = {
|
||||
assessment: {
|
||||
id: "s1",
|
||||
instrument: "SCI",
|
||||
locale: "en",
|
||||
totalScore: 14,
|
||||
severityBand: "belowThreshold",
|
||||
item9Flagged: false,
|
||||
crisisShownAt: null,
|
||||
takenAt: "2026-06-20T00:00:00.000Z",
|
||||
},
|
||||
actionThreshold: 16,
|
||||
crisis: null,
|
||||
};
|
||||
const html = withProviders(
|
||||
<AssessmentResult
|
||||
result={result}
|
||||
onTakeAnother={() => {}}
|
||||
onBack={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain(mh.followUpHint.sci);
|
||||
expect(html).toContain(mh.band.SCI.belowThreshold);
|
||||
expect(html).toContain("BMJ Open");
|
||||
});
|
||||
|
||||
it("pinned history renders the WHO-5 / SCI series through the same component", () => {
|
||||
const row: AssessmentRow = {
|
||||
id: "t1",
|
||||
instrument: "WHO5",
|
||||
locale: "en",
|
||||
totalScore: 72,
|
||||
severityBand: "good",
|
||||
item9Flagged: false,
|
||||
crisisShownAt: null,
|
||||
takenAt: "2026-06-20T00:00:00.000Z",
|
||||
};
|
||||
const html = withProviders(
|
||||
<AssessmentHistory rows={[row]} instrument="WHO5" />,
|
||||
);
|
||||
expect(html).toContain('data-pinned="WHO5"');
|
||||
expect(html).toContain(mh.band.WHO5.good);
|
||||
});
|
||||
});
|
||||
|
||||
describe("instrument card (med-/Vorsorge card anatomy + detail opener)", () => {
|
||||
const lastRow: AssessmentRow = {
|
||||
id: "l1",
|
||||
instrument: "PHQ9",
|
||||
@@ -217,25 +378,43 @@ describe("instrument card (v1.27.6 — med-/Vorsorge card anatomy)", () => {
|
||||
takenAt: "2026-06-20T00:00:00.000Z",
|
||||
};
|
||||
|
||||
it("renders the shared med-card header (title + category badge), no trend target", () => {
|
||||
const html = withProviders(
|
||||
<InstrumentCard instrument="PHQ9" last={undefined} onStart={() => {}} />,
|
||||
function card(
|
||||
instrument: AssessmentRow["instrument"],
|
||||
last?: AssessmentRow,
|
||||
): string {
|
||||
return withProviders(
|
||||
<InstrumentCard
|
||||
instrument={instrument}
|
||||
last={last}
|
||||
onStart={() => {}}
|
||||
onOpenDetail={() => {}}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
it("renders the shared med-card header and the clickable detail body", () => {
|
||||
const html = card("PHQ9");
|
||||
// The med-card header primitive paints the bold name + category badge.
|
||||
expect(html).toContain(mh.instrument.phq9);
|
||||
expect(html).toContain(mh.instrumentSub.phq9);
|
||||
// The former trend-detail button is gone…
|
||||
expect(html).not.toContain('data-slot="instrument-card-open"');
|
||||
// v1.27.9 — the card body opens the per-instrument detail…
|
||||
expect(html).toContain('data-slot="instrument-card-open"');
|
||||
expect(html).toContain(mh.openDetail);
|
||||
// …and the Start action remains the single bottom-pinned action.
|
||||
expect(html).toContain(mh.start);
|
||||
// No history yet → the calm no-check-in line, no fabricated dashes.
|
||||
expect(html).toContain(mh.noResultYet);
|
||||
// v1.27.9 — the required attribution footer rides every card.
|
||||
expect(html).toContain('data-slot="instrument-card-attribution"');
|
||||
});
|
||||
|
||||
it("carries the instrument's licence line on the WHO-5 / SCI cards", () => {
|
||||
expect(card("WHO5")).toContain("CC BY-NC-SA 3.0 IGO");
|
||||
expect(card("SCI")).toContain("Sleepio Limited");
|
||||
});
|
||||
|
||||
it("shows last test (relative) and last result (score + band word)", () => {
|
||||
const html = withProviders(
|
||||
<InstrumentCard instrument="PHQ9" last={lastRow} onStart={() => {}} />,
|
||||
);
|
||||
const html = card("PHQ9", lastRow);
|
||||
expect(html).toContain(mh.lastResult);
|
||||
expect(html).toContain(mh.lastScore);
|
||||
// Score + band word ride one value slot ("8 · Mild").
|
||||
@@ -244,3 +423,46 @@ describe("instrument card (v1.27.6 — med-/Vorsorge card anatomy)", () => {
|
||||
expect(html).not.toContain(mh.noResultYet);
|
||||
});
|
||||
});
|
||||
|
||||
describe("instrument detail (v1.27.9 — opened from a card)", () => {
|
||||
const rows: AssessmentRow[] = [
|
||||
{
|
||||
id: "d1",
|
||||
instrument: "SCI",
|
||||
locale: "en",
|
||||
totalScore: 22,
|
||||
severityBand: "aboveThreshold",
|
||||
item9Flagged: false,
|
||||
crisisShownAt: null,
|
||||
takenAt: "2026-06-20T00:00:00.000Z",
|
||||
},
|
||||
{
|
||||
id: "d2",
|
||||
instrument: "PHQ9",
|
||||
locale: "en",
|
||||
totalScore: 4,
|
||||
severityBand: "minimal",
|
||||
item9Flagged: false,
|
||||
crisisShownAt: null,
|
||||
takenAt: "2026-06-01T00:00:00.000Z",
|
||||
},
|
||||
];
|
||||
|
||||
it("shows last score + band, the Start action, the pinned history and the attribution", () => {
|
||||
const html = withProviders(
|
||||
<InstrumentDetail instrument="SCI" rows={rows} onStart={() => {}} />,
|
||||
);
|
||||
expect(html).toContain('data-slot="instrument-detail"');
|
||||
expect(html).toContain('data-slot="instrument-detail-last-score"');
|
||||
expect(html).toContain(mh.band.SCI.aboveThreshold);
|
||||
expect(html).toContain('data-slot="instrument-detail-start"');
|
||||
expect(html).toContain(mh.start);
|
||||
// The pinned history (chart shell + dated list) rides inside the detail.
|
||||
expect(html).toContain('data-pinned="SCI"');
|
||||
expect(html).toContain('data-slot="history-list"');
|
||||
// …filtered to the pinned instrument: the PHQ-9 row stays out.
|
||||
expect(html).not.toContain(mh.band.PHQ9.minimal);
|
||||
// Required attribution line.
|
||||
expect(html).toContain("Sleepio Limited");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* v1.25.3 — per-instrument screener trend chart.
|
||||
*
|
||||
* v1.25.5 — aligned to the dashboard's chart language exactly: a single
|
||||
* `type="monotone"` line over the primary colour with a soft gradient area
|
||||
* fill beneath (same `fillOpacity` + margins the dashboard line charts use),
|
||||
* the shared grid / axis tokens, and `RichChartTooltip`. The earlier
|
||||
* severity-band `ReferenceArea`s — unique to this surface — are gone so it
|
||||
* reads like every other trend; the band still surfaces in the tooltip.
|
||||
*
|
||||
* v1.27.9 — back on the surface, but only inside the per-instrument detail
|
||||
* (opened from a card): the landing stays free of any score curve, so the
|
||||
* trend is opt-in behind a deliberate click. The Y domain and band wording
|
||||
* come from the instrument registry, so the WHO-5 (0–100, higher = better)
|
||||
* and the SCI (0–32, higher = better) render through the same component.
|
||||
*
|
||||
* One instrument at a time: the ranges and band sets differ, so the parent
|
||||
* pins which series this paints.
|
||||
*/
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
Area,
|
||||
CartesianGrid,
|
||||
ComposedChart,
|
||||
Line,
|
||||
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;
|
||||
band: string;
|
||||
}
|
||||
|
||||
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,
|
||||
band: r.severityBand,
|
||||
}));
|
||||
}, [rows]);
|
||||
|
||||
const animate = !prefersReducedMotion();
|
||||
const primary = "var(--primary)";
|
||||
const gradientId = `mh-trend-${instrument}`;
|
||||
|
||||
if (points.length === 0) {
|
||||
return (
|
||||
<p className="text-muted-foreground py-10 text-center text-sm">
|
||||
{t("mentalHealth.history.empty")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={CHART_HEIGHT_PX}>
|
||||
<ComposedChart
|
||||
data={points}
|
||||
margin={{ top: 10, right: 8, bottom: 8, left: 8 }}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor={primary} stopOpacity={0.18} />
|
||||
<stop offset="95%" stopColor={primary} stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
stroke="var(--border)"
|
||||
vertical={false}
|
||||
/>
|
||||
<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,
|
||||
},
|
||||
{
|
||||
name: t("mentalHealth.history.bandLabel"),
|
||||
value: t(`mentalHealth.band.${instrument}.${point.band}`),
|
||||
color: "var(--muted-foreground)",
|
||||
},
|
||||
];
|
||||
return (
|
||||
<RichChartTooltip
|
||||
active
|
||||
label={formatDateShort(new Date(point.timestamp))}
|
||||
rows={rowsOut}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="value"
|
||||
stroke="none"
|
||||
fill={`url(#${gradientId})`}
|
||||
isAnimationActive={animate}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="value"
|
||||
stroke={primary}
|
||||
strokeWidth={2}
|
||||
dot={{ r: 3, fill: primary }}
|
||||
activeDot={{ r: 5 }}
|
||||
isAnimationActive={animate}
|
||||
/>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
@@ -1,30 +1,31 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* v1.25.3 — the history card: a dated list with severity badges.
|
||||
* v1.25.3 — the dated result history with severity badges.
|
||||
*
|
||||
* v1.27.6 — the trend CHART is gone from this surface. A rising score curve
|
||||
* on the check-in page itself reads as a verdict at the exact moment someone
|
||||
* is taking stock of how they feel; the page stays a calm list of past
|
||||
* check-ins. The score trend still exists — the totals ride PHQ9_SCORE /
|
||||
* GAD7_SCORE measurement rows, so Insights / Measurements carry the curve
|
||||
* for whoever seeks it out.
|
||||
* v1.27.9 — pinned-only: the combined landing card (with the instrument
|
||||
* toggle) is gone; the landing shows intro + instrument cards and nothing
|
||||
* else. This component now renders ONE instrument's detail body — trend
|
||||
* chart (lazy Recharts, the labs-detail precedent) over the dated list —
|
||||
* inside the detail surface a card click opens. The history is deliberately
|
||||
* opt-in behind that click, never pushed onto the main page.
|
||||
*
|
||||
* PHQ-9 / GAD-7 toggle: the two instruments have different score ranges +
|
||||
* bands, so the list shows 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.
|
||||
* 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";
|
||||
@@ -32,8 +33,24 @@ import { Activity } from "lucide-react";
|
||||
import { CrisisCard } from "./crisis-card";
|
||||
import type { AssessmentRow, CrisisSet, InstrumentId } from "./types";
|
||||
|
||||
function lower(id: InstrumentId): "phq9" | "gad7" {
|
||||
return id === "PHQ9" ? "phq9" : "gad7";
|
||||
// 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>
|
||||
);
|
||||
}
|
||||
|
||||
/** Re-derive the STATIC crisis set from a row's locale — no decryption. */
|
||||
@@ -48,122 +65,92 @@ function crisisFromRow(row: AssessmentRow): CrisisSet {
|
||||
};
|
||||
}
|
||||
|
||||
export function AssessmentHistory({ rows }: { rows: AssessmentRow[] }) {
|
||||
export function AssessmentHistory({
|
||||
rows,
|
||||
instrument,
|
||||
}: {
|
||||
/** ALL assessments (any instrument); filtered internally. */
|
||||
rows: AssessmentRow[];
|
||||
/** The instrument this detail surface is pinned to. */
|
||||
instrument: InstrumentId;
|
||||
}) {
|
||||
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(
|
||||
const forInstrument = useMemo(
|
||||
() =>
|
||||
rows
|
||||
.filter((r) => r.instrument === tab)
|
||||
.filter((r) => r.instrument === instrument)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(b.takenAt).getTime() - new Date(a.takenAt).getTime(),
|
||||
),
|
||||
[rows, tab],
|
||||
[rows, instrument],
|
||||
);
|
||||
|
||||
const isEmpty = rows.length === 0;
|
||||
if (forInstrument.length === 0) {
|
||||
return (
|
||||
<div data-slot="mental-health-history" data-pinned={instrument}>
|
||||
<EmptyState
|
||||
icon={<Activity className="size-6" />}
|
||||
title={t("mentalHealth.history.title")}
|
||||
description={t("mentalHealth.history.empty")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 differ, so
|
||||
the list shows one instrument at a time. */}
|
||||
<div
|
||||
className="flex gap-1"
|
||||
role="tablist"
|
||||
aria-label={t("mentalHealth.history.title")}
|
||||
>
|
||||
{(["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>
|
||||
<div
|
||||
data-slot="mental-health-history"
|
||||
data-pinned={instrument}
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
<AssessmentHistoryChart instrument={instrument} rows={forInstrument} />
|
||||
|
||||
{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>
|
||||
<ul className="flex flex-col gap-1.5" data-slot="history-list">
|
||||
{forInstrument.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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,12 @@
|
||||
* 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.
|
||||
*
|
||||
* v1.27.9 — direction-aware follow-up hints from the registry: PHQ-9/GAD-7
|
||||
* keep the ≥10 professional-help suggestion, a WHO-5 total of 50 or below
|
||||
* gets a gentle pointer to the PHQ-9 check-in (per the WHO scoring comment),
|
||||
* and an SCI total of 16 or below gets the paper's neutral band wording.
|
||||
* The attribution footer now comes from the instrument definition.
|
||||
*
|
||||
* 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.
|
||||
@@ -16,14 +22,10 @@ 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 { INSTRUMENTS, needsFollowUp } 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";
|
||||
}
|
||||
import type { CreateResponse } from "./types";
|
||||
|
||||
export function AssessmentResult({
|
||||
result,
|
||||
@@ -36,7 +38,8 @@ export function AssessmentResult({
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const instrument = result.assessment.instrument;
|
||||
const key = lower(instrument);
|
||||
const def = INSTRUMENTS[instrument];
|
||||
const key = def.i18nKey;
|
||||
|
||||
// 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
|
||||
@@ -104,9 +107,16 @@ export function AssessmentResult({
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{result.assessment.totalScore >= result.actionThreshold && (
|
||||
<p className="text-muted-foreground bg-muted/40 rounded-md p-3 text-xs">
|
||||
{t("mentalHealth.considerProfessional")}
|
||||
{/* Direction-aware follow-up hint: PHQ-9/GAD-7 point up (≥ 10),
|
||||
WHO-5 (≤ 50 → gentle PHQ-9 pointer) and SCI (≤ 16 → the paper's
|
||||
neutral band wording) point down. Soft suggestions, never a
|
||||
diagnosis. */}
|
||||
{needsFollowUp(instrument, result.assessment.totalScore) && (
|
||||
<p
|
||||
className="text-muted-foreground bg-muted/40 rounded-md p-3 text-xs"
|
||||
data-slot="result-follow-up-hint"
|
||||
>
|
||||
{t(`mentalHealth.followUpHint.${key}`)}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
@@ -114,11 +124,11 @@ export function AssessmentResult({
|
||||
|
||||
{result.crisis && <CrisisCard crisis={result.crisis} />}
|
||||
|
||||
<p className="text-muted-foreground text-[11px] leading-snug">
|
||||
<p className="text-muted-foreground text-xs leading-snug">
|
||||
<span className="font-medium">
|
||||
{t("mentalHealth.attributionLabel")}:
|
||||
</span>{" "}
|
||||
{PHQ_GAD_ATTRIBUTION}
|
||||
{def.attribution}
|
||||
</p>
|
||||
|
||||
<Button variant="outline" onClick={onTakeAnother}>
|
||||
|
||||
@@ -12,6 +12,14 @@
|
||||
* Answering the last question arms the submit directly — the next thing
|
||||
* the user sees is the dignified result view.
|
||||
*
|
||||
* v1.27.9 — the wizard drives from the instrument registry instead of a
|
||||
* hard-wired 0–3 scale: option values/order, option-label scheme (shared
|
||||
* anchors vs the SCI's per-item anchor groups), and the recall-stem caption
|
||||
* (the WHO-5 / SCI items are fragments under a shared stem on the source
|
||||
* forms) all come from `INSTRUMENTS`. Locales without a validated item
|
||||
* translation (SCI outside English) get an honest chrome note; the items
|
||||
* themselves stay in the validated source language.
|
||||
*
|
||||
* Header: instrument title + ONE standardized explanation line, NO
|
||||
* disclaimer (§2 — the "voluntary self-test, not a diagnosis" disclaimer
|
||||
* renders only on the landing, never while taking the test).
|
||||
@@ -22,7 +30,12 @@ import { ArrowLeft } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { INSTRUMENTS } from "@/lib/mental-health/instruments";
|
||||
import {
|
||||
INSTRUMENTS,
|
||||
hasValidatedItems,
|
||||
optionLabelKey,
|
||||
stemKey,
|
||||
} from "@/lib/mental-health/instruments";
|
||||
|
||||
import {
|
||||
isComplete,
|
||||
@@ -33,11 +46,7 @@ import {
|
||||
} from "./check-in-nav";
|
||||
import type { InstrumentId } from "./types";
|
||||
|
||||
const SCALE = [0, 1, 2, 3] as const;
|
||||
|
||||
function lower(id: InstrumentId): "phq9" | "gad7" {
|
||||
return id === "PHQ9" ? "phq9" : "gad7";
|
||||
}
|
||||
const FUNCTIONAL_SCALE = [0, 1, 2, 3] as const;
|
||||
|
||||
export function CheckInWizard({
|
||||
instrument,
|
||||
@@ -54,15 +63,16 @@ export function CheckInWizard({
|
||||
isPending: boolean;
|
||||
isError: boolean;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const key = lower(instrument);
|
||||
const itemCount = INSTRUMENTS[instrument].itemCount;
|
||||
const { t, locale } = useTranslations();
|
||||
const def = INSTRUMENTS[instrument];
|
||||
const key = def.i18nKey;
|
||||
const itemCount = def.itemCount;
|
||||
// v1.27.8 — the PHQ-9 asks its validated functional-impairment
|
||||
// follow-up as a regular LAST question (10 of 10) instead of a
|
||||
// separate review-step widget. It is OPTIONAL per the instrument
|
||||
// (answering is not required to submit) and never scores into the
|
||||
// total — the value rides the existing `functionalDifficulty` API
|
||||
// field. GAD-7 has no such item and keeps its 7 steps.
|
||||
// field. The other instruments have no such item.
|
||||
const hasFunctionalStep = instrument === "PHQ9";
|
||||
const totalSteps = itemCount + (hasFunctionalStep ? 1 : 0);
|
||||
|
||||
@@ -79,6 +89,9 @@ export function CheckInWizard({
|
||||
const questionLabel = onFunctionalStep
|
||||
? t("mentalHealth.functionalTitle")
|
||||
: t(`mentalHealth.items.${key}.${step}`);
|
||||
// Recall-stem caption above the question (WHO-5 / SCI fragments).
|
||||
const stem = onFunctionalStep ? null : stemKey(instrument, questionIndex);
|
||||
const scale = onFunctionalStep ? FUNCTIONAL_SCALE : def.optionOrder;
|
||||
|
||||
// a11y: announce each step by moving focus to its heading (the question
|
||||
// text). Mirrors the medication dialog's per-step title focus so a
|
||||
@@ -135,6 +148,16 @@ export function CheckInWizard({
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t(`mentalHealth.instrumentDescription.${key}`)}
|
||||
</p>
|
||||
{/* Honest translation note: the instrument is validated in its source
|
||||
language; the surrounding chrome stays localized. */}
|
||||
{!hasValidatedItems(instrument, locale) && (
|
||||
<p
|
||||
className="text-muted-foreground text-xs"
|
||||
data-slot="check-in-validated-note"
|
||||
>
|
||||
{t("mentalHealth.validatedInEnglishNote")}
|
||||
</p>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<Card className="gap-0 p-0">
|
||||
@@ -146,6 +169,11 @@ export function CheckInWizard({
|
||||
total: totalSteps,
|
||||
})}
|
||||
</p>
|
||||
{stem && (
|
||||
<p className="text-sm" data-slot="check-in-stem">
|
||||
{t(`mentalHealth.stems.${stem}`)}
|
||||
</p>
|
||||
)}
|
||||
<h2
|
||||
ref={headingRef}
|
||||
tabIndex={-1}
|
||||
@@ -163,7 +191,7 @@ export function CheckInWizard({
|
||||
role="group"
|
||||
aria-label={questionLabel}
|
||||
>
|
||||
{SCALE.map((v) => {
|
||||
{scale.map((v) => {
|
||||
const selected = onFunctionalStep
|
||||
? functional === v
|
||||
: items[questionIndex] === v;
|
||||
@@ -180,7 +208,9 @@ export function CheckInWizard({
|
||||
>
|
||||
{onFunctionalStep
|
||||
? t(`mentalHealth.functional.${v}`)
|
||||
: t(`mentalHealth.options.${v}`)}
|
||||
: t(
|
||||
`mentalHealth.${optionLabelKey(instrument, questionIndex, v)}`,
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -3,54 +3,81 @@
|
||||
/**
|
||||
* v1.25.3 — one neutral card per instrument on the landing.
|
||||
*
|
||||
* v1.27.6 — rebuilt onto the shared med-/Vorsorge card anatomy so the two
|
||||
* test cards read exactly like their peers: `MedicationCardHeader` (bold
|
||||
* v1.27.6 — rebuilt onto the shared med-/Vorsorge card anatomy so the test
|
||||
* cards read exactly like their peers: `MedicationCardHeader` (bold
|
||||
* name + outline category badge), a label/value block in the next-last
|
||||
* grammar ("last test" → relative day, "last result" → score + band word),
|
||||
* and a single bottom-pinned Start action. The surface stays NEUTRAL
|
||||
* regardless of the last band — no severity tint (house rule: status reads
|
||||
* through discreet text only, never a card wash).
|
||||
*
|
||||
* v1.27.9 — the card generalises over the four-instrument registry and
|
||||
* carries the instrument's required attribution/licence line as a small
|
||||
* factual footer (WHO CC BY-NC-SA citation for the WHO-5, the Sleepio
|
||||
* CC BY-NC citation for the SCI, the Pfizer grant for PHQ/GAD). A locale
|
||||
* without validated item wording additionally gets the honest
|
||||
* "validated in English" note. The card BODY (header + last-result block)
|
||||
* opens the per-instrument detail — the Vorsorge-/med-card interaction —
|
||||
* while the bottom-pinned Start button stays a separate, non-overlapping
|
||||
* action.
|
||||
*/
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { MedicationCardHeader } from "@/components/medications/medication-card-header";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { relativeCalendarDate } from "@/lib/i18n/relative-time";
|
||||
import {
|
||||
INSTRUMENTS,
|
||||
hasValidatedItems,
|
||||
} from "@/lib/mental-health/instruments";
|
||||
|
||||
import type { AssessmentRow, InstrumentId } from "./types";
|
||||
|
||||
function lower(id: InstrumentId): "phq9" | "gad7" {
|
||||
return id === "PHQ9" ? "phq9" : "gad7";
|
||||
}
|
||||
|
||||
export function InstrumentCard({
|
||||
instrument,
|
||||
last,
|
||||
onStart,
|
||||
onOpenDetail,
|
||||
}: {
|
||||
instrument: InstrumentId;
|
||||
/** Most-recent assessment for this instrument, or undefined when none yet. */
|
||||
last: AssessmentRow | undefined;
|
||||
onStart: () => void;
|
||||
/**
|
||||
* Open this instrument's detail (last score + band, trend chart, dated
|
||||
* history). The card body is the target; Start stays separate.
|
||||
*/
|
||||
onOpenDetail: () => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const { t, locale } = useTranslations();
|
||||
const { date: formatDate } = useFormatters();
|
||||
const key = lower(instrument);
|
||||
const def = INSTRUMENTS[instrument];
|
||||
const key = def.i18nKey;
|
||||
const title = t(`mentalHealth.instrument.${key}`);
|
||||
|
||||
return (
|
||||
<Card className="h-full gap-3 md:gap-3" data-slot="instrument-card">
|
||||
<MedicationCardHeader
|
||||
name={title}
|
||||
dose=""
|
||||
categoryLabel={t(`mentalHealth.instrumentSub.${key}`)}
|
||||
/>
|
||||
<CardContent className="flex h-full flex-col space-y-3.5">
|
||||
{/* Body is the detail target: clicking the header / last-result block
|
||||
opens this instrument's detail (chart + dated history), like a
|
||||
medication card. The Start button below stays a separate action. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenDetail}
|
||||
data-slot="instrument-card-open"
|
||||
aria-label={`${title} — ${t("mentalHealth.openDetail")}`}
|
||||
className="focus-visible:ring-ring/60 hover:bg-accent/40 flex cursor-pointer flex-col gap-3 rounded-t-xl text-left transition-colors focus-visible:ring-2 focus-visible:outline-none"
|
||||
>
|
||||
<MedicationCardHeader
|
||||
name={title}
|
||||
dose=""
|
||||
categoryLabel={t(`mentalHealth.instrumentSub.${key}`)}
|
||||
/>
|
||||
|
||||
{/* Label/value block in the Vorsorge card's next-last grammar. With
|
||||
no history yet, one calm line replaces the pair — no dashes
|
||||
pretending a value exists. */}
|
||||
{last ? (
|
||||
<div className="min-h-[2.75rem] space-y-1.5 text-sm">
|
||||
<div className="min-h-[2.75rem] w-full space-y-1.5 px-4 text-sm md:px-6">
|
||||
<div className="text-muted-foreground flex items-baseline justify-between gap-3">
|
||||
<span className="min-w-0 flex-shrink truncate font-medium">
|
||||
{t("mentalHealth.lastResult")}
|
||||
@@ -74,16 +101,29 @@ export function InstrumentCard({
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-muted-foreground flex min-h-[2.75rem] items-center text-sm">
|
||||
<div className="text-muted-foreground flex min-h-[2.75rem] items-center px-4 text-sm md:px-6">
|
||||
{t("mentalHealth.noResultYet")}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<CardContent className="flex h-full flex-col">
|
||||
{/* Bottom-pinned single primary action — the med-/Vorsorge card slot. */}
|
||||
<div className="mt-auto pt-0">
|
||||
<div className="mt-auto space-y-2 pt-0">
|
||||
<Button type="button" className="min-h-11 w-full" onClick={onStart}>
|
||||
{t("mentalHealth.start")}
|
||||
</Button>
|
||||
{/* Required attribution — factual and unobtrusive; identical slot on
|
||||
every card so the grid stays symmetric. */}
|
||||
<p
|
||||
className="text-muted-foreground text-xs leading-snug"
|
||||
data-slot="instrument-card-attribution"
|
||||
>
|
||||
{!hasValidatedItems(instrument, locale) && (
|
||||
<>{t("mentalHealth.validatedInEnglishNote")} </>
|
||||
)}
|
||||
{def.attribution}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* v1.27.9 — per-instrument detail surface, opened by clicking an instrument
|
||||
* card (the Vorsorge-/med-card detail interaction). Hosted in a
|
||||
* `ResponsiveSheet` by the page orchestrator; this component owns the calm
|
||||
* detail spine: last score + band → Start action → trend chart + dated
|
||||
* history (via the pinned `AssessmentHistory`) → the instrument's required
|
||||
* attribution line. The history and the score curve live ONLY here — the
|
||||
* landing stays a quiet card grid, so the trend is opt-in behind a
|
||||
* deliberate click, never pushed at the moment someone arrives to check in.
|
||||
*/
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { relativeCalendarDate } from "@/lib/i18n/relative-time";
|
||||
import { INSTRUMENTS } from "@/lib/mental-health/instruments";
|
||||
|
||||
import { AssessmentHistory } from "./assessment-history";
|
||||
import type { AssessmentRow, InstrumentId } from "./types";
|
||||
|
||||
export function InstrumentDetail({
|
||||
instrument,
|
||||
rows,
|
||||
onStart,
|
||||
}: {
|
||||
instrument: InstrumentId;
|
||||
/** ALL assessments; the pinned history filters to this instrument. */
|
||||
rows: AssessmentRow[];
|
||||
/** Start a check-in for this instrument (closes the host sheet). */
|
||||
onStart: () => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const { date: formatDate } = useFormatters();
|
||||
const def = INSTRUMENTS[instrument];
|
||||
const last = rows.find((r) => r.instrument === instrument);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4" data-slot="instrument-detail">
|
||||
{/* Last result, in the card's label/value grammar — score + band word
|
||||
on one slot, the relative day on the other. Neutral text, no tint. */}
|
||||
{last && (
|
||||
<div className="space-y-1.5 text-sm">
|
||||
<div className="text-muted-foreground flex items-baseline justify-between gap-3">
|
||||
<span className="min-w-0 flex-shrink truncate font-medium">
|
||||
{t("mentalHealth.lastResult")}
|
||||
</span>
|
||||
<span className="text-foreground text-right">
|
||||
{relativeCalendarDate(last.takenAt, t, formatDate)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-muted-foreground flex items-baseline justify-between gap-3">
|
||||
<span className="min-w-0 flex-shrink truncate font-medium">
|
||||
{t("mentalHealth.lastScore")}
|
||||
</span>
|
||||
<span
|
||||
className="text-foreground text-right"
|
||||
data-slot="instrument-detail-last-score"
|
||||
>
|
||||
{last.totalScore}
|
||||
{" · "}
|
||||
{t(`mentalHealth.band.${last.instrument}.${last.severityBand}`)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* The Start action stays reachable here too — the detail is a calm
|
||||
reading surface, not a dead end. */}
|
||||
<Button
|
||||
type="button"
|
||||
className="min-h-11 w-full"
|
||||
onClick={onStart}
|
||||
data-slot="instrument-detail-start"
|
||||
>
|
||||
{t("mentalHealth.start")}
|
||||
</Button>
|
||||
|
||||
<AssessmentHistory rows={rows} instrument={instrument} />
|
||||
|
||||
{/* Required attribution — same factual line as the card + result view. */}
|
||||
<p className="text-muted-foreground text-xs leading-snug">
|
||||
{def.attribution}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -20,9 +20,12 @@
|
||||
* cards (last-result line + Start) + a history card. The disclaimer renders
|
||||
* ONLY here (a muted caption) and behind the InfoHint — never while testing.
|
||||
*
|
||||
* v1.27.6 — the trend chart (and the per-card trend sheet) left this surface;
|
||||
* the score curve lives in Insights / Measurements via the PHQ9_SCORE /
|
||||
* GAD7_SCORE measurement rows. The landing keeps the calm dated list only.
|
||||
* v1.27.9 — the landing is intro + instrument cards, nothing else: the
|
||||
* combined history card left the main page entirely. Each card opens a
|
||||
* per-instrument detail sheet (the Vorsorge-/med-card detail interaction)
|
||||
* carrying last score + band, the trend chart, the dated history, and the
|
||||
* Start action — the Verlauf is opt-in behind that click, never pushed onto
|
||||
* the page someone arrives at to take stock.
|
||||
*/
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
@@ -30,12 +33,14 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { apiGet, apiPost } from "@/lib/api/api-fetch";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { INSTRUMENTS, INSTRUMENT_ORDER } from "@/lib/mental-health/instruments";
|
||||
import { PageHeader } from "@/components/ui/page-header";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
|
||||
import { AssessmentHistory } from "./assessment-history";
|
||||
import { AssessmentResult } from "./assessment-result";
|
||||
import { CheckInWizard } from "./check-in-wizard";
|
||||
import { InstrumentCard } from "./instrument-card";
|
||||
import { InstrumentDetail } from "./instrument-detail";
|
||||
import type {
|
||||
AssessmentRow,
|
||||
CreateResponse,
|
||||
@@ -50,6 +55,11 @@ export function MentalWellbeing() {
|
||||
const [phase, setPhase] = useState<Phase>("choose");
|
||||
const [instrument, setInstrument] = useState<InstrumentId>("PHQ9");
|
||||
const [result, setResult] = useState<CreateResponse | null>(null);
|
||||
// Which instrument's detail sheet is open (null = closed). Clicking a card
|
||||
// body opens THAT instrument's detail; Start stays a separate action.
|
||||
const [detailInstrument, setDetailInstrument] = useState<InstrumentId | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const { data: history } = useQuery({
|
||||
queryKey: queryKeys.mentalHealthAssessments(),
|
||||
@@ -82,6 +92,7 @@ export function MentalWellbeing() {
|
||||
}, [history]);
|
||||
|
||||
function begin(id: InstrumentId) {
|
||||
setDetailInstrument(null);
|
||||
setInstrument(id);
|
||||
setResult(null);
|
||||
mutation.reset();
|
||||
@@ -116,9 +127,10 @@ export function MentalWellbeing() {
|
||||
description={t("mentalHealth.pageDescription")}
|
||||
/>
|
||||
|
||||
{/* v1.27.6 — one gentle cadence hint: both instruments ask about
|
||||
the last two weeks, so every 2–4 weeks is a sensible rhythm.
|
||||
Body prose (foreground), not another muted description line. */}
|
||||
{/* v1.27.6 — one gentle cadence hint; v1.27.9 states each recall
|
||||
window honestly (PHQ-9 / GAD-7 / WHO-5 look back two weeks, the
|
||||
SCI asks about the past month). Body prose (foreground), not
|
||||
another muted description line. */}
|
||||
<p className="max-w-prose text-sm">
|
||||
{t("mentalHealth.pageRhythmHint")}
|
||||
</p>
|
||||
@@ -126,19 +138,45 @@ export function MentalWellbeing() {
|
||||
<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) => (
|
||||
{INSTRUMENT_ORDER.map((id) => (
|
||||
<li key={id} className="contents">
|
||||
<InstrumentCard
|
||||
instrument={id}
|
||||
last={lastByInstrument.get(id)}
|
||||
onStart={() => begin(id)}
|
||||
onOpenDetail={() => setDetailInstrument(id)}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<AssessmentHistory rows={history?.assessments ?? []} />
|
||||
{/* Per-instrument detail, opened from a card body: last result,
|
||||
trend chart, dated history, Start — the Verlauf lives here,
|
||||
not on the landing. */}
|
||||
<ResponsiveSheet
|
||||
open={detailInstrument !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setDetailInstrument(null);
|
||||
}}
|
||||
contentWidth="lg"
|
||||
title={
|
||||
detailInstrument
|
||||
? t(
|
||||
`mentalHealth.instrument.${INSTRUMENTS[detailInstrument].i18nKey}`,
|
||||
)
|
||||
: t("mentalHealth.history.title")
|
||||
}
|
||||
description={t("mentalHealth.history.chartTitle")}
|
||||
>
|
||||
{detailInstrument && (
|
||||
<InstrumentDetail
|
||||
instrument={detailInstrument}
|
||||
rows={history?.assessments ?? []}
|
||||
onStart={() => begin(detailInstrument)}
|
||||
/>
|
||||
)}
|
||||
</ResponsiveSheet>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -105,10 +105,14 @@ const EXPECTED_TYPES = [
|
||||
"PAIN_NRS",
|
||||
"WAIST_CIRCUMFERENCE",
|
||||
"WAIST_TO_HEIGHT",
|
||||
// ── v1.27.9 — screening scores (WHO-5 percentage 0–100, SCI total 0–32;
|
||||
// both server-derived from a completed assessment, higher = better) ──
|
||||
"WHO5_SCORE",
|
||||
"SCI_SCORE",
|
||||
] as const;
|
||||
|
||||
describe("measurementTypeEnum coverage", () => {
|
||||
it("exposes the 73 canonical measurement types", () => {
|
||||
it("exposes the 75 canonical measurement types", () => {
|
||||
expect([...measurementTypeEnum.options].sort()).toEqual(
|
||||
[...EXPECTED_TYPES].sort(),
|
||||
);
|
||||
@@ -264,6 +268,12 @@ describe("measurementTypeEnum coverage", () => {
|
||||
"PAIN_NRS",
|
||||
"WAIST_CIRCUMFERENCE",
|
||||
"WAIST_TO_HEIGHT",
|
||||
// v1.27.9 — WHO-5 / SCI screening totals follow the PHQ-9 / GAD-7
|
||||
// precedent: opt-in questionnaire derivations, excluded from the vitals
|
||||
// table; they ride the module-gated mental-health export section
|
||||
// instead. See doctor-report-pdf-core.ts for the matching rationale.
|
||||
"WHO5_SCORE",
|
||||
"SCI_SCORE",
|
||||
]);
|
||||
|
||||
it("doctor-report PDF vital types cover the canonical enum minus documented exclusions", () => {
|
||||
|
||||
@@ -1620,6 +1620,9 @@ const MEASUREMENT_TYPE_MODULE: Record<string, ModuleKey> = {
|
||||
// opted into.
|
||||
PHQ9_SCORE: "mentalHealth",
|
||||
GAD7_SCORE: "mentalHealth",
|
||||
// v1.27.9 — the WHO-5 / SCI totals ride the same module gate.
|
||||
WHO5_SCORE: "mentalHealth",
|
||||
SCI_SCORE: "mentalHealth",
|
||||
};
|
||||
|
||||
function filterMeasurementKeys<T>(
|
||||
|
||||
@@ -201,6 +201,11 @@ export const DOCTOR_REPORT_TYPE_UNIT_KEYS: Record<string, string | null> = {
|
||||
* v1.19.0 — Oura resilience. `RESILIENCE` is an ordinal-encoded categorical
|
||||
* band (limited=1 … exceptional=5), a derived recovery composite — descriptive,
|
||||
* not a measured clinical vital — so it is excluded too.
|
||||
*
|
||||
* v1.27.9 — WHO-5 / SCI screening totals. `WHO5_SCORE` + `SCI_SCORE` follow
|
||||
* the PHQ-9 / GAD-7 precedent: opt-in, derived from an in-app questionnaire,
|
||||
* excluded from the vitals table (they ride the module-gated mental-health
|
||||
* export section instead, see `doctor-report-data.ts`).
|
||||
*/
|
||||
export const DOCTOR_REPORT_VITAL_TYPES = [
|
||||
"WEIGHT",
|
||||
|
||||
@@ -133,6 +133,10 @@ export const ALLOWED_CHART_TOKENS = [
|
||||
// generic chart.
|
||||
"metric:PHQ9_SCORE",
|
||||
"metric:GAD7_SCORE",
|
||||
// v1.27.9 — WHO-5 (0–100 percentage) + SCI (0–32) screening totals; same
|
||||
// continuous-series contract, higher = better.
|
||||
"metric:WHO5_SCORE",
|
||||
"metric:SCI_SCORE",
|
||||
"metric:GRIP_STRENGTH",
|
||||
"metric:PAIN_NRS",
|
||||
"metric:WAIST_CIRCUMFERENCE",
|
||||
|
||||
@@ -184,6 +184,28 @@ describe("runReminderSatisfyForUser", () => {
|
||||
expect(updates[0].data.lastSatisfiedAt).toEqual(measuredAt);
|
||||
});
|
||||
|
||||
it("satisfies the WHO-5 / SCI screening reminders on the identical contract (v1.27.9)", async () => {
|
||||
for (const measurementType of ["WHO5_SCORE", "SCI_SCORE"]) {
|
||||
const measuredAt = new Date("2026-07-01T10:00:00Z");
|
||||
const { prisma, updates } = makePrisma({
|
||||
reminders: [reminderRow({ measurementType, intervalDays: 28 })],
|
||||
measurement: { measuredAt },
|
||||
});
|
||||
const isModuleEnabled = vi.fn(async () => true);
|
||||
|
||||
const summary = await runReminderSatisfyForUser(
|
||||
prisma as never,
|
||||
"u1",
|
||||
new Date("2026-07-01T12:00:00Z"),
|
||||
{ isModuleEnabled },
|
||||
);
|
||||
|
||||
expect(summary.satisfied, `${measurementType} should satisfy`).toBe(1);
|
||||
expect(isModuleEnabled).toHaveBeenCalledWith("u1", "mentalHealth");
|
||||
expect(updates[0].data.lastSatisfiedAt).toEqual(measuredAt);
|
||||
}
|
||||
});
|
||||
|
||||
it("produces no engine activity for a screening reminder when the mental-health module is off", async () => {
|
||||
const { prisma, updates } = makePrisma({
|
||||
reminders: [
|
||||
|
||||
@@ -67,6 +67,8 @@ function moduleForMeasurementType(
|
||||
return "sleep";
|
||||
case "PHQ9_SCORE":
|
||||
case "GAD7_SCORE":
|
||||
case "WHO5_SCORE":
|
||||
case "SCI_SCORE":
|
||||
return "mentalHealth";
|
||||
default:
|
||||
return null;
|
||||
|
||||
@@ -89,6 +89,10 @@ const MEASUREMENT_TYPES_WITHOUT_HK_COUNTERPART = new Set<MeasurementType>([
|
||||
"GRIP_STRENGTH",
|
||||
"PAIN_NRS",
|
||||
"WAIST_TO_HEIGHT",
|
||||
// v1.27.9 — WHO-5 / SCI screening totals: same in-app questionnaire
|
||||
// derivation as PHQ-9 / GAD-7. No HK mapping by design.
|
||||
"WHO5_SCORE",
|
||||
"SCI_SCORE",
|
||||
]);
|
||||
|
||||
describe("APPLE_HEALTH_TYPE_MAP", () => {
|
||||
|
||||
@@ -209,6 +209,9 @@ export const MEASUREMENT_CATEGORIES: ReadonlyMap<
|
||||
// PHQ-9 / GAD-7 totals are composite screener indices → scores cluster.
|
||||
["PHQ9_SCORE", "scores"],
|
||||
["GAD7_SCORE", "scores"],
|
||||
// ── v1.27.9 — WHO-5 / SCI totals join the same screener-index cluster.
|
||||
["WHO5_SCORE", "scores"],
|
||||
["SCI_SCORE", "scores"],
|
||||
// Grip strength is a strength/fitness reading → activity cluster.
|
||||
["GRIP_STRENGTH", "activity"],
|
||||
// Pain NRS is a patient-reported clinical point-in-time signal → vitals.
|
||||
|
||||
@@ -2,9 +2,14 @@ import { describe, it, expect } from "vitest";
|
||||
|
||||
import {
|
||||
INSTRUMENTS,
|
||||
INSTRUMENT_MEASUREMENT_TYPE,
|
||||
hasValidatedItems,
|
||||
isSafetyFlagged,
|
||||
needsFollowUp,
|
||||
optionLabelKey,
|
||||
scoreTotal,
|
||||
severityBand,
|
||||
isSafetyFlagged,
|
||||
stemKey,
|
||||
} from "../instruments";
|
||||
|
||||
/**
|
||||
@@ -12,19 +17,40 @@ import {
|
||||
* GAD-7 totals, the validated severity-band edges (5 / 10 / 15 / 20 and
|
||||
* 5 / 10 / 15), the ≥10 action threshold, and the item-9 ANY-non-zero safety
|
||||
* flag. A future off-by-one on any band edge or the safety predicate must fail
|
||||
* here. Ground truth: Kroenke 2001 (PHQ-9) / Spitzer 2006 (GAD-7), mirrored in
|
||||
* `.planning/research/v125/clinical-instruments.md`.
|
||||
* here. Ground truth: Kroenke 2001 (PHQ-9) / Spitzer 2006 (GAD-7).
|
||||
*
|
||||
* v1.27.9 — the WHO-5 / SCI pins join. Ground truth: the official WHO
|
||||
* publication WHO/UCN/MSD/MHE/2024.1 ("raw score … zero to 25 … multiplied by
|
||||
* four" → 0–100; "a percentage score below 50 … an indication for further
|
||||
* assessment") and Espie et al., BMJ Open 2014;4:e004183 ("total score ranges
|
||||
* from 0 to 32, with higher values indicative of better sleep"; "an SCI
|
||||
* cut-off ≤16 … 'probable insomnia disorder'").
|
||||
*/
|
||||
describe("mental-health instruments", () => {
|
||||
describe("scoreTotal", () => {
|
||||
it("sums the item answers", () => {
|
||||
expect(scoreTotal([0, 1, 2, 3, 0, 1, 2, 3, 1])).toBe(13);
|
||||
expect(scoreTotal([0, 0, 0, 0, 0, 0, 0])).toBe(0);
|
||||
it("sums the item answers for PHQ-9 / GAD-7 (multiplier 1)", () => {
|
||||
expect(scoreTotal("PHQ9", [0, 1, 2, 3, 0, 1, 2, 3, 1])).toBe(13);
|
||||
expect(scoreTotal("GAD7", [0, 0, 0, 0, 0, 0, 0])).toBe(0);
|
||||
});
|
||||
|
||||
it("returns the max when every item is 3", () => {
|
||||
expect(scoreTotal(Array(9).fill(3))).toBe(27); // PHQ-9 max
|
||||
expect(scoreTotal(Array(7).fill(3))).toBe(21); // GAD-7 max
|
||||
expect(scoreTotal("PHQ9", Array(9).fill(3))).toBe(27); // PHQ-9 max
|
||||
expect(scoreTotal("GAD7", Array(7).fill(3))).toBe(21); // GAD-7 max
|
||||
});
|
||||
|
||||
it("WHO-5 reports raw-sum × 4 (the official 0–100 percentage)", () => {
|
||||
// WHO scoring page: raw 0–25, "multiplied by four" → 0–100.
|
||||
expect(scoreTotal("WHO5", [0, 0, 0, 0, 0])).toBe(0);
|
||||
expect(scoreTotal("WHO5", [5, 5, 5, 5, 5])).toBe(100);
|
||||
expect(scoreTotal("WHO5", [3, 3, 3, 3, 3])).toBe(60);
|
||||
// Raw 12 (below the raw-13 cut) → 48 on the percentage scale.
|
||||
expect(scoreTotal("WHO5", [3, 3, 2, 2, 2])).toBe(48);
|
||||
});
|
||||
|
||||
it("SCI sums to 0–32 (higher = better sleep)", () => {
|
||||
expect(scoreTotal("SCI", Array(8).fill(0))).toBe(0);
|
||||
expect(scoreTotal("SCI", Array(8).fill(4))).toBe(32);
|
||||
expect(scoreTotal("SCI", [4, 3, 2, 1, 0, 4, 1, 1])).toBe(16);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -62,12 +88,47 @@ describe("mental-health instruments", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("action threshold is ≥10 for both instruments", () => {
|
||||
it("PHQ-9", () => {
|
||||
expect(INSTRUMENTS.PHQ9.actionThreshold).toBe(10);
|
||||
describe("severityBand — WHO-5 (below 50 = low; achievable totals are ×4)", () => {
|
||||
const cases: Array<[number, string]> = [
|
||||
[0, "low"],
|
||||
[48, "low"], // raw 12 — below the WHO raw-13 cut
|
||||
[52, "good"], // raw 13 — at/above the cut
|
||||
[100, "good"],
|
||||
];
|
||||
it.each(cases)("WHO5 total %i → %s", (total, band) => {
|
||||
expect(severityBand("WHO5", total)).toBe(band);
|
||||
});
|
||||
it("GAD-7", () => {
|
||||
expect(INSTRUMENTS.GAD7.actionThreshold).toBe(10);
|
||||
});
|
||||
|
||||
describe("severityBand — SCI (≤16 probable-insomnia range per Espie 2014)", () => {
|
||||
const cases: Array<[number, string]> = [
|
||||
[0, "belowThreshold"],
|
||||
[16, "belowThreshold"],
|
||||
[17, "aboveThreshold"],
|
||||
[32, "aboveThreshold"],
|
||||
];
|
||||
it.each(cases)("SCI total %i → %s", (total, band) => {
|
||||
expect(severityBand("SCI", total)).toBe(band);
|
||||
});
|
||||
});
|
||||
|
||||
describe("needsFollowUp — direction-aware thresholds", () => {
|
||||
it("PHQ-9 / GAD-7 point up (≥ 10)", () => {
|
||||
expect(needsFollowUp("PHQ9", 9)).toBe(false);
|
||||
expect(needsFollowUp("PHQ9", 10)).toBe(true);
|
||||
expect(needsFollowUp("GAD7", 9)).toBe(false);
|
||||
expect(needsFollowUp("GAD7", 10)).toBe(true);
|
||||
});
|
||||
|
||||
it("WHO-5 points down (≤ 50 → gentle PHQ-9 pointer)", () => {
|
||||
expect(needsFollowUp("WHO5", 48)).toBe(true);
|
||||
expect(needsFollowUp("WHO5", 50)).toBe(true);
|
||||
expect(needsFollowUp("WHO5", 52)).toBe(false);
|
||||
});
|
||||
|
||||
it("SCI points down (≤ 16)", () => {
|
||||
expect(needsFollowUp("SCI", 16)).toBe(true);
|
||||
expect(needsFollowUp("SCI", 17)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -82,13 +143,70 @@ describe("mental-health instruments", () => {
|
||||
it("does not flag when item 9 = 0 even with a high total", () => {
|
||||
// Every other item maxed (24), item 9 = 0 → severe band, no flag.
|
||||
const items = [3, 3, 3, 3, 3, 3, 3, 3, 0];
|
||||
expect(severityBand("PHQ9", scoreTotal(items))).toBe("severe");
|
||||
expect(severityBand("PHQ9", scoreTotal("PHQ9", items))).toBe("severe");
|
||||
expect(isSafetyFlagged("PHQ9", items)).toBe(false);
|
||||
});
|
||||
|
||||
it("never flags GAD-7 (no safety item)", () => {
|
||||
it("never flags the instruments without a safety item", () => {
|
||||
expect(isSafetyFlagged("GAD7", [3, 3, 3, 3, 3, 3, 3])).toBe(false);
|
||||
expect(isSafetyFlagged("WHO5", [0, 0, 0, 0, 0])).toBe(false);
|
||||
expect(isSafetyFlagged("SCI", [0, 0, 0, 0, 0, 0, 0, 0])).toBe(false);
|
||||
expect(INSTRUMENTS.GAD7.safetyItemIndex).toBeNull();
|
||||
expect(INSTRUMENTS.WHO5.safetyItemIndex).toBeNull();
|
||||
expect(INSTRUMENTS.SCI.safetyItemIndex).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("registry shape — the config the surfaces drive from", () => {
|
||||
it("presents options in the source order (WHO-5 leads with 5, SCI with 4)", () => {
|
||||
expect(INSTRUMENTS.WHO5.optionOrder).toEqual([5, 4, 3, 2, 1, 0]);
|
||||
expect(INSTRUMENTS.SCI.optionOrder).toEqual([4, 3, 2, 1, 0]);
|
||||
expect(INSTRUMENTS.PHQ9.optionOrder).toEqual([0, 1, 2, 3]);
|
||||
});
|
||||
|
||||
it("resolves option-label keys per scheme (shared vs SCI per-item groups)", () => {
|
||||
expect(optionLabelKey("PHQ9", 0, 2)).toBe("options.2");
|
||||
expect(optionLabelKey("WHO5", 4, 5)).toBe("who5Options.5");
|
||||
// SCI: items 1–2 duration, 3 nights, 4 quality, 5–7 impact, 8 problem duration.
|
||||
expect(optionLabelKey("SCI", 0, 4)).toBe("sciOptions.duration.4");
|
||||
expect(optionLabelKey("SCI", 2, 0)).toBe("sciOptions.nights.0");
|
||||
expect(optionLabelKey("SCI", 3, 2)).toBe("sciOptions.quality.2");
|
||||
expect(optionLabelKey("SCI", 6, 1)).toBe("sciOptions.impact.1");
|
||||
expect(optionLabelKey("SCI", 7, 3)).toBe("sciOptions.problemDuration.3");
|
||||
});
|
||||
|
||||
it("maps recall stems (WHO-5 one stem; SCI three sections; PHQ/GAD none)", () => {
|
||||
expect(stemKey("WHO5", 0)).toBe("who5.period");
|
||||
expect(stemKey("SCI", 0)).toBe("sci.night");
|
||||
expect(stemKey("SCI", 3)).toBe("sci.night");
|
||||
expect(stemKey("SCI", 4)).toBe("sci.impact");
|
||||
expect(stemKey("SCI", 7)).toBe("sci.finally");
|
||||
expect(stemKey("PHQ9", 0)).toBeNull();
|
||||
expect(stemKey("GAD7", 0)).toBeNull();
|
||||
});
|
||||
|
||||
it("declares validated item locales honestly (SCI = English only)", () => {
|
||||
for (const locale of ["de", "en", "es", "fr", "it", "pl"]) {
|
||||
expect(hasValidatedItems("WHO5", locale)).toBe(true);
|
||||
expect(hasValidatedItems("PHQ9", locale)).toBe(true);
|
||||
}
|
||||
expect(hasValidatedItems("SCI", "en")).toBe(true);
|
||||
expect(hasValidatedItems("SCI", "en-GB")).toBe(true);
|
||||
expect(hasValidatedItems("SCI", "de")).toBe(false);
|
||||
expect(hasValidatedItems("SCI", "pl")).toBe(false);
|
||||
});
|
||||
|
||||
it("projects each instrument onto its measurement type", () => {
|
||||
expect(INSTRUMENT_MEASUREMENT_TYPE.WHO5).toBe("WHO5_SCORE");
|
||||
expect(INSTRUMENT_MEASUREMENT_TYPE.SCI).toBe("SCI_SCORE");
|
||||
});
|
||||
|
||||
it("every instrument carries a non-empty attribution line", () => {
|
||||
for (const def of Object.values(INSTRUMENTS)) {
|
||||
expect(def.attribution.length).toBeGreaterThan(20);
|
||||
}
|
||||
expect(INSTRUMENTS.WHO5.attribution).toContain("CC BY-NC-SA 3.0 IGO");
|
||||
expect(INSTRUMENTS.SCI.attribution).toContain("BMJ Open");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,58 +1,123 @@
|
||||
/**
|
||||
* v1.25 — validated mental-health screener definitions (PHQ-9 + GAD-7).
|
||||
* v1.27.9 — the registry generalises to carry the WHO-5 well-being index and
|
||||
* the Sleep Condition Indicator (SCI) on the same infrastructure: per-item
|
||||
* answer scales, response-option schemes, score direction, and the reported
|
||||
* total (WHO-5 reports raw-sum × 4 as a 0–100 percentage) are now instrument
|
||||
* properties instead of PHQ/GAD assumptions.
|
||||
*
|
||||
* Pure, server-authoritative scoring. The instrument item wording, 0–3 scale,
|
||||
* totals, and severity bands are taken from the primary sources (Kroenke 2001
|
||||
* for PHQ-9; Spitzer 2006 for GAD-7). This module owns the canonical NUMERIC
|
||||
* contract; the human wording lives in `messages/*.json` under
|
||||
* `mentalHealth.items.*` (officially-validated translations per locale, English
|
||||
* fallback otherwise — re-wording invalidates the score). The de/es/fr/it/pl
|
||||
* item wordings are the public-domain Pfizer/PRIME-MD validated localized
|
||||
* versions distributed via the MAPI Research Trust (de: Löwe/Gräfe et al.); they
|
||||
* are NOT machine-translated. See `.planning/research/v125/clinical-instruments.md`.
|
||||
* Pure, server-authoritative scoring. Item wording, response anchors, scoring
|
||||
* and band thresholds are taken from the primary sources only:
|
||||
* - PHQ-9: Kroenke 2001; GAD-7: Spitzer 2006 (public-domain Pfizer grant).
|
||||
* - WHO-5: WHO/UCN/MSD/MHE/2024.1 (© WHO 2024, CC BY-NC-SA 3.0 IGO) — raw
|
||||
* 0–25, × 4 → 0–100; "a percentage score below 50 … an indication for
|
||||
* further assessment". WHO publishes official translations for all six
|
||||
* app locales; they ship verbatim.
|
||||
* - SCI: Espie et al., BMJ Open 2014;4:e004183 (© Sleepio Ltd, licensed
|
||||
* free for non-commercial use, CC BY-NC) — 8 items, 0–4 each, total 0–32,
|
||||
* HIGHER = better sleep; "a score of ≤16 … seems reasonable to detect
|
||||
* possible insomnia disorder". Item text is validated in English; app
|
||||
* locales without a validated translation present the English items.
|
||||
* This module owns the canonical NUMERIC contract; the human wording lives in
|
||||
* `messages/*.json` under `mentalHealth.items.*` (validated translations per
|
||||
* locale where they exist — re-wording invalidates the score). See
|
||||
* `.planning/research/screening-instruments-2026-07.md`.
|
||||
*
|
||||
* PHQ-9 item 9 (index 8) is the passive self-harm ideation screen and is the
|
||||
* safety-critical field (see `crisis-resources.ts` + the API handler): any
|
||||
* non-zero answer triggers a calm, non-alarmist crisis-resource signpost. The
|
||||
* raw item answers are NEVER exposed to the Coach / Insights / export surfaces —
|
||||
* only the derived total score rides a `*_SCORE` Measurement row.
|
||||
* non-zero answer triggers a calm, non-alarmist crisis-resource signpost.
|
||||
* Neither the WHO-5 (all items positively worded) nor the SCI (sleep-specific
|
||||
* items only) carries a crisis item. The raw item answers are NEVER exposed
|
||||
* to the Coach / Insights / export surfaces — only the derived total score
|
||||
* rides a `*_SCORE` Measurement row.
|
||||
*/
|
||||
|
||||
export type InstrumentId = "PHQ9" | "GAD7";
|
||||
export type InstrumentId = "PHQ9" | "GAD7" | "WHO5" | "SCI";
|
||||
|
||||
export interface InstrumentDefinition {
|
||||
id: InstrumentId;
|
||||
/** Lowercase i18n slug — the key segment under `mentalHealth.*`. */
|
||||
i18nKey: "phq9" | "gad7" | "who5" | "sci";
|
||||
/** Number of scored items (PHQ-9 = 9, GAD-7 = 7, WHO-5 = 5, SCI = 8). */
|
||||
itemCount: number;
|
||||
/** Highest per-item answer value (PHQ/GAD 3, SCI 4, WHO-5 5). */
|
||||
itemMax: number;
|
||||
/**
|
||||
* Answer values in the order the source instrument presents them. The
|
||||
* PHQ/GAD forms run 0→3; the WHO-5 form leads with 5 ("All of the time");
|
||||
* the SCI form leads with 4.
|
||||
*/
|
||||
optionOrder: readonly number[];
|
||||
/**
|
||||
* i18n namespace for the response-option labels under `mentalHealth.`:
|
||||
* without `optionGroups` one shared anchor set (`<ns>.<value>`); with
|
||||
* `optionGroups` (SCI) per-item anchor groups (`<ns>.<group>.<value>`,
|
||||
* group = `optionGroups[itemIndex]`).
|
||||
*/
|
||||
optionNamespace: string;
|
||||
optionGroups?: readonly string[];
|
||||
/**
|
||||
* Recall-stem caption key per item under `mentalHealth.stems.` — the WHO-5
|
||||
* and SCI items are fragments under a shared stem on the source forms, so
|
||||
* the wizard paints the applicable stem above the question. PHQ/GAD keep
|
||||
* their frame in the instrument description line (shipped contract).
|
||||
*/
|
||||
stemKeys?: readonly string[];
|
||||
/** Multiplier from item-sum to the reported total (WHO-5: × 4). */
|
||||
scoreMultiplier: number;
|
||||
/** Inclusive REPORTED total range (WHO-5: 0–100 percentage). */
|
||||
minScore: number;
|
||||
maxScore: number;
|
||||
/** Whether a HIGHER total is the good direction (WHO-5 / SCI). */
|
||||
higherIsBetter: boolean;
|
||||
/** Total-score LOINC for the FHIR/export contract; null when none exists. */
|
||||
totalLoinc: string | null;
|
||||
/** Severity bands, ordered low→high. `min`/`max` inclusive over the total. */
|
||||
bands: ReadonlyArray<{ key: string; min: number; max: number }>;
|
||||
/**
|
||||
* Follow-up threshold on the reported scale. The concerning side follows
|
||||
* the direction: PHQ/GAD ≥ threshold, WHO-5/SCI ≤ threshold (WHO-5 "below
|
||||
* 50" — 50 itself is unreachable on the ×4 scale; SCI ≤ 16 per Espie 2014).
|
||||
*/
|
||||
actionThreshold: number;
|
||||
/** Item index (0-based) of the safety-critical self-harm item, or null. */
|
||||
safetyItemIndex: number | null;
|
||||
/**
|
||||
* Locales whose bundled item wording is a validated translation. A locale
|
||||
* outside this list renders the validated source-language items plus an
|
||||
* honest chrome note — NEVER a self-made translation.
|
||||
*/
|
||||
validatedItemLocales: readonly string[];
|
||||
/**
|
||||
* Required attribution — render VERBATIM on the instrument's start card and
|
||||
* the result view (and in exports). Stays in English by convention (the
|
||||
* developers' names and licence identifiers are not translated).
|
||||
*/
|
||||
attribution: string;
|
||||
}
|
||||
|
||||
const ALL_APP_LOCALES = ["de", "en", "es", "fr", "it", "pl"] as const;
|
||||
|
||||
/**
|
||||
* Required attribution — render VERBATIM on every screen displaying the
|
||||
* instrument and in the PDF/FHIR export. The PHQ family is public-domain (Pfizer
|
||||
* 2010); this is the canonical footer text. By convention it stays in English
|
||||
* (the developers' names are not translated).
|
||||
* The PHQ family is public-domain (Pfizer 2010); this is the canonical footer
|
||||
* text per the instrument's own permission grant.
|
||||
*/
|
||||
export const PHQ_GAD_ATTRIBUTION =
|
||||
const PHQ_GAD_ATTRIBUTION =
|
||||
"Developed by Drs. Robert L. Spitzer, Janet B.W. Williams, Kurt Kroenke and " +
|
||||
"colleagues, with an educational grant from Pfizer Inc. No permission " +
|
||||
"required to reproduce, translate, display or distribute.";
|
||||
|
||||
export interface InstrumentDefinition {
|
||||
id: InstrumentId;
|
||||
/** Number of scored items (PHQ-9 = 9, GAD-7 = 7). */
|
||||
itemCount: number;
|
||||
/** Inclusive total range. */
|
||||
minScore: number;
|
||||
maxScore: number;
|
||||
/** Total-score LOINC for the FHIR/export contract. */
|
||||
totalLoinc: string;
|
||||
/** Severity bands, ordered low→high. `min`/`max` inclusive over the total. */
|
||||
bands: ReadonlyArray<{ key: string; min: number; max: number }>;
|
||||
/** Optimal screening cut-off (≥ this total = consider professional follow-up). */
|
||||
actionThreshold: number;
|
||||
/** Item index (0-based) of the safety-critical self-harm item, or null. */
|
||||
safetyItemIndex: number | null;
|
||||
}
|
||||
|
||||
const PHQ9: InstrumentDefinition = {
|
||||
id: "PHQ9",
|
||||
i18nKey: "phq9",
|
||||
itemCount: 9,
|
||||
itemMax: 3,
|
||||
optionOrder: [0, 1, 2, 3],
|
||||
optionNamespace: "options",
|
||||
scoreMultiplier: 1,
|
||||
minScore: 0,
|
||||
maxScore: 27,
|
||||
higherIsBetter: false,
|
||||
totalLoinc: "44261-6",
|
||||
bands: [
|
||||
{ key: "minimal", min: 0, max: 4 },
|
||||
@@ -64,13 +129,21 @@ const PHQ9: InstrumentDefinition = {
|
||||
actionThreshold: 10,
|
||||
// Item 9 ("Thoughts that you would be better off dead…") — the safety field.
|
||||
safetyItemIndex: 8,
|
||||
validatedItemLocales: ALL_APP_LOCALES,
|
||||
attribution: PHQ_GAD_ATTRIBUTION,
|
||||
};
|
||||
|
||||
const GAD7: InstrumentDefinition = {
|
||||
id: "GAD7",
|
||||
i18nKey: "gad7",
|
||||
itemCount: 7,
|
||||
itemMax: 3,
|
||||
optionOrder: [0, 1, 2, 3],
|
||||
optionNamespace: "options",
|
||||
scoreMultiplier: 1,
|
||||
minScore: 0,
|
||||
maxScore: 21,
|
||||
higherIsBetter: false,
|
||||
totalLoinc: "70274-6",
|
||||
bands: [
|
||||
{ key: "minimal", min: 0, max: 4 },
|
||||
@@ -80,25 +153,137 @@ const GAD7: InstrumentDefinition = {
|
||||
],
|
||||
actionThreshold: 10,
|
||||
safetyItemIndex: null,
|
||||
validatedItemLocales: ALL_APP_LOCALES,
|
||||
attribution: PHQ_GAD_ATTRIBUTION,
|
||||
};
|
||||
|
||||
/**
|
||||
* WHO-5 well-being index — five positively-worded items over the last two
|
||||
* weeks, 6-point 5→0 scale. Reported score = raw sum × 4 (0–100, higher =
|
||||
* better well-being) per the official WHO scoring. "A percentage score below
|
||||
* 50 … has been suggested as a cut-off for poor mental well-being and as an
|
||||
* indication for further assessment" (WHO/UCN/MSD/MHE/2024.1) — the result
|
||||
* view answers a ≤ 50 total with a gentle pointer to the PHQ-9 check-in.
|
||||
* Achievable totals are multiples of 4, so the 0–50 band equals "below 50".
|
||||
*/
|
||||
const WHO5: InstrumentDefinition = {
|
||||
id: "WHO5",
|
||||
i18nKey: "who5",
|
||||
itemCount: 5,
|
||||
itemMax: 5,
|
||||
optionOrder: [5, 4, 3, 2, 1, 0],
|
||||
optionNamespace: "who5Options",
|
||||
stemKeys: Array(5).fill("who5.period"),
|
||||
scoreMultiplier: 4,
|
||||
minScore: 0,
|
||||
maxScore: 100,
|
||||
higherIsBetter: true,
|
||||
totalLoinc: null,
|
||||
bands: [
|
||||
{ key: "low", min: 0, max: 50 },
|
||||
{ key: "good", min: 51, max: 100 },
|
||||
],
|
||||
actionThreshold: 50,
|
||||
safetyItemIndex: null,
|
||||
// WHO publishes official de/en/es/fr/it/pl versions — all bundled verbatim.
|
||||
validatedItemLocales: ALL_APP_LOCALES,
|
||||
attribution:
|
||||
"World Health Organization. The World Health Organization-Five Well-Being " +
|
||||
"Index (WHO-5). Geneva: World Health Organization; 2024. Licence: " +
|
||||
"CC BY-NC-SA 3.0 IGO. WHO does not endorse this application.",
|
||||
};
|
||||
|
||||
/**
|
||||
* Sleep Condition Indicator — eight DSM-5-aligned items, 0–4 each with
|
||||
* item-specific anchors, total 0–32, HIGHER = better sleep. "A score of ≤16
|
||||
* on the SCI seems reasonable to detect possible insomnia disorder" (Espie
|
||||
* et al. 2014) — the band wording stays neutral per the paper, no diagnosis
|
||||
* language. Items 1–4 ask about a typical night in the last month, items 5–7
|
||||
* about the past month's daytime impact, item 8 about problem duration.
|
||||
*/
|
||||
const SCI: InstrumentDefinition = {
|
||||
id: "SCI",
|
||||
i18nKey: "sci",
|
||||
itemCount: 8,
|
||||
itemMax: 4,
|
||||
optionOrder: [4, 3, 2, 1, 0],
|
||||
optionNamespace: "sciOptions",
|
||||
optionGroups: [
|
||||
"duration",
|
||||
"duration",
|
||||
"nights",
|
||||
"quality",
|
||||
"impact",
|
||||
"impact",
|
||||
"impact",
|
||||
"problemDuration",
|
||||
],
|
||||
stemKeys: [
|
||||
"sci.night",
|
||||
"sci.night",
|
||||
"sci.night",
|
||||
"sci.night",
|
||||
"sci.impact",
|
||||
"sci.impact",
|
||||
"sci.impact",
|
||||
"sci.finally",
|
||||
],
|
||||
scoreMultiplier: 1,
|
||||
minScore: 0,
|
||||
maxScore: 32,
|
||||
higherIsBetter: true,
|
||||
totalLoinc: null,
|
||||
bands: [
|
||||
{ key: "belowThreshold", min: 0, max: 16 },
|
||||
{ key: "aboveThreshold", min: 17, max: 32 },
|
||||
],
|
||||
actionThreshold: 16,
|
||||
safetyItemIndex: null,
|
||||
// Validated in English (the CC BY-NC BMJ Open publication). French/Italian
|
||||
// validations exist in the literature but their item text is not openly
|
||||
// redistributable, so every non-EN locale presents the English items with
|
||||
// an honest note — never a self-made translation.
|
||||
validatedItemLocales: ["en"],
|
||||
attribution:
|
||||
"Espie CA, Kyle SD, Hames P, et al. The Sleep Condition Indicator: a " +
|
||||
"clinical screening tool to evaluate insomnia disorder. BMJ Open " +
|
||||
"2014;4:e004183. © Sleepio Limited; free for non-commercial use (CC BY-NC).",
|
||||
};
|
||||
|
||||
export const INSTRUMENTS: Record<InstrumentId, InstrumentDefinition> = {
|
||||
PHQ9,
|
||||
GAD7,
|
||||
WHO5,
|
||||
SCI,
|
||||
};
|
||||
|
||||
/** Landing / history ordering — the shipped pair first, then the new pair. */
|
||||
export const INSTRUMENT_ORDER: readonly InstrumentId[] = [
|
||||
"PHQ9",
|
||||
"GAD7",
|
||||
"WHO5",
|
||||
"SCI",
|
||||
];
|
||||
|
||||
/** The DB MeasurementType the instrument's total score projects to. */
|
||||
export const INSTRUMENT_MEASUREMENT_TYPE: Record<InstrumentId, string> = {
|
||||
PHQ9: "PHQ9_SCORE",
|
||||
GAD7: "GAD7_SCORE",
|
||||
WHO5: "WHO5_SCORE",
|
||||
SCI: "SCI_SCORE",
|
||||
};
|
||||
|
||||
/** Sum the item answers into a total (server-authoritative). */
|
||||
export function scoreTotal(items: readonly number[]): number {
|
||||
return items.reduce((sum, v) => sum + v, 0);
|
||||
/**
|
||||
* The REPORTED total for a completed administration (server-authoritative):
|
||||
* item-sum × the instrument's multiplier (WHO-5 reports the 0–100 percentage,
|
||||
* everything else the raw sum).
|
||||
*/
|
||||
export function scoreTotal(id: InstrumentId, items: readonly number[]): number {
|
||||
const sum = items.reduce((acc, v) => acc + v, 0);
|
||||
return sum * INSTRUMENTS[id].scoreMultiplier;
|
||||
}
|
||||
|
||||
/** Resolve the severity-band key for a total score. */
|
||||
/** Resolve the severity-band key for a reported total score. */
|
||||
export function severityBand(id: InstrumentId, total: number): string {
|
||||
const def = INSTRUMENTS[id];
|
||||
const band = def.bands.find((b) => total >= b.min && total <= b.max);
|
||||
@@ -106,6 +291,50 @@ export function severityBand(id: InstrumentId, total: number): string {
|
||||
return band?.key ?? def.bands[def.bands.length - 1].key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the gentle follow-up hint applies for a reported total. Direction-
|
||||
* aware: PHQ-9/GAD-7 point up (≥ 10), WHO-5 and the SCI point down (≤ 50 /
|
||||
* ≤ 16 — lower totals mean lower well-being / worse sleep).
|
||||
*/
|
||||
export function needsFollowUp(id: InstrumentId, total: number): boolean {
|
||||
const def = INSTRUMENTS[id];
|
||||
return def.higherIsBetter
|
||||
? total <= def.actionThreshold
|
||||
: total >= def.actionThreshold;
|
||||
}
|
||||
|
||||
/**
|
||||
* i18n key (under `mentalHealth.`) for one response option of one item —
|
||||
* shared anchors (`options.2`), instrument-specific shared anchors
|
||||
* (`who5Options.5`), or the SCI's per-item anchor groups
|
||||
* (`sciOptions.duration.4`).
|
||||
*/
|
||||
export function optionLabelKey(
|
||||
id: InstrumentId,
|
||||
itemIndex: number,
|
||||
value: number,
|
||||
): string {
|
||||
const def = INSTRUMENTS[id];
|
||||
return def.optionGroups
|
||||
? `${def.optionNamespace}.${def.optionGroups[itemIndex]}.${value}`
|
||||
: `${def.optionNamespace}.${value}`;
|
||||
}
|
||||
|
||||
/** Stem caption key (under `mentalHealth.stems.`) for an item, or null. */
|
||||
export function stemKey(id: InstrumentId, itemIndex: number): string | null {
|
||||
return INSTRUMENTS[id].stemKeys?.[itemIndex] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the bundled item wording for `locale` is a validated translation.
|
||||
* When false the surface shows the validated source-language items plus a
|
||||
* localized "validated in English" chrome note.
|
||||
*/
|
||||
export function hasValidatedItems(id: InstrumentId, locale: string): boolean {
|
||||
const base = locale.toLowerCase().split("-")[0];
|
||||
return INSTRUMENTS[id].validatedItemLocales.includes(base);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the safety signpost must be shown for these answers. True iff the
|
||||
* instrument has a safety item and that item was answered with any non-zero
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* OpenAPI route table for the opt-in mental-health screeners
|
||||
* (`/api/mental-health/assessments`). PHQ-9 / GAD-7.
|
||||
* (`/api/mental-health/assessments`). PHQ-9 / GAD-7 / WHO-5 / SCI.
|
||||
*
|
||||
* Part of the OpenAPI route table; aggregated in `./index.ts`. The request body
|
||||
* reuses the runtime Zod schema from `@/lib/validations/mental-health` so the
|
||||
@@ -23,7 +23,7 @@ import { dataEnvelope, stdResponses } from "./shared";
|
||||
createAssessmentSchema.meta({
|
||||
id: "CreateMentalHealthAssessmentRequest",
|
||||
description:
|
||||
"Record one completed PHQ-9 (9 items) / GAD-7 (7 items) administration. Each item answer is 0–3; the array length must match the instrument. The item answers are encrypted at rest and never returned. The server computes the total + severity band + item-9 safety flag. Opt-in, beside mood tracking — this is a screening surface, not a diagnosis.",
|
||||
"Record one completed screener administration: PHQ-9 (9 items, 0–3), GAD-7 (7 items, 0–3), WHO-5 (5 items, 0–5) or SCI (8 items, 0–4). The array length and per-item ceiling must match the instrument. The item answers are encrypted at rest and never returned. The server computes the total (WHO-5 reports raw-sum × 4 as 0–100) + severity band + item-9 safety flag. Opt-in, beside mood tracking — this is a screening surface, not a diagnosis.",
|
||||
});
|
||||
|
||||
listAssessmentsSchema.meta({
|
||||
@@ -35,7 +35,7 @@ listAssessmentsSchema.meta({
|
||||
const assessmentRow = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
instrument: z.enum(["PHQ9", "GAD7"]),
|
||||
instrument: z.enum(["PHQ9", "GAD7", "WHO5", "SCI"]),
|
||||
locale: z.string(),
|
||||
version: z.string(),
|
||||
totalScore: z.number(),
|
||||
@@ -87,7 +87,7 @@ export const mentalHealthPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Mental health"],
|
||||
summary: "List the caller's screener history",
|
||||
description:
|
||||
"Returns PHQ-9 / GAD-7 administrations (newest first). Totals + bands + flags only; raw item answers are never returned.",
|
||||
"Returns PHQ-9 / GAD-7 / WHO-5 / SCI administrations (newest first). Totals + bands + flags only; raw item answers are never returned.",
|
||||
requestParams: { query: listAssessmentsSchema },
|
||||
responses: {
|
||||
"200": {
|
||||
@@ -106,9 +106,9 @@ export const mentalHealthPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
post: {
|
||||
tags: ["Mental health"],
|
||||
summary: "Record a completed PHQ-9 / GAD-7 screener",
|
||||
summary: "Record a completed screener",
|
||||
description:
|
||||
"Stores one administration (item answers encrypted) and writes the derived total as a server-owned PHQ9_SCORE / GAD7_SCORE measurement (source COMPUTED — never client-writable). On a positive PHQ-9 item-9 the response carries the locale-aware crisis-resource set.",
|
||||
"Stores one administration (item answers encrypted) and writes the derived total as a server-owned PHQ9_SCORE / GAD7_SCORE / WHO5_SCORE / SCI_SCORE measurement (source COMPUTED — never client-writable). WHO-5 and SCI totals run HIGHER = better; their follow-up thresholds point downwards (≤ 50 / ≤ 16). On a positive PHQ-9 item-9 the response carries the locale-aware crisis-resource set.",
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": { schema: createAssessmentSchema },
|
||||
|
||||
@@ -205,6 +205,10 @@ export function getPRDirection(
|
||||
// all). All defer to a null PR direction.
|
||||
case "PHQ9_SCORE":
|
||||
case "GAD7_SCORE":
|
||||
// v1.27.9 — WHO-5 / SCI screening totals follow the same no-gamification
|
||||
// safety rule (no streaks / "best score" framing around a screener).
|
||||
case "WHO5_SCORE":
|
||||
case "SCI_SCORE":
|
||||
case "PAIN_NRS":
|
||||
case "WAIST_CIRCUMFERENCE":
|
||||
case "WAIST_TO_HEIGHT":
|
||||
|
||||
@@ -1272,6 +1272,47 @@ export const SIGNALS: Record<string, SignalDefinition> = {
|
||||
category: "survey",
|
||||
},
|
||||
},
|
||||
// ── v1.27.9 — WHO-5 well-being + Sleep Condition Indicator totals ───────
|
||||
// Same privacy posture as PHQ-9 / GAD-7 (off Coach snapshot + MCP; item
|
||||
// content never rides a Measurement row). Direction inverts: HIGHER is
|
||||
// better for both. No verified total-score LOINC exists for either, so no
|
||||
// FHIR coding is attached (the export skips uncoded signals rather than
|
||||
// shipping an invented code).
|
||||
WHO5_SCORE: {
|
||||
key: "WHO5_SCORE",
|
||||
kind: "score",
|
||||
source: { measurementType: "WHO5_SCORE" },
|
||||
displayName: "WHO-5 well-being index (0–100)",
|
||||
unit: "score",
|
||||
direction: "higher-better",
|
||||
archetype: "physiological-vital",
|
||||
// "Below 50" is the WHO-suggested cut-off; achievable totals are
|
||||
// multiples of 4, so ≥ 52 is the good side.
|
||||
normalRange: { low: 52, high: 100 },
|
||||
surfaces: {
|
||||
detailPage: false,
|
||||
correlationEligible: false,
|
||||
coachSnapshot: false,
|
||||
mcp: false,
|
||||
},
|
||||
},
|
||||
SCI_SCORE: {
|
||||
key: "SCI_SCORE",
|
||||
kind: "score",
|
||||
source: { measurementType: "SCI_SCORE" },
|
||||
displayName: "Sleep Condition Indicator (total)",
|
||||
unit: "score",
|
||||
direction: "higher-better",
|
||||
archetype: "physiological-vital",
|
||||
// > 16 identified "no insomnia disorder" in Espie et al. 2014.
|
||||
normalRange: { low: 17, high: 32 },
|
||||
surfaces: {
|
||||
detailPage: false,
|
||||
correlationEligible: false,
|
||||
coachSnapshot: false,
|
||||
mcp: false,
|
||||
},
|
||||
},
|
||||
// ── v1.25 clinical-signals wave — physical / clinical measurements ──────
|
||||
GRIP_STRENGTH: {
|
||||
key: "GRIP_STRENGTH",
|
||||
|
||||
@@ -37,10 +37,26 @@ describe("measurementReminderTypeEnum (V3 — completeness)", () => {
|
||||
// v1.27.6 — plannable mental-wellbeing screenings
|
||||
"PHQ9_SCORE",
|
||||
"GAD7_SCORE",
|
||||
// v1.27.9 — WHO-5 + SCI join on the identical contract
|
||||
"WHO5_SCORE",
|
||||
"SCI_SCORE",
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts the WHO-5 / SCI screenings with the 4-week default cadence", () => {
|
||||
for (const measurementType of ["WHO5_SCORE", "SCI_SCORE"]) {
|
||||
const parsed = createMeasurementReminderSchema.safeParse({
|
||||
label: "Screening check-in",
|
||||
measurementType,
|
||||
intervalDays: 28,
|
||||
});
|
||||
expect(parsed.success, `expected ${measurementType} to be allowed`).toBe(
|
||||
true,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts a plannable screening reminder with the 4-week default cadence", () => {
|
||||
const parsed = createMeasurementReminderSchema.safeParse({
|
||||
label: "PHQ-9 Check-in",
|
||||
|
||||
@@ -473,9 +473,15 @@ describe("measurement validation", () => {
|
||||
it("rejects a COMPUTED-attributed screener score row on a client write", () => {
|
||||
// v1.27.6 — the mental-wellbeing screener projects each completed
|
||||
// administration onto a COMPUTED-source PHQ9_SCORE / GAD7_SCORE row
|
||||
// (the RECOVERY_SCORE precedent). COMPUTED is not client-attributable,
|
||||
// so a client can never mint a server-owned score trend point.
|
||||
for (const type of ["PHQ9_SCORE", "GAD7_SCORE"]) {
|
||||
// (the RECOVERY_SCORE precedent; v1.27.9 adds WHO5_SCORE / SCI_SCORE).
|
||||
// COMPUTED is not client-attributable, so a client can never mint a
|
||||
// server-owned score trend point.
|
||||
for (const type of [
|
||||
"PHQ9_SCORE",
|
||||
"GAD7_SCORE",
|
||||
"WHO5_SCORE",
|
||||
"SCI_SCORE",
|
||||
]) {
|
||||
const parsed = createMeasurementSchema.safeParse({
|
||||
type,
|
||||
value: 5,
|
||||
|
||||
@@ -64,11 +64,16 @@ export const measurementReminderTypeEnum = z
|
||||
// the dispatcher (see `moduleForMeasurementType`).
|
||||
"PHQ9_SCORE",
|
||||
"GAD7_SCORE",
|
||||
// v1.27.9 — WHO-5 + SCI join the plannable screenings on the identical
|
||||
// contract (auto-resolve on the server-owned COMPUTED score row, module-
|
||||
// gated in the dispatcher).
|
||||
"WHO5_SCORE",
|
||||
"SCI_SCORE",
|
||||
])
|
||||
.meta({
|
||||
id: "MeasurementReminderType",
|
||||
description:
|
||||
"Auto-resolve target metric (vitals + body-composition family + the PHQ-9 / GAD-7 screenings). BP resolves on BLOOD_PRESSURE_SYS (the SYS row is the 'a BP was measured' sentinel); a screening reminder resolves on the server-written PHQ9_SCORE / GAD7_SCORE row when a check-in completes. Omit for a free-text Vorsorge that resolves only on a manual satisfy or a matching lab result.",
|
||||
"Auto-resolve target metric (vitals + body-composition family + the PHQ-9 / GAD-7 / WHO-5 / SCI screenings). BP resolves on BLOOD_PRESSURE_SYS (the SYS row is the 'a BP was measured' sentinel); a screening reminder resolves on the server-written *_SCORE row when a check-in completes. Omit for a free-text Vorsorge that resolves only on a manual satisfy or a matching lab result.",
|
||||
});
|
||||
|
||||
export type MeasurementReminderType = z.infer<
|
||||
@@ -85,6 +90,8 @@ export type MeasurementReminderType = z.infer<
|
||||
export const SCREENING_REMINDER_TYPES: ReadonlySet<string> = new Set([
|
||||
"PHQ9_SCORE",
|
||||
"GAD7_SCORE",
|
||||
"WHO5_SCORE",
|
||||
"SCI_SCORE",
|
||||
]);
|
||||
|
||||
/** Whether a reminder's `measurementType` targets a screening check-in. */
|
||||
|
||||
@@ -135,6 +135,11 @@ export const measurementTypeEnum = z.enum([
|
||||
"PAIN_NRS",
|
||||
"WAIST_CIRCUMFERENCE",
|
||||
"WAIST_TO_HEIGHT",
|
||||
// ── v1.27.9 — screening scores (same assessment contract as PHQ-9/GAD-7,
|
||||
// inverted direction: HIGHER is better). WHO-5 stores the reported 0–100
|
||||
// percentage (raw sum × 4); SCI stores the 0–32 total.
|
||||
"WHO5_SCORE",
|
||||
"SCI_SCORE",
|
||||
]);
|
||||
|
||||
/**
|
||||
@@ -379,6 +384,9 @@ const unitMap: Record<string, string> = {
|
||||
// ── v1.25 — clinical-signals wave ──
|
||||
PHQ9_SCORE: "score",
|
||||
GAD7_SCORE: "score",
|
||||
// ── v1.27.9 — screening scores ──
|
||||
WHO5_SCORE: "score",
|
||||
SCI_SCORE: "score",
|
||||
GRIP_STRENGTH: "kg",
|
||||
PAIN_NRS: "score",
|
||||
WAIST_CIRCUMFERENCE: "cm",
|
||||
@@ -581,6 +589,10 @@ export const VALUE_RANGES: Record<string, { min: number; max: number }> = {
|
||||
// PHQ-9 total 0–27, GAD-7 total 0–21 — server-derived from the screener.
|
||||
PHQ9_SCORE: { min: 0, max: 27 },
|
||||
GAD7_SCORE: { min: 0, max: 21 },
|
||||
// ── v1.27.9 — screening scores. WHO-5 reported percentage 0–100 (raw × 4),
|
||||
// SCI total 0–32 — both server-derived, higher = better.
|
||||
WHO5_SCORE: { min: 0, max: 100 },
|
||||
SCI_SCORE: { min: 0, max: 32 },
|
||||
// Grip strength kg — a child can read in the single digits, an elite athlete
|
||||
// tops ~90 kg; 120 is a generous ceiling that still rejects sensor noise.
|
||||
GRIP_STRENGTH: { min: 0, max: 120 },
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
/**
|
||||
* v1.25 — request/response validation for the opt-in mental-health screeners
|
||||
* (PHQ-9 / GAD-7). The item answers are 0–3 integers; the array length must
|
||||
* match the instrument (PHQ-9 = 9, GAD-7 = 7). `userId` is NEVER a body field —
|
||||
* it is narrowed from the session in the handler (CLAUDE.md convention).
|
||||
* (PHQ-9 / GAD-7; v1.27.9 adds WHO-5 + SCI). The item answers are small
|
||||
* integers whose ceiling is instrument-specific (PHQ/GAD 0–3, SCI 0–4,
|
||||
* WHO-5 0–5); the array length must match the instrument. `userId` is NEVER
|
||||
* a body field — it is narrowed from the session in the handler (CLAUDE.md
|
||||
* convention).
|
||||
*/
|
||||
import { z } from "zod/v4";
|
||||
import {
|
||||
@@ -10,7 +12,7 @@ import {
|
||||
type InstrumentId,
|
||||
} from "@/lib/mental-health/instruments";
|
||||
|
||||
export const assessmentInstrumentEnum = z.enum(["PHQ9", "GAD7"]);
|
||||
export const assessmentInstrumentEnum = z.enum(["PHQ9", "GAD7", "WHO5", "SCI"]);
|
||||
|
||||
/**
|
||||
* Client provenance for an administration. Closed enum so the column can never
|
||||
@@ -21,12 +23,15 @@ export const assessmentInstrumentEnum = z.enum(["PHQ9", "GAD7"]);
|
||||
*/
|
||||
export const assessmentSourceEnum = z.enum(["WEB", "IOS"]);
|
||||
|
||||
const itemAnswer = z.number().int().min(0).max(3);
|
||||
// Structural ceiling — the WHO-5's 0–5 scale is the widest. The exact
|
||||
// per-instrument ceiling (PHQ/GAD 3, SCI 4) is enforced in the superRefine
|
||||
// below, where the instrument is known.
|
||||
const itemAnswer = z.number().int().min(0).max(5);
|
||||
|
||||
export const createAssessmentSchema = z
|
||||
.object({
|
||||
instrument: assessmentInstrumentEnum,
|
||||
/** Per-item answers, each 0–3, ordered as the instrument presents them. */
|
||||
/** Per-item answers, ordered as the instrument presents them. */
|
||||
items: z.array(itemAnswer).min(1).max(12),
|
||||
/** Optional functional-impairment follow-up (not scored into the total). */
|
||||
functionalDifficulty: z.number().int().min(0).max(3).optional(),
|
||||
@@ -55,6 +60,15 @@ export const createAssessmentSchema = z
|
||||
message: `${val.instrument} expects exactly ${def.itemCount} item answers`,
|
||||
});
|
||||
}
|
||||
val.items.forEach((answer, index) => {
|
||||
if (answer > def.itemMax) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["items", index],
|
||||
message: `${val.instrument} answers range 0–${def.itemMax}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
export const listAssessmentsSchema = z.object({
|
||||
|
||||
Reference in new issue
Block a user