feat(mental-health): measure screener change against the meaningful-change threshold

The assessments list now carries, per PHQ-9, GAD-7 and WHO-5, the latest
total against the previous and the first administration, classified
against the change studies treat as meaningful: 5 points on the PHQ-9,
4 on the GAD-7, 10 on the WHO-5 percentage scale. A smaller change reads
as within the threshold rather than better or worse. The instrument
detail states each change and whether it reaches that size, in neutral
words. The read takes totals and dates only, never an answer.
This commit is contained in:
Marc-André Bombeck committed 2026-10-21 21:59:42 +02:00
1 parent 2ba6214a65
commit 5c64192afc
20 files changed
+659 -15

No files matched your search

@@ -30,6 +30,7 @@ const ANALYSIS_MODULES = [
"lib/insights/discovery-matrix.ts",
"lib/insights/derived/changepoint.ts",
"lib/labs/lab-trend.ts",
"lib/mental-health/assessment-trend.ts",
] as const;
/** Reachable under `lib/ai/` without being a model: the at-rest cipher. */
@@ -479,4 +479,50 @@ describe("GET /api/mental-health/assessments", () => {
.calls[0][0] as { orderBy: unknown };
expect(findArgs.orderBy).toEqual([{ takenAt: "desc" }, { id: "desc" }]);
});
it("carries each screener's change against its meaningful-change threshold (v1.44)", async () => {
const at = (d: string) => new Date(`${d}T09:00:00.000Z`);
const history = [
{
id: "p2",
instrument: "PHQ9",
totalScore: 8,
takenAt: at("2026-06-01"),
},
{
id: "p1",
instrument: "PHQ9",
totalScore: 15,
takenAt: at("2026-02-01"),
},
];
vi.mocked(prisma.mentalHealthAssessment.findMany)
.mockResolvedValueOnce([] as never)
.mockResolvedValueOnce(history as never);
const res = await (GET as unknown as (r: NextRequest) => Promise<Response>)(
new NextRequest(
new URL("http://localhost/api/mental-health/assessments?limit=1"),
),
);
const body = await res.json();
expect(body.data.trends).toEqual([
expect.objectContaining({
instrument: "PHQ9",
n: 2,
threshold: 5,
changeFromPrevious: -7,
sincePrevious: "improved",
}),
]);
// The trend read selects totals and dates only, never the answers.
const trendArgs = vi.mocked(prisma.mentalHealthAssessment.findMany).mock
.calls[1][0] as { select: Record<string, boolean>; where: unknown };
expect(Object.keys(trendArgs.select).sort()).toEqual([
"id",
"instrument",
"takenAt",
"totalScore",
]);
expect(trendArgs.where).toMatchObject({ deletedAt: null });
});
});
+26 -2
View File
@@ -58,6 +58,16 @@ import type {
MeasurementSource,
} from "@/generated/prisma/client";
import { apiListSuccess } from "@/lib/api/list-cap";
import {
computeAssessmentTrends,
TREND_INSTRUMENTS,
} from "@/lib/mental-health/assessment-trend";
/**
* Upper bound on administrations the trends read; years of weekly check-ins
* across three instruments stay far below it. The cap falls on the oldest.
*/
const TREND_READ_CAP = 1500;
import { listPage } from "@/lib/http/list-query";
function shapeRow(row: {
@@ -149,11 +159,25 @@ export const GET = apiHandler(async (request: NextRequest) => {
},
}),
);
// v1.44 — the change of each screener against its meaningful-change
// threshold, over the whole history rather than the page: totals and dates
// only, never an item answer.
const trendRows = await prisma.mentalHealthAssessment.findMany({
where: {
userId: user.id,
deletedAt: null,
instrument: { in: TREND_INSTRUMENTS },
},
orderBy: [{ takenAt: "desc" }, { id: "desc" }],
take: TREND_READ_CAP,
select: { id: true, instrument: true, totalScore: true, takenAt: true },
});
const trends = computeAssessmentTrends(trendRows);
annotate({
action: { name: "mental-health.list" },
meta: { count: rows.length },
meta: { count: rows.length, trends: trends.length },
});
return apiListSuccess({ assessments: rows.map(shapeRow) }, cap);
return apiListSuccess({ assessments: rows.map(shapeRow), trends }, cap);
});
// `withIdempotency` lets a double-tap / retry re-send the same
@@ -0,0 +1,65 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { I18nProvider } from "@/lib/i18n/context";
import { primeLocales } from "@/__tests__/helpers/prime-locales";
import { AssessmentTrendNote } from "../assessment-trend-note";
import type { AssessmentTrendDto } from "../types";
primeLocales("de");
const TREND: AssessmentTrendDto = {
instrument: "PHQ9",
n: 3,
threshold: 5,
higherIsBetter: false,
first: { score: 16, takenAt: "2026-01-10T09:00:00.000Z" },
previous: { score: 12, takenAt: "2026-03-15T09:00:00.000Z" },
latest: { score: 9, takenAt: "2026-06-01T09:00:00.000Z" },
changeFromPrevious: -3,
changeFromFirst: -7,
sincePrevious: "within_threshold",
sinceFirst: "improved",
};
const render = (trend: AssessmentTrendDto, locale: "en" | "de" = "en") =>
renderToStaticMarkup(
<I18nProvider initialLocale={locale}>
<AssessmentTrendNote trend={trend} />
</I18nProvider>,
);
describe("<AssessmentTrendNote>", () => {
it("states each change and whether it reaches the threshold", () => {
const html = render(TREND);
expect(html).toContain("-3 points");
expect(html).toContain("smaller than the 5-point change");
expect(html).toContain("-7 points");
expect(html).toContain('data-verdict="improved"');
});
it("shows a rise with its sign, in German with du", () => {
const html = render(
{ ...TREND, n: 2, changeFromPrevious: 6, sincePrevious: "worsened" },
"de",
);
expect(html).toContain("Seit deinem vorigen Check-in");
expect(html).toContain("+6 Punkte");
expect(html).not.toContain("ersten");
});
it("is silent with a single check-in", () => {
expect(
render({
...TREND,
n: 1,
previous: null,
changeFromPrevious: null,
changeFromFirst: null,
sincePrevious: null,
sinceFirst: null,
}),
).toBe("");
});
});
@@ -0,0 +1,58 @@
"use client";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import type { AssessmentTrendDto } from "./types";
/**
* v1.44 — how the latest score compares with the previous check-in (and,
* from the third, with the first), against the change size studies treat as
* meaningful for this instrument. Every number and verdict is the server's;
* the wording stays neutral: a change, its size, and whether it reaches the
* threshold. No band words, no advice.
*/
export function AssessmentTrendNote({ trend }: { trend: AssessmentTrendDto }) {
const { t } = useTranslations();
const { date: formatDate, number } = useFormatters();
if (trend.previous === null || trend.changeFromPrevious === null) return null;
const signed = (delta: number) =>
delta > 0 ? `+${number(delta, 0)}` : number(delta, 0);
const line = (
key: "sincePrevious" | "sinceFirst",
at: string,
delta: number,
verdict: NonNullable<AssessmentTrendDto["sincePrevious"]>,
) =>
`${t(`mentalHealth.trend.${key}`, {
date: formatDate(at),
change: signed(delta),
})} ${t(`mentalHealth.trend.verdict.${verdict}`, {
threshold: trend.threshold,
})}`;
return (
<div className="space-y-1 text-sm" data-slot="assessment-trend">
{trend.sincePrevious ? (
<p data-verdict={trend.sincePrevious}>
{line(
"sincePrevious",
trend.previous.takenAt,
trend.changeFromPrevious,
trend.sincePrevious,
)}
</p>
) : null}
{trend.n >= 3 && trend.sinceFirst && trend.changeFromFirst !== null ? (
<p data-verdict={trend.sinceFirst}>
{line(
"sinceFirst",
trend.first.takenAt,
trend.changeFromFirst,
trend.sinceFirst,
)}
</p>
) : null}
</div>
);
}
@@ -21,16 +21,20 @@ 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";
import type { AssessmentRow, AssessmentTrendDto, InstrumentId } from "./types";
import { AssessmentTrendNote } from "./assessment-trend-note";
export function InstrumentDetail({
instrument,
rows,
trend,
onStart,
}: {
instrument: InstrumentId;
/** ALL assessments; the pinned history filters to this instrument. */
rows: AssessmentRow[];
/** v1.44 — the server's change against the meaningful-change threshold. */
trend?: AssessmentTrendDto;
/** Start a check-in for this instrument (closes the host sheet). */
onStart: () => void;
}) {
@@ -75,6 +79,8 @@ export function InstrumentDetail({
</div>
)}
{trend ? <AssessmentTrendNote trend={trend} /> : null}
{/* The Start action stays reachable here too — the detail is a calm
reading surface, not a dead end. */}
{canManageMind && (
@@ -45,6 +45,7 @@ import { InstrumentCard } from "./instrument-card";
import { InstrumentDetail } from "./instrument-detail";
import type {
AssessmentRow,
AssessmentTrendDto,
CreateResponse,
InstrumentId,
Phase,
@@ -81,9 +82,10 @@ export function MentalWellbeing() {
} = useQuery({
queryKey: queryKeys.mentalHealthAssessments(),
queryFn: () =>
apiGetList<{ assessments: AssessmentRow[] }>(
"/api/mental-health/assessments",
),
apiGetList<{
assessments: AssessmentRow[];
trends?: AssessmentTrendDto[];
}>("/api/mental-health/assessments"),
});
const history = historyPage?.data;
@@ -229,6 +231,9 @@ export function MentalWellbeing() {
<InstrumentDetail
instrument={detailInstrument}
rows={history?.assessments ?? []}
trend={history?.trends?.find(
(trend) => trend.instrument === detailInstrument,
)}
onStart={() => begin(detailInstrument)}
/>
)}
+15
View File
@@ -48,3 +48,18 @@ export interface CreateResponse {
actionThreshold: number;
crisis: CrisisSet | null;
}
/** v1.44 — `GET /api/mental-health/assessments` `trends[]` (`computeAssessmentTrends`). */
export interface AssessmentTrendDto {
instrument: "PHQ9" | "GAD7" | "WHO5";
n: number;
threshold: number;
higherIsBetter: boolean;
first: { score: number; takenAt: string };
previous: { score: number; takenAt: string } | null;
latest: { score: number; takenAt: string };
changeFromPrevious: number | null;
changeFromFirst: number | null;
sincePrevious: "improved" | "worsened" | "within_threshold" | null;
sinceFirst: "improved" | "worsened" | "within_threshold" | null;
}
@@ -0,0 +1,80 @@
import { describe, expect, it } from "vitest";
import {
computeAssessmentTrends,
MEANINGFUL_CHANGE,
verdictForChange,
} from "../assessment-trend";
const row = (
id: string,
instrument: string,
totalScore: number,
day: string,
) => ({
id,
instrument,
totalScore,
takenAt: new Date(`${day}T09:00:00.000Z`),
});
describe("meaningful-change thresholds", () => {
it("are the published minimal important differences", () => {
expect(MEANINGFUL_CHANGE).toEqual({ PHQ9: 5, GAD7: 4, WHO5: 10 });
});
it("classify in the instrument's direction, inclusive at the threshold", () => {
expect(verdictForChange(-5, 5, false)).toBe("improved");
expect(verdictForChange(-4, 5, false)).toBe("within_threshold");
expect(verdictForChange(5, 5, false)).toBe("worsened");
expect(verdictForChange(12, 10, true)).toBe("improved");
expect(verdictForChange(-10, 10, true)).toBe("worsened");
});
});
describe("computeAssessmentTrends", () => {
it("compares the latest score with the previous and the first", () => {
const trends = computeAssessmentTrends([
row("c", "PHQ9", 9, "2026-06-01"),
row("a", "PHQ9", 16, "2026-01-10"),
row("b", "PHQ9", 12, "2026-03-15"),
row("g", "GAD7", 10, "2026-03-15"),
row("h", "GAD7", 13, "2026-06-01"),
row("w", "WHO5", 44, "2026-06-01"),
row("s", "SCI", 20, "2026-06-01"),
]);
expect(trends.map((t) => t.instrument)).toEqual(["PHQ9", "GAD7", "WHO5"]);
const [phq, gad, who] = trends;
expect(phq).toMatchObject({
n: 3,
threshold: 5,
changeFromPrevious: -3,
changeFromFirst: -7,
sincePrevious: "within_threshold",
sinceFirst: "improved",
first: { score: 16 },
latest: { score: 9 },
});
expect(gad).toMatchObject({
n: 2,
changeFromPrevious: 3,
sincePrevious: "within_threshold",
});
expect(who).toMatchObject({
n: 1,
previous: null,
changeFromPrevious: null,
sincePrevious: null,
sinceFirst: null,
});
});
it("orders same-day administrations by id", () => {
const [phq] = computeAssessmentTrends([
row("b", "PHQ9", 20, "2026-06-01"),
row("a", "PHQ9", 10, "2026-06-01"),
]);
expect(phq.latest.score).toBe(20);
expect(phq.sincePrevious).toBe("worsened");
});
});
+134
View File
@@ -0,0 +1,134 @@
/**
* v1.44 — screener change measured against the size studies treat as
* meaningful, instead of the last score on its own.
*
* A difference between two administrations is only worth naming when it is
* at least the instrument's minimal clinically important difference, which
* the primary literature puts at:
*
* - PHQ-9: 5 points (Löwe, Unützer, Callahan, Perkins & Kroenke 2004,
* Med Care 42(12):1194–1201).
* - GAD-7: 4 points (Toussaint, Hüsing, Gumz et al. 2020, J Affect Disord
* 265:395–401).
* - WHO-5: 10 points on the 0–100 percentage scale (Topp, Østergaard,
* Søndergaard & Bech 2015, Psychother Psychosom 84(3):167–176).
*
* A change below the threshold reads as "within the threshold", not as
* better or worse; a change at or beyond it reads in the instrument's own
* direction (lower is better on PHQ-9 / GAD-7, higher on WHO-5). This is a
* description of the person's own scores, never a diagnosis and never a
* treatment response; the SCI has no published threshold of this kind and
* gets no trend. Pure; the read lives in the assessments route.
*/
import {
INSTRUMENTS,
type InstrumentId,
} from "@/lib/mental-health/instruments";
export const MEANINGFUL_CHANGE: Readonly<
Partial<Record<InstrumentId, number>>
> = {
PHQ9: 5,
GAD7: 4,
WHO5: 10,
};
export const TREND_INSTRUMENTS = Object.keys(
MEANINGFUL_CHANGE,
) as InstrumentId[];
export type ChangeVerdict = "improved" | "worsened" | "within_threshold";
export interface ScorePoint {
score: number;
takenAt: string;
}
export interface AssessmentTrend {
instrument: InstrumentId;
/** Administrations of this instrument on record. */
n: number;
/** The meaningful-change threshold, in the instrument's reported points. */
threshold: number;
higherIsBetter: boolean;
first: ScorePoint;
previous: ScorePoint | null;
latest: ScorePoint;
/** latest − previous; null with one administration. */
changeFromPrevious: number | null;
/** latest − first; null with one administration. */
changeFromFirst: number | null;
sincePrevious: ChangeVerdict | null;
sinceFirst: ChangeVerdict | null;
}
/** Classify a signed change against the threshold in the instrument's direction. */
export function verdictForChange(
delta: number,
threshold: number,
higherIsBetter: boolean,
): ChangeVerdict {
if (Math.abs(delta) < threshold) return "within_threshold";
return delta > 0 === higherIsBetter ? "improved" : "worsened";
}
/**
* One trend per instrument that has a threshold and at least one
* administration, in the instrument order. Rows may arrive in any order;
* they are ordered by `takenAt`, then `id`.
*/
export function computeAssessmentTrends(
rows: ReadonlyArray<{
id: string;
instrument: string;
totalScore: number;
takenAt: Date;
}>,
): AssessmentTrend[] {
const out: AssessmentTrend[] = [];
for (const instrument of TREND_INSTRUMENTS) {
const threshold = MEANINGFUL_CHANGE[instrument] as number;
const series = rows
.filter(
(r) => r.instrument === instrument && Number.isFinite(r.totalScore),
)
.sort(
(a, b) =>
a.takenAt.getTime() - b.takenAt.getTime() || (a.id < b.id ? -1 : 1),
);
if (series.length === 0) continue;
const { higherIsBetter } = INSTRUMENTS[instrument];
const point = (r: (typeof series)[number]): ScorePoint => ({
score: r.totalScore,
takenAt: r.takenAt.toISOString(),
});
const latest = series[series.length - 1];
const previous = series.length > 1 ? series[series.length - 2] : null;
const first = series[0];
const changeFromPrevious = previous
? latest.totalScore - previous.totalScore
: null;
const changeFromFirst =
series.length > 1 ? latest.totalScore - first.totalScore : null;
out.push({
instrument,
n: series.length,
threshold,
higherIsBetter,
first: point(first),
previous: previous ? point(previous) : null,
latest: point(latest),
changeFromPrevious,
changeFromFirst,
sincePrevious:
changeFromPrevious === null
? null
: verdictForChange(changeFromPrevious, threshold, higherIsBetter),
sinceFirst:
changeFromFirst === null
? null
: verdictForChange(changeFromFirst, threshold, higherIsBetter),
});
}
return out;
}
+41 -1
View File
@@ -100,8 +100,48 @@ const createResponse = z
})
.meta({ id: "CreateMentalHealthAssessmentResponse" });
const scorePoint = z.object({
score: z.number(),
takenAt: z.string(),
});
const changeVerdict = z
.enum(["improved", "worsened", "within_threshold"])
.nullable();
const assessmentTrend = z
.object({
instrument: z.enum(["PHQ9", "GAD7", "WHO5"]),
n: z.number().int(),
threshold: z
.number()
.describe(
"Meaningful-change threshold in reported points: PHQ-9 5, GAD-7 4, WHO-5 10 (0–100 scale).",
),
higherIsBetter: z.boolean(),
first: scorePoint,
previous: scorePoint.nullable(),
latest: scorePoint,
changeFromPrevious: z.number().nullable(),
changeFromFirst: z.number().nullable(),
sincePrevious: changeVerdict,
sinceFirst: changeVerdict,
})
.meta({
id: "MentalHealthAssessmentTrend",
description:
"v1.44 — the latest total against the previous and the first administration, classified against the instrument's meaningful-change threshold in its own direction. A change below the threshold is `within_threshold`. Server-computed over the whole history, not the page.",
});
const listResponse = z
.object({ assessments: z.array(assessmentRow) })
.object({
assessments: z.array(assessmentRow),
trends: z
.array(assessmentTrend)
.optional()
.describe(
"v1.44 — one per PHQ-9 / GAD-7 / WHO-5 with at least one administration. Absent on older servers.",
),
})
.meta({ id: "ListMentalHealthAssessmentsResponse" });
const detailResponse = z