mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +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
@@ -74922,6 +74922,11 @@ components:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/MentalHealthAssessment"
|
||||
trends:
|
||||
description: v1.44 — one per PHQ-9 / GAD-7 / WHO-5 with at least one administration. Absent on older servers.
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/MentalHealthAssessmentTrend"
|
||||
required:
|
||||
- assessments
|
||||
additionalProperties: false
|
||||
@@ -74971,6 +74976,99 @@ components:
|
||||
diagnosis). `item9Flagged` is true when the PHQ-9 self-harm item was answered > 0 — the client MUST show crisis
|
||||
resources when it is. Raw per-item answers are intentionally absent from list rows; the by-id detail read
|
||||
returns them decrypted.
|
||||
MentalHealthAssessmentTrend:
|
||||
type: object
|
||||
properties:
|
||||
instrument:
|
||||
type: string
|
||||
enum:
|
||||
- PHQ9
|
||||
- GAD7
|
||||
- WHO5
|
||||
n:
|
||||
type: integer
|
||||
minimum: -9007199254740991
|
||||
maximum: 9007199254740991
|
||||
threshold:
|
||||
type: number
|
||||
description: "Meaningful-change threshold in reported points: PHQ-9 5, GAD-7 4, WHO-5 10 (0–100 scale)."
|
||||
higherIsBetter:
|
||||
type: boolean
|
||||
first:
|
||||
type: object
|
||||
properties:
|
||||
score:
|
||||
type: number
|
||||
takenAt:
|
||||
type: string
|
||||
required:
|
||||
- score
|
||||
- takenAt
|
||||
additionalProperties: false
|
||||
previous:
|
||||
anyOf:
|
||||
- type: object
|
||||
properties:
|
||||
score:
|
||||
type: number
|
||||
takenAt:
|
||||
type: string
|
||||
required:
|
||||
- score
|
||||
- takenAt
|
||||
additionalProperties: false
|
||||
- type: "null"
|
||||
latest:
|
||||
type: object
|
||||
properties:
|
||||
score:
|
||||
type: number
|
||||
takenAt:
|
||||
type: string
|
||||
required:
|
||||
- score
|
||||
- takenAt
|
||||
additionalProperties: false
|
||||
changeFromPrevious:
|
||||
type:
|
||||
- number
|
||||
- "null"
|
||||
changeFromFirst:
|
||||
type:
|
||||
- number
|
||||
- "null"
|
||||
sincePrevious:
|
||||
anyOf:
|
||||
- type: string
|
||||
enum:
|
||||
- improved
|
||||
- worsened
|
||||
- within_threshold
|
||||
- type: "null"
|
||||
sinceFirst:
|
||||
anyOf:
|
||||
- type: string
|
||||
enum:
|
||||
- improved
|
||||
- worsened
|
||||
- within_threshold
|
||||
- type: "null"
|
||||
required:
|
||||
- instrument
|
||||
- n
|
||||
- threshold
|
||||
- higherIsBetter
|
||||
- first
|
||||
- previous
|
||||
- latest
|
||||
- changeFromPrevious
|
||||
- changeFromFirst
|
||||
- sincePrevious
|
||||
- sinceFirst
|
||||
additionalProperties: false
|
||||
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.
|
||||
CreateMentalHealthAssessmentEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
+10
-1
@@ -11075,7 +11075,16 @@
|
||||
"openDetail": "Details anzeigen",
|
||||
"historyLoadError": "Dein Testverlauf konnte nicht geladen werden",
|
||||
"historyLoadErrorHint": "Das heißt nicht, dass keine Ergebnisse vorliegen. Die Liste konnte nicht abgerufen werden. Versuche es erneut.",
|
||||
"privacyNote": "Deine Antworten sind verschlüsselt gespeichert und erreichen den KI-Coach nicht."
|
||||
"privacyNote": "Deine Antworten sind verschlüsselt gespeichert und erreichen den KI-Coach nicht.",
|
||||
"trend": {
|
||||
"sincePrevious": "Seit deinem vorigen Check-in am {date}: {change} Punkte.",
|
||||
"sinceFirst": "Seit deinem ersten Check-in am {date}: {change} Punkte.",
|
||||
"verdict": {
|
||||
"improved": "Das erreicht die Veränderung von {threshold} Punkten, die Studien als bedeutsam ansehen, in die bessere Richtung.",
|
||||
"worsened": "Das erreicht die Veränderung von {threshold} Punkten, die Studien als bedeutsam ansehen, in die weniger gute Richtung.",
|
||||
"within_threshold": "Das ist weniger als die Veränderung von {threshold} Punkten, die Studien als bedeutsam ansehen."
|
||||
}
|
||||
}
|
||||
},
|
||||
"records": {
|
||||
"aiInclusion": {
|
||||
|
||||
+10
-1
@@ -11075,7 +11075,16 @@
|
||||
"openDetail": "Show details",
|
||||
"historyLoadError": "Your assessment history could not be loaded",
|
||||
"historyLoadErrorHint": "This does not mean you have no results; the list could not be fetched. Try again.",
|
||||
"privacyNote": "Your answers are stored encrypted and never reach the AI Coach."
|
||||
"privacyNote": "Your answers are stored encrypted and never reach the AI Coach.",
|
||||
"trend": {
|
||||
"sincePrevious": "Since your previous check-in on {date}: {change} points.",
|
||||
"sinceFirst": "Since your first check-in on {date}: {change} points.",
|
||||
"verdict": {
|
||||
"improved": "That reaches the {threshold}-point change studies treat as meaningful, in the better direction.",
|
||||
"worsened": "That reaches the {threshold}-point change studies treat as meaningful, in the less good direction.",
|
||||
"within_threshold": "That is smaller than the {threshold}-point change studies treat as meaningful."
|
||||
}
|
||||
}
|
||||
},
|
||||
"records": {
|
||||
"aiInclusion": {
|
||||
|
||||
+10
-1
@@ -11075,7 +11075,16 @@
|
||||
"openDetail": "Ver detalles",
|
||||
"historyLoadError": "No se pudo cargar tu historial de evaluaciones",
|
||||
"historyLoadErrorHint": "Esto no significa que no tengas resultados: no se pudo obtener la lista. Vuelve a intentarlo.",
|
||||
"privacyNote": "Tus respuestas se guardan cifradas y nunca llegan al Coach de IA."
|
||||
"privacyNote": "Tus respuestas se guardan cifradas y nunca llegan al Coach de IA.",
|
||||
"trend": {
|
||||
"sincePrevious": "Desde tu revisión anterior del {date}: {change} puntos.",
|
||||
"sinceFirst": "Desde tu primera revisión del {date}: {change} puntos.",
|
||||
"verdict": {
|
||||
"improved": "Alcanza el cambio de {threshold} puntos que los estudios consideran relevante, en la dirección favorable.",
|
||||
"worsened": "Alcanza el cambio de {threshold} puntos que los estudios consideran relevante, en la dirección menos favorable.",
|
||||
"within_threshold": "Es menor que el cambio de {threshold} puntos que los estudios consideran relevante."
|
||||
}
|
||||
}
|
||||
},
|
||||
"records": {
|
||||
"aiInclusion": {
|
||||
|
||||
+10
-1
@@ -11075,7 +11075,16 @@
|
||||
"openDetail": "Voir les détails",
|
||||
"historyLoadError": "Ton historique d'évaluations n'a pas pu être chargé",
|
||||
"historyLoadErrorHint": "Cela ne veut pas dire que tu n'as aucun résultat : la liste n'a pas pu être récupérée. Réessaie.",
|
||||
"privacyNote": "Vos réponses sont stockées chiffrées et n'atteignent jamais le Coach IA."
|
||||
"privacyNote": "Vos réponses sont stockées chiffrées et n'atteignent jamais le Coach IA.",
|
||||
"trend": {
|
||||
"sincePrevious": "Depuis ton bilan précédent du {date} : {change} points.",
|
||||
"sinceFirst": "Depuis ton premier bilan du {date} : {change} points.",
|
||||
"verdict": {
|
||||
"improved": "Cela atteint la variation de {threshold} points que les études jugent significative, dans le bon sens.",
|
||||
"worsened": "Cela atteint la variation de {threshold} points que les études jugent significative, dans le sens moins favorable.",
|
||||
"within_threshold": "C'est moins que la variation de {threshold} points que les études jugent significative."
|
||||
}
|
||||
}
|
||||
},
|
||||
"records": {
|
||||
"aiInclusion": {
|
||||
|
||||
+10
-1
@@ -11075,7 +11075,16 @@
|
||||
"openDetail": "Mostra dettagli",
|
||||
"historyLoadError": "Non è stato possibile caricare la cronologia dei test",
|
||||
"historyLoadErrorHint": "Questo non significa che non ci siano risultati: non è stato possibile recuperare l'elenco. Riprova.",
|
||||
"privacyNote": "Le tue risposte sono salvate cifrate e non raggiungono mai il Coach IA."
|
||||
"privacyNote": "Le tue risposte sono salvate cifrate e non raggiungono mai il Coach IA.",
|
||||
"trend": {
|
||||
"sincePrevious": "Dal tuo check-in precedente del {date}: {change} punti.",
|
||||
"sinceFirst": "Dal tuo primo check-in del {date}: {change} punti.",
|
||||
"verdict": {
|
||||
"improved": "Raggiunge la variazione di {threshold} punti che gli studi considerano rilevante, nella direzione migliore.",
|
||||
"worsened": "Raggiunge la variazione di {threshold} punti che gli studi considerano rilevante, nella direzione meno buona.",
|
||||
"within_threshold": "È meno della variazione di {threshold} punti che gli studi considerano rilevante."
|
||||
}
|
||||
}
|
||||
},
|
||||
"records": {
|
||||
"aiInclusion": {
|
||||
|
||||
+10
-1
@@ -11075,7 +11075,16 @@
|
||||
"openDetail": "자세히 보기",
|
||||
"historyLoadError": "검사 기록을 불러오지 못했어요",
|
||||
"historyLoadErrorHint": "결과가 없다는 뜻은 아니에요. 목록을 가져오지 못했을 뿐이에요. 다시 시도해 주세요.",
|
||||
"privacyNote": "답변은 암호화되어 저장되고, AI 코치에게는 전달되지 않아요."
|
||||
"privacyNote": "답변은 암호화되어 저장되고, AI 코치에게는 전달되지 않아요.",
|
||||
"trend": {
|
||||
"sincePrevious": "{date} 이전 체크인 이후: {change}점.",
|
||||
"sinceFirst": "{date} 첫 체크인 이후: {change}점.",
|
||||
"verdict": {
|
||||
"improved": "연구에서 의미 있다고 보는 {threshold}점 변화에 도달했고, 좋아지는 방향입니다.",
|
||||
"worsened": "연구에서 의미 있다고 보는 {threshold}점 변화에 도달했고, 덜 좋은 방향입니다.",
|
||||
"within_threshold": "연구에서 의미 있다고 보는 {threshold}점 변화보다 작습니다."
|
||||
}
|
||||
}
|
||||
},
|
||||
"records": {
|
||||
"aiInclusion": {
|
||||
|
||||
+10
-1
@@ -11075,7 +11075,16 @@
|
||||
"openDetail": "Details tonen",
|
||||
"historyLoadError": "Je testgeschiedenis kon niet worden geladen",
|
||||
"historyLoadErrorHint": "Dit betekent niet dat je geen resultaten hebt; de lijst kon niet worden opgehaald. Probeer het opnieuw.",
|
||||
"privacyNote": "Je antwoorden worden versleuteld opgeslagen en komen nooit bij de AI-coach terecht."
|
||||
"privacyNote": "Je antwoorden worden versleuteld opgeslagen en komen nooit bij de AI-coach terecht.",
|
||||
"trend": {
|
||||
"sincePrevious": "Sinds je vorige check-in op {date}: {change} punten.",
|
||||
"sinceFirst": "Sinds je eerste check-in op {date}: {change} punten.",
|
||||
"verdict": {
|
||||
"improved": "Dat bereikt de verandering van {threshold} punten die studies als betekenisvol zien, in de betere richting.",
|
||||
"worsened": "Dat bereikt de verandering van {threshold} punten die studies als betekenisvol zien, in de minder goede richting.",
|
||||
"within_threshold": "Dat is minder dan de verandering van {threshold} punten die studies als betekenisvol zien."
|
||||
}
|
||||
}
|
||||
},
|
||||
"records": {
|
||||
"aiInclusion": {
|
||||
|
||||
+10
-1
@@ -11075,7 +11075,16 @@
|
||||
"openDetail": "Pokaż szczegóły",
|
||||
"historyLoadError": "Nie udało się wczytać historii testów",
|
||||
"historyLoadErrorHint": "To nie znaczy, że nie masz wyników; nie udało się pobrać listy. Spróbuj ponownie.",
|
||||
"privacyNote": "Twoje odpowiedzi są przechowywane w postaci zaszyfrowanej i nigdy nie trafiają do Coacha AI."
|
||||
"privacyNote": "Twoje odpowiedzi są przechowywane w postaci zaszyfrowanej i nigdy nie trafiają do Coacha AI.",
|
||||
"trend": {
|
||||
"sincePrevious": "Od poprzedniego sprawdzenia {date}: {change} pkt.",
|
||||
"sinceFirst": "Od pierwszego sprawdzenia {date}: {change} pkt.",
|
||||
"verdict": {
|
||||
"improved": "To osiąga zmianę o {threshold} pkt, którą badania uznają za istotną, w lepszym kierunku.",
|
||||
"worsened": "To osiąga zmianę o {threshold} pkt, którą badania uznają za istotną, w mniej korzystnym kierunku.",
|
||||
"within_threshold": "To mniej niż zmiana o {threshold} pkt, którą badania uznają za istotną."
|
||||
}
|
||||
}
|
||||
},
|
||||
"records": {
|
||||
"aiInclusion": {
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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