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

@@ -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