mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
Merge branch 'feat/v144-report' into release/v1.44.0
This commit is contained in:
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}
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in new issue
Block a user