diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index c9b3511d3..1b7a1c041 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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: diff --git a/messages/de.json b/messages/de.json index fc61d1d46..7fa10ef44 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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": { diff --git a/messages/en.json b/messages/en.json index be2db6114..3e8a2fd08 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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": { diff --git a/messages/es.json b/messages/es.json index 287b36a61..74d46bd52 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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": { diff --git a/messages/fr.json b/messages/fr.json index d1c564694..b5fe83be3 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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": { diff --git a/messages/it.json b/messages/it.json index dd0c39841..d4b271a51 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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": { diff --git a/messages/ko.json b/messages/ko.json index f8ac7397c..80faf9d15 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -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": { diff --git a/messages/nl.json b/messages/nl.json index bc81ba2ae..e4bf20265 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -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": { diff --git a/messages/pl.json b/messages/pl.json index 3939cf2eb..8913a25f1 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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": { diff --git a/src/__tests__/analysis-depth-no-ai-guard.test.ts b/src/__tests__/analysis-depth-no-ai-guard.test.ts index f5aa84195..b3b9e4067 100644 --- a/src/__tests__/analysis-depth-no-ai-guard.test.ts +++ b/src/__tests__/analysis-depth-no-ai-guard.test.ts @@ -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. */ diff --git a/src/app/api/mental-health/assessments/__tests__/route.test.ts b/src/app/api/mental-health/assessments/__tests__/route.test.ts index 715d25a99..3dfa8ded0 100644 --- a/src/app/api/mental-health/assessments/__tests__/route.test.ts +++ b/src/app/api/mental-health/assessments/__tests__/route.test.ts @@ -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)( + 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; where: unknown }; + expect(Object.keys(trendArgs.select).sort()).toEqual([ + "id", + "instrument", + "takenAt", + "totalScore", + ]); + expect(trendArgs.where).toMatchObject({ deletedAt: null }); + }); }); diff --git a/src/app/api/mental-health/assessments/route.ts b/src/app/api/mental-health/assessments/route.ts index 610bceab3..740ae8d0d 100644 --- a/src/app/api/mental-health/assessments/route.ts +++ b/src/app/api/mental-health/assessments/route.ts @@ -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 diff --git a/src/components/mental-health/__tests__/assessment-trend-note.test.tsx b/src/components/mental-health/__tests__/assessment-trend-note.test.tsx new file mode 100644 index 000000000..3c81d65ee --- /dev/null +++ b/src/components/mental-health/__tests__/assessment-trend-note.test.tsx @@ -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( + + + , + ); + +describe("", () => { + 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(""); + }); +}); diff --git a/src/components/mental-health/assessment-trend-note.tsx b/src/components/mental-health/assessment-trend-note.tsx new file mode 100644 index 000000000..f604ba1f1 --- /dev/null +++ b/src/components/mental-health/assessment-trend-note.tsx @@ -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, + ) => + `${t(`mentalHealth.trend.${key}`, { + date: formatDate(at), + change: signed(delta), + })} ${t(`mentalHealth.trend.verdict.${verdict}`, { + threshold: trend.threshold, + })}`; + + return ( +
+ {trend.sincePrevious ? ( +

+ {line( + "sincePrevious", + trend.previous.takenAt, + trend.changeFromPrevious, + trend.sincePrevious, + )} +

+ ) : null} + {trend.n >= 3 && trend.sinceFirst && trend.changeFromFirst !== null ? ( +

+ {line( + "sinceFirst", + trend.first.takenAt, + trend.changeFromFirst, + trend.sinceFirst, + )} +

+ ) : null} +
+ ); +} diff --git a/src/components/mental-health/instrument-detail.tsx b/src/components/mental-health/instrument-detail.tsx index fb1b2d36b..4e798ee49 100644 --- a/src/components/mental-health/instrument-detail.tsx +++ b/src/components/mental-health/instrument-detail.tsx @@ -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({ )} + {trend ? : null} + {/* The Start action stays reachable here too — the detail is a calm reading surface, not a dead end. */} {canManageMind && ( diff --git a/src/components/mental-health/mental-wellbeing.tsx b/src/components/mental-health/mental-wellbeing.tsx index 3919ed1e6..a6341fb47 100644 --- a/src/components/mental-health/mental-wellbeing.tsx +++ b/src/components/mental-health/mental-wellbeing.tsx @@ -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() { trend.instrument === detailInstrument, + )} onStart={() => begin(detailInstrument)} /> )} diff --git a/src/components/mental-health/types.ts b/src/components/mental-health/types.ts index 26d47f766..b5b679876 100644 --- a/src/components/mental-health/types.ts +++ b/src/components/mental-health/types.ts @@ -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; +} diff --git a/src/lib/mental-health/__tests__/assessment-trend.test.ts b/src/lib/mental-health/__tests__/assessment-trend.test.ts new file mode 100644 index 000000000..5af9791c0 --- /dev/null +++ b/src/lib/mental-health/__tests__/assessment-trend.test.ts @@ -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"); + }); +}); diff --git a/src/lib/mental-health/assessment-trend.ts b/src/lib/mental-health/assessment-trend.ts new file mode 100644 index 000000000..1922be139 --- /dev/null +++ b/src/lib/mental-health/assessment-trend.ts @@ -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> +> = { + 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; +} diff --git a/src/lib/openapi/routes/mental-health.ts b/src/lib/openapi/routes/mental-health.ts index b7b81e7d8..8a15fd8e7 100644 --- a/src/lib/openapi/routes/mental-health.ts +++ b/src/lib/openapi/routes/mental-health.ts @@ -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