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

@@ -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({