mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
feat(mental-health): add the WHO-5 and SCI screening instruments
Two new self-report instruments join the mental-wellbeing engine on the existing PHQ-9 / GAD-7 infrastructure: - WHO-5 Well-Being Index: 5 items on the 6-point 5..0 scale over the last two weeks; the reported total is raw-sum x 4 (0-100) per the official WHO scoring (WHO/UCN/MSD/MHE/2024.1, CC BY-NC-SA 3.0 IGO). A total of 50 or below follows the WHO comment (below 50 = indication for further assessment). - Sleep Condition Indicator: 8 DSM-5-aligned items, 0-4 each with item-specific anchors, total 0-32, higher = better sleep; a total of 16 or below maps to the probable-insomnia range of the source publication (Espie et al., BMJ Open 2014;4:e004183, CC BY-NC, (c) Sleepio Limited). The instrument registry generalises rather than forking: per-item answer ceilings, option order/label schemes, recall-stem captions, a reported- score multiplier, score direction (higher-is-better inverts the follow-up threshold), per-instrument attribution lines, and the validated-item locale set are now instrument properties. Scoring stays server- authoritative; the create route validates the per-instrument answer ceiling and projects the total onto a COMPUTED WHO5_SCORE / SCI_SCORE measurement row (never client-attributable), which also auto-satisfies a planned screening reminder. Migration 0226 adds the enum values (additive, idempotent). OpenAPI regenerated. Neither instrument carries a self-harm item; the PHQ-9 item-9 crisis routing is unchanged.
This commit is contained in:
1 parent
4d0f16d5a9
commit
1262333acc
17 files changed
+666
-84
No files matched your search
+34
-9
@@ -2103,6 +2103,8 @@ paths:
|
||||
- PAIN_NRS
|
||||
- WAIST_CIRCUMFERENCE
|
||||
- WAIST_TO_HEIGHT
|
||||
- WHO5_SCORE
|
||||
- SCI_SCORE
|
||||
- in: query
|
||||
name: from
|
||||
schema:
|
||||
@@ -2299,6 +2301,8 @@ paths:
|
||||
- PAIN_NRS
|
||||
- WAIST_CIRCUMFERENCE
|
||||
- WAIST_TO_HEIGHT
|
||||
- WHO5_SCORE
|
||||
- SCI_SCORE
|
||||
value:
|
||||
type: number
|
||||
measuredAt:
|
||||
@@ -5865,6 +5869,8 @@ paths:
|
||||
- PAIN_NRS
|
||||
- WAIST_CIRCUMFERENCE
|
||||
- WAIST_TO_HEIGHT
|
||||
- WHO5_SCORE
|
||||
- SCI_SCORE
|
||||
description: "The measurement type to read (single metric — no fan-out). Closed enum: an unknown type 422s."
|
||||
required: true
|
||||
description: "The measurement type to read (single metric — no fan-out). Closed enum: an unknown type 422s."
|
||||
@@ -8103,8 +8109,8 @@ 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.
|
||||
description: Returns PHQ-9 / GAD-7 / WHO-5 / SCI administrations (newest first). Totals + bands + flags only; raw item
|
||||
answers are never returned.
|
||||
parameters:
|
||||
- in: query
|
||||
name: instrument
|
||||
@@ -8113,6 +8119,8 @@ paths:
|
||||
enum:
|
||||
- PHQ9
|
||||
- GAD7
|
||||
- WHO5
|
||||
- SCI
|
||||
- in: query
|
||||
name: limit
|
||||
schema:
|
||||
@@ -8138,10 +8146,11 @@ 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.
|
||||
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:
|
||||
@@ -8153,6 +8162,8 @@ paths:
|
||||
enum:
|
||||
- PHQ9
|
||||
- GAD7
|
||||
- WHO5
|
||||
- SCI
|
||||
items:
|
||||
minItems: 1
|
||||
maxItems: 12
|
||||
@@ -8160,7 +8171,7 @@ paths:
|
||||
items:
|
||||
type: integer
|
||||
minimum: 0
|
||||
maximum: 3
|
||||
maximum: 5
|
||||
functionalDifficulty:
|
||||
type: integer
|
||||
minimum: 0
|
||||
@@ -11145,9 +11156,11 @@ components:
|
||||
- BODY_MASS_INDEX
|
||||
- PHQ9_SCORE
|
||||
- GAD7_SCORE
|
||||
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
|
||||
- WHO5_SCORE
|
||||
- SCI_SCORE
|
||||
description: 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.
|
||||
MeasurementReminderUpdate:
|
||||
type: object
|
||||
@@ -13904,6 +13917,8 @@ components:
|
||||
- PAIN_NRS
|
||||
- WAIST_CIRCUMFERENCE
|
||||
- WAIST_TO_HEIGHT
|
||||
- WHO5_SCORE
|
||||
- SCI_SCORE
|
||||
value:
|
||||
type: number
|
||||
unit:
|
||||
@@ -14227,6 +14242,8 @@ components:
|
||||
- PAIN_NRS
|
||||
- WAIST_CIRCUMFERENCE
|
||||
- WAIST_TO_HEIGHT
|
||||
- WHO5_SCORE
|
||||
- SCI_SCORE
|
||||
additionalProperties:
|
||||
type: array
|
||||
items:
|
||||
@@ -14310,6 +14327,8 @@ components:
|
||||
- PAIN_NRS
|
||||
- WAIST_CIRCUMFERENCE
|
||||
- WAIST_TO_HEIGHT
|
||||
- WHO5_SCORE
|
||||
- SCI_SCORE
|
||||
value:
|
||||
type: number
|
||||
description: Daily mean (spot metrics) or SUM (cumulative).
|
||||
@@ -14414,6 +14433,8 @@ components:
|
||||
- PAIN_NRS
|
||||
- WAIST_CIRCUMFERENCE
|
||||
- WAIST_TO_HEIGHT
|
||||
- WHO5_SCORE
|
||||
- SCI_SCORE
|
||||
description: Per-type daily series, keyed by MeasurementType.
|
||||
required:
|
||||
- series
|
||||
@@ -14653,6 +14674,8 @@ components:
|
||||
- PAIN_NRS
|
||||
- WAIST_CIRCUMFERENCE
|
||||
- WAIST_TO_HEIGHT
|
||||
- WHO5_SCORE
|
||||
- SCI_SCORE
|
||||
value:
|
||||
type: number
|
||||
unit:
|
||||
@@ -25212,6 +25235,8 @@ components:
|
||||
enum:
|
||||
- PHQ9
|
||||
- GAD7
|
||||
- WHO5
|
||||
- SCI
|
||||
locale:
|
||||
type: string
|
||||
version:
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
-- v1.27.9 — screening-score enum values for the two new self-report
|
||||
-- instruments: the WHO-5 well-being index (percentage 0–100, raw sum × 4)
|
||||
-- and the Sleep Condition Indicator (total 0–32, higher = better sleep).
|
||||
--
|
||||
-- Same contract as PHQ9_SCORE / GAD7_SCORE (0213/0225 lineage): the value is
|
||||
-- server-derived from a completed MentalHealthAssessment and written as a
|
||||
-- `COMPUTED`-source Measurement row, so clients can never forge a trend point
|
||||
-- through the measurement write surfaces. Additive, idempotent.
|
||||
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'WHO5_SCORE';
|
||||
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'SCI_SCORE';
|
||||
|
||||
-- The assessment discriminator learns the two new instruments (0213 created
|
||||
-- it with PHQ9/GAD7 only).
|
||||
ALTER TYPE "assessment_instrument" ADD VALUE IF NOT EXISTS 'WHO5';
|
||||
ALTER TYPE "assessment_instrument" ADD VALUE IF NOT EXISTS 'SCI';
|
||||
+11
-2
@@ -1284,17 +1284,26 @@ enum MeasurementType {
|
||||
// waist-to-height ratio (WHtR; keep waist < half your height ⇒ < 0.5).
|
||||
WAIST_CIRCUMFERENCE // cm — waist circumference (WHO bands; ethnicity-aware at display).
|
||||
WAIST_TO_HEIGHT // ratio — waist-to-height ratio (WHtR ≥ 0.5 = increased risk).
|
||||
// ── v1.27.9 — screening scores (WHO-5 well-being + Sleep Condition Indicator) ──
|
||||
// Same contract as PHQ9_SCORE / GAD7_SCORE: server-derived from a completed
|
||||
// `MentalHealthAssessment`, `source = COMPUTED`, one row per administration.
|
||||
// Note the inverted direction versus PHQ-9 / GAD-7: HIGHER is better for both.
|
||||
WHO5_SCORE // score (0–100) — WHO-5 well-being percentage (raw 0–25 × 4). Opt-in. See MentalHealthAssessment.
|
||||
SCI_SCORE // score (0–32) — Sleep Condition Indicator total; higher = better sleep. Opt-in. See MentalHealthAssessment.
|
||||
|
||||
@@map("measurement_type")
|
||||
}
|
||||
|
||||
/// v1.25 — validated mental-health self-assessment instruments: PHQ-9
|
||||
/// (depression) + GAD-7 (anxiety). The discriminator on
|
||||
/// `MentalHealthAssessment`; extensible (WHO-5 / PSS-10 are natural future
|
||||
/// siblings) without a schema change.
|
||||
/// `MentalHealthAssessment`. v1.27.9 adds the WHO-5 well-being index and the
|
||||
/// Sleep Condition Indicator (SCI) — same administration contract, inverted
|
||||
/// score direction (higher = better).
|
||||
enum AssessmentInstrument {
|
||||
PHQ9
|
||||
GAD7
|
||||
WHO5
|
||||
SCI
|
||||
|
||||
@@map("assessment_instrument")
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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