mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
feat(medications): show the change at the start as a segmented fit
The efficacy tab keeps its before/after comparison and gains, per metric target, the level change and the change in weekly trend at the start, each with a 95 % interval. The fit covers up to 90 days before and 180 after the start and ends before the first dose change. After the start, days are left out when fewer than 80 % of the doses of the preceding 14 days were taken (cadence-aware, from the dose ledger) or when the medication was paused or between courses. Other medications that started, stopped, changed dose or paused inside the window are listed. Below 14 days with readings across 21 days on either side the view says what is missing instead of showing numbers. The efficacy response gains a nullable segmented block per target.
This commit is contained in:
1 parent
cb8ed92d78
commit
1cf0582603
18 files changed
+1607
-18
No files matched your search
@@ -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 ? (
|
||||
|
||||
Reference in new issue
Block a user