mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
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:
1 parent
8617fa7129
commit
783255d833
9 files changed
+96
-33
No files matched your search
@@ -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,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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user