Merge branch 'feat/v144-analysis' into release/v1.44.0

This commit is contained in:
Marc-André Bombeck committed 2026-10-21 22:27:59 +02:00
commit 67d04630c3
74 files changed
+4633 -120

No files matched your search

@@ -155,6 +155,12 @@ export function HealthStatusCard({
before: metricValue(s.metric, s.beforeMean),
after: metricValue(s.metric, s.afterMean),
})}
{s.beforeDays !== undefined && s.afterDays !== undefined
? ` ${t("insights.healthStatus.shiftDays", {
before: s.beforeDays,
after: s.afterDays,
})}`
: null}
</p>
</div>
</div>
@@ -80,8 +80,28 @@ const CHANNEL_LABEL_KEY: Record<string, string> = {
// v1.43 — the Dawarich movement channels.
MOVEMENT_AWAY: "environment.movement.awayFromHome",
MOVEMENT_DISTANCE: "environment.movement.distance",
// v1.44 — the extended channel families.
HRV_RMSSD: "measurements.typeHrvRmssd",
EXERCISE_MINUTES: "measurements.typeExerciseMinutes",
STAND_MINUTES: "measurements.typeStandMinutes",
MINDFUL_MINUTES: "measurements.typeMindfulMinutes",
HYDRATION: "nutrients.names.water",
CAFFEINE: "nutrients.names.caffeine",
ALCOHOL: "insights.mood.discovery.channel.ALCOHOL",
WORKOUT_MINUTES: "insights.mood.discovery.channel.WORKOUT_MINUTES",
TRAINING_LOAD: "insights.mood.discovery.channel.TRAINING_LOAD",
};
/** A micronutrient channel (`NUTRIENT:<code>`) reads under its catalog name. */
const NUTRIENT_PREFIX = "NUTRIENT:";
function channelLabelKey(channelKey: string): string {
if (channelKey.startsWith(NUTRIENT_PREFIX)) {
return `nutrients.names.${channelKey.slice(NUTRIENT_PREFIX.length)}`;
}
return CHANNEL_LABEL_KEY[channelKey] ?? channelKey;
}
/** Keep only the discovered pairs that involve the mood channel. */
export function moodPairsOf(
discovered: DiscoveredCorrelation[],
@@ -131,8 +151,7 @@ export function MoodDiscoveredRelations({
const dynamicLabel = moodIsOutcome
? pair.behaviourLabel
: pair.outcomeLabel;
const factorLabel =
dynamicLabel ?? t(CHANNEL_LABEL_KEY[channelKey] ?? channelKey);
const factorLabel = dynamicLabel ?? t(channelLabelKey(channelKey));
if (
patternId &&
dismissal.isDismissed(patternId, pair.dismissed === true)
@@ -0,0 +1,74 @@
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 { LabTrendCard } from "../lab-trend-card";
import type { BiomarkerTrendDto } from "../types";
primeLocales("de");
const OK: BiomarkerTrendDto = {
status: "ok",
n: 4,
firstAt: "2023-01-10",
lastAt: "2026-01-10",
spanDays: 1096,
slopePerYear: 12.5,
slopeCi95: [4.25, 20.75],
pValue: 0.02,
fittedNow: 130,
asOf: "2026-10-21",
projection: "exits",
exitDate: "2027-11-02",
exitBound: "high",
};
function render(
trend: BiomarkerTrendDto | undefined,
locale: "en" | "de" = "en",
) {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>
<LabTrendCard trend={trend} unit="mg/dL" />
</I18nProvider>,
);
}
describe("<LabTrendCard>", () => {
it("words the server's slope and labels the exit as a projection", () => {
const html = render(OK);
expect(html).toContain("Rising by about 13 mg/dL a year across 4 readings");
expect(html).toContain('data-projection="exits"');
expect(html).toContain("upper end of your reference range");
expect(html).toContain("projection, not a forecast");
expect(html).toContain("4.25 to 20.75 mg/dL");
});
it("speaks German with du", () => {
const html = render(OK, "de");
expect(html).toContain("deines Referenzbereichs");
expect(html).toContain("Fortschreibung, keine Vorhersage");
});
it("draws no projection sentence without a clear trend, and nothing when insufficient", () => {
const html = render({
...OK,
projection: "no_clear_trend",
exitDate: null,
exitBound: null,
});
expect(html).toContain('data-projection="no_clear_trend"');
expect(html).not.toContain("forecast");
expect(
render({
status: "insufficient",
reason: "too_few_values",
n: 2,
spanDays: 40,
}),
).toBe("");
expect(render(undefined)).toBe("");
});
});
@@ -55,6 +55,7 @@ import { importWithRetry } from "@/lib/retry-import";
import { BiomarkerAliases } from "./biomarker-aliases";
import { BiomarkerForm } from "./biomarker-form";
import { LabForm } from "./lab-form";
import { LabTrendCard } from "./lab-trend-card";
import type {
BiomarkerDto,
LabResultDto,
@@ -505,6 +506,10 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
/>
</CardContent>
</Card>
<LabTrendCard
trend={marker?.trend}
unit={marker?.unit ?? latest?.unit ?? ""}
/>
</>
)}
+81
View File
@@ -0,0 +1,81 @@
"use client";
import { TrendingUp } from "lucide-react";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { TileHeader } from "@/components/insights/tile-header";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import type { BiomarkerTrendDto } from "./types";
/**
* v1.44 — the marker's slope over all its readings, and where that straight
* line would leave the reference band.
*
* Server-authoritative: every number is the route's (`computeLabTrend`); this
* only words it. Renders nothing below three readings over a year. The exit
* sentence names itself a projection, never a forecast.
*/
export function LabTrendCard({
trend,
unit,
}: {
trend: BiomarkerTrendDto | undefined;
unit: string;
}) {
const { t } = useTranslations();
const fmt = useFormatters();
if (!trend || trend.status !== "ok") return null;
const digits = Math.abs(trend.slopePerYear) >= 10 ? 0 : 2;
const per = (v: number) => fmt.number(Math.abs(v), digits);
const direction =
trend.slopePerYear > 0
? "rising"
: trend.slopePerYear < 0
? "falling"
: "flat";
const slopeLine = t(`labs.trend.slope.${direction}`, {
amount: per(trend.slopePerYear),
unit,
n: trend.n,
since: fmt.dateShortSmartCalendar(trend.firstAt),
});
const projectionLine =
trend.projection === "exits" && trend.exitDate
? t(`labs.trend.projection.exits.${trend.exitBound ?? "high"}`, {
date: fmt.dateShortSmartCalendar(trend.exitDate),
})
: trend.projection === "no_band"
? null
: t(`labs.trend.projection.${trend.projection}`);
return (
<Card data-slot="lab-trend">
<CardHeader>
<TileHeader icon={TrendingUp} title={t("labs.trend.title")} />
</CardHeader>
<CardContent className="space-y-1">
<p className="text-sm" data-slot="lab-trend-slope">
{slopeLine}
</p>
{projectionLine ? (
<p
className="text-sm"
data-slot="lab-trend-projection"
data-projection={trend.projection}
>
{projectionLine}
</p>
) : null}
<p className="text-muted-foreground text-xs">
{t("labs.trend.interval", {
lo: fmt.number(trend.slopeCi95[0], 2),
hi: fmt.number(trend.slopeCi95[1], 2),
unit,
})}
</p>
</CardContent>
</Card>
);
}
+32
View File
@@ -108,8 +108,40 @@ export interface BiomarkerDto {
hidden: boolean;
createdAt: string;
updatedAt: string;
/** v1.44 — the detail read's trend line; absent on the list and older servers. */
trend?: BiomarkerTrendDto;
}
/** v1.44 — `GET /api/biomarkers/{id}` `trend` (`computeLabTrend`). */
export type BiomarkerTrendDto =
| {
status: "ok";
n: number;
firstAt: string;
lastAt: string;
spanDays: number;
slopePerYear: number;
slopeCi95: [number, number];
pValue: number;
fittedNow: number;
asOf: string;
projection:
| "exits"
| "beyond_horizon"
| "no_exit"
| "already_outside"
| "no_clear_trend"
| "no_band";
exitDate: string | null;
exitBound: "low" | "high" | null;
}
| {
status: "insufficient";
reason: "too_few_values" | "span_too_short";
n: number;
spanDays: number;
};
export interface BiomarkerListResponse {
biomarkers: BiomarkerDto[];
}
@@ -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)}
/>
)}
+15
View File
@@ -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,59 @@
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 type { TrainingLoad } from "@/lib/insights/training-load";
import { TrainingLoadCard } from "../training-load-card";
primeLocales("de");
const OK: TrainingLoad = {
status: "ok",
asOf: "2026-10-20",
ctl: 54.2,
atl: 61.8,
tsb: -7.6,
daysCovered: 30,
trainingDays: 18,
warmingUp: true,
series: [],
};
const render = (load: TrainingLoad | undefined, locale: "en" | "de" = "en") =>
renderToStaticMarkup(
<I18nProvider initialLocale={locale}>
<TrainingLoadCard trainingLoad={load} />
</I18nProvider>,
);
describe("<TrainingLoadCard>", () => {
it("shows the server's fitness, fatigue and form", () => {
const html = render(OK);
expect(html).toContain("Training load");
expect(html).toMatch(/training-load-fitness[^]*?>54</);
expect(html).toMatch(/training-load-fatigue[^]*?>62</);
expect(html).toContain("-8");
expect(html).toContain("Based on 30 days so far");
});
it("signs a positive form and drops the warm-up note once covered", () => {
const html = render({ ...OK, tsb: 4.4, warmingUp: false }, "de");
expect(html).toContain("+");
expect(html).toContain("Trainingslast");
expect(html).not.toContain("bisher");
});
it("renders nothing while insufficient", () => {
expect(
render({
status: "insufficient",
reason: "too_little_history",
daysCovered: 12,
trainingDays: 2,
}),
).toBe("");
expect(render(undefined)).toBe("");
});
});
@@ -0,0 +1,60 @@
"use client";
import { Gauge } from "lucide-react";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { TileHeader } from "@/components/insights/tile-header";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import type { TrainingLoad } from "@/lib/insights/training-load";
/**
* v1.44 — fitness, fatigue and form from the stored training impulse, the
* server's figures as given. Renders nothing until the model has 28 days and
* three training days to stand on.
*/
export function TrainingLoadCard({
trainingLoad,
}: {
trainingLoad: TrainingLoad | undefined;
}) {
const { t } = useTranslations();
const fmt = useFormatters();
if (!trainingLoad || trainingLoad.status !== "ok") return null;
const stats = [
{ key: "fitness", value: trainingLoad.ctl },
{ key: "fatigue", value: trainingLoad.atl },
{ key: "form", value: trainingLoad.tsb },
] as const;
return (
<Card data-slot="training-load">
<CardHeader>
<TileHeader icon={Gauge} title={t("insights.workouts.load.title")} />
</CardHeader>
<CardContent className="space-y-2">
<dl className="grid grid-cols-3 gap-3">
{stats.map((stat) => (
<div key={stat.key} data-slot={`training-load-${stat.key}`}>
<dt className="text-muted-foreground text-xs">
{t(`insights.workouts.load.${stat.key}`)}
</dt>
<dd className="text-foreground text-lg font-semibold tabular-nums">
{stat.key === "form" && stat.value > 0 ? "+" : ""}
{fmt.number(stat.value, 0)}
</dd>
</div>
))}
</dl>
<p className="text-sm">{t("insights.workouts.load.explainer")}</p>
{trainingLoad.warmingUp ? (
<p className="text-muted-foreground text-xs">
{t("insights.workouts.load.warmingUp", {
days: trainingLoad.daysCovered,
})}
</p>
) : null}
</CardContent>
</Card>
);
}