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