Merge branch 'feat/w-g-screenings' into release/v1.27.10

This commit is contained in:
Marc-André Bombeck committed 2026-07-05 20:36:50 +02:00
commit 6e59fe5ce0
40 files changed
+2180 -352

No files matched your search

@@ -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,
+8 -1
View File
@@ -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. */
+12
View File
@@ -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 },
+20 -6
View File
@@ -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({