mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +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,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;
|
||||
}
|
||||
Reference in new issue
Block a user