mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +00:00
Merge branch 'feat/w-g-screenings' into release/v1.27.10
This commit is contained in:
commit
6e59fe5ce0
40 files changed
+2180
-352
No files matched your search
@@ -105,10 +105,14 @@ const EXPECTED_TYPES = [
|
||||
"PAIN_NRS",
|
||||
"WAIST_CIRCUMFERENCE",
|
||||
"WAIST_TO_HEIGHT",
|
||||
// ── v1.27.9 — screening scores (WHO-5 percentage 0–100, SCI total 0–32;
|
||||
// both server-derived from a completed assessment, higher = better) ──
|
||||
"WHO5_SCORE",
|
||||
"SCI_SCORE",
|
||||
] as const;
|
||||
|
||||
describe("measurementTypeEnum coverage", () => {
|
||||
it("exposes the 73 canonical measurement types", () => {
|
||||
it("exposes the 75 canonical measurement types", () => {
|
||||
expect([...measurementTypeEnum.options].sort()).toEqual(
|
||||
[...EXPECTED_TYPES].sort(),
|
||||
);
|
||||
@@ -264,6 +268,12 @@ describe("measurementTypeEnum coverage", () => {
|
||||
"PAIN_NRS",
|
||||
"WAIST_CIRCUMFERENCE",
|
||||
"WAIST_TO_HEIGHT",
|
||||
// v1.27.9 — WHO-5 / SCI screening totals follow the PHQ-9 / GAD-7
|
||||
// precedent: opt-in questionnaire derivations, excluded from the vitals
|
||||
// table; they ride the module-gated mental-health export section
|
||||
// instead. See doctor-report-pdf-core.ts for the matching rationale.
|
||||
"WHO5_SCORE",
|
||||
"SCI_SCORE",
|
||||
]);
|
||||
|
||||
it("doctor-report PDF vital types cover the canonical enum minus documented exclusions", () => {
|
||||
|
||||
@@ -1620,6 +1620,9 @@ const MEASUREMENT_TYPE_MODULE: Record<string, ModuleKey> = {
|
||||
// opted into.
|
||||
PHQ9_SCORE: "mentalHealth",
|
||||
GAD7_SCORE: "mentalHealth",
|
||||
// v1.27.9 — the WHO-5 / SCI totals ride the same module gate.
|
||||
WHO5_SCORE: "mentalHealth",
|
||||
SCI_SCORE: "mentalHealth",
|
||||
};
|
||||
|
||||
function filterMeasurementKeys<T>(
|
||||
|
||||
@@ -201,6 +201,11 @@ export const DOCTOR_REPORT_TYPE_UNIT_KEYS: Record<string, string | null> = {
|
||||
* v1.19.0 — Oura resilience. `RESILIENCE` is an ordinal-encoded categorical
|
||||
* band (limited=1 … exceptional=5), a derived recovery composite — descriptive,
|
||||
* not a measured clinical vital — so it is excluded too.
|
||||
*
|
||||
* v1.27.9 — WHO-5 / SCI screening totals. `WHO5_SCORE` + `SCI_SCORE` follow
|
||||
* the PHQ-9 / GAD-7 precedent: opt-in, derived from an in-app questionnaire,
|
||||
* excluded from the vitals table (they ride the module-gated mental-health
|
||||
* export section instead, see `doctor-report-data.ts`).
|
||||
*/
|
||||
export const DOCTOR_REPORT_VITAL_TYPES = [
|
||||
"WEIGHT",
|
||||
|
||||
@@ -133,6 +133,10 @@ export const ALLOWED_CHART_TOKENS = [
|
||||
// generic chart.
|
||||
"metric:PHQ9_SCORE",
|
||||
"metric:GAD7_SCORE",
|
||||
// v1.27.9 — WHO-5 (0–100 percentage) + SCI (0–32) screening totals; same
|
||||
// continuous-series contract, higher = better.
|
||||
"metric:WHO5_SCORE",
|
||||
"metric:SCI_SCORE",
|
||||
"metric:GRIP_STRENGTH",
|
||||
"metric:PAIN_NRS",
|
||||
"metric:WAIST_CIRCUMFERENCE",
|
||||
|
||||
@@ -184,6 +184,28 @@ describe("runReminderSatisfyForUser", () => {
|
||||
expect(updates[0].data.lastSatisfiedAt).toEqual(measuredAt);
|
||||
});
|
||||
|
||||
it("satisfies the WHO-5 / SCI screening reminders on the identical contract (v1.27.9)", async () => {
|
||||
for (const measurementType of ["WHO5_SCORE", "SCI_SCORE"]) {
|
||||
const measuredAt = new Date("2026-07-01T10:00:00Z");
|
||||
const { prisma, updates } = makePrisma({
|
||||
reminders: [reminderRow({ measurementType, intervalDays: 28 })],
|
||||
measurement: { measuredAt },
|
||||
});
|
||||
const isModuleEnabled = vi.fn(async () => true);
|
||||
|
||||
const summary = await runReminderSatisfyForUser(
|
||||
prisma as never,
|
||||
"u1",
|
||||
new Date("2026-07-01T12:00:00Z"),
|
||||
{ isModuleEnabled },
|
||||
);
|
||||
|
||||
expect(summary.satisfied, `${measurementType} should satisfy`).toBe(1);
|
||||
expect(isModuleEnabled).toHaveBeenCalledWith("u1", "mentalHealth");
|
||||
expect(updates[0].data.lastSatisfiedAt).toEqual(measuredAt);
|
||||
}
|
||||
});
|
||||
|
||||
it("produces no engine activity for a screening reminder when the mental-health module is off", async () => {
|
||||
const { prisma, updates } = makePrisma({
|
||||
reminders: [
|
||||
|
||||
@@ -67,6 +67,8 @@ function moduleForMeasurementType(
|
||||
return "sleep";
|
||||
case "PHQ9_SCORE":
|
||||
case "GAD7_SCORE":
|
||||
case "WHO5_SCORE":
|
||||
case "SCI_SCORE":
|
||||
return "mentalHealth";
|
||||
default:
|
||||
return null;
|
||||
|
||||
@@ -89,6 +89,10 @@ const MEASUREMENT_TYPES_WITHOUT_HK_COUNTERPART = new Set<MeasurementType>([
|
||||
"GRIP_STRENGTH",
|
||||
"PAIN_NRS",
|
||||
"WAIST_TO_HEIGHT",
|
||||
// v1.27.9 — WHO-5 / SCI screening totals: same in-app questionnaire
|
||||
// derivation as PHQ-9 / GAD-7. No HK mapping by design.
|
||||
"WHO5_SCORE",
|
||||
"SCI_SCORE",
|
||||
]);
|
||||
|
||||
describe("APPLE_HEALTH_TYPE_MAP", () => {
|
||||
|
||||
@@ -209,6 +209,9 @@ export const MEASUREMENT_CATEGORIES: ReadonlyMap<
|
||||
// PHQ-9 / GAD-7 totals are composite screener indices → scores cluster.
|
||||
["PHQ9_SCORE", "scores"],
|
||||
["GAD7_SCORE", "scores"],
|
||||
// ── v1.27.9 — WHO-5 / SCI totals join the same screener-index cluster.
|
||||
["WHO5_SCORE", "scores"],
|
||||
["SCI_SCORE", "scores"],
|
||||
// Grip strength is a strength/fitness reading → activity cluster.
|
||||
["GRIP_STRENGTH", "activity"],
|
||||
// Pain NRS is a patient-reported clinical point-in-time signal → vitals.
|
||||
|
||||
@@ -2,9 +2,14 @@ import { describe, it, expect } from "vitest";
|
||||
|
||||
import {
|
||||
INSTRUMENTS,
|
||||
INSTRUMENT_MEASUREMENT_TYPE,
|
||||
hasValidatedItems,
|
||||
isSafetyFlagged,
|
||||
needsFollowUp,
|
||||
optionLabelKey,
|
||||
scoreTotal,
|
||||
severityBand,
|
||||
isSafetyFlagged,
|
||||
stemKey,
|
||||
} from "../instruments";
|
||||
|
||||
/**
|
||||
@@ -12,19 +17,40 @@ import {
|
||||
* GAD-7 totals, the validated severity-band edges (5 / 10 / 15 / 20 and
|
||||
* 5 / 10 / 15), the ≥10 action threshold, and the item-9 ANY-non-zero safety
|
||||
* flag. A future off-by-one on any band edge or the safety predicate must fail
|
||||
* here. Ground truth: Kroenke 2001 (PHQ-9) / Spitzer 2006 (GAD-7), mirrored in
|
||||
* `.planning/research/v125/clinical-instruments.md`.
|
||||
* here. Ground truth: Kroenke 2001 (PHQ-9) / Spitzer 2006 (GAD-7).
|
||||
*
|
||||
* v1.27.9 — the WHO-5 / SCI pins join. Ground truth: the official WHO
|
||||
* publication WHO/UCN/MSD/MHE/2024.1 ("raw score … zero to 25 … multiplied by
|
||||
* four" → 0–100; "a percentage score below 50 … an indication for further
|
||||
* assessment") and Espie et al., BMJ Open 2014;4:e004183 ("total score ranges
|
||||
* from 0 to 32, with higher values indicative of better sleep"; "an SCI
|
||||
* cut-off ≤16 … 'probable insomnia disorder'").
|
||||
*/
|
||||
describe("mental-health instruments", () => {
|
||||
describe("scoreTotal", () => {
|
||||
it("sums the item answers", () => {
|
||||
expect(scoreTotal([0, 1, 2, 3, 0, 1, 2, 3, 1])).toBe(13);
|
||||
expect(scoreTotal([0, 0, 0, 0, 0, 0, 0])).toBe(0);
|
||||
it("sums the item answers for PHQ-9 / GAD-7 (multiplier 1)", () => {
|
||||
expect(scoreTotal("PHQ9", [0, 1, 2, 3, 0, 1, 2, 3, 1])).toBe(13);
|
||||
expect(scoreTotal("GAD7", [0, 0, 0, 0, 0, 0, 0])).toBe(0);
|
||||
});
|
||||
|
||||
it("returns the max when every item is 3", () => {
|
||||
expect(scoreTotal(Array(9).fill(3))).toBe(27); // PHQ-9 max
|
||||
expect(scoreTotal(Array(7).fill(3))).toBe(21); // GAD-7 max
|
||||
expect(scoreTotal("PHQ9", Array(9).fill(3))).toBe(27); // PHQ-9 max
|
||||
expect(scoreTotal("GAD7", Array(7).fill(3))).toBe(21); // GAD-7 max
|
||||
});
|
||||
|
||||
it("WHO-5 reports raw-sum × 4 (the official 0–100 percentage)", () => {
|
||||
// WHO scoring page: raw 0–25, "multiplied by four" → 0–100.
|
||||
expect(scoreTotal("WHO5", [0, 0, 0, 0, 0])).toBe(0);
|
||||
expect(scoreTotal("WHO5", [5, 5, 5, 5, 5])).toBe(100);
|
||||
expect(scoreTotal("WHO5", [3, 3, 3, 3, 3])).toBe(60);
|
||||
// Raw 12 (below the raw-13 cut) → 48 on the percentage scale.
|
||||
expect(scoreTotal("WHO5", [3, 3, 2, 2, 2])).toBe(48);
|
||||
});
|
||||
|
||||
it("SCI sums to 0–32 (higher = better sleep)", () => {
|
||||
expect(scoreTotal("SCI", Array(8).fill(0))).toBe(0);
|
||||
expect(scoreTotal("SCI", Array(8).fill(4))).toBe(32);
|
||||
expect(scoreTotal("SCI", [4, 3, 2, 1, 0, 4, 1, 1])).toBe(16);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -62,12 +88,47 @@ describe("mental-health instruments", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("action threshold is ≥10 for both instruments", () => {
|
||||
it("PHQ-9", () => {
|
||||
expect(INSTRUMENTS.PHQ9.actionThreshold).toBe(10);
|
||||
describe("severityBand — WHO-5 (below 50 = low; achievable totals are ×4)", () => {
|
||||
const cases: Array<[number, string]> = [
|
||||
[0, "low"],
|
||||
[48, "low"], // raw 12 — below the WHO raw-13 cut
|
||||
[52, "good"], // raw 13 — at/above the cut
|
||||
[100, "good"],
|
||||
];
|
||||
it.each(cases)("WHO5 total %i → %s", (total, band) => {
|
||||
expect(severityBand("WHO5", total)).toBe(band);
|
||||
});
|
||||
it("GAD-7", () => {
|
||||
expect(INSTRUMENTS.GAD7.actionThreshold).toBe(10);
|
||||
});
|
||||
|
||||
describe("severityBand — SCI (≤16 probable-insomnia range per Espie 2014)", () => {
|
||||
const cases: Array<[number, string]> = [
|
||||
[0, "belowThreshold"],
|
||||
[16, "belowThreshold"],
|
||||
[17, "aboveThreshold"],
|
||||
[32, "aboveThreshold"],
|
||||
];
|
||||
it.each(cases)("SCI total %i → %s", (total, band) => {
|
||||
expect(severityBand("SCI", total)).toBe(band);
|
||||
});
|
||||
});
|
||||
|
||||
describe("needsFollowUp — direction-aware thresholds", () => {
|
||||
it("PHQ-9 / GAD-7 point up (≥ 10)", () => {
|
||||
expect(needsFollowUp("PHQ9", 9)).toBe(false);
|
||||
expect(needsFollowUp("PHQ9", 10)).toBe(true);
|
||||
expect(needsFollowUp("GAD7", 9)).toBe(false);
|
||||
expect(needsFollowUp("GAD7", 10)).toBe(true);
|
||||
});
|
||||
|
||||
it("WHO-5 points down (≤ 50 → gentle PHQ-9 pointer)", () => {
|
||||
expect(needsFollowUp("WHO5", 48)).toBe(true);
|
||||
expect(needsFollowUp("WHO5", 50)).toBe(true);
|
||||
expect(needsFollowUp("WHO5", 52)).toBe(false);
|
||||
});
|
||||
|
||||
it("SCI points down (≤ 16)", () => {
|
||||
expect(needsFollowUp("SCI", 16)).toBe(true);
|
||||
expect(needsFollowUp("SCI", 17)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -82,13 +143,70 @@ describe("mental-health instruments", () => {
|
||||
it("does not flag when item 9 = 0 even with a high total", () => {
|
||||
// Every other item maxed (24), item 9 = 0 → severe band, no flag.
|
||||
const items = [3, 3, 3, 3, 3, 3, 3, 3, 0];
|
||||
expect(severityBand("PHQ9", scoreTotal(items))).toBe("severe");
|
||||
expect(severityBand("PHQ9", scoreTotal("PHQ9", items))).toBe("severe");
|
||||
expect(isSafetyFlagged("PHQ9", items)).toBe(false);
|
||||
});
|
||||
|
||||
it("never flags GAD-7 (no safety item)", () => {
|
||||
it("never flags the instruments without a safety item", () => {
|
||||
expect(isSafetyFlagged("GAD7", [3, 3, 3, 3, 3, 3, 3])).toBe(false);
|
||||
expect(isSafetyFlagged("WHO5", [0, 0, 0, 0, 0])).toBe(false);
|
||||
expect(isSafetyFlagged("SCI", [0, 0, 0, 0, 0, 0, 0, 0])).toBe(false);
|
||||
expect(INSTRUMENTS.GAD7.safetyItemIndex).toBeNull();
|
||||
expect(INSTRUMENTS.WHO5.safetyItemIndex).toBeNull();
|
||||
expect(INSTRUMENTS.SCI.safetyItemIndex).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("registry shape — the config the surfaces drive from", () => {
|
||||
it("presents options in the source order (WHO-5 leads with 5, SCI with 4)", () => {
|
||||
expect(INSTRUMENTS.WHO5.optionOrder).toEqual([5, 4, 3, 2, 1, 0]);
|
||||
expect(INSTRUMENTS.SCI.optionOrder).toEqual([4, 3, 2, 1, 0]);
|
||||
expect(INSTRUMENTS.PHQ9.optionOrder).toEqual([0, 1, 2, 3]);
|
||||
});
|
||||
|
||||
it("resolves option-label keys per scheme (shared vs SCI per-item groups)", () => {
|
||||
expect(optionLabelKey("PHQ9", 0, 2)).toBe("options.2");
|
||||
expect(optionLabelKey("WHO5", 4, 5)).toBe("who5Options.5");
|
||||
// SCI: items 1–2 duration, 3 nights, 4 quality, 5–7 impact, 8 problem duration.
|
||||
expect(optionLabelKey("SCI", 0, 4)).toBe("sciOptions.duration.4");
|
||||
expect(optionLabelKey("SCI", 2, 0)).toBe("sciOptions.nights.0");
|
||||
expect(optionLabelKey("SCI", 3, 2)).toBe("sciOptions.quality.2");
|
||||
expect(optionLabelKey("SCI", 6, 1)).toBe("sciOptions.impact.1");
|
||||
expect(optionLabelKey("SCI", 7, 3)).toBe("sciOptions.problemDuration.3");
|
||||
});
|
||||
|
||||
it("maps recall stems (WHO-5 one stem; SCI three sections; PHQ/GAD none)", () => {
|
||||
expect(stemKey("WHO5", 0)).toBe("who5.period");
|
||||
expect(stemKey("SCI", 0)).toBe("sci.night");
|
||||
expect(stemKey("SCI", 3)).toBe("sci.night");
|
||||
expect(stemKey("SCI", 4)).toBe("sci.impact");
|
||||
expect(stemKey("SCI", 7)).toBe("sci.finally");
|
||||
expect(stemKey("PHQ9", 0)).toBeNull();
|
||||
expect(stemKey("GAD7", 0)).toBeNull();
|
||||
});
|
||||
|
||||
it("declares validated item locales honestly (SCI = English only)", () => {
|
||||
for (const locale of ["de", "en", "es", "fr", "it", "pl"]) {
|
||||
expect(hasValidatedItems("WHO5", locale)).toBe(true);
|
||||
expect(hasValidatedItems("PHQ9", locale)).toBe(true);
|
||||
}
|
||||
expect(hasValidatedItems("SCI", "en")).toBe(true);
|
||||
expect(hasValidatedItems("SCI", "en-GB")).toBe(true);
|
||||
expect(hasValidatedItems("SCI", "de")).toBe(false);
|
||||
expect(hasValidatedItems("SCI", "pl")).toBe(false);
|
||||
});
|
||||
|
||||
it("projects each instrument onto its measurement type", () => {
|
||||
expect(INSTRUMENT_MEASUREMENT_TYPE.WHO5).toBe("WHO5_SCORE");
|
||||
expect(INSTRUMENT_MEASUREMENT_TYPE.SCI).toBe("SCI_SCORE");
|
||||
});
|
||||
|
||||
it("every instrument carries a non-empty attribution line", () => {
|
||||
for (const def of Object.values(INSTRUMENTS)) {
|
||||
expect(def.attribution.length).toBeGreaterThan(20);
|
||||
}
|
||||
expect(INSTRUMENTS.WHO5.attribution).toContain("CC BY-NC-SA 3.0 IGO");
|
||||
expect(INSTRUMENTS.SCI.attribution).toContain("BMJ Open");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,58 +1,123 @@
|
||||
/**
|
||||
* v1.25 — validated mental-health screener definitions (PHQ-9 + GAD-7).
|
||||
* v1.27.9 — the registry generalises to carry the WHO-5 well-being index and
|
||||
* the Sleep Condition Indicator (SCI) on the same infrastructure: per-item
|
||||
* answer scales, response-option schemes, score direction, and the reported
|
||||
* total (WHO-5 reports raw-sum × 4 as a 0–100 percentage) are now instrument
|
||||
* properties instead of PHQ/GAD assumptions.
|
||||
*
|
||||
* Pure, server-authoritative scoring. The instrument item wording, 0–3 scale,
|
||||
* totals, and severity bands are taken from the primary sources (Kroenke 2001
|
||||
* for PHQ-9; Spitzer 2006 for GAD-7). This module owns the canonical NUMERIC
|
||||
* contract; the human wording lives in `messages/*.json` under
|
||||
* `mentalHealth.items.*` (officially-validated translations per locale, English
|
||||
* fallback otherwise — re-wording invalidates the score). The de/es/fr/it/pl
|
||||
* item wordings are the public-domain Pfizer/PRIME-MD validated localized
|
||||
* versions distributed via the MAPI Research Trust (de: Löwe/Gräfe et al.); they
|
||||
* are NOT machine-translated. See `.planning/research/v125/clinical-instruments.md`.
|
||||
* Pure, server-authoritative scoring. Item wording, response anchors, scoring
|
||||
* and band thresholds are taken from the primary sources only:
|
||||
* - PHQ-9: Kroenke 2001; GAD-7: Spitzer 2006 (public-domain Pfizer grant).
|
||||
* - WHO-5: WHO/UCN/MSD/MHE/2024.1 (© WHO 2024, CC BY-NC-SA 3.0 IGO) — raw
|
||||
* 0–25, × 4 → 0–100; "a percentage score below 50 … an indication for
|
||||
* further assessment". WHO publishes official translations for all six
|
||||
* app locales; they ship verbatim.
|
||||
* - SCI: Espie et al., BMJ Open 2014;4:e004183 (© Sleepio Ltd, licensed
|
||||
* free for non-commercial use, CC BY-NC) — 8 items, 0–4 each, total 0–32,
|
||||
* HIGHER = better sleep; "a score of ≤16 … seems reasonable to detect
|
||||
* possible insomnia disorder". Item text is validated in English; app
|
||||
* locales without a validated translation present the English items.
|
||||
* This module owns the canonical NUMERIC contract; the human wording lives in
|
||||
* `messages/*.json` under `mentalHealth.items.*` (validated translations per
|
||||
* locale where they exist — re-wording invalidates the score). See
|
||||
* `.planning/research/screening-instruments-2026-07.md`.
|
||||
*
|
||||
* PHQ-9 item 9 (index 8) is the passive self-harm ideation screen and is the
|
||||
* safety-critical field (see `crisis-resources.ts` + the API handler): any
|
||||
* non-zero answer triggers a calm, non-alarmist crisis-resource signpost. The
|
||||
* raw item answers are NEVER exposed to the Coach / Insights / export surfaces —
|
||||
* only the derived total score rides a `*_SCORE` Measurement row.
|
||||
* non-zero answer triggers a calm, non-alarmist crisis-resource signpost.
|
||||
* Neither the WHO-5 (all items positively worded) nor the SCI (sleep-specific
|
||||
* items only) carries a crisis item. The raw item answers are NEVER exposed
|
||||
* to the Coach / Insights / export surfaces — only the derived total score
|
||||
* rides a `*_SCORE` Measurement row.
|
||||
*/
|
||||
|
||||
export type InstrumentId = "PHQ9" | "GAD7";
|
||||
export type InstrumentId = "PHQ9" | "GAD7" | "WHO5" | "SCI";
|
||||
|
||||
export interface InstrumentDefinition {
|
||||
id: InstrumentId;
|
||||
/** Lowercase i18n slug — the key segment under `mentalHealth.*`. */
|
||||
i18nKey: "phq9" | "gad7" | "who5" | "sci";
|
||||
/** Number of scored items (PHQ-9 = 9, GAD-7 = 7, WHO-5 = 5, SCI = 8). */
|
||||
itemCount: number;
|
||||
/** Highest per-item answer value (PHQ/GAD 3, SCI 4, WHO-5 5). */
|
||||
itemMax: number;
|
||||
/**
|
||||
* Answer values in the order the source instrument presents them. The
|
||||
* PHQ/GAD forms run 0→3; the WHO-5 form leads with 5 ("All of the time");
|
||||
* the SCI form leads with 4.
|
||||
*/
|
||||
optionOrder: readonly number[];
|
||||
/**
|
||||
* i18n namespace for the response-option labels under `mentalHealth.`:
|
||||
* without `optionGroups` one shared anchor set (`<ns>.<value>`); with
|
||||
* `optionGroups` (SCI) per-item anchor groups (`<ns>.<group>.<value>`,
|
||||
* group = `optionGroups[itemIndex]`).
|
||||
*/
|
||||
optionNamespace: string;
|
||||
optionGroups?: readonly string[];
|
||||
/**
|
||||
* Recall-stem caption key per item under `mentalHealth.stems.` — the WHO-5
|
||||
* and SCI items are fragments under a shared stem on the source forms, so
|
||||
* the wizard paints the applicable stem above the question. PHQ/GAD keep
|
||||
* their frame in the instrument description line (shipped contract).
|
||||
*/
|
||||
stemKeys?: readonly string[];
|
||||
/** Multiplier from item-sum to the reported total (WHO-5: × 4). */
|
||||
scoreMultiplier: number;
|
||||
/** Inclusive REPORTED total range (WHO-5: 0–100 percentage). */
|
||||
minScore: number;
|
||||
maxScore: number;
|
||||
/** Whether a HIGHER total is the good direction (WHO-5 / SCI). */
|
||||
higherIsBetter: boolean;
|
||||
/** Total-score LOINC for the FHIR/export contract; null when none exists. */
|
||||
totalLoinc: string | null;
|
||||
/** Severity bands, ordered low→high. `min`/`max` inclusive over the total. */
|
||||
bands: ReadonlyArray<{ key: string; min: number; max: number }>;
|
||||
/**
|
||||
* Follow-up threshold on the reported scale. The concerning side follows
|
||||
* the direction: PHQ/GAD ≥ threshold, WHO-5/SCI ≤ threshold (WHO-5 "below
|
||||
* 50" — 50 itself is unreachable on the ×4 scale; SCI ≤ 16 per Espie 2014).
|
||||
*/
|
||||
actionThreshold: number;
|
||||
/** Item index (0-based) of the safety-critical self-harm item, or null. */
|
||||
safetyItemIndex: number | null;
|
||||
/**
|
||||
* Locales whose bundled item wording is a validated translation. A locale
|
||||
* outside this list renders the validated source-language items plus an
|
||||
* honest chrome note — NEVER a self-made translation.
|
||||
*/
|
||||
validatedItemLocales: readonly string[];
|
||||
/**
|
||||
* Required attribution — render VERBATIM on the instrument's start card and
|
||||
* the result view (and in exports). Stays in English by convention (the
|
||||
* developers' names and licence identifiers are not translated).
|
||||
*/
|
||||
attribution: string;
|
||||
}
|
||||
|
||||
const ALL_APP_LOCALES = ["de", "en", "es", "fr", "it", "pl"] as const;
|
||||
|
||||
/**
|
||||
* Required attribution — render VERBATIM on every screen displaying the
|
||||
* instrument and in the PDF/FHIR export. The PHQ family is public-domain (Pfizer
|
||||
* 2010); this is the canonical footer text. By convention it stays in English
|
||||
* (the developers' names are not translated).
|
||||
* The PHQ family is public-domain (Pfizer 2010); this is the canonical footer
|
||||
* text per the instrument's own permission grant.
|
||||
*/
|
||||
export const PHQ_GAD_ATTRIBUTION =
|
||||
const PHQ_GAD_ATTRIBUTION =
|
||||
"Developed by Drs. Robert L. Spitzer, Janet B.W. Williams, Kurt Kroenke and " +
|
||||
"colleagues, with an educational grant from Pfizer Inc. No permission " +
|
||||
"required to reproduce, translate, display or distribute.";
|
||||
|
||||
export interface InstrumentDefinition {
|
||||
id: InstrumentId;
|
||||
/** Number of scored items (PHQ-9 = 9, GAD-7 = 7). */
|
||||
itemCount: number;
|
||||
/** Inclusive total range. */
|
||||
minScore: number;
|
||||
maxScore: number;
|
||||
/** Total-score LOINC for the FHIR/export contract. */
|
||||
totalLoinc: string;
|
||||
/** Severity bands, ordered low→high. `min`/`max` inclusive over the total. */
|
||||
bands: ReadonlyArray<{ key: string; min: number; max: number }>;
|
||||
/** Optimal screening cut-off (≥ this total = consider professional follow-up). */
|
||||
actionThreshold: number;
|
||||
/** Item index (0-based) of the safety-critical self-harm item, or null. */
|
||||
safetyItemIndex: number | null;
|
||||
}
|
||||
|
||||
const PHQ9: InstrumentDefinition = {
|
||||
id: "PHQ9",
|
||||
i18nKey: "phq9",
|
||||
itemCount: 9,
|
||||
itemMax: 3,
|
||||
optionOrder: [0, 1, 2, 3],
|
||||
optionNamespace: "options",
|
||||
scoreMultiplier: 1,
|
||||
minScore: 0,
|
||||
maxScore: 27,
|
||||
higherIsBetter: false,
|
||||
totalLoinc: "44261-6",
|
||||
bands: [
|
||||
{ key: "minimal", min: 0, max: 4 },
|
||||
@@ -64,13 +129,21 @@ const PHQ9: InstrumentDefinition = {
|
||||
actionThreshold: 10,
|
||||
// Item 9 ("Thoughts that you would be better off dead…") — the safety field.
|
||||
safetyItemIndex: 8,
|
||||
validatedItemLocales: ALL_APP_LOCALES,
|
||||
attribution: PHQ_GAD_ATTRIBUTION,
|
||||
};
|
||||
|
||||
const GAD7: InstrumentDefinition = {
|
||||
id: "GAD7",
|
||||
i18nKey: "gad7",
|
||||
itemCount: 7,
|
||||
itemMax: 3,
|
||||
optionOrder: [0, 1, 2, 3],
|
||||
optionNamespace: "options",
|
||||
scoreMultiplier: 1,
|
||||
minScore: 0,
|
||||
maxScore: 21,
|
||||
higherIsBetter: false,
|
||||
totalLoinc: "70274-6",
|
||||
bands: [
|
||||
{ key: "minimal", min: 0, max: 4 },
|
||||
@@ -80,25 +153,137 @@ const GAD7: InstrumentDefinition = {
|
||||
],
|
||||
actionThreshold: 10,
|
||||
safetyItemIndex: null,
|
||||
validatedItemLocales: ALL_APP_LOCALES,
|
||||
attribution: PHQ_GAD_ATTRIBUTION,
|
||||
};
|
||||
|
||||
/**
|
||||
* WHO-5 well-being index — five positively-worded items over the last two
|
||||
* weeks, 6-point 5→0 scale. Reported score = raw sum × 4 (0–100, higher =
|
||||
* better well-being) per the official WHO scoring. "A percentage score below
|
||||
* 50 … has been suggested as a cut-off for poor mental well-being and as an
|
||||
* indication for further assessment" (WHO/UCN/MSD/MHE/2024.1) — the result
|
||||
* view answers a ≤ 50 total with a gentle pointer to the PHQ-9 check-in.
|
||||
* Achievable totals are multiples of 4, so the 0–50 band equals "below 50".
|
||||
*/
|
||||
const WHO5: InstrumentDefinition = {
|
||||
id: "WHO5",
|
||||
i18nKey: "who5",
|
||||
itemCount: 5,
|
||||
itemMax: 5,
|
||||
optionOrder: [5, 4, 3, 2, 1, 0],
|
||||
optionNamespace: "who5Options",
|
||||
stemKeys: Array(5).fill("who5.period"),
|
||||
scoreMultiplier: 4,
|
||||
minScore: 0,
|
||||
maxScore: 100,
|
||||
higherIsBetter: true,
|
||||
totalLoinc: null,
|
||||
bands: [
|
||||
{ key: "low", min: 0, max: 50 },
|
||||
{ key: "good", min: 51, max: 100 },
|
||||
],
|
||||
actionThreshold: 50,
|
||||
safetyItemIndex: null,
|
||||
// WHO publishes official de/en/es/fr/it/pl versions — all bundled verbatim.
|
||||
validatedItemLocales: ALL_APP_LOCALES,
|
||||
attribution:
|
||||
"World Health Organization. The World Health Organization-Five Well-Being " +
|
||||
"Index (WHO-5). Geneva: World Health Organization; 2024. Licence: " +
|
||||
"CC BY-NC-SA 3.0 IGO. WHO does not endorse this application.",
|
||||
};
|
||||
|
||||
/**
|
||||
* Sleep Condition Indicator — eight DSM-5-aligned items, 0–4 each with
|
||||
* item-specific anchors, total 0–32, HIGHER = better sleep. "A score of ≤16
|
||||
* on the SCI seems reasonable to detect possible insomnia disorder" (Espie
|
||||
* et al. 2014) — the band wording stays neutral per the paper, no diagnosis
|
||||
* language. Items 1–4 ask about a typical night in the last month, items 5–7
|
||||
* about the past month's daytime impact, item 8 about problem duration.
|
||||
*/
|
||||
const SCI: InstrumentDefinition = {
|
||||
id: "SCI",
|
||||
i18nKey: "sci",
|
||||
itemCount: 8,
|
||||
itemMax: 4,
|
||||
optionOrder: [4, 3, 2, 1, 0],
|
||||
optionNamespace: "sciOptions",
|
||||
optionGroups: [
|
||||
"duration",
|
||||
"duration",
|
||||
"nights",
|
||||
"quality",
|
||||
"impact",
|
||||
"impact",
|
||||
"impact",
|
||||
"problemDuration",
|
||||
],
|
||||
stemKeys: [
|
||||
"sci.night",
|
||||
"sci.night",
|
||||
"sci.night",
|
||||
"sci.night",
|
||||
"sci.impact",
|
||||
"sci.impact",
|
||||
"sci.impact",
|
||||
"sci.finally",
|
||||
],
|
||||
scoreMultiplier: 1,
|
||||
minScore: 0,
|
||||
maxScore: 32,
|
||||
higherIsBetter: true,
|
||||
totalLoinc: null,
|
||||
bands: [
|
||||
{ key: "belowThreshold", min: 0, max: 16 },
|
||||
{ key: "aboveThreshold", min: 17, max: 32 },
|
||||
],
|
||||
actionThreshold: 16,
|
||||
safetyItemIndex: null,
|
||||
// Validated in English (the CC BY-NC BMJ Open publication). French/Italian
|
||||
// validations exist in the literature but their item text is not openly
|
||||
// redistributable, so every non-EN locale presents the English items with
|
||||
// an honest note — never a self-made translation.
|
||||
validatedItemLocales: ["en"],
|
||||
attribution:
|
||||
"Espie CA, Kyle SD, Hames P, et al. The Sleep Condition Indicator: a " +
|
||||
"clinical screening tool to evaluate insomnia disorder. BMJ Open " +
|
||||
"2014;4:e004183. © Sleepio Limited; free for non-commercial use (CC BY-NC).",
|
||||
};
|
||||
|
||||
export const INSTRUMENTS: Record<InstrumentId, InstrumentDefinition> = {
|
||||
PHQ9,
|
||||
GAD7,
|
||||
WHO5,
|
||||
SCI,
|
||||
};
|
||||
|
||||
/** Landing / history ordering — the shipped pair first, then the new pair. */
|
||||
export const INSTRUMENT_ORDER: readonly InstrumentId[] = [
|
||||
"PHQ9",
|
||||
"GAD7",
|
||||
"WHO5",
|
||||
"SCI",
|
||||
];
|
||||
|
||||
/** The DB MeasurementType the instrument's total score projects to. */
|
||||
export const INSTRUMENT_MEASUREMENT_TYPE: Record<InstrumentId, string> = {
|
||||
PHQ9: "PHQ9_SCORE",
|
||||
GAD7: "GAD7_SCORE",
|
||||
WHO5: "WHO5_SCORE",
|
||||
SCI: "SCI_SCORE",
|
||||
};
|
||||
|
||||
/** Sum the item answers into a total (server-authoritative). */
|
||||
export function scoreTotal(items: readonly number[]): number {
|
||||
return items.reduce((sum, v) => sum + v, 0);
|
||||
/**
|
||||
* The REPORTED total for a completed administration (server-authoritative):
|
||||
* item-sum × the instrument's multiplier (WHO-5 reports the 0–100 percentage,
|
||||
* everything else the raw sum).
|
||||
*/
|
||||
export function scoreTotal(id: InstrumentId, items: readonly number[]): number {
|
||||
const sum = items.reduce((acc, v) => acc + v, 0);
|
||||
return sum * INSTRUMENTS[id].scoreMultiplier;
|
||||
}
|
||||
|
||||
/** Resolve the severity-band key for a total score. */
|
||||
/** Resolve the severity-band key for a reported total score. */
|
||||
export function severityBand(id: InstrumentId, total: number): string {
|
||||
const def = INSTRUMENTS[id];
|
||||
const band = def.bands.find((b) => total >= b.min && total <= b.max);
|
||||
@@ -106,6 +291,50 @@ export function severityBand(id: InstrumentId, total: number): string {
|
||||
return band?.key ?? def.bands[def.bands.length - 1].key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the gentle follow-up hint applies for a reported total. Direction-
|
||||
* aware: PHQ-9/GAD-7 point up (≥ 10), WHO-5 and the SCI point down (≤ 50 /
|
||||
* ≤ 16 — lower totals mean lower well-being / worse sleep).
|
||||
*/
|
||||
export function needsFollowUp(id: InstrumentId, total: number): boolean {
|
||||
const def = INSTRUMENTS[id];
|
||||
return def.higherIsBetter
|
||||
? total <= def.actionThreshold
|
||||
: total >= def.actionThreshold;
|
||||
}
|
||||
|
||||
/**
|
||||
* i18n key (under `mentalHealth.`) for one response option of one item —
|
||||
* shared anchors (`options.2`), instrument-specific shared anchors
|
||||
* (`who5Options.5`), or the SCI's per-item anchor groups
|
||||
* (`sciOptions.duration.4`).
|
||||
*/
|
||||
export function optionLabelKey(
|
||||
id: InstrumentId,
|
||||
itemIndex: number,
|
||||
value: number,
|
||||
): string {
|
||||
const def = INSTRUMENTS[id];
|
||||
return def.optionGroups
|
||||
? `${def.optionNamespace}.${def.optionGroups[itemIndex]}.${value}`
|
||||
: `${def.optionNamespace}.${value}`;
|
||||
}
|
||||
|
||||
/** Stem caption key (under `mentalHealth.stems.`) for an item, or null. */
|
||||
export function stemKey(id: InstrumentId, itemIndex: number): string | null {
|
||||
return INSTRUMENTS[id].stemKeys?.[itemIndex] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the bundled item wording for `locale` is a validated translation.
|
||||
* When false the surface shows the validated source-language items plus a
|
||||
* localized "validated in English" chrome note.
|
||||
*/
|
||||
export function hasValidatedItems(id: InstrumentId, locale: string): boolean {
|
||||
const base = locale.toLowerCase().split("-")[0];
|
||||
return INSTRUMENTS[id].validatedItemLocales.includes(base);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the safety signpost must be shown for these answers. True iff the
|
||||
* instrument has a safety item and that item was answered with any non-zero
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* OpenAPI route table for the opt-in mental-health screeners
|
||||
* (`/api/mental-health/assessments`). PHQ-9 / GAD-7.
|
||||
* (`/api/mental-health/assessments`). PHQ-9 / GAD-7 / WHO-5 / SCI.
|
||||
*
|
||||
* Part of the OpenAPI route table; aggregated in `./index.ts`. The request body
|
||||
* reuses the runtime Zod schema from `@/lib/validations/mental-health` so the
|
||||
@@ -23,7 +23,7 @@ import { dataEnvelope, stdResponses } from "./shared";
|
||||
createAssessmentSchema.meta({
|
||||
id: "CreateMentalHealthAssessmentRequest",
|
||||
description:
|
||||
"Record one completed PHQ-9 (9 items) / GAD-7 (7 items) administration. Each item answer is 0–3; the array length must match the instrument. The item answers are encrypted at rest and never returned. The server computes the total + severity band + item-9 safety flag. Opt-in, beside mood tracking — this is a screening surface, not a diagnosis.",
|
||||
"Record one completed screener administration: PHQ-9 (9 items, 0–3), GAD-7 (7 items, 0–3), WHO-5 (5 items, 0–5) or SCI (8 items, 0–4). The array length and per-item ceiling must match the instrument. The item answers are encrypted at rest and never returned. The server computes the total (WHO-5 reports raw-sum × 4 as 0–100) + severity band + item-9 safety flag. Opt-in, beside mood tracking — this is a screening surface, not a diagnosis.",
|
||||
});
|
||||
|
||||
listAssessmentsSchema.meta({
|
||||
@@ -35,7 +35,7 @@ listAssessmentsSchema.meta({
|
||||
const assessmentRow = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
instrument: z.enum(["PHQ9", "GAD7"]),
|
||||
instrument: z.enum(["PHQ9", "GAD7", "WHO5", "SCI"]),
|
||||
locale: z.string(),
|
||||
version: z.string(),
|
||||
totalScore: z.number(),
|
||||
@@ -87,7 +87,7 @@ export const mentalHealthPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Mental health"],
|
||||
summary: "List the caller's screener history",
|
||||
description:
|
||||
"Returns PHQ-9 / GAD-7 administrations (newest first). Totals + bands + flags only; raw item answers are never returned.",
|
||||
"Returns PHQ-9 / GAD-7 / WHO-5 / SCI administrations (newest first). Totals + bands + flags only; raw item answers are never returned.",
|
||||
requestParams: { query: listAssessmentsSchema },
|
||||
responses: {
|
||||
"200": {
|
||||
@@ -106,9 +106,9 @@ export const mentalHealthPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
post: {
|
||||
tags: ["Mental health"],
|
||||
summary: "Record a completed PHQ-9 / GAD-7 screener",
|
||||
summary: "Record a completed screener",
|
||||
description:
|
||||
"Stores one administration (item answers encrypted) and writes the derived total as a server-owned PHQ9_SCORE / GAD7_SCORE measurement (source COMPUTED — never client-writable). On a positive PHQ-9 item-9 the response carries the locale-aware crisis-resource set.",
|
||||
"Stores one administration (item answers encrypted) and writes the derived total as a server-owned PHQ9_SCORE / GAD7_SCORE / WHO5_SCORE / SCI_SCORE measurement (source COMPUTED — never client-writable). WHO-5 and SCI totals run HIGHER = better; their follow-up thresholds point downwards (≤ 50 / ≤ 16). On a positive PHQ-9 item-9 the response carries the locale-aware crisis-resource set.",
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": { schema: createAssessmentSchema },
|
||||
|
||||
@@ -205,6 +205,10 @@ export function getPRDirection(
|
||||
// all). All defer to a null PR direction.
|
||||
case "PHQ9_SCORE":
|
||||
case "GAD7_SCORE":
|
||||
// v1.27.9 — WHO-5 / SCI screening totals follow the same no-gamification
|
||||
// safety rule (no streaks / "best score" framing around a screener).
|
||||
case "WHO5_SCORE":
|
||||
case "SCI_SCORE":
|
||||
case "PAIN_NRS":
|
||||
case "WAIST_CIRCUMFERENCE":
|
||||
case "WAIST_TO_HEIGHT":
|
||||
|
||||
@@ -1272,6 +1272,47 @@ export const SIGNALS: Record<string, SignalDefinition> = {
|
||||
category: "survey",
|
||||
},
|
||||
},
|
||||
// ── v1.27.9 — WHO-5 well-being + Sleep Condition Indicator totals ───────
|
||||
// Same privacy posture as PHQ-9 / GAD-7 (off Coach snapshot + MCP; item
|
||||
// content never rides a Measurement row). Direction inverts: HIGHER is
|
||||
// better for both. No verified total-score LOINC exists for either, so no
|
||||
// FHIR coding is attached (the export skips uncoded signals rather than
|
||||
// shipping an invented code).
|
||||
WHO5_SCORE: {
|
||||
key: "WHO5_SCORE",
|
||||
kind: "score",
|
||||
source: { measurementType: "WHO5_SCORE" },
|
||||
displayName: "WHO-5 well-being index (0–100)",
|
||||
unit: "score",
|
||||
direction: "higher-better",
|
||||
archetype: "physiological-vital",
|
||||
// "Below 50" is the WHO-suggested cut-off; achievable totals are
|
||||
// multiples of 4, so ≥ 52 is the good side.
|
||||
normalRange: { low: 52, high: 100 },
|
||||
surfaces: {
|
||||
detailPage: false,
|
||||
correlationEligible: false,
|
||||
coachSnapshot: false,
|
||||
mcp: false,
|
||||
},
|
||||
},
|
||||
SCI_SCORE: {
|
||||
key: "SCI_SCORE",
|
||||
kind: "score",
|
||||
source: { measurementType: "SCI_SCORE" },
|
||||
displayName: "Sleep Condition Indicator (total)",
|
||||
unit: "score",
|
||||
direction: "higher-better",
|
||||
archetype: "physiological-vital",
|
||||
// > 16 identified "no insomnia disorder" in Espie et al. 2014.
|
||||
normalRange: { low: 17, high: 32 },
|
||||
surfaces: {
|
||||
detailPage: false,
|
||||
correlationEligible: false,
|
||||
coachSnapshot: false,
|
||||
mcp: false,
|
||||
},
|
||||
},
|
||||
// ── v1.25 clinical-signals wave — physical / clinical measurements ──────
|
||||
GRIP_STRENGTH: {
|
||||
key: "GRIP_STRENGTH",
|
||||
|
||||
@@ -37,10 +37,26 @@ describe("measurementReminderTypeEnum (V3 — completeness)", () => {
|
||||
// v1.27.6 — plannable mental-wellbeing screenings
|
||||
"PHQ9_SCORE",
|
||||
"GAD7_SCORE",
|
||||
// v1.27.9 — WHO-5 + SCI join on the identical contract
|
||||
"WHO5_SCORE",
|
||||
"SCI_SCORE",
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts the WHO-5 / SCI screenings with the 4-week default cadence", () => {
|
||||
for (const measurementType of ["WHO5_SCORE", "SCI_SCORE"]) {
|
||||
const parsed = createMeasurementReminderSchema.safeParse({
|
||||
label: "Screening check-in",
|
||||
measurementType,
|
||||
intervalDays: 28,
|
||||
});
|
||||
expect(parsed.success, `expected ${measurementType} to be allowed`).toBe(
|
||||
true,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts a plannable screening reminder with the 4-week default cadence", () => {
|
||||
const parsed = createMeasurementReminderSchema.safeParse({
|
||||
label: "PHQ-9 Check-in",
|
||||
|
||||
@@ -473,9 +473,15 @@ describe("measurement validation", () => {
|
||||
it("rejects a COMPUTED-attributed screener score row on a client write", () => {
|
||||
// v1.27.6 — the mental-wellbeing screener projects each completed
|
||||
// administration onto a COMPUTED-source PHQ9_SCORE / GAD7_SCORE row
|
||||
// (the RECOVERY_SCORE precedent). COMPUTED is not client-attributable,
|
||||
// so a client can never mint a server-owned score trend point.
|
||||
for (const type of ["PHQ9_SCORE", "GAD7_SCORE"]) {
|
||||
// (the RECOVERY_SCORE precedent; v1.27.9 adds WHO5_SCORE / SCI_SCORE).
|
||||
// COMPUTED is not client-attributable, so a client can never mint a
|
||||
// server-owned score trend point.
|
||||
for (const type of [
|
||||
"PHQ9_SCORE",
|
||||
"GAD7_SCORE",
|
||||
"WHO5_SCORE",
|
||||
"SCI_SCORE",
|
||||
]) {
|
||||
const parsed = createMeasurementSchema.safeParse({
|
||||
type,
|
||||
value: 5,
|
||||
|
||||
@@ -64,11 +64,16 @@ export const measurementReminderTypeEnum = z
|
||||
// the dispatcher (see `moduleForMeasurementType`).
|
||||
"PHQ9_SCORE",
|
||||
"GAD7_SCORE",
|
||||
// v1.27.9 — WHO-5 + SCI join the plannable screenings on the identical
|
||||
// contract (auto-resolve on the server-owned COMPUTED score row, module-
|
||||
// gated in the dispatcher).
|
||||
"WHO5_SCORE",
|
||||
"SCI_SCORE",
|
||||
])
|
||||
.meta({
|
||||
id: "MeasurementReminderType",
|
||||
description:
|
||||
"Auto-resolve target metric (vitals + body-composition family + the PHQ-9 / GAD-7 screenings). BP resolves on BLOOD_PRESSURE_SYS (the SYS row is the 'a BP was measured' sentinel); a screening reminder resolves on the server-written PHQ9_SCORE / GAD7_SCORE row when a check-in completes. Omit for a free-text Vorsorge that resolves only on a manual satisfy or a matching lab result.",
|
||||
"Auto-resolve target metric (vitals + body-composition family + the PHQ-9 / GAD-7 / WHO-5 / SCI screenings). BP resolves on BLOOD_PRESSURE_SYS (the SYS row is the 'a BP was measured' sentinel); a screening reminder resolves on the server-written *_SCORE row when a check-in completes. Omit for a free-text Vorsorge that resolves only on a manual satisfy or a matching lab result.",
|
||||
});
|
||||
|
||||
export type MeasurementReminderType = z.infer<
|
||||
@@ -85,6 +90,8 @@ export type MeasurementReminderType = z.infer<
|
||||
export const SCREENING_REMINDER_TYPES: ReadonlySet<string> = new Set([
|
||||
"PHQ9_SCORE",
|
||||
"GAD7_SCORE",
|
||||
"WHO5_SCORE",
|
||||
"SCI_SCORE",
|
||||
]);
|
||||
|
||||
/** Whether a reminder's `measurementType` targets a screening check-in. */
|
||||
|
||||
@@ -135,6 +135,11 @@ export const measurementTypeEnum = z.enum([
|
||||
"PAIN_NRS",
|
||||
"WAIST_CIRCUMFERENCE",
|
||||
"WAIST_TO_HEIGHT",
|
||||
// ── v1.27.9 — screening scores (same assessment contract as PHQ-9/GAD-7,
|
||||
// inverted direction: HIGHER is better). WHO-5 stores the reported 0–100
|
||||
// percentage (raw sum × 4); SCI stores the 0–32 total.
|
||||
"WHO5_SCORE",
|
||||
"SCI_SCORE",
|
||||
]);
|
||||
|
||||
/**
|
||||
@@ -379,6 +384,9 @@ const unitMap: Record<string, string> = {
|
||||
// ── v1.25 — clinical-signals wave ──
|
||||
PHQ9_SCORE: "score",
|
||||
GAD7_SCORE: "score",
|
||||
// ── v1.27.9 — screening scores ──
|
||||
WHO5_SCORE: "score",
|
||||
SCI_SCORE: "score",
|
||||
GRIP_STRENGTH: "kg",
|
||||
PAIN_NRS: "score",
|
||||
WAIST_CIRCUMFERENCE: "cm",
|
||||
@@ -581,6 +589,10 @@ export const VALUE_RANGES: Record<string, { min: number; max: number }> = {
|
||||
// PHQ-9 total 0–27, GAD-7 total 0–21 — server-derived from the screener.
|
||||
PHQ9_SCORE: { min: 0, max: 27 },
|
||||
GAD7_SCORE: { min: 0, max: 21 },
|
||||
// ── v1.27.9 — screening scores. WHO-5 reported percentage 0–100 (raw × 4),
|
||||
// SCI total 0–32 — both server-derived, higher = better.
|
||||
WHO5_SCORE: { min: 0, max: 100 },
|
||||
SCI_SCORE: { min: 0, max: 32 },
|
||||
// Grip strength kg — a child can read in the single digits, an elite athlete
|
||||
// tops ~90 kg; 120 is a generous ceiling that still rejects sensor noise.
|
||||
GRIP_STRENGTH: { min: 0, max: 120 },
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
/**
|
||||
* v1.25 — request/response validation for the opt-in mental-health screeners
|
||||
* (PHQ-9 / GAD-7). The item answers are 0–3 integers; the array length must
|
||||
* match the instrument (PHQ-9 = 9, GAD-7 = 7). `userId` is NEVER a body field —
|
||||
* it is narrowed from the session in the handler (CLAUDE.md convention).
|
||||
* (PHQ-9 / GAD-7; v1.27.9 adds WHO-5 + SCI). The item answers are small
|
||||
* integers whose ceiling is instrument-specific (PHQ/GAD 0–3, SCI 0–4,
|
||||
* WHO-5 0–5); the array length must match the instrument. `userId` is NEVER
|
||||
* a body field — it is narrowed from the session in the handler (CLAUDE.md
|
||||
* convention).
|
||||
*/
|
||||
import { z } from "zod/v4";
|
||||
import {
|
||||
@@ -10,7 +12,7 @@ import {
|
||||
type InstrumentId,
|
||||
} from "@/lib/mental-health/instruments";
|
||||
|
||||
export const assessmentInstrumentEnum = z.enum(["PHQ9", "GAD7"]);
|
||||
export const assessmentInstrumentEnum = z.enum(["PHQ9", "GAD7", "WHO5", "SCI"]);
|
||||
|
||||
/**
|
||||
* Client provenance for an administration. Closed enum so the column can never
|
||||
@@ -21,12 +23,15 @@ export const assessmentInstrumentEnum = z.enum(["PHQ9", "GAD7"]);
|
||||
*/
|
||||
export const assessmentSourceEnum = z.enum(["WEB", "IOS"]);
|
||||
|
||||
const itemAnswer = z.number().int().min(0).max(3);
|
||||
// Structural ceiling — the WHO-5's 0–5 scale is the widest. The exact
|
||||
// per-instrument ceiling (PHQ/GAD 3, SCI 4) is enforced in the superRefine
|
||||
// below, where the instrument is known.
|
||||
const itemAnswer = z.number().int().min(0).max(5);
|
||||
|
||||
export const createAssessmentSchema = z
|
||||
.object({
|
||||
instrument: assessmentInstrumentEnum,
|
||||
/** Per-item answers, each 0–3, ordered as the instrument presents them. */
|
||||
/** Per-item answers, ordered as the instrument presents them. */
|
||||
items: z.array(itemAnswer).min(1).max(12),
|
||||
/** Optional functional-impairment follow-up (not scored into the total). */
|
||||
functionalDifficulty: z.number().int().min(0).max(3).optional(),
|
||||
@@ -55,6 +60,15 @@ export const createAssessmentSchema = z
|
||||
message: `${val.instrument} expects exactly ${def.itemCount} item answers`,
|
||||
});
|
||||
}
|
||||
val.items.forEach((answer, index) => {
|
||||
if (answer > def.itemMax) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["items", index],
|
||||
message: `${val.instrument} answers range 0–${def.itemMax}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
export const listAssessmentsSchema = z.object({
|
||||
|
||||
Reference in new issue
Block a user