diff --git a/CHANGELOG.md b/CHANGELOG.md index f340e7822..955e7cb9a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 4ac166721..8d60cb066 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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. diff --git a/package.json b/package.json index e1d16a182..af0eebd99 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/public/sw.js b/public/sw.js index fbed073e5..ba3e14d3e 100644 --- a/public/sw.js +++ b/public/sw.js @@ -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/*` diff --git a/src/app/api/__tests__/module-route-gate-inventory.test.ts b/src/app/api/__tests__/module-route-gate-inventory.test.ts index 432602444..c7c294aa3 100644 --- a/src/app/api/__tests__/module-route-gate-inventory.test.ts +++ b/src/app/api/__tests__/module-route-gate-inventory.test.ts @@ -204,6 +204,12 @@ const EXEMPT_ROUTES: ReadonlyArray = [ "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", diff --git a/src/lib/medications/efficacy/build-efficacy.ts b/src/lib/medications/efficacy/build-efficacy.ts index 7c825aa5e..3d35b8f3a 100644 --- a/src/lib/medications/efficacy/build-efficacy.ts +++ b/src/lib/medications/efficacy/build-efficacy.ts @@ -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 + 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>, ): Promise { 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 { 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, diff --git a/src/lib/medications/med-target-map.ts b/src/lib/medications/med-target-map.ts index 500ede128..01d360a65 100644 --- a/src/lib/medications/med-target-map.ts +++ b/src/lib/medications/med-target-map.ts @@ -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"; } diff --git a/src/lib/openapi/routes/medications/paths.ts b/src/lib/openapi/routes/medications/paths.ts index ba7c41bae..dd0e226b1 100644 --- a/src/lib/openapi/routes/medications/paths.ts +++ b/src/lib/openapi/routes/medications/paths.ts @@ -558,7 +558,7 @@ export const medicationPaths: NonNullable = { "/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: { diff --git a/src/lib/validations/medication-efficacy.ts b/src/lib/validations/medication-efficacy.ts index 36da6b89a..86826ef7b 100644 --- a/src/lib/validations/medication-efficacy.ts +++ b/src/lib/validations/medication-efficacy.ts @@ -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(