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:
Marc-André Bombeck committed 2026-10-21 21:50:27 +02:00
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 ? (
@@ -0,0 +1,208 @@
/**
* The medication-effect assembly around the segmented regression: the window,
* the adherence and not-taking exclusions, the dose-change cut, the confounder
* list and the minimum-data floor. The regression itself is pinned to
* reference values in `src/lib/stats/__tests__/segmented-regression.test.ts`.
*/
import { describe, expect, it } from "vitest";
import {
buildSegmentedEffect,
notTakingPredicate,
type SegmentedEffectInput,
} from "@/lib/medications/efficacy/segmented-effect";
const DAY = 24 * 60 * 60 * 1000;
const PIVOT = "2026-03-01";
function dayAt(offset: number): string {
return new Date(Date.parse(`${PIVOT}T00:00:00Z`) + offset * DAY)
.toISOString()
.slice(0, 10);
}
/** 140 before, a clean −10 shift and −0.7/week (−0.1/day) slope after. */
function series(fromOffset: number, toOffset: number) {
const out: { day: string; value: number }[] = [];
for (let t = fromOffset; t <= toOffset; t++) {
out.push({
day: dayAt(t),
value: t < 0 ? 140 : 130 - 0.1 * t,
});
}
return out;
}
function input(over: Partial<SegmentedEffectInput> = {}): SegmentedEffectInput {
return {
dayValues: series(-60, 59),
pivotDay: PIVOT,
lastDay: dayAt(59),
adherenceDays: null,
notTakingOn: () => false,
ownDoseChangeDays: [],
otherMedicationEvents: [],
...over,
};
}
describe("buildSegmentedEffect", () => {
it("recovers the known level and weekly slope change", () => {
const r = buildSegmentedEffect(input());
expect(r.present).toBe(true);
expect(r.levelChange!.estimate).toBeCloseTo(-10, 9);
expect(r.slopeChangePerWeek!.estimate).toBeCloseTo(-0.7, 9);
expect(r.preSlopePerWeek!.estimate).toBeCloseTo(0, 9);
expect(r.days).toEqual({
before: 60,
after: 60,
excludedLowAdherence: 0,
excludedNotTaking: 0,
});
expect(r.window).toEqual({
from: dayAt(-90),
to: dayAt(59),
endReason: "today",
});
expect(r.adherence.status).toBe("not_tracked");
expect(r.level).toBe(0.95);
});
it("caps the post window at 180 days", () => {
const r = buildSegmentedEffect(
input({ dayValues: series(-30, 300), lastDay: dayAt(300) }),
);
expect(r.window).toEqual({
from: dayAt(-90),
to: dayAt(179),
endReason: "window",
});
expect(r.days.after).toBe(180);
});
it("drops values older than the 90-day pre window", () => {
const r = buildSegmentedEffect(input({ dayValues: series(-200, 59) }));
expect(r.days.before).toBe(90);
});
it("ends the window the day before the first own dose change", () => {
const r = buildSegmentedEffect(
input({ ownDoseChangeDays: [PIVOT, dayAt(40)] }),
);
expect(r.window?.to).toBe(dayAt(39));
expect(r.window?.endReason).toBe("dose_change");
expect(r.days.after).toBe(40);
});
it("leaves out post-start days whose trailing 14-day adherence is below 80 %", () => {
// One dose a day; every dose from day 20 to day 29 missed.
const adherenceDays = Array.from({ length: 60 }, (_, t) => ({
day: dayAt(t),
taken: t >= 20 && t < 30 ? 0 : 1,
missed: t >= 20 && t < 30 ? 1 : 0,
}));
const r = buildSegmentedEffect(input({ adherenceDays }));
expect(r.adherence.status).toBe("tracked");
// Day d is low when misses in [d-13, d] exceed 2 (3/14 = 78.6 %): from
// day 22 (3 misses) through day 29+13-2 = day 40 (3 misses left).
expect(r.days.excludedLowAdherence).toBe(19);
expect(r.days.after).toBe(60 - 19);
// The fit on the remaining days still recovers the clean effect.
expect(r.levelChange!.estimate).toBeCloseTo(-10, 9);
});
it("treats a lookback without any expected dose as unknown, not low", () => {
const r = buildSegmentedEffect(input({ adherenceDays: [] }));
expect(r.days.excludedLowAdherence).toBe(0);
expect(r.present).toBe(true);
});
it("leaves out days the medication was not being taken", () => {
const r = buildSegmentedEffect(
input({ notTakingOn: (d) => d >= dayAt(10) && d < dayAt(20) }),
);
expect(r.days.excludedNotTaking).toBe(10);
});
it("needs 14 days on each side", () => {
const before = buildSegmentedEffect(input({ dayValues: series(-13, 59) }));
expect(before.present).toBe(false);
expect(before.reason).toBe("insufficient_before");
expect(before.levelChange).toBeNull();
const after = buildSegmentedEffect(
input({ dayValues: series(-60, 12), lastDay: dayAt(12) }),
);
expect(after.reason).toBe("insufficient_after");
});
it("needs each side to span 21 days, not just hold 14 values", () => {
// 14 consecutive days right before the start: enough values, too short.
const r = buildSegmentedEffect(input({ dayValues: series(-14, 59) }));
expect(r.days.before).toBe(14);
expect(r.reason).toBe("insufficient_before");
});
it("answers no_start without a start date", () => {
const r = buildSegmentedEffect(input({ pivotDay: null }));
expect(r).toMatchObject({ present: false, reason: "no_start" });
expect(r.window).toBeNull();
});
it("lists only the other medications' changes inside the window, oldest first", () => {
const r = buildSegmentedEffect(
input({
otherMedicationEvents: [
{ medicationName: "B", event: "dose_change", date: dayAt(30) },
{ medicationName: "A", event: "started", date: dayAt(-20) },
{ medicationName: "C", event: "stopped", date: dayAt(-120) },
],
}),
);
expect(r.confounders).toEqual([
{ medicationName: "A", event: "started", date: dayAt(-20) },
{ medicationName: "B", event: "dose_change", date: dayAt(30) },
]);
});
it("flags an interval that spans zero", () => {
const flat = series(-60, 59).map((p, i) => ({
day: p.day,
value: 120 + (i % 2 === 0 ? 3 : -3),
}));
const r = buildSegmentedEffect(input({ dayValues: flat }));
expect(r.present).toBe(true);
expect(r.levelChange!.includesZero).toBe(true);
});
});
describe("notTakingPredicate", () => {
it("covers a pause up to the resume day and the gaps between courses", () => {
const pred = notTakingPredicate(
{
pauseEras: [
{
pausedAt: new Date("2026-03-10T08:00:00Z"),
resumedAt: new Date("2026-03-15T08:00:00Z"),
},
],
courses: [
{
startsOn: new Date("2026-03-01T00:00:00Z"),
endsOn: new Date("2026-03-31T00:00:00Z"),
},
{
startsOn: new Date("2026-05-01T00:00:00Z"),
endsOn: null,
},
],
} as never,
"UTC",
);
expect(pred("2026-03-09")).toBe(false);
expect(pred("2026-03-10")).toBe(true);
expect(pred("2026-03-14")).toBe(true);
expect(pred("2026-03-15")).toBe(false);
expect(pred("2026-04-10")).toBe(true);
expect(pred("2026-05-02")).toBe(false);
});
});
+33 -10
View File
@@ -24,7 +24,6 @@ import { probeRollupCoverage } from "@/lib/rollups/measurement-coverage";
import { readDayMeanSeries } from "@/lib/insights/derived/baseline";
import {
buildComplianceMedicationContext,
COURSES_COMPLIANCE_SELECT,
buildMedicationComplianceBundle,
dailyComplianceRatesFromLedger,
lastNonSkippedTakenAt,
@@ -40,6 +39,11 @@ import {
type MedTargetTier,
} from "@/lib/medications/med-target-map";
import { dueSchedules } from "@/lib/medications/intake-tracking";
import {
EFFECT_MEDICATION_INCLUDE,
loadSegmentedEffects,
} from "@/lib/medications/efficacy/load-segmented-effects";
import type { EfficacySegmented } from "@/lib/medications/efficacy/segmented-effect";
const DAY_MS = 24 * 60 * 60 * 1000;
@@ -93,6 +97,12 @@ export interface EfficacyTargetView {
beforeAfter: EfficacyBeforeAfter;
/** Metric targets only; null for lab targets (changepoint reads metrics). */
levelShift: EfficacyLevelShift | null;
/**
* The segmented-regression effect (level + slope change with 95 %
* intervals, adherence-adjusted). Metric targets only; null for a lab,
* whose readings are too sparse for a daily time series.
*/
segmented: EfficacySegmented | null;
}
export interface EfficacyMarkers {
@@ -294,6 +304,7 @@ async function buildMetricTarget(
series,
beforeAfter: beforeAfterFromSeries(straddle, pivotMs),
levelShift,
segmented: null,
};
}
@@ -343,6 +354,7 @@ async function buildLabTarget(
series,
beforeAfter: beforeAfterFromSeries(straddle, pivotMs),
levelShift: null,
segmented: null,
};
}
@@ -351,7 +363,7 @@ async function buildLabTarget(
* override wins (tier "override"); otherwise the derived ATC/name resolution.
* Returns the tier + class provenance for the DTO alongside the target list.
*/
async function resolveEffectiveTargets(med: {
export async function resolveEffectiveTargets(med: {
id: string;
name: string;
treatmentClass: string;
@@ -417,14 +429,8 @@ export async function buildMedicationEfficacy(
): Promise<MedicationEfficacyDTO | null> {
const med = await prisma.medication.findFirst({
where: { id: medicationId, userId },
include: {
schedules: true,
scheduleRevisions: { orderBy: { validFrom: "asc" } },
pauseEras: { select: { pausedAt: true, resumedAt: true } },
// v1.40 (#1024) — the courses, so a gap between two expects nothing.
courses: COURSES_COMPLIANCE_SELECT,
doseChanges: { orderBy: { effectiveFrom: "asc" } },
},
// The courses ride along, so a gap between two expects nothing (#1024).
include: EFFECT_MEDICATION_INCLUDE,
});
if (!med) return null;
@@ -570,6 +576,23 @@ export async function buildMedicationEfficacy(
}
}
// The segmented effect, per metric target, around `startsOn` only: the
// first-reading fallback has no "before" by definition.
const segmented = await loadSegmentedEffects({
userId,
med,
targets: targets
.filter((t) => t.kind === "metric")
.map((t) => t.key as MeasurementType),
timezone,
now,
});
for (const view of targets) {
if (view.kind === "metric") {
view.segmented = segmented.get(view.key as MeasurementType) ?? null;
}
}
// Adherence lane — the cadence-aware per-day rate from the SAME ledger the
// compliance surfaces read; never recomputed here.
const fetchFrom = new Date(
@@ -0,0 +1,247 @@
/**
* The database half of the segmented medication effect: one intake-event
* read, one confounder read and one per-day SQL fold per target, handed to the
* pure assembly in `./segmented-effect.ts`.
*/
import type { MeasurementType, Prisma } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import {
buildComplianceMedicationContext,
buildMedicationComplianceBundle,
COURSES_COMPLIANCE_SELECT,
dailyComplianceRatesFromLedger,
lastNonSkippedTakenAt,
} from "@/lib/analytics/compliance";
import { dueSchedules } from "@/lib/medications/intake-tracking";
import { dayKeyOfDate } from "@/lib/medications/course-window";
import { readDayAggregates } from "@/lib/measurements/day-aggregates";
import { plausibleMetricRange } from "@/lib/measurements/value-domain";
import { userDayKey } from "@/lib/tz/format";
import { dayKeyAsUtcMidnight } from "@/lib/tz/date-only";
import {
buildSegmentedEffect,
notTakingPredicate,
SEGMENTED_POST_WINDOW_DAYS,
SEGMENTED_PRE_WINDOW_DAYS,
type EfficacyConfounder,
type EfficacySegmented,
type SegmentedEffectInput,
} from "./segmented-effect";
const DAY_MS = 24 * 60 * 60 * 1000;
/** The medication shape the effect needs: the efficacy builder's include. */
export const EFFECT_MEDICATION_INCLUDE = {
schedules: true,
scheduleRevisions: { orderBy: { validFrom: "asc" } },
pauseEras: { select: { pausedAt: true, resumedAt: true } },
courses: COURSES_COMPLIANCE_SELECT,
doseChanges: { orderBy: { effectiveFrom: "asc" } },
} satisfies Prisma.MedicationInclude;
export type EffectMedication = Prisma.MedicationGetPayload<{
include: typeof EFFECT_MEDICATION_INCLUDE;
}>;
/** Other medications' starts, stops, dose changes and pauses inside a window. */
async function loadConfounders(
userId: string,
medicationId: string,
windowFrom: Date,
windowTo: Date,
timezone: string,
): Promise<EfficacyConfounder[]> {
const inWindow = { gte: windowFrom, lte: windowTo };
const others = await prisma.medication.findMany({
where: {
userId,
id: { not: medicationId },
OR: [
{ startsOn: inWindow },
{ endsOn: inWindow },
{ doseChanges: { some: { effectiveFrom: inWindow } } },
{ pauseEras: { some: { pausedAt: inWindow } } },
{ pauseEras: { some: { resumedAt: inWindow } } },
],
},
select: {
name: true,
startsOn: true,
endsOn: true,
doseChanges: {
where: { effectiveFrom: inWindow },
select: { effectiveFrom: true },
},
pauseEras: { select: { pausedAt: true, resumedAt: true } },
},
take: 50,
});
const out: EfficacyConfounder[] = [];
const within = (d: Date | null): d is Date =>
d !== null &&
d.getTime() >= windowFrom.getTime() &&
d.getTime() <= windowTo.getTime();
for (const m of others) {
if (within(m.startsOn)) {
out.push({
medicationName: m.name,
event: "started",
date: dayKeyOfDate(m.startsOn),
});
}
if (within(m.endsOn)) {
out.push({
medicationName: m.name,
event: "stopped",
date: dayKeyOfDate(m.endsOn),
});
}
for (const dc of m.doseChanges) {
out.push({
medicationName: m.name,
event: "dose_change",
date: userDayKey(dc.effectiveFrom, timezone),
});
}
for (const p of m.pauseEras) {
if (within(p.pausedAt)) {
out.push({
medicationName: m.name,
event: "paused",
date: userDayKey(p.pausedAt, timezone),
});
}
if (within(p.resumedAt)) {
out.push({
medicationName: m.name,
event: "resumed",
date: userDayKey(p.resumedAt, timezone),
});
}
}
}
return out;
}
/**
* Load and fit the segmented effect for each metric target of one medication.
* `now` bounds every read: a report pinned in the past passes its own end so
* nothing after it is used. Reads: one intake-event query, one confounder
* query, and one per-day SQL fold per target.
*/
export async function loadSegmentedEffects(params: {
userId: string;
med: EffectMedication;
targets: readonly MeasurementType[];
timezone: string;
now: Date;
}): Promise<Map<MeasurementType, EfficacySegmented>> {
const { userId, med, targets, timezone, now } = params;
const out = new Map<MeasurementType, EfficacySegmented>();
if (targets.length === 0) return out;
const pivotDay = med.startsOn ? dayKeyOfDate(med.startsOn) : null;
const lastDay = userDayKey(now, timezone);
if (pivotDay === null || pivotDay > lastDay) {
const empty = buildSegmentedEffect({
dayValues: [],
pivotDay: null,
lastDay,
adherenceDays: null,
notTakingOn: () => false,
ownDoseChangeDays: [],
otherMedicationEvents: [],
});
for (const t of targets) out.set(t, empty);
return out;
}
// A day of margin either side: the SQL folds on local days, the window is
// cut on day keys afterwards.
const pivotMs = dayKeyAsUtcMidnight(pivotDay).getTime();
const since = new Date(pivotMs - (SEGMENTED_PRE_WINDOW_DAYS + 1) * DAY_MS);
const until = new Date(
Math.min(
now.getTime(),
pivotMs + (SEGMENTED_POST_WINDOW_DAYS + 1) * DAY_MS,
),
);
let adherenceDays: SegmentedEffectInput["adherenceDays"] = null;
if (med.trackIntake) {
const events = await prisma.medicationIntakeEvent.findMany({
where: {
medicationId: med.id,
userId,
deletedAt: null,
scheduledFor: { gte: since, lte: until },
},
orderBy: { scheduledFor: "desc" },
select: {
takenAt: true,
skipped: true,
scheduledFor: true,
autoMissed: true,
attributionSource: true,
},
});
const ctx = buildComplianceMedicationContext(
med,
lastNonSkippedTakenAt(events),
timezone,
);
const bundle = buildMedicationComplianceBundle(
events,
dueSchedules(med),
ctx,
until,
);
adherenceDays = dailyComplianceRatesFromLedger(
bundle.ledgerRows,
timezone,
).map((d) => ({
day: userDayKey(d.date, timezone),
taken: d.taken,
missed: d.missed,
}));
}
const confounders = await loadConfounders(
userId,
med.id,
since,
until,
timezone,
);
const notTakingOn = notTakingPredicate(med, timezone);
const ownDoseChangeDays = med.doseChanges.map((dc) =>
userDayKey(dc.effectiveFrom, timezone),
);
for (const target of targets) {
const rows = await readDayAggregates({
userId,
type: target,
since,
until,
timeZone: timezone,
valueRange: plausibleMetricRange(target),
});
out.set(
target,
buildSegmentedEffect({
dayValues: rows.map((r) => ({
day: r.day,
value: r.dayMean ?? r.sum / r.n,
})),
pivotDay,
lastDay,
adherenceDays,
notTakingOn,
ownDoseChangeDays,
otherMedicationEvents: confounders,
}),
);
}
return out;
}
@@ -0,0 +1,345 @@
/**
* The medication effect as an interrupted time series: the target metric's
* daily values over a window around the start, fitted with a segmented
* regression (level change + slope change, Newey–West intervals).
*
* Adherence-adjusted by exclusion. After the start, a day is left out when the
* doses expected over the trailing fourteen days were taken less than 80 % of
* the time, and when the medication was paused or between courses that day.
* The adherence comes from the cadence-aware dose ledger the compliance
* surfaces read (`dailyComplianceRatesFromLedger`), never a recount. With
* intake tracking off nothing is excluded and the DTO says so.
*
* The window is ninety days before the start and up to 180 after it, cut short
* at the medication's own first dose change (the estimate then describes the
* starting dose, not a blend of two). Other medications that started, stopped,
* changed dose or paused inside the window are listed as caveats: the model
* cannot tell their effect from this one.
*
* Descriptive and association-only, like the rest of the efficacy DTO: there
* is no verdict field. The renderer phrases the numbers and the caveats; it
* never decides whether a medication "works".
*
* Pure: the database reads live in `./load-segmented-effects.ts`, so the
* client renderer can import the constants and types from here. Computed on
* request, without AI.
*/
import { dayKeyOfDate } from "@/lib/medications/course-window";
import { userDayKey } from "@/lib/tz/format";
import { dayKeyAsUtcMidnight } from "@/lib/tz/date-only";
import {
fitSegmentedRegression,
type SegmentedEstimate,
} from "@/lib/stats/segmented-regression";
const DAY_MS = 24 * 60 * 60 * 1000;
/** Days of target data the window reaches back over before the start. */
export const SEGMENTED_PRE_WINDOW_DAYS = 90;
/** Days after the start the window reaches forward over, at most. */
export const SEGMENTED_POST_WINDOW_DAYS = 180;
/** Days with a value required on each side of the start. */
export const SEGMENTED_MIN_DAYS_PER_SIDE = 14;
/** Calendar span (first to last value) required on each side. */
export const SEGMENTED_MIN_SPAN_DAYS = 21;
/** Trailing window the per-day adherence is judged over. */
export const ADHERENCE_LOOKBACK_DAYS = 14;
/** Adherence below this share leaves a post-start day out. */
export const ADHERENCE_FLOOR = 0.8;
/** Confidence level of every interval. */
export const SEGMENTED_LEVEL = 0.95;
/** An estimate in display terms: the unit per day for a level, per week for a slope. */
export interface EfficacyEstimate {
estimate: number;
ciLow: number;
ciHigh: number;
standardError: number;
/** True when the interval spans zero (the data is also consistent with no change). */
includesZero: boolean;
}
export type EfficacyConfounderEvent =
"started" | "stopped" | "dose_change" | "paused" | "resumed";
export interface EfficacyConfounder {
medicationName: string;
event: EfficacyConfounderEvent;
/** YYYY-MM-DD. */
date: string;
}
export interface EfficacySegmented {
present: boolean;
reason?:
"no_start" | "insufficient_before" | "insufficient_after" | "fit_failed";
/** Confidence level of the intervals (0.95). */
level: number;
/** The window actually fitted (YYYY-MM-DD, inclusive). */
window: {
from: string;
to: string;
endReason: "today" | "window" | "dose_change";
} | null;
/** Days with a value used on each side, and the days left out after the start. */
days: {
before: number;
after: number;
excludedLowAdherence: number;
excludedNotTaking: number;
};
adherence: {
/** "not_tracked" when the medication logs no intake, so nothing was excluded. */
status: "tracked" | "not_tracked";
floorPct: number;
lookbackDays: number;
};
/** Immediate shift at the start, in the target's unit. */
levelChange: EfficacyEstimate | null;
/** Change in the weekly trend after the start, unit per week. */
slopeChangePerWeek: EfficacyEstimate | null;
/** The weekly trend before the start, unit per week. */
preSlopePerWeek: EfficacyEstimate | null;
fit: {
n: number;
df: number;
rSquared: number;
residualAutocorrelation: number;
hacLag: number;
} | null;
/** Other medication changes inside the window, oldest first. */
confounders: EfficacyConfounder[];
}
function dayIndex(day: string): number {
return Math.round(dayKeyAsUtcMidnight(day).getTime() / DAY_MS);
}
function dayKeyOfIndex(index: number): string {
return dayKeyOfDate(new Date(index * DAY_MS));
}
function scaled(e: SegmentedEstimate, factor: number): EfficacyEstimate {
const ciLow = e.ciLow * factor;
const ciHigh = e.ciHigh * factor;
return {
estimate: e.estimate * factor,
ciLow,
ciHigh,
standardError: e.standardError * factor,
includesZero: ciLow <= 0 && ciHigh >= 0,
};
}
/** The input to the pure assembly — every day already a local day key. */
export interface SegmentedEffectInput {
/** One value per local day (any order). */
dayValues: ReadonlyArray<{ day: string; value: number }>;
/** The start day, or null when the medication carries no start date. */
pivotDay: string | null;
/** The last day the window may reach (today, or a report's last day). */
lastDay: string;
/** Per-day dose tallies from the ledger; null when intake is not tracked. */
adherenceDays: ReadonlyArray<{
day: string;
taken: number;
missed: number;
}> | null;
/** Whether the medication was not being taken on a day (paused, between courses). */
notTakingOn: (day: string) => boolean;
/** The medication's own dose changes (YYYY-MM-DD). */
ownDoseChangeDays: readonly string[];
/** Other medications' changes (YYYY-MM-DD), any order. */
otherMedicationEvents: readonly EfficacyConfounder[];
}
const EMPTY_DAYS = {
before: 0,
after: 0,
excludedLowAdherence: 0,
excludedNotTaking: 0,
};
/**
* Assemble the segmented effect from already-resolved day data. Pure, so the
* window, exclusion and minimum-data rules are testable without a database.
*/
export function buildSegmentedEffect(
input: SegmentedEffectInput,
): EfficacySegmented {
const adherence = {
status: input.adherenceDays === null ? "not_tracked" : "tracked",
floorPct: Math.round(ADHERENCE_FLOOR * 100),
lookbackDays: ADHERENCE_LOOKBACK_DAYS,
} as const;
const absent = (
reason: NonNullable<EfficacySegmented["reason"]>,
window: EfficacySegmented["window"] = null,
days = EMPTY_DAYS,
confounders: EfficacyConfounder[] = [],
): EfficacySegmented => ({
present: false,
reason,
level: SEGMENTED_LEVEL,
window,
days,
adherence,
levelChange: null,
slopeChangePerWeek: null,
preSlopePerWeek: null,
fit: null,
confounders,
});
if (input.pivotDay === null) return absent("no_start");
const pivot = dayIndex(input.pivotDay);
const from = pivot - SEGMENTED_PRE_WINDOW_DAYS;
let to = Math.min(
dayIndex(input.lastDay),
pivot + SEGMENTED_POST_WINDOW_DAYS - 1,
);
let endReason: "today" | "window" | "dose_change" =
to === dayIndex(input.lastDay) ? "today" : "window";
// The first own dose change after the start ends the window the day
// before it: one estimate per dose. A dose recorded on the start day is
// the starting dose, not a change.
const firstChange = input.ownDoseChangeDays
.map(dayIndex)
.filter((d) => d > pivot)
.sort((a, b) => a - b)[0];
if (firstChange !== undefined && firstChange - 1 < to) {
to = firstChange - 1;
endReason = "dose_change";
}
const window = {
from: dayKeyOfIndex(from),
to: dayKeyOfIndex(to),
endReason,
};
const confounders = input.otherMedicationEvents
.filter((c) => {
const d = dayIndex(c.date);
return d >= from && d <= to;
})
.slice()
.sort((a, b) => a.date.localeCompare(b.date));
// Per-day adherence, judged over the trailing lookback.
const adherenceByDay = new Map<number, { taken: number; missed: number }>();
for (const a of input.adherenceDays ?? []) {
adherenceByDay.set(dayIndex(a.day), { taken: a.taken, missed: a.missed });
}
const lowAdherenceOn = (d: number): boolean => {
if (input.adherenceDays === null) return false;
let taken = 0;
let missed = 0;
for (let k = d - ADHERENCE_LOOKBACK_DAYS + 1; k <= d; k++) {
const a = adherenceByDay.get(k);
if (!a) continue;
taken += a.taken;
missed += a.missed;
}
// No dose expected in the lookback: adherence is unknown, not low.
if (taken + missed === 0) return false;
return taken / (taken + missed) < ADHERENCE_FLOOR;
};
const points: { t: number; y: number }[] = [];
const days = { ...EMPTY_DAYS };
const sorted = input.dayValues
.map((v) => ({ d: dayIndex(v.day), day: v.day, value: v.value }))
.filter((v) => v.d >= from && v.d <= to && Number.isFinite(v.value))
.sort((a, b) => a.d - b.d);
let beforeFirst: number | null = null;
let beforeLast: number | null = null;
let afterFirst: number | null = null;
let afterLast: number | null = null;
for (const v of sorted) {
const t = v.d - pivot;
if (t >= 0) {
if (input.notTakingOn(v.day)) {
days.excludedNotTaking += 1;
continue;
}
if (lowAdherenceOn(v.d)) {
days.excludedLowAdherence += 1;
continue;
}
days.after += 1;
afterFirst ??= t;
afterLast = t;
} else {
days.before += 1;
beforeFirst ??= t;
beforeLast = t;
}
points.push({ t, y: v.value });
}
const span = (a: number | null, b: number | null) =>
a === null || b === null ? 0 : b - a + 1;
if (
days.before < SEGMENTED_MIN_DAYS_PER_SIDE ||
span(beforeFirst, beforeLast) < SEGMENTED_MIN_SPAN_DAYS
) {
return absent("insufficient_before", window, days, confounders);
}
if (
days.after < SEGMENTED_MIN_DAYS_PER_SIDE ||
span(afterFirst, afterLast) < SEGMENTED_MIN_SPAN_DAYS
) {
return absent("insufficient_after", window, days, confounders);
}
const fit = fitSegmentedRegression(points, { level: SEGMENTED_LEVEL });
if (!fit) return absent("fit_failed", window, days, confounders);
return {
present: true,
level: SEGMENTED_LEVEL,
window,
days,
adherence,
levelChange: scaled(fit.levelChange, 1),
slopeChangePerWeek: scaled(fit.slopeChange, 7),
preSlopePerWeek: scaled(fit.preSlope, 7),
fit: {
n: fit.n,
df: fit.df,
rSquared: fit.rSquared,
residualAutocorrelation: fit.residualAutocorrelation,
hacLag: fit.hacLag,
},
confounders,
};
}
/** Day keys on which a medication was not being taken (paused, between courses). */
export function notTakingPredicate(
med: {
pauseEras: ReadonlyArray<{ pausedAt: Date; resumedAt: Date | null }>;
courses: ReadonlyArray<{ startsOn: Date; endsOn: Date | null }>;
},
timezone: string,
): (day: string) => boolean {
const pauses = med.pauseEras.map((p) => ({
from: userDayKey(p.pausedAt, timezone),
to: p.resumedAt ? userDayKey(p.resumedAt, timezone) : null,
}));
const courses = med.courses.map((c) => ({
from: dayKeyOfDate(c.startsOn),
to: c.endsOn ? dayKeyOfDate(c.endsOn) : null,
}));
return (day: string) => {
// Paused from the pause day up to (not including) the resume day.
if (pauses.some((p) => day >= p.from && (p.to === null || day < p.to))) {
return true;
}
if (courses.length === 0) return false;
return !courses.some(
(c) => day >= c.from && (c.to === null || day <= c.to),
);
};
}
@@ -0,0 +1,38 @@
/**
* How a segmented-effect estimate is printed, shared by the efficacy tab, the
* PDF report and the clinician view so all three show the same figure.
*
* The precision follows the uncertainty: one significant digit of the standard
* error decides the decimals (an SE of 0.6 mmHg prints one decimal, 0.02 kg
* prints two). More digits than that would claim a precision the data does not
* have; fewer would hide a real interval. Capped at three decimals.
*/
import type { EfficacyEstimate } from "./segmented-effect";
export function estimateDecimals(e: Pick<EfficacyEstimate, "standardError">) {
const se = Math.abs(e.standardError);
if (!Number.isFinite(se) || se === 0) return 1;
return Math.min(3, Math.max(0, -Math.floor(Math.log10(se))));
}
/**
* The three numbers of an estimate as display strings, the point estimate
* signed. `num` is the caller's locale formatter.
*/
export function formatEstimate(
e: EfficacyEstimate,
num: (value: number, decimals: number) => string,
): { value: string; low: string; high: string } {
const decimals = estimateDecimals(e);
// A value that rounds to zero prints unsigned, never "-0.0".
const signed = (v: number) => {
const text = num(v, decimals);
const rounded = Number(v.toFixed(decimals));
return rounded > 0 ? `+${text}` : rounded === 0 ? num(0, decimals) : text;
};
return {
value: signed(e.estimate),
low: num(e.ciLow, decimals),
high: num(e.ciHigh, decimals),
};
}
@@ -81,6 +81,81 @@ const levelShiftSchema = z
})
.nullable();
const estimateSchema = z
.object({
estimate: z.number(),
ciLow: z.number(),
ciHigh: z.number(),
standardError: z.number(),
includesZero: z.boolean(),
})
.nullable();
/**
* The segmented-regression (interrupted time series) effect: the immediate
* level change and the change in weekly trend at the start, each with a 95 %
* interval (Newey–West), fitted over up to 90 days before and 180 after with
* low-adherence and not-taking days after the start left out. Descriptive
* only; `present: false` carries the reason the data floor was not met.
*/
const segmentedSchema = z
.object({
present: z.boolean(),
reason: z
.enum([
"no_start",
"insufficient_before",
"insufficient_after",
"fit_failed",
])
.optional(),
level: z.number(),
window: z
.object({
from: z.string(),
to: z.string(),
endReason: z.enum(["today", "window", "dose_change"]),
})
.nullable(),
days: z.object({
before: z.number().int(),
after: z.number().int(),
excludedLowAdherence: z.number().int(),
excludedNotTaking: z.number().int(),
}),
adherence: z.object({
status: z.enum(["tracked", "not_tracked"]),
floorPct: z.number().int(),
lookbackDays: z.number().int(),
}),
levelChange: estimateSchema,
slopeChangePerWeek: estimateSchema,
preSlopePerWeek: estimateSchema,
fit: z
.object({
n: z.number().int(),
df: z.number().int(),
rSquared: z.number(),
residualAutocorrelation: z.number(),
hacLag: z.number().int(),
})
.nullable(),
confounders: z.array(
z.object({
medicationName: z.string(),
event: z.enum([
"started",
"stopped",
"dose_change",
"paused",
"resumed",
]),
date: z.string(),
}),
),
})
.nullable();
const targetViewSchema = z.object({
kind: z.enum(["metric", "lab"]),
key: z.string(),
@@ -91,6 +166,7 @@ const targetViewSchema = z.object({
series: z.array(seriesPointSchema),
beforeAfter: beforeAfterSchema,
levelShift: levelShiftSchema,
segmented: segmentedSchema,
});
export const medicationEfficacyResponseSchema = z.object({