chore(release): v1.28.4 — medication efficacy

Per-medication effect view: plots the target metric/lab with start, dose
changes, and pauses marked, a before/after-start comparison, and adherence
overlaid — descriptive only, never a verdict or dose advice. Compact summary on
the medication insights area. Target resolved from ATC class or name, with a
per-user override. Migration 0232.
This commit is contained in:
Marc-André Bombeck committed 2026-07-09 13:14:07 +02:00
1 parent 8617fa7129
commit 783255d833
9 files changed
+96 -33

No files matched your search

+10
View File
@@ -2,6 +2,16 @@
## [Unreleased]
## [1.28.4] — 2026-07-09 — Medication efficacy
A per-medication "effect" view: see whether a medication is moving the outcome
it targets, against your own data. The detail page gains a tab that plots the
target metric (or lab) with the start date, dose changes, and pauses marked, a
before/after-start comparison, and your adherence overlaid — strictly
descriptive, never a verdict or dose advice. A compact version surfaces on the
medication insights area. The target is resolved from the drug's class or its
name, and you can override which metric a medication is tracked against.
## [1.28.3] — 2026-07-09 — Consistent spacing and alignment
A consistency pass across the app, driven by a layout audit. Insight cards now
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
version: 1.28.3
version: 1.28.4
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.28.3",
"version": "1.28.4",
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
"license": "PolyForm-Noncommercial-1.0.0",
"homepage": "https://healthlog.dev",
+1 -1
View File
@@ -36,7 +36,7 @@ try {
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
const CACHE_VERSION =
(typeof self !== "undefined" && self.__APP_VERSION__) ||
/* @sw-version-fallback */ "v1.28.3";
/* @sw-version-fallback */ "v1.28.4";
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
// v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`
@@ -204,6 +204,12 @@ const EXEMPT_ROUTES: ReadonlyArray<string> = [
"src/app/api/medications/[id]/schedule-revisions/[revisionId]/route.ts",
"src/app/api/medications/[id]/side-effects/route.ts",
"src/app/api/medications/[id]/side-effects/[logId]/route.ts",
// The efficacy read + user-override target share the medication data-layer
// reasoning: they compute over the user's own medication + metric/lab rows
// and persist only a per-user override. The module gate governs the Wirkung
// SURFACE (the detail-page tab + the insights summary), not the row store.
"src/app/api/medications/[id]/efficacy/route.ts",
"src/app/api/medications/[id]/efficacy/target/route.ts",
// ── INFRA / UI-ONLY ───────────────────────────────────────────────
// Static FHIR CapabilityStatement — server metadata, no user data.
"src/app/api/fhir/metadata/route.ts",
+71 -17
View File
@@ -63,7 +63,8 @@ export interface EfficacySeriesPoint {
export interface EfficacyBeforeAfter {
present: boolean;
/** Why the delta is absent — mirrors the tools' honest `{present:false}`. */
reason?: "insufficient_before" | "insufficient_after" | "no_start" | "no_data";
reason?:
"insufficient_before" | "insufficient_after" | "no_start" | "no_data";
before?: { mean: number; count: number; from: string; to: string };
after?: { mean: number; count: number; from: string; to: string };
/** after − before; `pct` relative to before, null when before is 0. */
@@ -143,7 +144,10 @@ const OVERRIDE_METRIC_OPTIONS: { key: MeasurementType; label: string }[] = [
/** Fixed reference bands for the metric targets `resolveRichMetric` omits. */
const METRIC_META_FALLBACK: Partial<
Record<MeasurementType, { label: string; unit: string; band: { low: number; high: number } | null }>
Record<
MeasurementType,
{ label: string; unit: string; band: { low: number; high: number } | null }
>
> = {
BLOOD_PRESSURE_SYS: {
label: "Systolic blood pressure",
@@ -217,7 +221,8 @@ export function beforeAfterFromSeries(
},
delta: {
mean: round1(diff),
pct: beforeMean !== 0 ? round1((diff / Math.abs(beforeMean)) * 100) : null,
pct:
beforeMean !== 0 ? round1((diff / Math.abs(beforeMean)) * 100) : null,
},
};
}
@@ -233,20 +238,37 @@ async function buildMetricTarget(
coverage: Awaited<ReturnType<typeof probeRollupCoverage>>,
): Promise<EfficacyTargetView> {
const meta = resolveMetricMeta(mt);
const { points } = await readDayMeanSeries(userId, mt, windowDays, now, coverage);
const { points } = await readDayMeanSeries(
userId,
mt,
windowDays,
now,
coverage,
);
const series: EfficacySeriesPoint[] = points.map((p) => ({
t: p.day,
value: round1(p.mean),
}));
const straddle = points.map((p) => ({ at: dayToInstant(p.day), value: p.mean }));
const straddle = points.map((p) => ({
at: dayToInstant(p.day),
value: p.mean,
}));
// Level-shift note — metric targets only, and only when the metric resolves
// through the rich-read resolver (blood pressure is multi-series and does
// not, so it carries no changepoint note by construction).
let levelShift: EfficacyLevelShift | null = null;
if (resolveRichMetric(mt)) {
const cp = await detectChangepoints(userId, { metric: mt, window: "lastYear" });
if (cp.present && cp.changepoints && cp.changepoints.length > 0 && pivotMs !== null) {
const cp = await detectChangepoints(userId, {
metric: mt,
window: "lastYear",
});
if (
cp.present &&
cp.changepoints &&
cp.changepoints.length > 0 &&
pivotMs !== null
) {
const nearest = cp.changepoints
.map((c) => ({ at: c.at, dist: Math.abs(Date.parse(c.at) - pivotMs) }))
.sort((a, b) => a.dist - b.dist)[0];
@@ -282,7 +304,7 @@ async function buildLabTarget(
pivotMs: number | null,
): Promise<EfficacyTargetView> {
const hist = await getLabHistory(userId, { analyte, limit: 50 });
const readings = (hist.present ? hist.readings ?? [] : [])
const readings = (hist.present ? (hist.readings ?? []) : [])
.filter((r) => r.value !== null)
.slice()
// getLabHistory returns newest-first; the view + before/after want oldest-first.
@@ -293,7 +315,9 @@ async function buildLabTarget(
(r) => r.referenceLow !== null || r.referenceHigh !== null,
);
const referenceBand =
withBounds && withBounds.referenceLow !== null && withBounds.referenceHigh !== null
withBounds &&
withBounds.referenceLow !== null &&
withBounds.referenceHigh !== null
? { low: withBounds.referenceLow, high: withBounds.referenceHigh }
: null;
@@ -369,7 +393,11 @@ async function resolveEffectiveTargets(med: {
atcCode: med.atcCode,
});
if (derived) {
return { tier: derived.tier, cls: derived.cls, targets: [...derived.targets] };
return {
tier: derived.tier,
cls: derived.cls,
targets: [...derived.targets],
};
}
return { tier: "none", cls: null, targets: [] };
}
@@ -407,7 +435,11 @@ export async function buildMedicationEfficacy(
// One-shot meds have a single administration and no trend to relate.
if (med.oneShot) {
annotate({
action: { name: "medication.efficacy.build", entity_type: "medication", entity_id: med.id },
action: {
name: "medication.efficacy.build",
entity_type: "medication",
entity_id: med.id,
},
meta: { eligible: false, reason: "one_shot" },
});
return {
@@ -437,7 +469,10 @@ export async function buildMedicationEfficacy(
select: { id: true, name: true, unit: true },
});
const overrideOptions = {
metrics: OVERRIDE_METRIC_OPTIONS.map((m) => ({ key: m.key, label: m.label })),
metrics: OVERRIDE_METRIC_OPTIONS.map((m) => ({
key: m.key,
label: m.label,
})),
biomarkers: biomarkerRows.map((b) => ({
id: b.id,
label: b.name,
@@ -447,7 +482,11 @@ export async function buildMedicationEfficacy(
if (resolved.targets.length === 0) {
annotate({
action: { name: "medication.efficacy.build", entity_type: "medication", entity_id: med.id },
action: {
name: "medication.efficacy.build",
entity_type: "medication",
entity_id: med.id,
},
meta: { eligible: false, reason: "no_target" },
});
return {
@@ -476,7 +515,10 @@ export async function buildMedicationEfficacy(
const spanFromPivot = pivotMs !== null ? now.getTime() - pivotMs : 0;
const windowDays = Math.min(
MAX_WINDOW_DAYS,
Math.max(90, Math.ceil((spanFromPivot + BEFORE_WINDOW_DAYS * DAY_MS) / DAY_MS)),
Math.max(
90,
Math.ceil((spanFromPivot + BEFORE_WINDOW_DAYS * DAY_MS) / DAY_MS),
),
);
// Build each target view. Fallback pivot uses the primary metric's first day.
@@ -550,8 +592,16 @@ export async function buildMedicationEfficacy(
lastNonSkippedTakenAt(events),
timezone,
);
const bundle = buildMedicationComplianceBundle(events, med.schedules, ctx, now);
const windowFloorMs = pivotMs !== null ? pivotMs - BEFORE_WINDOW_DAYS * DAY_MS : fetchFrom.getTime();
const bundle = buildMedicationComplianceBundle(
events,
med.schedules,
ctx,
now,
);
const windowFloorMs =
pivotMs !== null
? pivotMs - BEFORE_WINDOW_DAYS * DAY_MS
: fetchFrom.getTime();
const adherence: EfficacyAdherencePoint[] = dailyComplianceRatesFromLedger(
bundle.ledgerRows,
timezone,
@@ -579,7 +629,11 @@ export async function buildMedicationEfficacy(
}));
annotate({
action: { name: "medication.efficacy.build", entity_type: "medication", entity_id: med.id },
action: {
name: "medication.efficacy.build",
entity_type: "medication",
entity_id: med.id,
},
meta: {
eligible: true,
tier: resolved.tier,
+3 -6
View File
@@ -192,11 +192,7 @@ export type MedTarget =
* thyroid→TSH) and the two supplement markers named in the plan.
*/
export type EfficacyMedClass =
| MedTargetClass
| "statin"
| "thyroid"
| "vitamin_d"
| "iron";
MedTargetClass | "statin" | "thyroid" | "vitamin_d" | "iron";
/** Class → its ordered target list (primary first). Closed + conservative. */
const EFFICACY_TARGETS: Readonly<
@@ -308,7 +304,8 @@ function classFromName(
const normalised = normaliseName(name);
if (!normalised) return null;
if (STATIN_NEEDLES.some((n) => containsWord(normalised, n))) return "statin";
if (THYROID_NEEDLES.some((n) => containsWord(normalised, n))) return "thyroid";
if (THYROID_NEEDLES.some((n) => containsWord(normalised, n)))
return "thyroid";
if (VITAMIN_D_NEEDLES.some((n) => containsWord(normalised, n))) {
return "vitamin_d";
}
+1 -1
View File
@@ -558,7 +558,7 @@ export const medicationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"/api/medications/{id}/efficacy": {
get: {
tags: ["Medications"],
summary: "Efficacy view for a medication (\"Wirkung\")",
summary: 'Efficacy view for a medication ("Wirkung")',
description:
"Returns the resolved, strictly-descriptive efficacy DTO: the outcome metric(s)/lab(s) the medication's class targets, the target series with start/dose-change/pause markers, a before/after-start comparison, the cadence-aware adherence lane, an optional conservative level-shift note, the data-floor state, and the retarget options. Association-only — there is no verdict / score field. `eligible:false` (with `reason`) marks a one-shot or no-target medication whose tab is hidden.",
requestParams: {
+2 -6
View File
@@ -87,9 +87,7 @@ const targetViewSchema = z.object({
label: z.string(),
unit: z.string().nullable(),
primary: z.boolean(),
referenceBand: z
.object({ low: z.number(), high: z.number() })
.nullable(),
referenceBand: z.object({ low: z.number(), high: z.number() }).nullable(),
series: z.array(seriesPointSchema),
beforeAfter: beforeAfterSchema,
levelShift: levelShiftSchema,
@@ -111,9 +109,7 @@ export const medicationEfficacyResponseSchema = z.object({
start: z.string().nullable(),
startSource: z.enum(["startsOn", "firstReading"]).nullable(),
doseChanges: z.array(z.object({ at: z.string(), label: z.string() })),
pauses: z.array(
z.object({ from: z.string(), to: z.string().nullable() }),
),
pauses: z.array(z.object({ from: z.string(), to: z.string().nullable() })),
}),
targets: z.array(targetViewSchema),
adherence: z.array(