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

This commit is contained in:
Marc-André Bombeck committed 2026-10-21 22:18:50 +02:00
commit 4e71cf4dc2
58 files changed
+5703 -91

No files matched your search

@@ -0,0 +1,192 @@
/**
* The two leaves the v1.44 report added, on the clinician page: the per-sport
* workout summary and the medication effect around each start. Content and
* order follow the PDF blocks in `doctor-report-pdf/activity-effect-sections`,
* and both read the same aggregated payload.
*
* A pure server component: no client hooks, no markdown — every value renders
* as escaped React text.
*/
import type { MeasurementType } from "@/generated/prisma/client";
import type { DoctorReportData } from "@/lib/doctor-report-data";
import { reportStatUnit } from "@/lib/doctor-report/stat-display";
import { resolveGlucoseUnit } from "@/lib/glucose";
import { formatEstimate } from "@/lib/medications/efficacy/segmented-format";
import { reportMeasurementLabelKey } from "@/lib/report-selection/catalogue";
import { reportSportLabel } from "@/lib/doctor-report/sport-label";
import {
LeafSection,
StatRow,
type LeafScope,
type Translate,
} from "./report-sections";
const REASON_KEYS = {
no_start: "doctorReport.medicationEffect.reasonNoStart",
insufficient_before: "doctorReport.medicationEffect.reasonBefore",
insufficient_after: "doctorReport.medicationEffect.reasonAfter",
fit_failed: "doctorReport.medicationEffect.reasonFit",
} as const;
const CONFOUNDER_KEYS = {
started: "medications.efficacy.segmented.confounderStarted",
stopped: "medications.efficacy.segmented.confounderStopped",
dose_change: "medications.efficacy.segmented.confounderDoseChange",
paused: "medications.efficacy.segmented.confounderPaused",
resumed: "medications.efficacy.segmented.confounderResumed",
} as const;
export function WorkoutsSection({
t,
report,
scope,
fmtDigits,
}: {
t: Translate;
report: DoctorReportData;
scope: LeafScope;
fmtDigits: (n: number, digits: number) => string;
}) {
const w = report.workouts ?? null;
return (
<LeafSection
t={t}
scope={scope}
leaves={["WORKOUTS"]}
title={t("doctorReport.workouts.title")}
empty={w === null || w.sessions === 0}
>
{w ? (
<>
<p className="text-muted-foreground mb-2 text-xs">
{t("doctorReport.workouts.summary", {
sessions: w.sessions,
hours: fmtDigits(w.totalDurationSec / 3600, 1),
perWeek: fmtDigits(w.sessionsPerWeek, 1),
minutesPerWeek: fmtDigits(w.minutesPerWeek, 0),
})}
</p>
{w.bySport.map((s) => (
<StatRow
key={s.sportType}
label={`${reportSportLabel(t, s.sportType)} (${s.sessions})`}
value={[
`${fmtDigits(s.durationSec / 3600, 1)} h`,
s.distanceM !== null
? `${fmtDigits(s.distanceM / 1000, 1)} km`
: null,
s.energyKcal !== null
? `${fmtDigits(s.energyKcal, 0)} kcal`
: null,
s.avgHeartRate !== null
? `${fmtDigits(s.avgHeartRate, 0)} bpm`
: null,
]
.filter(Boolean)
.join(", ")}
/>
))}
{w.sportCount > w.bySport.length ? (
<p className="text-muted-foreground mt-2 text-xs">
{t("doctorReport.workouts.moreSports", {
count: w.sportCount - w.bySport.length,
})}
</p>
) : null}
</>
) : null}
</LeafSection>
);
}
export function MedicationEffectSection({
t,
report,
scope,
fmtDigits,
fmtDay,
}: {
t: Translate;
report: DoctorReportData;
scope: LeafScope;
fmtDigits: (n: number, digits: number) => string;
/** A stated calendar day (YYYY-MM-DD) in the owner's date order. */
fmtDay: (day: string) => string;
}) {
const effects = report.medicationEffects ?? [];
const glucoseUnit = resolveGlucoseUnit(report.glucoseUnit ?? null);
return (
<LeafSection
t={t}
scope={scope}
leaves={["MEDICATION_EFFECT"]}
title={t("doctorReport.medicationEffect.title")}
empty={effects.length === 0}
>
{effects.flatMap((med) =>
med.targets.map((target) => {
const e = target.effect;
const label = `${med.medicationName}: ${t(
reportMeasurementLabelKey(target.type as MeasurementType),
)}`;
const unit = reportStatUnit(target.type, glucoseUnit, t);
if (!e.present || !e.levelChange || !e.slopeChangePerWeek) {
return (
<StatRow
key={`${med.medicationName}:${target.type}`}
label={label}
value={t(REASON_KEYS[e.reason ?? "fit_failed"])}
/>
);
}
const level = formatEstimate(e.levelChange, fmtDigits);
const slope = formatEstimate(e.slopeChangePerWeek, fmtDigits);
return (
<div
key={`${med.medicationName}:${target.type}`}
className="border-border/40 border-b py-2 last:border-0"
>
<p className="text-foreground text-sm font-medium">{label}</p>
<p className="text-foreground text-sm">
{t("medications.efficacy.segmented.level", {
...level,
unit,
})}
</p>
<p className="text-foreground text-sm">
{t("medications.efficacy.segmented.slope", {
...slope,
unit,
})}
</p>
<p className="text-muted-foreground text-xs">
{t("doctorReport.medicationEffect.days", {
before: e.days.before,
after: e.days.after,
excluded:
e.days.excludedLowAdherence + e.days.excludedNotTaking,
})}
</p>
{e.confounders.map((c) => (
<p
key={`${c.medicationName}:${c.event}:${c.date}`}
className="text-muted-foreground text-xs"
>
{t(CONFOUNDER_KEYS[c.event], {
name: c.medicationName,
date: fmtDay(c.date),
})}
</p>
))}
</div>
);
}),
)}
{effects.length > 0 ? (
<p className="text-muted-foreground mt-2 text-xs">
{t("doctorReport.medicationEffect.method")}
</p>
) : null}
</LeafSection>
);
}
@@ -52,6 +52,10 @@ import {
VisitsSection,
} from "./history-sections";
import { DoseLogSection, Glp1Section } from "./therapy-sections";
import {
MedicationEffectSection,
WorkoutsSection,
} from "./activity-effect-sections";
import {
CycleSection,
FamilyHistorySection,
@@ -219,6 +223,13 @@ export function ClinicianView({
</Section>
) : null}
<WorkoutsSection
t={t}
report={report}
scope={scope}
fmtDigits={fmt.number}
/>
{/* ── glucose and labs ─────────────────────────────────── */}
<GlucoseSection
t={t}
@@ -241,6 +252,13 @@ export function ClinicianView({
scope={scope}
fmtDate={fmtDate}
/>
<MedicationEffectSection
t={t}
report={report}
scope={scope}
fmtDigits={fmt.number}
fmtDay={(day) => fmt.dateShortSmartCalendar(day)}
/>
<Glp1Section
t={t}
report={report}
+2 -2
View File
@@ -26,11 +26,11 @@ import {
} from "@/lib/doctor-report/stat-display";
import type { FractionDigitsFormat } from "@/lib/format/recorded-number";
import { convertGlucose, resolveGlucoseUnit } from "@/lib/glucose";
import { MEASUREMENT_TYPE_LABEL_KEYS } from "@/lib/measurements/type-label-keys";
import {
isReportLeafId,
isStructuredLeafId,
REPORT_GROUPS,
reportMeasurementLabelKey,
type ReportLeafId,
} from "@/lib/report-selection/catalogue";
import type { ReportSelection } from "@/lib/report-selection/selection";
@@ -248,7 +248,7 @@ export function MeasurementGroups({
return (
<StatRow
key={type}
label={t(MEASUREMENT_TYPE_LABEL_KEYS[type])}
label={t(reportMeasurementLabelKey(type))}
value={statSummary(t, reportStatUnit(type, glucoseUnit, t), {
latest: show(type, stat!.latest, digits),
avg: show(type, stat!.avg, digits),
@@ -0,0 +1,88 @@
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import enMessages from "../../../../../../messages/en.json";
import type { EfficacySegmented } from "@/lib/medications/efficacy/segmented-effect";
import { ChangeAtStartSummary } from "../change-at-start-summary";
function est(estimate: number, se: number) {
const ciLow = estimate - 2 * se;
const ciHigh = estimate + 2 * se;
return {
estimate,
ciLow,
ciHigh,
standardError: se,
includesZero: ciLow <= 0 && ciHigh >= 0,
};
}
const present: EfficacySegmented = {
present: true,
level: 0.95,
window: { from: "2026-01-01", to: "2026-05-30", endReason: "dose_change" },
days: {
before: 80,
after: 70,
excludedLowAdherence: 6,
excludedNotTaking: 0,
},
adherence: { status: "tracked", floorPct: 80, lookbackDays: 14 },
levelChange: est(-7.34, 0.6),
slopeChangePerWeek: est(-0.26, 0.16),
preSlopePerWeek: est(0.05, 0.1),
fit: {
n: 150,
df: 146,
rSquared: 0.6,
residualAutocorrelation: 0.3,
hacLag: 4,
},
confounders: [
{ medicationName: "Example B", event: "started", date: "2026-02-10" },
],
};
function render(segmented: EfficacySegmented) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en" initialMessages={enMessages as never}>
<ChangeAtStartSummary
segmented={segmented}
unit="mmHg"
minDays={14}
minSpanDays={21}
/>
</I18nProvider>,
);
}
describe("ChangeAtStartSummary", () => {
it("states the level and trend change with their ranges in plain words", () => {
const html = render(present);
expect(html).toContain("the level moved by -7.3 mmHg");
expect(html).toContain("95 % range -8.5 to -6.1");
expect(html).toContain("changed by -0.3 mmHg per week");
// The slope interval spans zero, so the no-change reading is said aloud.
expect(html).toContain("also consistent with no change");
expect(html).toContain("Based on 80 days before and 70 days after");
expect(html).toContain("6 days after the start were left out");
expect(html).toContain("describes the starting dose");
expect(html).toContain("Example B started on");
expect(html).not.toMatch(/work(s|ing)|effective|successful/i);
});
it("names the missing floor instead of a number", () => {
const html = render({
...present,
present: false,
reason: "insufficient_before",
levelChange: null,
slopeChangePerWeek: null,
preSlopePerWeek: null,
fit: null,
});
expect(html).toContain("at least 14 days spread over 21 days before");
expect(html).not.toContain("level moved");
});
});
@@ -0,0 +1,155 @@
"use client";
/**
* The segmented-regression read-out on a Wirkung target card: the level
* change and the change in weekly trend at the start, each with its 95 %
* range, in plain sentences, followed by what the fit rests on (days used,
* days left out and why) and the other medication changes it cannot separate
* from this one. Renders the server DTO; recomputes nothing.
*/
import type { EfficacySegmented } from "@/lib/medications/efficacy/segmented-effect";
import { formatEstimate } from "@/lib/medications/efficacy/segmented-format";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
const CONFOUNDER_KEYS = {
started: "medications.efficacy.segmented.confounderStarted",
stopped: "medications.efficacy.segmented.confounderStopped",
dose_change: "medications.efficacy.segmented.confounderDoseChange",
paused: "medications.efficacy.segmented.confounderPaused",
resumed: "medications.efficacy.segmented.confounderResumed",
} as const;
const NEED_KEYS = {
no_start: "medications.efficacy.segmented.needStart",
insufficient_before: "medications.efficacy.segmented.needBefore",
insufficient_after: "medications.efficacy.segmented.needAfter",
fit_failed: "medications.efficacy.segmented.fitFailed",
} as const;
export function ChangeAtStartSummary({
segmented,
unit,
minDays,
minSpanDays,
}: {
segmented: EfficacySegmented;
unit: string | null;
minDays: number;
minSpanDays: number;
}) {
const { t } = useTranslations();
const fmt = useFormatters();
const num = (v: number, d: number) => fmt.number(v, d);
const u = unit ?? "";
if (
!segmented.present ||
!segmented.levelChange ||
!segmented.slopeChangePerWeek ||
!segmented.preSlopePerWeek ||
!segmented.window
) {
return (
<div
className="border-border/60 rounded-lg border border-dashed p-3"
data-slot="medication-wirkung-segmented-empty"
>
<p className="text-muted-foreground text-sm">
{t(NEED_KEYS[segmented.reason ?? "fit_failed"], {
days: minDays,
span: minSpanDays,
})}
</p>
</div>
);
}
const level = formatEstimate(segmented.levelChange, num);
const slope = formatEstimate(segmented.slopeChangePerWeek, num);
const pre = formatEstimate(segmented.preSlopePerWeek, num);
const anyZero =
segmented.levelChange.includesZero ||
segmented.slopeChangePerWeek.includesZero;
return (
<div
className="bg-muted/40 space-y-2 rounded-lg p-3"
data-slot="medication-wirkung-segmented"
>
<p className="text-foreground text-sm font-medium">
{t("medications.efficacy.segmented.title")}
</p>
<p className="text-foreground text-sm">
{t("medications.efficacy.segmented.level", { ...level, unit: u })}
</p>
<p className="text-foreground text-sm">
{t("medications.efficacy.segmented.slope", { ...slope, unit: u })}
</p>
<p className="text-muted-foreground text-xs">
{t("medications.efficacy.segmented.preSlope", {
value: pre.value,
unit: u,
})}
</p>
{anyZero ? (
<p className="text-foreground text-sm">
{t("medications.efficacy.segmented.includesZero")}
</p>
) : null}
<p className="text-muted-foreground text-xs">
{t("medications.efficacy.segmented.basis", {
before: segmented.days.before,
after: segmented.days.after,
from: fmt.dateShortSmartCalendar(segmented.window.from),
to: fmt.dateShortSmartCalendar(segmented.window.to),
})}
</p>
{segmented.days.excludedLowAdherence > 0 ? (
<p className="text-muted-foreground text-xs">
{t("medications.efficacy.segmented.excludedAdherence", {
count: segmented.days.excludedLowAdherence,
floor: segmented.adherence.floorPct,
lookback: segmented.adherence.lookbackDays,
})}
</p>
) : null}
{segmented.days.excludedNotTaking > 0 ? (
<p className="text-muted-foreground text-xs">
{t("medications.efficacy.segmented.excludedNotTaking", {
count: segmented.days.excludedNotTaking,
})}
</p>
) : null}
{segmented.adherence.status === "not_tracked" ? (
<p className="text-muted-foreground text-xs">
{t("medications.efficacy.segmented.notTracked")}
</p>
) : null}
{segmented.window.endReason === "dose_change" ? (
<p className="text-muted-foreground text-xs">
{t("medications.efficacy.segmented.endDoseChange")}
</p>
) : null}
{segmented.confounders.length > 0 ? (
<div data-slot="medication-wirkung-confounders">
<p className="text-foreground text-sm">
{t("medications.efficacy.segmented.confoundersTitle")}
</p>
<ul className="text-foreground list-disc pl-4 text-sm">
{segmented.confounders.map((c) => (
<li key={`${c.medicationName}:${c.event}:${c.date}`}>
{t(CONFOUNDER_KEYS[c.event], {
name: c.medicationName,
date: fmt.dateShortSmartCalendar(c.date),
})}
</li>
))}
</ul>
</div>
) : null}
<p className="text-muted-foreground text-xs">
{t("medications.efficacy.segmented.method")}
</p>
</div>
);
}
@@ -43,6 +43,11 @@ import type {
EfficacyTargetView,
} from "@/lib/medications/efficacy/build-efficacy";
import { EmptyState } from "@/components/ui/empty-state";
import {
SEGMENTED_MIN_DAYS_PER_SIDE,
SEGMENTED_MIN_SPAN_DAYS,
} from "@/lib/medications/efficacy/segmented-effect";
import { ChangeAtStartSummary } from "./change-at-start-summary";
// v1.30 UI audit (M8) — `EfficacyChart` mounts inside `TargetCard`'s own
// `<Card>` as a bare 220px `ResponsiveContainer` with no chrome of its own.
@@ -310,6 +315,15 @@ function TargetCard({
<BeforeAfterCard target={target} minWeeks={dto.minWeeksPerSide} />
{target.segmented ? (
<ChangeAtStartSummary
segmented={target.segmented}
unit={target.unit}
minDays={SEGMENTED_MIN_DAYS_PER_SIDE}
minSpanDays={SEGMENTED_MIN_SPAN_DAYS}
/>
) : null}
{target.levelShift?.present &&
target.levelShift.nearStart &&
target.levelShift.at ? (
@@ -191,11 +191,11 @@ describe("<HealthRecordExportPanel> — the first run", () => {
expect(groupChip(html, "vitals")).toBe("0/7");
expect(groupChip(html, "body")).toBe("0/12");
expect(groupChip(html, "labs")).toBe("0/1");
expect(groupChip(html, "medications")).toBe("0/4");
expect(groupChip(html, "medications")).toBe("0/5");
// ALLERGIES, ILLNESS_EPISODES, VISITS, SURGICAL_HISTORY, IMMUNIZATIONS.
expect(groupChip(html, "history")).toBe("0/5");
expect(groupChip(html, "cardio")).toBe("0/15");
expect(groupChip(html, "activity")).toBe("0/11");
expect(groupChip(html, "activity")).toBe("0/12");
expect(groupChip(html, "sleepRecovery")).toBe("0/16");
expect(groupChip(html, "mobility")).toBe("0/11");
expect(groupChip(html, "environment")).toBe("0/7");