chore(release): v1.28.20 — estimated drug level in efficacy + insights

The GLP-1 estimated active-substance curve (the modelled drug level from logged
doses) now also renders in the efficacy (Wirkung) tab and in the Insights
medication overview, not only under the injection tab. Both reuse the same
self-contained DrugLevelChart, gated on the GLP-1 treatment class, so the drug
level sits next to the efficacy readouts. Additive — no existing surface changes;
non-GLP-1 medications render nothing.
This commit is contained in:
Marc-André Bombeck committed 2026-07-10 14:33:47 +02:00
1 parent 5f558abc33
commit 349cfa4d80
8 files changed
+159 -4

No files matched your search

+8
View File
@@ -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.
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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",
+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.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/*`
+11
View File
@@ -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). */}
<Glp1SubstanceCurveSummary
medications={medications.map((m) => ({
id: m.id,
name: m.name,
dose: m.dose,
}))}
/>
<MetricTargetSummary slug="medications" />
{/* v1.12.2 — the assessment is the LAST block on every bespoke
@@ -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: () => <ChartSkeleton /> },
);
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<Glp1TimelineResponse>("/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 (
<div className="space-y-4" data-slot="insights-glp1-substance-curve">
{glp1Meds.map((med) => (
<div key={med.id} className="space-y-2">
{showNameLabel ? (
<p className="text-muted-foreground px-1 text-xs font-medium">
{med.name}
</p>
) : null}
<DrugLevelChart
medication={{ id: med.id, name: med.name, dose: med.dose }}
/>
</div>
))}
</div>
);
}
@@ -44,12 +44,39 @@ const EfficacyChart = dynamic(
{ ssr: false, loading: () => <ChartSkeleton /> },
);
// 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: () => <ChartSkeleton /> },
);
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 ? (
<div data-slot="wirkung-drug-level-section">
<DrugLevelChart
medication={{
id: medicationId,
name: medicationName,
dose: medicationDose,
}}
/>
</div>
) : null}
<p
className="text-muted-foreground text-xs"
data-slot="medication-wirkung-disclaimer"
@@ -560,7 +560,13 @@ export function MedicationDetailTabs({
descriptive; hidden for one-shot + no-target medications. */}
{hasEfficacyTarget && (
<TabsContent value="wirkung" className="space-y-6 pt-2">
<EfficacyTab medicationId={id} active={activeTab === "wirkung"} />
<EfficacyTab
medicationId={id}
active={activeTab === "wirkung"}
isGlp1={isGlp1}
medicationName={medication.name}
medicationDose={medication.dose}
/>
</TabsContent>
)}