mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
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:
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 });
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user