From 1262333accd5a08d48199a21f8cbb55e554b3d52 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sun, 5 Jul 2026 20:33:29 +0200
Subject: [PATCH 1/3] 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.
---
docs/api/openapi.yaml | 43 ++-
.../0226_v1279_screening_scores/migration.sql | 15 +
prisma/schema.prisma | 13 +-
.../assessments/__tests__/route.test.ts | 92 ++++++
.../api/mental-health/assessments/route.ts | 5 +-
.../measurement-type-enum-coverage.test.ts | 12 +-
.../jobs/__tests__/reminder-satisfy.test.ts | 22 ++
src/lib/jobs/measurement-reminder.ts | 2 +
.../__tests__/apple-health-mapping.test.ts | 4 +
.../__tests__/instruments.test.ts | 148 ++++++++-
src/lib/mental-health/instruments.ts | 307 +++++++++++++++---
src/lib/openapi/routes/mental-health.ts | 12 +-
.../__tests__/measurement-reminders.test.ts | 16 +
.../validations/__tests__/measurement.test.ts | 12 +-
src/lib/validations/measurement-reminders.ts | 9 +-
src/lib/validations/measurement.ts | 12 +
src/lib/validations/mental-health.ts | 26 +-
17 files changed, 666 insertions(+), 84 deletions(-)
create mode 100644 prisma/migrations/0226_v1279_screening_scores/migration.sql
diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml
index 88bc29ec8..e8dc5bb0a 100644
--- a/docs/api/openapi.yaml
+++ b/docs/api/openapi.yaml
@@ -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:
diff --git a/prisma/migrations/0226_v1279_screening_scores/migration.sql b/prisma/migrations/0226_v1279_screening_scores/migration.sql
new file mode 100644
index 000000000..ab9f6f77b
--- /dev/null
+++ b/prisma/migrations/0226_v1279_screening_scores/migration.sql
@@ -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';
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 4c25019fb..a4f69bf9c 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -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")
}
diff --git a/src/app/api/mental-health/assessments/__tests__/route.test.ts b/src/app/api/mental-health/assessments/__tests__/route.test.ts
index ec64359db..24f2ba941 100644
--- a/src/app/api/mental-health/assessments/__tests__/route.test.ts
+++ b/src/app/api/mental-health/assessments/__tests__/route.test.ts
@@ -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
diff --git a/src/app/api/mental-health/assessments/route.ts b/src/app/api/mental-health/assessments/route.ts
index 457d82dd1..939802cdb 100644
--- a/src/app/api/mental-health/assessments/route.ts
+++ b/src/app/api/mental-health/assessments/route.ts
@@ -229,8 +229,9 @@ async function postAssessment(request: NextRequest): Promise {
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";
diff --git a/src/lib/__tests__/measurement-type-enum-coverage.test.ts b/src/lib/__tests__/measurement-type-enum-coverage.test.ts
index 6f98364cd..d4869a3cc 100644
--- a/src/lib/__tests__/measurement-type-enum-coverage.test.ts
+++ b/src/lib/__tests__/measurement-type-enum-coverage.test.ts
@@ -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", () => {
diff --git a/src/lib/jobs/__tests__/reminder-satisfy.test.ts b/src/lib/jobs/__tests__/reminder-satisfy.test.ts
index add9c1298..ea5d9f841 100644
--- a/src/lib/jobs/__tests__/reminder-satisfy.test.ts
+++ b/src/lib/jobs/__tests__/reminder-satisfy.test.ts
@@ -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: [
diff --git a/src/lib/jobs/measurement-reminder.ts b/src/lib/jobs/measurement-reminder.ts
index 1138cf54b..6041c8d05 100644
--- a/src/lib/jobs/measurement-reminder.ts
+++ b/src/lib/jobs/measurement-reminder.ts
@@ -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;
diff --git a/src/lib/measurements/__tests__/apple-health-mapping.test.ts b/src/lib/measurements/__tests__/apple-health-mapping.test.ts
index d109e739c..f57427ded 100644
--- a/src/lib/measurements/__tests__/apple-health-mapping.test.ts
+++ b/src/lib/measurements/__tests__/apple-health-mapping.test.ts
@@ -89,6 +89,10 @@ const MEASUREMENT_TYPES_WITHOUT_HK_COUNTERPART = new Set([
"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", () => {
diff --git a/src/lib/mental-health/__tests__/instruments.test.ts b/src/lib/mental-health/__tests__/instruments.test.ts
index a97e92853..55cdf3d9d 100644
--- a/src/lib/mental-health/__tests__/instruments.test.ts
+++ b/src/lib/mental-health/__tests__/instruments.test.ts
@@ -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");
});
});
});
diff --git a/src/lib/mental-health/instruments.ts b/src/lib/mental-health/instruments.ts
index d54b7ac44..92103184b 100644
--- a/src/lib/mental-health/instruments.ts
+++ b/src/lib/mental-health/instruments.ts
@@ -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 (`.`); with
+ * `optionGroups` (SCI) per-item anchor groups (`..`,
+ * 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 = {
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 = {
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
diff --git a/src/lib/openapi/routes/mental-health.ts b/src/lib/openapi/routes/mental-health.ts
index 21f66a1fd..5f61be24d 100644
--- a/src/lib/openapi/routes/mental-health.ts
+++ b/src/lib/openapi/routes/mental-health.ts
@@ -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 = {
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 = {
},
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 },
diff --git a/src/lib/validations/__tests__/measurement-reminders.test.ts b/src/lib/validations/__tests__/measurement-reminders.test.ts
index 9ac7458cc..325e21019 100644
--- a/src/lib/validations/__tests__/measurement-reminders.test.ts
+++ b/src/lib/validations/__tests__/measurement-reminders.test.ts
@@ -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",
diff --git a/src/lib/validations/__tests__/measurement.test.ts b/src/lib/validations/__tests__/measurement.test.ts
index b110d1e9f..281e7cdf9 100644
--- a/src/lib/validations/__tests__/measurement.test.ts
+++ b/src/lib/validations/__tests__/measurement.test.ts
@@ -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,
diff --git a/src/lib/validations/measurement-reminders.ts b/src/lib/validations/measurement-reminders.ts
index 39794123a..0ee684b90 100644
--- a/src/lib/validations/measurement-reminders.ts
+++ b/src/lib/validations/measurement-reminders.ts
@@ -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 = new Set([
"PHQ9_SCORE",
"GAD7_SCORE",
+ "WHO5_SCORE",
+ "SCI_SCORE",
]);
/** Whether a reminder's `measurementType` targets a screening check-in. */
diff --git a/src/lib/validations/measurement.ts b/src/lib/validations/measurement.ts
index e78adc2be..5b5961e8d 100644
--- a/src/lib/validations/measurement.ts
+++ b/src/lib/validations/measurement.ts
@@ -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 = {
// ── 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 = {
// 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 },
diff --git a/src/lib/validations/mental-health.ts b/src/lib/validations/mental-health.ts
index 9cd08f999..8415afe60 100644
--- a/src/lib/validations/mental-health.ts
+++ b/src/lib/validations/mental-health.ts
@@ -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({
From 03d4496d3a17bafcf3bf45d59201c7ccfffe656f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sun, 5 Jul 2026 20:33:44 +0200
Subject: [PATCH 2/3] feat(measurements): surface the WHO5_SCORE and SCI_SCORE
trends
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Wire the two new screening totals through the read surfaces on the
PHQ-9 / GAD-7 precedent: signal-registry entries (higher-better
direction, off the Coach snapshot and MCP by construction, no FHIR
coding attached — neither total has a verified LOINC, and the export
skips uncoded signals rather than shipping an invented code), the
scores category cluster, list labels/icons/colours, the generic
insight-chart tokens, a null PR direction (screeners are never
gamified), and the module-gated mental-health section of the doctor
report. The vitals-table exclusion rationale is documented alongside
the existing screener entries.
---
.../measurements/measurement-list-meta.ts | 9 ++++
src/lib/doctor-report-data.ts | 3 ++
src/lib/doctor-report-pdf-core.ts | 5 +++
src/lib/insights/chart-tokens.ts | 4 ++
src/lib/measurements/categories.ts | 3 ++
src/lib/personal-records/pr-direction.ts | 4 ++
src/lib/signals/registry.ts | 41 +++++++++++++++++++
7 files changed, 69 insertions(+)
diff --git a/src/components/measurements/measurement-list-meta.ts b/src/components/measurements/measurement-list-meta.ts
index aabef8a6d..799c02bd5 100644
--- a/src/components/measurements/measurement-list-meta.ts
+++ b/src/components/measurements/measurement-list-meta.ts
@@ -125,6 +125,9 @@ export const MEASUREMENT_TYPE_LABEL_KEYS: Record = {
// ── 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 = {
// 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 = {
// 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",
diff --git a/src/lib/doctor-report-data.ts b/src/lib/doctor-report-data.ts
index b129d7ac7..b4aa23537 100644
--- a/src/lib/doctor-report-data.ts
+++ b/src/lib/doctor-report-data.ts
@@ -1620,6 +1620,9 @@ const MEASUREMENT_TYPE_MODULE: Record = {
// 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(
diff --git a/src/lib/doctor-report-pdf-core.ts b/src/lib/doctor-report-pdf-core.ts
index 40a06a450..40784dd8d 100644
--- a/src/lib/doctor-report-pdf-core.ts
+++ b/src/lib/doctor-report-pdf-core.ts
@@ -201,6 +201,11 @@ export const DOCTOR_REPORT_TYPE_UNIT_KEYS: Record = {
* 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",
diff --git a/src/lib/insights/chart-tokens.ts b/src/lib/insights/chart-tokens.ts
index df71e2e86..b73fc1539 100644
--- a/src/lib/insights/chart-tokens.ts
+++ b/src/lib/insights/chart-tokens.ts
@@ -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",
diff --git a/src/lib/measurements/categories.ts b/src/lib/measurements/categories.ts
index b9065d0a2..8e758ad0c 100644
--- a/src/lib/measurements/categories.ts
+++ b/src/lib/measurements/categories.ts
@@ -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.
diff --git a/src/lib/personal-records/pr-direction.ts b/src/lib/personal-records/pr-direction.ts
index 097905945..ddf3b86ae 100644
--- a/src/lib/personal-records/pr-direction.ts
+++ b/src/lib/personal-records/pr-direction.ts
@@ -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":
diff --git a/src/lib/signals/registry.ts b/src/lib/signals/registry.ts
index 788b7e7c3..84dfd2670 100644
--- a/src/lib/signals/registry.ts
+++ b/src/lib/signals/registry.ts
@@ -1272,6 +1272,47 @@ export const SIGNALS: Record = {
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",
From 8f1485fdcbab81fcc286e9c3a50fe711f32db8b9 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sun, 5 Jul 2026 20:34:01 +0200
Subject: [PATCH 3/3] feat(mental-health): rebuild the check-in page around
four instrument cards
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The landing slims to intro + instrument cards: the combined history card
leaves the main page entirely. Each card body now opens a per-instrument
detail sheet (the med-/Vorsorge card interaction) carrying the last
score + band, the score-over-time trend chart (restored from the
pre-v1.27.6 chart, lazy Recharts, bands surfacing discreetly in the
tooltip), the dated result history with the crisis re-surface, the Start
action, and the instrument's required attribution line — the Verlauf is
deliberately opt-in behind a click, never pushed onto the page someone
arrives at to take stock.
The wizard drives from the registry: option values in source order (the
WHO-5 leads with 5 = all of the time, the SCI with 4), shared vs
per-item anchor groups, and recall-stem captions above the WHO-5 / SCI
item fragments. WHO-5 items and anchors ship VERBATIM from the official
WHO publication in all six locales; the SCI is validated in English, so
every non-English locale presents the English items plus a localized
'validated in English' note (a self-made translation would invalidate
the instrument — the leak-guard allowlist documents this as intended).
Result view: direction-aware follow-up hints (WHO-5 <= 50 points gently
to the PHQ-9 check-in; SCI <= 16 carries the paper's neutral band
wording; PHQ-9 / GAD-7 keep their >= 10 hint). The intro states each
recall window honestly (two weeks vs past month). The Vorsorge screening
group offers all four instruments. Chrome localized in de/en/es/fr/it/pl.
---
messages/de.json | 118 +++++++-
messages/en.json | 118 +++++++-
messages/es.json | 118 +++++++-
messages/fr.json | 118 +++++++-
messages/it.json | 118 +++++++-
messages/pl.json | 118 +++++++-
src/__tests__/i18n-english-leak-guard.test.ts | 33 +++
.../vorsorge-section.tsx | 6 +-
.../__tests__/mental-wellbeing.test.tsx | 270 ++++++++++++++++--
.../assessment-history-chart.tsx | 167 +++++++++++
.../mental-health/assessment-history.tsx | 225 +++++++--------
.../mental-health/assessment-result.tsx | 34 ++-
.../mental-health/check-in-wizard.tsx | 54 +++-
.../mental-health/instrument-card.tsx | 74 +++--
.../mental-health/instrument-detail.tsx | 86 ++++++
.../mental-health/mental-wellbeing.tsx | 56 +++-
16 files changed, 1445 insertions(+), 268 deletions(-)
create mode 100644 src/components/mental-health/assessment-history-chart.tsx
create mode 100644 src/components/mental-health/instrument-detail.tsx
diff --git a/messages/de.json b/messages/de.json
index 23c44b9ca..8db0b64a6 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -599,7 +599,9 @@
"typeWaistCircumference": "Taillenumfang",
"typeWaistToHeight": "Taille-Größe-Verhältnis",
"unitScore": "Pkt.",
- "unitRatio": "Verhältnis"
+ "unitRatio": "Verhältnis",
+ "typeWho5Score": "WHO-5-Wert",
+ "typeSciScore": "SCI-Wert"
},
"mood": {
"title": "Stimmung",
@@ -7639,8 +7641,8 @@
},
"mentalHealth": {
"pageTitle": "Check-in zum seelischen Wohlbefinden",
- "pageDescription": "Zwei validierte, freiwillige Selbsttests — PHQ-9 (Stimmung) und GAD-7 (Sorgen) — ergänzend zu deinem Stimmungstagebuch. Aussagekräftig ist der Verlauf über die Zeit, nicht ein einzelner Wert.",
- "pageRhythmHint": "Beide Tests blicken auf die letzten zwei Wochen zurück — ein Check-in alle zwei bis vier Wochen ist deshalb ein guter Rhythmus.",
+ "pageDescription": "Vier validierte, freiwillige Selbsttests — PHQ-9 (Stimmung), GAD-7 (Sorgen), WHO-5 (Wohlbefinden) und SCI (Schlaf) — ergänzend zu deinem Stimmungstagebuch. Aussagekräftig ist der Verlauf über die Zeit, nicht ein einzelner Wert.",
+ "pageRhythmHint": "PHQ-9, GAD-7 und der Wohlbefindens-Check blicken auf die letzten zwei Wochen zurück, der Schlaf-Check auf den letzten Monat — ein Check-in alle zwei bis vier Wochen ist ein guter Rhythmus.",
"attributionLabel": "Quelle",
"choosePrompt": "Check-in auswählen",
"start": "Starten",
@@ -7649,14 +7651,17 @@
"submitting": "Wird gespeichert…",
"saved": "Gespeichert",
"error": "Etwas ist schiefgelaufen. Bitte versuche es erneut.",
- "considerProfessional": "Dein Wert liegt in einem Bereich, in dem ein Gespräch mit einer Fachperson helfen kann. Das ist ein behutsamer Hinweis, keine Diagnose.",
"instrument": {
"phq9": "PHQ-9 · Stimmung",
- "gad7": "GAD-7 · Sorgen"
+ "gad7": "GAD-7 · Sorgen",
+ "who5": "WHO-5 · Wohlbefinden",
+ "sci": "SCI · Schlaf"
},
"instrumentSub": {
"phq9": "Depressions-Screening, 9 Fragen",
- "gad7": "Angst-Screening, 7 Fragen"
+ "gad7": "Angst-Screening, 7 Fragen",
+ "who5": "Wohlbefindens-Check, 5 Fragen",
+ "sci": "Schlaf-Check, 8 Fragen"
},
"options": {
"0": "Überhaupt nicht",
@@ -7691,16 +7696,23 @@
"mild": "Leicht",
"moderate": "Mittelgradig",
"severe": "Schwer"
+ },
+ "WHO5": {
+ "low": "Niedrigeres Wohlbefinden",
+ "good": "Gutes Wohlbefinden"
+ },
+ "SCI": {
+ "belowThreshold": "Möglicher Insomnie-Bereich",
+ "aboveThreshold": "Kein Insomnie-Hinweis"
}
},
"history": {
"title": "Dein Verlauf",
"empty": "Noch keine Check-ins. Dein Verlauf erscheint hier, sobald du einen abgeschlossen hast.",
"flaggedBadge": "Hilfe angezeigt",
- "tab": {
- "phq9": "PHQ-9",
- "gad7": "GAD-7"
- }
+ "chartTitle": "Punkteverlauf",
+ "totalLabel": "Punkte",
+ "bandLabel": "Einstufung"
},
"crisis": {
"title": "Du musst das nicht allein durchstehen",
@@ -7728,6 +7740,23 @@
"5": "Rastlosigkeit, sodass Stillsitzen schwerfällt",
"6": "Schnelle Verärgerung oder Gereiztheit",
"7": "Gefühl der Angst, als würde etwas Schlimmes passieren"
+ },
+ "who5": {
+ "1": "… war ich froh und guter Laune",
+ "2": "… habe ich mich ruhig und entspannt gefühlt",
+ "3": "… habe ich mich energisch und aktiv gefühlt",
+ "4": "… habe ich mich beim Aufwachen frisch und ausgeruht gefühlt",
+ "5": "… war mein Alltag voller Dinge, die mich interessieren"
+ },
+ "sci": {
+ "1": "… how long does it take you to fall asleep?",
+ "2": "… if you then wake up during the night … how long are you awake for in total? (add all the wakenings up)",
+ "3": "… how many nights a week do you have a problem with your sleep?",
+ "4": "… how would you rate your sleep quality?",
+ "5": "… affected your mood, energy, or relationships?",
+ "6": "… affected your concentration, productivity, or ability to stay awake?",
+ "7": "… troubled you in general?",
+ "8": "… how long have you had a problem with your sleep?"
}
},
"crisisResource": {
@@ -7755,14 +7784,79 @@
},
"instrumentDescription": {
"phq9": "Depressions-Screening mit 9 Fragen. Beziehe dich auf die letzten zwei Wochen.",
- "gad7": "Angst-Screening mit 7 Fragen. Beziehe dich auf die letzten zwei Wochen."
+ "gad7": "Angst-Screening mit 7 Fragen. Beziehe dich auf die letzten zwei Wochen.",
+ "who5": "Wohlbefindens-Check mit 5 Fragen. Beziehe dich auf die letzten zwei Wochen; höhere Werte bedeuten besseres Wohlbefinden.",
+ "sci": "Schlaf-Check mit 8 Fragen. Die meisten Fragen beziehen sich auf den letzten Monat; höhere Werte bedeuten besseren Schlaf."
},
"progress": "{current} / {total}",
"next": "Weiter",
"lastResult": "Letzter Test",
"lastScore": "Letztes Ergebnis",
"noResultYet": "Noch kein Check-in",
- "backToList": "Übersicht"
+ "backToList": "Übersicht",
+ "followUpHint": {
+ "phq9": "Dein Wert liegt in einem Bereich, in dem ein Gespräch mit einer Fachperson helfen kann. Das ist ein behutsamer Hinweis, keine Diagnose.",
+ "gad7": "Dein Wert liegt in einem Bereich, in dem ein Gespräch mit einer Fachperson helfen kann. Das ist ein behutsamer Hinweis, keine Diagnose.",
+ "who5": "Ein Wohlbefindens-Wert von 50 oder darunter kann einen genaueren Blick wert sein. Wenn du magst, schaut der PHQ-9-Check-in auf dieser Seite genauer auf die Stimmung. Das ist ein behutsamer Hinweis, keine Diagnose.",
+ "sci": "In der Studie zu diesem Fragebogen waren Werte von 16 oder darunter mit wahrscheinlicher Insomnie verbunden. Wenn schlechter Schlaf dich weiter belastet, kann ein Gespräch mit einer Fachperson helfen. Das ist keine Diagnose."
+ },
+ "validatedInEnglishNote": "Dieser Fragebogen ist in englischer Sprache validiert; die Fragen erscheinen im englischen Original.",
+ "who5Options": {
+ "5": "Die ganze Zeit",
+ "4": "Meistens",
+ "3": "Etwas mehr als die Hälfte der Zeit",
+ "2": "Etwas weniger als die Hälfte der Zeit",
+ "1": "Ab und zu",
+ "0": "Zu keinem Zeitpunkt"
+ },
+ "sciOptions": {
+ "duration": {
+ "4": "0–15 min",
+ "3": "16–30 min",
+ "2": "31–45 min",
+ "1": "46–60 min",
+ "0": "≥ 61 min"
+ },
+ "nights": {
+ "4": "0–1",
+ "3": "2",
+ "2": "3",
+ "1": "4",
+ "0": "5–7"
+ },
+ "quality": {
+ "4": "Very good",
+ "3": "Good",
+ "2": "Average",
+ "1": "Poor",
+ "0": "Very poor"
+ },
+ "impact": {
+ "4": "Not at all",
+ "3": "A little",
+ "2": "Somewhat",
+ "1": "Much",
+ "0": "Very much"
+ },
+ "problemDuration": {
+ "4": "I don't have a problem / < 1 month",
+ "3": "1–2 months",
+ "2": "3–6 months",
+ "1": "7–12 months",
+ "0": "> 1 year"
+ }
+ },
+ "stems": {
+ "who5": {
+ "period": "In den letzten zwei Wochen …"
+ },
+ "sci": {
+ "night": "Thinking about a typical night in the last month …",
+ "impact": "Thinking about the past month, to what extent has poor sleep …",
+ "finally": "Finally …"
+ }
+ },
+ "openDetail": "Details anzeigen"
},
"records": {
"disclaimer": "Das sind deine eigenen Angaben, keine ärztliche Diagnose. HealthLog stellt keine Diagnose, behandelt nicht und deutet nichts.",
diff --git a/messages/en.json b/messages/en.json
index 9c733b7c2..a3c55c157 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -599,7 +599,9 @@
"typeWaistCircumference": "Waist circumference",
"typeWaistToHeight": "Waist-to-height ratio",
"unitScore": "pts",
- "unitRatio": "ratio"
+ "unitRatio": "ratio",
+ "typeWho5Score": "WHO-5 score",
+ "typeSciScore": "SCI score"
},
"mood": {
"title": "Mood",
@@ -7639,8 +7641,8 @@
},
"mentalHealth": {
"pageTitle": "Mental wellbeing check-in",
- "pageDescription": "Two validated, opt-in self-assessments — PHQ-9 (mood) and GAD-7 (worry) — that sit beside your mood tracking. The value is the trend over time, not any single number.",
- "pageRhythmHint": "Both tests look back over the last two weeks, so a check-in every two to four weeks is a comfortable rhythm.",
+ "pageDescription": "Validated, opt-in self-checks — PHQ-9 (mood), GAD-7 (worry), WHO-5 (well-being) and SCI (sleep) — that sit beside your mood tracking. The value is the trend over time, not any single number.",
+ "pageRhythmHint": "PHQ-9, GAD-7 and the well-being check look back over the last two weeks; the sleep check asks about the past month. A check-in every two to four weeks is a comfortable rhythm.",
"attributionLabel": "Attribution",
"choosePrompt": "Choose a check-in",
"start": "Start",
@@ -7649,14 +7651,17 @@
"submitting": "Saving…",
"saved": "Saved",
"error": "Something went wrong. Please try again.",
- "considerProfessional": "Your score is in a range where talking to a health professional may help. This is a gentle suggestion, not a diagnosis.",
"instrument": {
"phq9": "PHQ-9 · mood",
- "gad7": "GAD-7 · worry"
+ "gad7": "GAD-7 · worry",
+ "who5": "WHO-5 · well-being",
+ "sci": "SCI · sleep"
},
"instrumentSub": {
"phq9": "Depression screen, 9 questions",
- "gad7": "Anxiety screen, 7 questions"
+ "gad7": "Anxiety screen, 7 questions",
+ "who5": "Well-being check, 5 questions",
+ "sci": "Sleep check, 8 questions"
},
"options": {
"0": "Not at all",
@@ -7691,16 +7696,23 @@
"mild": "Mild",
"moderate": "Moderate",
"severe": "Severe"
+ },
+ "WHO5": {
+ "low": "Lower well-being",
+ "good": "Good well-being"
+ },
+ "SCI": {
+ "belowThreshold": "Possible insomnia range",
+ "aboveThreshold": "No insomnia indication"
}
},
"history": {
"title": "Your history",
"empty": "No check-ins yet. Your trend will appear here once you complete one.",
"flaggedBadge": "Support shown",
- "tab": {
- "phq9": "PHQ-9",
- "gad7": "GAD-7"
- }
+ "chartTitle": "Score trend",
+ "totalLabel": "Score",
+ "bandLabel": "Category"
},
"crisis": {
"title": "You don't have to face this alone",
@@ -7728,6 +7740,23 @@
"5": "Being so restless that it is hard to sit still",
"6": "Becoming easily annoyed or irritable",
"7": "Feeling afraid as if something awful might happen"
+ },
+ "who5": {
+ "1": "I have felt cheerful and in good spirits",
+ "2": "I have felt calm and relaxed",
+ "3": "I have felt active and vigorous",
+ "4": "I woke up feeling fresh and rested",
+ "5": "My daily life has been filled with things that interest me"
+ },
+ "sci": {
+ "1": "… how long does it take you to fall asleep?",
+ "2": "… if you then wake up during the night … how long are you awake for in total? (add all the wakenings up)",
+ "3": "… how many nights a week do you have a problem with your sleep?",
+ "4": "… how would you rate your sleep quality?",
+ "5": "… affected your mood, energy, or relationships?",
+ "6": "… affected your concentration, productivity, or ability to stay awake?",
+ "7": "… troubled you in general?",
+ "8": "… how long have you had a problem with your sleep?"
}
},
"crisisResource": {
@@ -7755,14 +7784,79 @@
},
"instrumentDescription": {
"phq9": "9-question depression screen. Answer for the last two weeks.",
- "gad7": "7-question anxiety screen. Answer for the last two weeks."
+ "gad7": "7-question anxiety screen. Answer for the last two weeks.",
+ "who5": "5-question well-being check. Answer for the last two weeks; higher numbers mean better well-being.",
+ "sci": "8-question sleep check. Most questions ask about the past month; higher scores mean better sleep."
},
"progress": "{current} / {total}",
"next": "Next",
"lastResult": "Last test",
"lastScore": "Last result",
"noResultYet": "No check-in yet",
- "backToList": "Overview"
+ "backToList": "Overview",
+ "followUpHint": {
+ "phq9": "Your score is in a range where talking to a health professional may help. This is a gentle suggestion, not a diagnosis.",
+ "gad7": "Your score is in a range where talking to a health professional may help. This is a gentle suggestion, not a diagnosis.",
+ "who5": "A well-being score of 50 or below can be worth a closer look. If you like, the PHQ-9 check-in on this page looks at mood in more depth. This is a gentle suggestion, not a diagnosis.",
+ "sci": "In the study behind this questionnaire, scores of 16 or below were associated with probable insomnia. If poor sleep keeps troubling you, talking to a health professional may help. This is not a diagnosis."
+ },
+ "validatedInEnglishNote": "This questionnaire is validated in English; the questions are shown in the original English wording.",
+ "who5Options": {
+ "5": "All of the time",
+ "4": "Most of the time",
+ "3": "More than half of the time",
+ "2": "Less than half of the time",
+ "1": "Some of the time",
+ "0": "At no time"
+ },
+ "sciOptions": {
+ "duration": {
+ "4": "0–15 min",
+ "3": "16–30 min",
+ "2": "31–45 min",
+ "1": "46–60 min",
+ "0": "≥ 61 min"
+ },
+ "nights": {
+ "4": "0–1",
+ "3": "2",
+ "2": "3",
+ "1": "4",
+ "0": "5–7"
+ },
+ "quality": {
+ "4": "Very good",
+ "3": "Good",
+ "2": "Average",
+ "1": "Poor",
+ "0": "Very poor"
+ },
+ "impact": {
+ "4": "Not at all",
+ "3": "A little",
+ "2": "Somewhat",
+ "1": "Much",
+ "0": "Very much"
+ },
+ "problemDuration": {
+ "4": "I don't have a problem / < 1 month",
+ "3": "1–2 months",
+ "2": "3–6 months",
+ "1": "7–12 months",
+ "0": "> 1 year"
+ }
+ },
+ "stems": {
+ "who5": {
+ "period": "Over the last two weeks …"
+ },
+ "sci": {
+ "night": "Thinking about a typical night in the last month …",
+ "impact": "Thinking about the past month, to what extent has poor sleep …",
+ "finally": "Finally …"
+ }
+ },
+ "openDetail": "Show details"
},
"records": {
"disclaimer": "These are your own records, not a medical diagnosis. HealthLog does not diagnose, treat, or interpret them.",
diff --git a/messages/es.json b/messages/es.json
index 08069dda2..e3d4857e2 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -599,7 +599,9 @@
"typeWaistCircumference": "Perímetro de cintura",
"typeWaistToHeight": "Índice cintura-altura",
"unitScore": "pts",
- "unitRatio": "ratio"
+ "unitRatio": "ratio",
+ "typeWho5Score": "Puntuación WHO-5",
+ "typeSciScore": "Puntuación SCI"
},
"mood": {
"title": "Estado de ánimo",
@@ -7639,8 +7641,8 @@
},
"mentalHealth": {
"pageTitle": "Revisión de bienestar mental",
- "pageDescription": "Dos autoevaluaciones validadas y opcionales — PHQ-9 (estado de ánimo) y GAD-7 (preocupación) — junto a tu registro de ánimo. Lo valioso es la tendencia en el tiempo, no un único número.",
- "pageRhythmHint": "Ambos tests se refieren a las últimas dos semanas, por lo que un check-in cada dos a cuatro semanas es un buen ritmo.",
+ "pageDescription": "Cuatro autoevaluaciones validadas y opcionales — PHQ-9 (estado de ánimo), GAD-7 (preocupación), WHO-5 (bienestar) y SCI (sueño) — junto a tu registro de ánimo. Lo valioso es la tendencia en el tiempo, no un único número.",
+ "pageRhythmHint": "El PHQ-9, el GAD-7 y el test de bienestar se refieren a las últimas dos semanas; el test de sueño pregunta por el último mes. Un check-in cada dos a cuatro semanas es un buen ritmo.",
"attributionLabel": "Fuente",
"choosePrompt": "Elige una revisión",
"start": "Empezar",
@@ -7649,14 +7651,17 @@
"submitting": "Guardando…",
"saved": "Guardado",
"error": "Algo salió mal. Inténtalo de nuevo.",
- "considerProfessional": "Tu puntuación está en un rango en el que hablar con un profesional de salud puede ayudar. Es una sugerencia amable, no un diagnóstico.",
"instrument": {
"phq9": "PHQ-9 · ánimo",
- "gad7": "GAD-7 · preocupación"
+ "gad7": "GAD-7 · preocupación",
+ "who5": "WHO-5 · bienestar",
+ "sci": "SCI · sueño"
},
"instrumentSub": {
"phq9": "Cribado de depresión, 9 preguntas",
- "gad7": "Cribado de ansiedad, 7 preguntas"
+ "gad7": "Cribado de ansiedad, 7 preguntas",
+ "who5": "Test de bienestar, 5 preguntas",
+ "sci": "Test de sueño, 8 preguntas"
},
"options": {
"0": "Nunca",
@@ -7691,16 +7696,23 @@
"mild": "Leve",
"moderate": "Moderada",
"severe": "Grave"
+ },
+ "WHO5": {
+ "low": "Bienestar más bajo",
+ "good": "Buen bienestar"
+ },
+ "SCI": {
+ "belowThreshold": "Rango de posible insomnio",
+ "aboveThreshold": "Sin indicios de insomnio"
}
},
"history": {
"title": "Tu historial",
"empty": "Aún no hay revisiones. Tu tendencia aparecerá aquí cuando completes una.",
"flaggedBadge": "Apoyo mostrado",
- "tab": {
- "phq9": "PHQ-9",
- "gad7": "GAD-7"
- }
+ "chartTitle": "Evolución de la puntuación",
+ "totalLabel": "Puntuación",
+ "bandLabel": "Categoría"
},
"crisis": {
"title": "No tienes que afrontar esto a solas",
@@ -7728,6 +7740,23 @@
"5": "Estar tan inquieto/a que es difícil quedarse sentado/a tranquilamente",
"6": "Molestarse o irritarse fácilmente",
"7": "Sentir miedo como si algo terrible fuera a suceder"
+ },
+ "who5": {
+ "1": "Me he sentido alegre y de buen humor",
+ "2": "Me he sentido tranquilo y relajado",
+ "3": "Me he sentido activo y enérgico",
+ "4": "Me he despertado fresco y descansado",
+ "5": "Mi vida cotidiana ha estado llena de cosas que me interesan"
+ },
+ "sci": {
+ "1": "… how long does it take you to fall asleep?",
+ "2": "… if you then wake up during the night … how long are you awake for in total? (add all the wakenings up)",
+ "3": "… how many nights a week do you have a problem with your sleep?",
+ "4": "… how would you rate your sleep quality?",
+ "5": "… affected your mood, energy, or relationships?",
+ "6": "… affected your concentration, productivity, or ability to stay awake?",
+ "7": "… troubled you in general?",
+ "8": "… how long have you had a problem with your sleep?"
}
},
"crisisResource": {
@@ -7755,14 +7784,79 @@
},
"instrumentDescription": {
"phq9": "Cribado de depresión de 9 preguntas. Responde pensando en las últimas dos semanas.",
- "gad7": "Cribado de ansiedad de 7 preguntas. Responde pensando en las últimas dos semanas."
+ "gad7": "Cribado de ansiedad de 7 preguntas. Responde pensando en las últimas dos semanas.",
+ "who5": "Test de bienestar de 5 preguntas. Responde pensando en las últimas dos semanas; cifras mayores significan mayor bienestar.",
+ "sci": "Test de sueño de 8 preguntas. La mayoría se refiere al último mes; puntuaciones mayores significan mejor sueño."
},
"progress": "{current} / {total}",
"next": "Siguiente",
"lastResult": "Última prueba",
"lastScore": "Último resultado",
"noResultYet": "Aún sin check-in",
- "backToList": "Vista general"
+ "backToList": "Vista general",
+ "followUpHint": {
+ "phq9": "Tu puntuación está en un rango en el que hablar con un profesional de salud puede ayudar. Es una sugerencia amable, no un diagnóstico.",
+ "gad7": "Tu puntuación está en un rango en el que hablar con un profesional de salud puede ayudar. Es una sugerencia amable, no un diagnóstico.",
+ "who5": "Una puntuación de bienestar de 50 o menos puede merecer una mirada más atenta. Si quieres, el PHQ-9 de esta página explora el estado de ánimo con más detalle. Es una sugerencia amable, no un diagnóstico.",
+ "sci": "En el estudio de este cuestionario, puntuaciones de 16 o menos se asociaron con insomnio probable. Si dormir mal te sigue afectando, hablar con un profesional de salud puede ayudar. No es un diagnóstico."
+ },
+ "validatedInEnglishNote": "Este cuestionario está validado en inglés; las preguntas se muestran en su versión original en inglés.",
+ "who5Options": {
+ "5": "Todo el tiempo",
+ "4": "La mayor parte del tiempo",
+ "3": "Más de la mitad del tiempo",
+ "2": "Menos de la mitad del tiempo",
+ "1": "De vez en cuando",
+ "0": "Nunca"
+ },
+ "sciOptions": {
+ "duration": {
+ "4": "0–15 min",
+ "3": "16–30 min",
+ "2": "31–45 min",
+ "1": "46–60 min",
+ "0": "≥ 61 min"
+ },
+ "nights": {
+ "4": "0–1",
+ "3": "2",
+ "2": "3",
+ "1": "4",
+ "0": "5–7"
+ },
+ "quality": {
+ "4": "Very good",
+ "3": "Good",
+ "2": "Average",
+ "1": "Poor",
+ "0": "Very poor"
+ },
+ "impact": {
+ "4": "Not at all",
+ "3": "A little",
+ "2": "Somewhat",
+ "1": "Much",
+ "0": "Very much"
+ },
+ "problemDuration": {
+ "4": "I don't have a problem / < 1 month",
+ "3": "1–2 months",
+ "2": "3–6 months",
+ "1": "7–12 months",
+ "0": "> 1 year"
+ }
+ },
+ "stems": {
+ "who5": {
+ "period": "Durante las últimas dos semanas:"
+ },
+ "sci": {
+ "night": "Thinking about a typical night in the last month …",
+ "impact": "Thinking about the past month, to what extent has poor sleep …",
+ "finally": "Finally …"
+ }
+ },
+ "openDetail": "Ver detalles"
},
"records": {
"disclaimer": "Son tus propios registros, no un diagnóstico médico. HealthLog no diagnostica, no trata ni los interpreta.",
diff --git a/messages/fr.json b/messages/fr.json
index 29e16188a..fdc353789 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -599,7 +599,9 @@
"typeWaistCircumference": "Tour de taille",
"typeWaistToHeight": "Rapport taille-taille",
"unitScore": "pts",
- "unitRatio": "ratio"
+ "unitRatio": "ratio",
+ "typeWho5Score": "Score WHO-5",
+ "typeSciScore": "Score SCI"
},
"mood": {
"title": "Humeur",
@@ -7639,8 +7641,8 @@
},
"mentalHealth": {
"pageTitle": "Point sur le bien-être mental",
- "pageDescription": "Deux auto-évaluations validées et facultatives — PHQ-9 (humeur) et GAD-7 (inquiétude) — en complément de ton suivi de l'humeur. Ce qui compte, c'est la tendance dans le temps, pas un chiffre isolé.",
- "pageRhythmHint": "Les deux tests portent sur les deux dernières semaines — un check-in toutes les deux à quatre semaines est donc un bon rythme.",
+ "pageDescription": "Quatre auto-évaluations validées et facultatives — PHQ-9 (humeur), GAD-7 (inquiétude), WHO-5 (bien-être) et SCI (sommeil) — en complément de ton suivi de l'humeur. Ce qui compte, c'est la tendance dans le temps, pas un chiffre isolé.",
+ "pageRhythmHint": "Le PHQ-9, le GAD-7 et le test de bien-être portent sur les deux dernières semaines ; le test de sommeil porte sur le dernier mois. Un check-in toutes les deux à quatre semaines est un bon rythme.",
"attributionLabel": "Source",
"choosePrompt": "Choisis un bilan",
"start": "Commencer",
@@ -7649,14 +7651,17 @@
"submitting": "Enregistrement…",
"saved": "Enregistré",
"error": "Une erreur est survenue. Réessaie.",
- "considerProfessional": "Ton score se situe dans une plage où parler à un professionnel de santé peut aider. C'est une suggestion bienveillante, pas un diagnostic.",
"instrument": {
"phq9": "PHQ-9 · humeur",
- "gad7": "GAD-7 · inquiétude"
+ "gad7": "GAD-7 · inquiétude",
+ "who5": "WHO-5 · bien-être",
+ "sci": "SCI · sommeil"
},
"instrumentSub": {
"phq9": "Dépistage de la dépression, 9 questions",
- "gad7": "Dépistage de l'anxiété, 7 questions"
+ "gad7": "Dépistage de l'anxiété, 7 questions",
+ "who5": "Test de bien-être, 5 questions",
+ "sci": "Test de sommeil, 8 questions"
},
"options": {
"0": "Jamais",
@@ -7691,16 +7696,23 @@
"mild": "Légère",
"moderate": "Modérée",
"severe": "Sévère"
+ },
+ "WHO5": {
+ "low": "Bien-être plus faible",
+ "good": "Bon bien-être"
+ },
+ "SCI": {
+ "belowThreshold": "Zone d'insomnie possible",
+ "aboveThreshold": "Pas d'indice d'insomnie"
}
},
"history": {
"title": "Ton historique",
"empty": "Aucun bilan pour le moment. Ta tendance apparaîtra ici dès que tu en auras terminé un.",
"flaggedBadge": "Aide affichée",
- "tab": {
- "phq9": "PHQ-9",
- "gad7": "GAD-7"
- }
+ "chartTitle": "Évolution du score",
+ "totalLabel": "Score",
+ "bandLabel": "Catégorie"
},
"crisis": {
"title": "Tu n'as pas à affronter cela seul(e)",
@@ -7728,6 +7740,23 @@
"5": "Être si agité(e) qu'il est difficile de rester tranquille",
"6": "Devenir facilement contrarié(e) ou irritable",
"7": "Avoir peur que quelque chose d'épouvantable puisse arriver"
+ },
+ "who5": {
+ "1": "Je me suis senti(e) bien et de bonne humeur",
+ "2": "Je me suis senti(e) calme et tranquille",
+ "3": "Je me suis senti(e) plein(e) d'énergie et vigoureux(se)",
+ "4": "Je me suis réveillé(e) en me sentant frais(che) et dispos(e)",
+ "5": "Ma vie quotidienne a été remplie de choses intéressantes"
+ },
+ "sci": {
+ "1": "… how long does it take you to fall asleep?",
+ "2": "… if you then wake up during the night … how long are you awake for in total? (add all the wakenings up)",
+ "3": "… how many nights a week do you have a problem with your sleep?",
+ "4": "… how would you rate your sleep quality?",
+ "5": "… affected your mood, energy, or relationships?",
+ "6": "… affected your concentration, productivity, or ability to stay awake?",
+ "7": "… troubled you in general?",
+ "8": "… how long have you had a problem with your sleep?"
}
},
"crisisResource": {
@@ -7755,14 +7784,79 @@
},
"instrumentDescription": {
"phq9": "Dépistage de la dépression en 9 questions. Répondez en pensant aux deux dernières semaines.",
- "gad7": "Dépistage de l'anxiété en 7 questions. Répondez en pensant aux deux dernières semaines."
+ "gad7": "Dépistage de l'anxiété en 7 questions. Répondez en pensant aux deux dernières semaines.",
+ "who5": "Test de bien-être en 5 questions. Répondez en pensant aux deux dernières semaines ; des chiffres plus élevés signifient un meilleur bien-être.",
+ "sci": "Test de sommeil en 8 questions. La plupart des questions portent sur le dernier mois ; des scores plus élevés signifient un meilleur sommeil."
},
"progress": "{current} / {total}",
"next": "Suivant",
"lastResult": "Dernier test",
"lastScore": "Dernier résultat",
"noResultYet": "Pas encore de check-in",
- "backToList": "Vue d'ensemble"
+ "backToList": "Vue d'ensemble",
+ "followUpHint": {
+ "phq9": "Ton score se situe dans une plage où parler à un professionnel de santé peut aider. C'est une suggestion bienveillante, pas un diagnostic.",
+ "gad7": "Ton score se situe dans une plage où parler à un professionnel de santé peut aider. C'est une suggestion bienveillante, pas un diagnostic.",
+ "who5": "Un score de bien-être de 50 ou moins peut mériter un regard plus attentif. Si tu le souhaites, le PHQ-9 sur cette page explore l'humeur plus en détail. C'est une suggestion bienveillante, pas un diagnostic.",
+ "sci": "Dans l'étude de ce questionnaire, des scores de 16 ou moins étaient associés à une insomnie probable. Si un mauvais sommeil continue de te peser, parler à un professionnel de santé peut aider. Ce n'est pas un diagnostic."
+ },
+ "validatedInEnglishNote": "Ce questionnaire est validé en anglais ; les questions sont affichées dans leur version originale anglaise.",
+ "who5Options": {
+ "5": "Tout le temps",
+ "4": "La plupart du temps",
+ "3": "Plus de la moitié du temps",
+ "2": "Moins de la moitié du temps",
+ "1": "De temps en temps",
+ "0": "Jamais"
+ },
+ "sciOptions": {
+ "duration": {
+ "4": "0–15 min",
+ "3": "16–30 min",
+ "2": "31–45 min",
+ "1": "46–60 min",
+ "0": "≥ 61 min"
+ },
+ "nights": {
+ "4": "0–1",
+ "3": "2",
+ "2": "3",
+ "1": "4",
+ "0": "5–7"
+ },
+ "quality": {
+ "4": "Very good",
+ "3": "Good",
+ "2": "Average",
+ "1": "Poor",
+ "0": "Very poor"
+ },
+ "impact": {
+ "4": "Not at all",
+ "3": "A little",
+ "2": "Somewhat",
+ "1": "Much",
+ "0": "Very much"
+ },
+ "problemDuration": {
+ "4": "I don't have a problem / < 1 month",
+ "3": "1–2 months",
+ "2": "3–6 months",
+ "1": "7–12 months",
+ "0": "> 1 year"
+ }
+ },
+ "stems": {
+ "who5": {
+ "period": "Au cours des deux dernières semaines"
+ },
+ "sci": {
+ "night": "Thinking about a typical night in the last month …",
+ "impact": "Thinking about the past month, to what extent has poor sleep …",
+ "finally": "Finally …"
+ }
+ },
+ "openDetail": "Voir les détails"
},
"records": {
"disclaimer": "Ce sont vos propres informations, pas un diagnostic médical. HealthLog ne diagnostique pas, ne traite pas et ne les interprète pas.",
diff --git a/messages/it.json b/messages/it.json
index baafa256b..1feae5809 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -599,7 +599,9 @@
"typeWaistCircumference": "Circonferenza vita",
"typeWaistToHeight": "Rapporto vita-altezza",
"unitScore": "pti",
- "unitRatio": "rapporto"
+ "unitRatio": "rapporto",
+ "typeWho5Score": "Punteggio WHO-5",
+ "typeSciScore": "Punteggio SCI"
},
"mood": {
"title": "Umore",
@@ -7639,8 +7641,8 @@
},
"mentalHealth": {
"pageTitle": "Check-in sul benessere mentale",
- "pageDescription": "Due autovalutazioni validate e facoltative — PHQ-9 (umore) e GAD-7 (preoccupazione) — accanto al tuo diario dell'umore. Ciò che conta è l'andamento nel tempo, non un singolo numero.",
- "pageRhythmHint": "Entrambi i test riguardano le ultime due settimane, quindi un check-in ogni due-quattro settimane è un buon ritmo.",
+ "pageDescription": "Quattro autovalutazioni validate e facoltative — PHQ-9 (umore), GAD-7 (preoccupazione), WHO-5 (benessere) e SCI (sonno) — accanto al tuo diario dell'umore. Ciò che conta è l'andamento nel tempo, non un singolo numero.",
+ "pageRhythmHint": "PHQ-9, GAD-7 e il test sul benessere riguardano le ultime due settimane; il test sul sonno chiede dell'ultimo mese. Un check-in ogni due-quattro settimane è un buon ritmo.",
"attributionLabel": "Fonte",
"choosePrompt": "Scegli un check-in",
"start": "Inizia",
@@ -7649,14 +7651,17 @@
"submitting": "Salvataggio…",
"saved": "Salvato",
"error": "Qualcosa è andato storto. Riprova.",
- "considerProfessional": "Il tuo punteggio è in un intervallo in cui parlare con un professionista sanitario può aiutare. È un suggerimento gentile, non una diagnosi.",
"instrument": {
"phq9": "PHQ-9 · umore",
- "gad7": "GAD-7 · preoccupazione"
+ "gad7": "GAD-7 · preoccupazione",
+ "who5": "WHO-5 · benessere",
+ "sci": "SCI · sonno"
},
"instrumentSub": {
"phq9": "Screening della depressione, 9 domande",
- "gad7": "Screening dell'ansia, 7 domande"
+ "gad7": "Screening dell'ansia, 7 domande",
+ "who5": "Test sul benessere, 5 domande",
+ "sci": "Test sul sonno, 8 domande"
},
"options": {
"0": "Per niente",
@@ -7691,16 +7696,23 @@
"mild": "Lieve",
"moderate": "Moderata",
"severe": "Grave"
+ },
+ "WHO5": {
+ "low": "Benessere più basso",
+ "good": "Buon benessere"
+ },
+ "SCI": {
+ "belowThreshold": "Fascia di possibile insonnia",
+ "aboveThreshold": "Nessun indizio di insonnia"
}
},
"history": {
"title": "La tua cronologia",
"empty": "Ancora nessun check-in. Il tuo andamento apparirà qui dopo che ne avrai completato uno.",
"flaggedBadge": "Supporto mostrato",
- "tab": {
- "phq9": "PHQ-9",
- "gad7": "GAD-7"
- }
+ "chartTitle": "Andamento del punteggio",
+ "totalLabel": "Punteggio",
+ "bandLabel": "Categoria"
},
"crisis": {
"title": "Non devi affrontare tutto questo da solo/a",
@@ -7728,6 +7740,23 @@
"5": "Essere così irrequieto/a da far fatica a stare seduto/a fermo/a",
"6": "Infastidirsi o irritarsi facilmente",
"7": "Avere paura come se potesse accadere qualcosa di terribile"
+ },
+ "who5": {
+ "1": "Mi sono sentito/a allegro/a e di buon umore",
+ "2": "Mi sono sentito/a calmo/a e rilassato/a",
+ "3": "Mi sono sentito/a attivo/a ed energico/a",
+ "4": "Mi sono svegliato/a sentendomi fresco/a e riposato/a",
+ "5": "La mia vita di tutti i giorni è stata piena di cose che mi interessano"
+ },
+ "sci": {
+ "1": "… how long does it take you to fall asleep?",
+ "2": "… if you then wake up during the night … how long are you awake for in total? (add all the wakenings up)",
+ "3": "… how many nights a week do you have a problem with your sleep?",
+ "4": "… how would you rate your sleep quality?",
+ "5": "… affected your mood, energy, or relationships?",
+ "6": "… affected your concentration, productivity, or ability to stay awake?",
+ "7": "… troubled you in general?",
+ "8": "… how long have you had a problem with your sleep?"
}
},
"crisisResource": {
@@ -7755,14 +7784,79 @@
},
"instrumentDescription": {
"phq9": "Screening della depressione in 9 domande. Rispondi pensando alle ultime due settimane.",
- "gad7": "Screening dell'ansia in 7 domande. Rispondi pensando alle ultime due settimane."
+ "gad7": "Screening dell'ansia in 7 domande. Rispondi pensando alle ultime due settimane.",
+ "who5": "Test sul benessere in 5 domande. Rispondi pensando alle ultime due settimane; numeri più alti indicano un maggior benessere.",
+ "sci": "Test sul sonno in 8 domande. La maggior parte riguarda l'ultimo mese; punteggi più alti indicano un sonno migliore."
},
"progress": "{current} / {total}",
"next": "Avanti",
"lastResult": "Ultimo test",
"lastScore": "Ultimo risultato",
"noResultYet": "Ancora nessun check-in",
- "backToList": "Panoramica"
+ "backToList": "Panoramica",
+ "followUpHint": {
+ "phq9": "Il tuo punteggio è in un intervallo in cui parlare con un professionista sanitario può aiutare. È un suggerimento gentile, non una diagnosi.",
+ "gad7": "Il tuo punteggio è in un intervallo in cui parlare con un professionista sanitario può aiutare. È un suggerimento gentile, non una diagnosi.",
+ "who5": "Un punteggio di benessere pari o inferiore a 50 può meritare uno sguardo più attento. Se vuoi, il PHQ-9 in questa pagina esplora l'umore più in dettaglio. È un suggerimento gentile, non una diagnosi.",
+ "sci": "Nello studio di questo questionario, punteggi pari o inferiori a 16 erano associati a insonnia probabile. Se dormire male continua a pesarti, parlare con un professionista sanitario può aiutare. Non è una diagnosi."
+ },
+ "validatedInEnglishNote": "Questo questionario è validato in inglese; le domande sono mostrate nella versione originale inglese.",
+ "who5Options": {
+ "5": "Sempre",
+ "4": "La maggior parte del tempo",
+ "3": "Più della metà del tempo",
+ "2": "Meno della metà del tempo",
+ "1": "A volte",
+ "0": "Mai"
+ },
+ "sciOptions": {
+ "duration": {
+ "4": "0–15 min",
+ "3": "16–30 min",
+ "2": "31–45 min",
+ "1": "46–60 min",
+ "0": "≥ 61 min"
+ },
+ "nights": {
+ "4": "0–1",
+ "3": "2",
+ "2": "3",
+ "1": "4",
+ "0": "5–7"
+ },
+ "quality": {
+ "4": "Very good",
+ "3": "Good",
+ "2": "Average",
+ "1": "Poor",
+ "0": "Very poor"
+ },
+ "impact": {
+ "4": "Not at all",
+ "3": "A little",
+ "2": "Somewhat",
+ "1": "Much",
+ "0": "Very much"
+ },
+ "problemDuration": {
+ "4": "I don't have a problem / < 1 month",
+ "3": "1–2 months",
+ "2": "3–6 months",
+ "1": "7–12 months",
+ "0": "> 1 year"
+ }
+ },
+ "stems": {
+ "who5": {
+ "period": "Nelle ultime due settimane"
+ },
+ "sci": {
+ "night": "Thinking about a typical night in the last month …",
+ "impact": "Thinking about the past month, to what extent has poor sleep …",
+ "finally": "Finally …"
+ }
+ },
+ "openDetail": "Mostra dettagli"
},
"records": {
"disclaimer": "Sono i tuoi dati, non una diagnosi medica. HealthLog non diagnostica, non cura e non li interpreta.",
diff --git a/messages/pl.json b/messages/pl.json
index d129e4f98..5e32e0dd1 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -599,7 +599,9 @@
"typeWaistCircumference": "Obwód talii",
"typeWaistToHeight": "Stosunek talii do wzrostu",
"unitScore": "pkt",
- "unitRatio": "stosunek"
+ "unitRatio": "stosunek",
+ "typeWho5Score": "Wynik WHO-5",
+ "typeSciScore": "Wynik SCI"
},
"mood": {
"title": "Nastrój",
@@ -7639,8 +7641,8 @@
},
"mentalHealth": {
"pageTitle": "Sprawdzenie dobrostanu psychicznego",
- "pageDescription": "Dwa zwalidowane, dobrowolne testy — PHQ-9 (nastrój) i GAD-7 (lęk) — obok Twojego dziennika nastroju. Liczy się trend w czasie, a nie pojedynczy wynik.",
- "pageRhythmHint": "Oba testy dotyczą ostatnich dwóch tygodni, dlatego check-in co dwa–cztery tygodnie to dobry rytm.",
+ "pageDescription": "Cztery zwalidowane, dobrowolne testy — PHQ-9 (nastrój), GAD-7 (lęk), WHO-5 (samopoczucie) i SCI (sen) — obok Twojego dziennika nastroju. Liczy się trend w czasie, a nie pojedynczy wynik.",
+ "pageRhythmHint": "PHQ-9, GAD-7 i test samopoczucia dotyczą ostatnich dwóch tygodni; test snu pyta o ostatni miesiąc. Check-in co dwa–cztery tygodnie to dobry rytm.",
"attributionLabel": "Źródło",
"choosePrompt": "Wybierz sprawdzenie",
"start": "Rozpocznij",
@@ -7649,14 +7651,17 @@
"submitting": "Zapisywanie…",
"saved": "Zapisano",
"error": "Coś poszło nie tak. Spróbuj ponownie.",
- "considerProfessional": "Twój wynik mieści się w zakresie, w którym rozmowa ze specjalistą może pomóc. To życzliwa sugestia, a nie diagnoza.",
"instrument": {
"phq9": "PHQ-9 · nastrój",
- "gad7": "GAD-7 · lęk"
+ "gad7": "GAD-7 · lęk",
+ "who5": "WHO-5 · samopoczucie",
+ "sci": "SCI · sen"
},
"instrumentSub": {
"phq9": "Przesiew depresji, 9 pytań",
- "gad7": "Przesiew lęku, 7 pytań"
+ "gad7": "Przesiew lęku, 7 pytań",
+ "who5": "Test samopoczucia, 5 pytań",
+ "sci": "Test snu, 8 pytań"
},
"options": {
"0": "Wcale",
@@ -7691,16 +7696,23 @@
"mild": "Łagodny",
"moderate": "Umiarkowany",
"severe": "Ciężki"
+ },
+ "WHO5": {
+ "low": "Niższe samopoczucie",
+ "good": "Dobre samopoczucie"
+ },
+ "SCI": {
+ "belowThreshold": "Zakres możliwej bezsenności",
+ "aboveThreshold": "Brak wskazań na bezsenność"
}
},
"history": {
"title": "Twoja historia",
"empty": "Brak sprawdzeń. Twój trend pojawi się tutaj po ukończeniu pierwszego.",
"flaggedBadge": "Pokazano wsparcie",
- "tab": {
- "phq9": "PHQ-9",
- "gad7": "GAD-7"
- }
+ "chartTitle": "Przebieg wyniku",
+ "totalLabel": "Wynik",
+ "bandLabel": "Kategoria"
},
"crisis": {
"title": "Nie musisz mierzyć się z tym sam(a)",
@@ -7728,6 +7740,23 @@
"5": "Niepokój tak duży, że trudno usiedzieć w miejscu",
"6": "Łatwe wpadanie w rozdrażnienie lub irytację",
"7": "Odczuwanie lęku, jakby miało się zdarzyć coś strasznego"
+ },
+ "who5": {
+ "1": "Czułam się wesoła/Czułem się wesoły i w dobrym nastroju",
+ "2": "Czułam się spokojna i odprężona/Czułem się spokojny i odprężony",
+ "3": "Czułam się aktywna i energiczna/Czułem się aktywny i energiczny",
+ "4": "Budziłam się z uczuciem świeżości i wypoczęta/Budziłem się z uczuciem świeżości i wypoczęty",
+ "5": "Moje życie codzienne było wypełnione interesującymi mnie sprawami"
+ },
+ "sci": {
+ "1": "… how long does it take you to fall asleep?",
+ "2": "… if you then wake up during the night … how long are you awake for in total? (add all the wakenings up)",
+ "3": "… how many nights a week do you have a problem with your sleep?",
+ "4": "… how would you rate your sleep quality?",
+ "5": "… affected your mood, energy, or relationships?",
+ "6": "… affected your concentration, productivity, or ability to stay awake?",
+ "7": "… troubled you in general?",
+ "8": "… how long have you had a problem with your sleep?"
}
},
"crisisResource": {
@@ -7755,14 +7784,79 @@
},
"instrumentDescription": {
"phq9": "Przesiew w kierunku depresji, 9 pytań. Odpowiadaj, myśląc o ostatnich dwóch tygodniach.",
- "gad7": "Przesiew w kierunku lęku, 7 pytań. Odpowiadaj, myśląc o ostatnich dwóch tygodniach."
+ "gad7": "Przesiew w kierunku lęku, 7 pytań. Odpowiadaj, myśląc o ostatnich dwóch tygodniach.",
+ "who5": "Test samopoczucia, 5 pytań. Odpowiadaj, myśląc o ostatnich dwóch tygodniach; wyższe cyfry oznaczają lepsze samopoczucie.",
+ "sci": "Test snu, 8 pytań. Większość pytań dotyczy ostatniego miesiąca; wyższe wyniki oznaczają lepszy sen."
},
"progress": "{current} / {total}",
"next": "Dalej",
"lastResult": "Ostatni test",
"lastScore": "Ostatni wynik",
"noResultYet": "Brak check-inów",
- "backToList": "Przegląd"
+ "backToList": "Przegląd",
+ "followUpHint": {
+ "phq9": "Twój wynik mieści się w zakresie, w którym rozmowa ze specjalistą może pomóc. To życzliwa sugestia, a nie diagnoza.",
+ "gad7": "Twój wynik mieści się w zakresie, w którym rozmowa ze specjalistą może pomóc. To życzliwa sugestia, a nie diagnoza.",
+ "who5": "Wynik samopoczucia 50 lub niższy może być wart bliższego spojrzenia. Jeśli chcesz, PHQ-9 na tej stronie przygląda się nastrojowi dokładniej. To życzliwa sugestia, a nie diagnoza.",
+ "sci": "W badaniu tego kwestionariusza wyniki 16 lub niższe wiązały się z prawdopodobną bezsennością. Jeśli zły sen nadal Ci doskwiera, rozmowa ze specjalistą może pomóc. To nie jest diagnoza."
+ },
+ "validatedInEnglishNote": "Ten kwestionariusz jest zwalidowany w języku angielskim; pytania są wyświetlane w angielskim oryginale.",
+ "who5Options": {
+ "5": "Cały czas",
+ "4": "Prawie cały czas",
+ "3": "Więcej niż połowę czasu",
+ "2": "Mniej niż połowę czasu",
+ "1": "Od czasu do czasu",
+ "0": "Nigdy"
+ },
+ "sciOptions": {
+ "duration": {
+ "4": "0–15 min",
+ "3": "16–30 min",
+ "2": "31–45 min",
+ "1": "46–60 min",
+ "0": "≥ 61 min"
+ },
+ "nights": {
+ "4": "0–1",
+ "3": "2",
+ "2": "3",
+ "1": "4",
+ "0": "5–7"
+ },
+ "quality": {
+ "4": "Very good",
+ "3": "Good",
+ "2": "Average",
+ "1": "Poor",
+ "0": "Very poor"
+ },
+ "impact": {
+ "4": "Not at all",
+ "3": "A little",
+ "2": "Somewhat",
+ "1": "Much",
+ "0": "Very much"
+ },
+ "problemDuration": {
+ "4": "I don't have a problem / < 1 month",
+ "3": "1–2 months",
+ "2": "3–6 months",
+ "1": "7–12 months",
+ "0": "> 1 year"
+ }
+ },
+ "stems": {
+ "who5": {
+ "period": "W ciągu ostatnich dwóch tygodni"
+ },
+ "sci": {
+ "night": "Thinking about a typical night in the last month …",
+ "impact": "Thinking about the past month, to what extent has poor sleep …",
+ "finally": "Finally …"
+ }
+ },
+ "openDetail": "Pokaż szczegóły"
},
"records": {
"disclaimer": "To Twoje własne wpisy, a nie diagnoza lekarska. HealthLog nie diagnozuje, nie leczy i ich nie interpretuje.",
diff --git a/src/__tests__/i18n-english-leak-guard.test.ts b/src/__tests__/i18n-english-leak-guard.test.ts
index 9a3b00612..fadc16f8f 100644
--- a/src/__tests__/i18n-english-leak-guard.test.ts
+++ b/src/__tests__/i18n-english-leak-guard.test.ts
@@ -155,6 +155,39 @@ const LEGIT_IDENTICAL = new Set([
"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", () => {
diff --git a/src/components/measurement-reminders/vorsorge-section.tsx b/src/components/measurement-reminders/vorsorge-section.tsx
index 5c03acd8c..920ed7629 100644
--- a/src/components/measurement-reminders/vorsorge-section.tsx
+++ b/src/components/measurement-reminders/vorsorge-section.tsx
@@ -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;
diff --git a/src/components/mental-health/__tests__/mental-wellbeing.test.tsx b/src/components/mental-health/__tests__/mental-wellbeing.test.tsx
index 0c6868b49..5268847ee 100644
--- a/src/components/mental-health/__tests__/mental-wellbeing.test.tsx
+++ b/src/components/mental-health/__tests__/mental-wellbeing.test.tsx
@@ -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( );
+ const html = withProviders(
+ ,
+ );
- 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( );
+ it("renders the empty state with no rows for the pinned instrument", () => {
+ const empty = withProviders(
+ ,
+ );
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( );
+ 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( );
+ 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(
+ {}}
+ 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(
+ {}}
+ 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(
+ {}}
+ 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(
+ {}}
+ 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(
+ {}}
+ 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(
+ ,
+ );
+ 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(
- {}} />,
+ function card(
+ instrument: AssessmentRow["instrument"],
+ last?: AssessmentRow,
+ ): string {
+ return withProviders(
+ {}}
+ 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(
- {}} />,
- );
+ 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(
+ {}} />,
+ );
+ 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");
+ });
+});
diff --git a/src/components/mental-health/assessment-history-chart.tsx b/src/components/mental-health/assessment-history-chart.tsx
new file mode 100644
index 000000000..95c4dc8fe
--- /dev/null
+++ b/src/components/mental-health/assessment-history-chart.tsx
@@ -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(() => {
+ 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 (
+
+ {t("mentalHealth.history.empty")}
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+ formatDateShort(new Date(ts))}
+ tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
+ stroke="var(--border)"
+ minTickGap={32}
+ />
+
+ {
+ const active = props.active ?? false;
+ const payload = props.payload as
+ ReadonlyArray<{ payload?: ChartPoint }> | undefined;
+ const point = payload?.[0]?.payload;
+ if (!active || !point) {
+ return ;
+ }
+ 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 (
+
+ );
+ }}
+ />
+
+
+
+
+ );
+}
diff --git a/src/components/mental-health/assessment-history.tsx b/src/components/mental-health/assessment-history.tsx
index 1bda8bfe8..570e4c568 100644
--- a/src/components/mental-health/assessment-history.tsx
+++ b/src/components/mental-health/assessment-history.tsx
@@ -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 `` shell
+// holds the layout.
+const AssessmentHistoryChartLazy = dynamic(
+ () =>
+ importWithRetry(() => import("./assessment-history-chart")).then((mod) => ({
+ default: mod.AssessmentHistoryChart,
+ })),
+ { ssr: false, loading: () => },
+);
+function AssessmentHistoryChart(
+ props: ComponentProps,
+) {
+ return (
+
+
+
+ );
}
/** 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("PHQ9");
// Which flagged row's crisis card is currently expanded (re-surfaced).
const [openCrisisRowId, setOpenCrisisRowId] = useState(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 (
+
+ }
+ title={t("mentalHealth.history.title")}
+ description={t("mentalHealth.history.empty")}
+ />
+
+ );
+ }
return (
-
-
-
- {t("mentalHealth.history.title")}
-
-
-
- {isEmpty ? (
- }
- 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. */}
-
- {(["PHQ9", "GAD7"] as InstrumentId[]).map((id) => (
- {
- setTab(id);
- setOpenCrisisRowId(null);
- }}
- >
- {t(`mentalHealth.history.tab.${lower(id)}`)}
-
- ))}
-
+
+
- {forTab.length === 0 ? (
-
- {t("mentalHealth.history.empty")}
-
- ) : (
-
- {forTab.map((row) => {
- const flagged = row.item9Flagged;
- const open = openCrisisRowId === row.id;
- return (
-
-
-
- {formatDate(row.takenAt)}
-
-
- {flagged && (
-
- setOpenCrisisRowId(open ? null : row.id)
- }
- data-slot="history-flagged-marker"
- >
-
- {t("mentalHealth.history.flaggedBadge")}
-
-
- )}
-
- {t(
- `mentalHealth.band.${row.instrument}.${row.severityBand}`,
- )}
-
-
- {row.totalScore}
-
-
-
- {flagged && open && (
-
- )}
-
- );
- })}
-
- )}
- >
- )}
-
-
+
+ {forInstrument.map((row) => {
+ const flagged = row.item9Flagged;
+ const open = openCrisisRowId === row.id;
+ return (
+
+
+
+ {formatDate(row.takenAt)}
+
+
+ {flagged && (
+ setOpenCrisisRowId(open ? null : row.id)}
+ data-slot="history-flagged-marker"
+ >
+
+ {t("mentalHealth.history.flaggedBadge")}
+
+
+ )}
+
+ {t(
+ `mentalHealth.band.${row.instrument}.${row.severityBand}`,
+ )}
+
+
+ {row.totalScore}
+
+
+
+ {flagged && open && }
+
+ );
+ })}
+
+
);
}
diff --git a/src/components/mental-health/assessment-result.tsx b/src/components/mental-health/assessment-result.tsx
index 67cb1b6cb..ba3611ee7 100644
--- a/src/components/mental-health/assessment-result.tsx
+++ b/src/components/mental-health/assessment-result.tsx
@@ -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({
)}
- {result.assessment.totalScore >= result.actionThreshold && (
-
- {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) && (
+
+ {t(`mentalHealth.followUpHint.${key}`)}
)}
@@ -114,11 +124,11 @@ export function AssessmentResult({
{result.crisis && }
-
+
{t("mentalHealth.attributionLabel")}:
{" "}
- {PHQ_GAD_ATTRIBUTION}
+ {def.attribution}
diff --git a/src/components/mental-health/check-in-wizard.tsx b/src/components/mental-health/check-in-wizard.tsx
index f5873b1ab..d85341645 100644
--- a/src/components/mental-health/check-in-wizard.tsx
+++ b/src/components/mental-health/check-in-wizard.tsx
@@ -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({
{t(`mentalHealth.instrumentDescription.${key}`)}
+ {/* Honest translation note: the instrument is validated in its source
+ language; the surrounding chrome stays localized. */}
+ {!hasValidatedItems(instrument, locale) && (
+
+ {t("mentalHealth.validatedInEnglishNote")}
+
+ )}
@@ -146,6 +169,11 @@ export function CheckInWizard({
total: totalSteps,
})}
+ {stem && (
+
+ {t(`mentalHealth.stems.${stem}`)}
+
+ )}
- {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)}`,
+ )}
);
})}
diff --git a/src/components/mental-health/instrument-card.tsx b/src/components/mental-health/instrument-card.tsx
index 3f0858500..e908ae919 100644
--- a/src/components/mental-health/instrument-card.tsx
+++ b/src/components/mental-health/instrument-card.tsx
@@ -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 (
-
-
+ {/* 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. */}
+
+
+
{/* 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 ? (
-
+
{t("mentalHealth.lastResult")}
@@ -74,16 +101,29 @@ export function InstrumentCard({
) : (
-
+
{t("mentalHealth.noResultYet")}
)}
+
+
{/* Bottom-pinned single primary action — the med-/Vorsorge card slot. */}
-
+
{t("mentalHealth.start")}
+ {/* Required attribution — factual and unobtrusive; identical slot on
+ every card so the grid stays symmetric. */}
+
+ {!hasValidatedItems(instrument, locale) && (
+ <>{t("mentalHealth.validatedInEnglishNote")} >
+ )}
+ {def.attribution}
+
diff --git a/src/components/mental-health/instrument-detail.tsx b/src/components/mental-health/instrument-detail.tsx
new file mode 100644
index 000000000..caf468b71
--- /dev/null
+++ b/src/components/mental-health/instrument-detail.tsx
@@ -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 (
+
+ {/* 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 && (
+
+
+
+ {t("mentalHealth.lastResult")}
+
+
+ {relativeCalendarDate(last.takenAt, t, formatDate)}
+
+
+
+
+ {t("mentalHealth.lastScore")}
+
+
+ {last.totalScore}
+ {" · "}
+ {t(`mentalHealth.band.${last.instrument}.${last.severityBand}`)}
+
+
+
+ )}
+
+ {/* The Start action stays reachable here too — the detail is a calm
+ reading surface, not a dead end. */}
+
+ {t("mentalHealth.start")}
+
+
+
+
+ {/* Required attribution — same factual line as the card + result view. */}
+
+ {def.attribution}
+
+
+ );
+}
diff --git a/src/components/mental-health/mental-wellbeing.tsx b/src/components/mental-health/mental-wellbeing.tsx
index ddb75d9c0..267c4e06d 100644
--- a/src/components/mental-health/mental-wellbeing.tsx
+++ b/src/components/mental-health/mental-wellbeing.tsx
@@ -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
("choose");
const [instrument, setInstrument] = useState("PHQ9");
const [result, setResult] = useState(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(
+ 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. */}
{t("mentalHealth.pageRhythmHint")}
@@ -126,19 +138,45 @@ export function MentalWellbeing() {
{t("mentalHealth.choosePrompt")}
- {(["PHQ9", "GAD7"] as InstrumentId[]).map((id) => (
+ {INSTRUMENT_ORDER.map((id) => (
begin(id)}
+ onOpenDetail={() => setDetailInstrument(id)}
/>
))}
-
+ {/* Per-instrument detail, opened from a card body: last result,
+ trend chart, dated history, Start — the Verlauf lives here,
+ not on the landing. */}
+ {
+ if (!open) setDetailInstrument(null);
+ }}
+ contentWidth="lg"
+ title={
+ detailInstrument
+ ? t(
+ `mentalHealth.instrument.${INSTRUMENTS[detailInstrument].i18nKey}`,
+ )
+ : t("mentalHealth.history.title")
+ }
+ description={t("mentalHealth.history.chartTitle")}
+ >
+ {detailInstrument && (
+ begin(detailInstrument)}
+ />
+ )}
+
>
)}