diff --git a/CHANGELOG.md b/CHANGELOG.md index 2fded7059..19de0f73c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ ## [Unreleased] +## [1.28.20] — 2026-07-10 — Estimated drug level where you read efficacy + +The estimated active-substance curve for a GLP-1 medication — the modelled drug +level from your logged doses — now also appears in the efficacy view and in the +Insights medication overview, not only under the injection tab. It is the same +estimate shown there, surfaced next to the efficacy readouts so the drug level +and its effect sit side by side. Non-GLP-1 medications are unaffected. + ## [1.28.19] — 2026-07-10 — Document and card polish Small refinements across a few surfaces. diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 78916a428..b735a4920 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.19 + version: 1.28.20 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 f14c617f3..8851553e9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.28.19", + "version": "1.28.20", "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 4eb8b44e0..d2129c86a 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.19"; + /* @sw-version-fallback */ "v1.28.20"; 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/insights/medications/page.tsx b/src/app/insights/medications/page.tsx index fc8eade3a..552b5eaaf 100644 --- a/src/app/insights/medications/page.tsx +++ b/src/app/insights/medications/page.tsx @@ -18,6 +18,7 @@ import { CoachLaunchButton } from "@/components/insights/coach-launch-button"; import { InsightStatusCard } from "@/components/insights/insight-status-card"; import { MetricTargetSummary } from "@/components/insights/metric-target-summary"; import { MedicationEfficacySummary } from "@/components/insights/medication-efficacy-summary"; +import { Glp1SubstanceCurveSummary } from "@/components/insights/glp1-substance-curve-summary"; import { SubPageShell } from "@/components/insights/sub-page-shell"; import { TileHeader } from "@/components/insights/tile-header"; import { apiGet } from "@/lib/api/api-fetch"; @@ -288,6 +289,16 @@ export default function InsightsMedikamentePage() { medications={medications.map((m) => ({ id: m.id, name: m.name }))} /> + {/* v1.28.20 — estimated active-substance curve for GLP-1 medications + (additive; renders nothing when the user has no GLP-1 medication). */} + ({ + id: m.id, + name: m.name, + dose: m.dose, + }))} + /> + {/* v1.12.2 — the assessment is the LAST block on every bespoke diff --git a/src/components/insights/glp1-substance-curve-summary.tsx b/src/components/insights/glp1-substance-curve-summary.tsx new file mode 100644 index 000000000..9b337043d --- /dev/null +++ b/src/components/insights/glp1-substance-curve-summary.tsx @@ -0,0 +1,85 @@ +"use client"; + +/** + * v1.28.20 — compact GLP-1 estimated active-substance curve for the Insights + * medication surface. Additive to the medication detail page's Injektion and + * Wirkung tabs: the same self-contained `DrugLevelChart` (modelled + * one-compartment level from the user's logged intakes + dose changes), + * surfaced where the compliance overview already lives. + * + * Gating: reads `GET /api/insights/glp1-timeline` — the server-authoritative + * GLP-1 signal. When `hasGlp1` is false the block renders nothing. The set of + * GLP-1 medication names comes from the timeline's entries (every entry is + * from a `treatmentClass === "GLP1"` medication), and the passed medication + * list is filtered to that set so non-GLP-1 medications never render a curve. + * + * The chart carries its own `MedicationDetailSection` card chrome + estimate + * disclaimer, so it reads as one system with the surrounding Insights cards + * without a second computation. Recharts loads through `next/dynamic` + * (client-only) to stay out of the page's initial bundle. + */ +import dynamic from "next/dynamic"; +import { useQuery } from "@tanstack/react-query"; + +import { ChartSkeleton } from "@/components/charts/chart-skeleton"; +import { queryKeys } from "@/lib/query-keys"; +import { apiGet } from "@/lib/api/api-fetch"; + +const DrugLevelChart = dynamic( + () => + import("@/components/charts/chart-runtime").then((mod) => ({ + default: mod.DrugLevelChart, + })), + { ssr: false, loading: () => }, +); + +interface Glp1TimelineResponse { + hasGlp1: boolean; + entries: { medicationName?: string }[]; +} + +export function Glp1SubstanceCurveSummary({ + medications, +}: { + medications: { id: string; name: string; dose: string }[]; +}) { + const { data } = useQuery({ + queryKey: queryKeys.insightsGlp1Timeline(200), + queryFn: () => + apiGet("/api/insights/glp1-timeline?limit=200"), + staleTime: 60_000, + }); + + if (!data?.hasGlp1) return null; + + // Every timeline entry is from a GLP-1 medication (server filters on + // `treatmentClass: "GLP1"`). Medication names match the comprehensive + // list exactly — both derive from `medication.name` server-side. + const glp1Names = new Set( + data.entries + .map((e) => e.medicationName) + .filter((n): n is string => typeof n === "string" && n.length > 0), + ); + + const glp1Meds = medications.filter((m) => glp1Names.has(m.name)); + if (glp1Meds.length === 0) return null; + + const showNameLabel = glp1Meds.length > 1; + + return ( +
+ {glp1Meds.map((med) => ( +
+ {showNameLabel ? ( +

+ {med.name} +

+ ) : null} + +
+ ))} +
+ ); +} diff --git a/src/components/medications/detail/efficacy/efficacy-tab.tsx b/src/components/medications/detail/efficacy/efficacy-tab.tsx index 2d465ecca..10914ecea 100644 --- a/src/components/medications/detail/efficacy/efficacy-tab.tsx +++ b/src/components/medications/detail/efficacy/efficacy-tab.tsx @@ -44,12 +44,39 @@ const EfficacyChart = dynamic( { ssr: false, loading: () => }, ); +// v1.28.20 — the estimated active-substance curve (Wirkstoffverlauf) also +// surfaces here, additive to the Injektion tab. Same self-contained chart, +// same `next/dynamic` (client-only) load that keeps Recharts out of the +// initial bundle. +const DrugLevelChart = dynamic( + () => + import("@/components/charts/chart-runtime").then((mod) => ({ + default: mod.DrugLevelChart, + })), + { ssr: false, loading: () => }, +); + export function EfficacyTab({ medicationId, active, + isGlp1 = false, + medicationName, + medicationDose, }: { medicationId: string; active: boolean; + /** + * True when the parent already knows the medication is a (non-one-shot) + * GLP-1 drug (`treatmentClass === "GLP1"`). Threaded from the detail-tabs + * parent because the efficacy DTO carries the target class, not the raw + * treatment class. Gates the estimated active-substance curve exactly the + * way the Injektion tab does — no Research-Mode wrapper, GLP-1 alone. + */ + isGlp1?: boolean; + /** Brand/generic name the substance-curve chart resolves to a catalog id. */ + medicationName?: string; + /** Headline dose string, the chart's per-intake dose fallback. */ + medicationDose?: string; }) { const { t } = useTranslations(); const { user } = useAuth(); @@ -136,6 +163,24 @@ export function EfficacyTab({ /> ))} + {/* v1.28.20 — estimated active-substance curve for GLP-1 medications, + mirroring the Injektion tab. The chart is self-contained (fetches + its own intake + dose-change data and renders its own + `MedicationDetailSection` card + estimate disclaimer); it slots + straight into the tab's `space-y-6` rhythm. Additive: no existing + Wirkung content changes. */} + {isGlp1 && medicationName != null && medicationDose != null ? ( +
+ +
+ ) : null} +