feat(medications): Wirkung tab + Insights efficacy summary

Add the per-medication "Wirkung" tab (hidden for one-shot and no-target meds)
rendering the target series with a start marker, dose-change markers, shaded
pause bands and a faint reference band, an adherence lane, a neutral
before/after-start card with honest empty states, an optional level-shift note,
and a retarget chooser. Weave a compact, read-only efficacy summary into the
Insights medication surface that reads the same DTO and links through to the
tab. All copy is descriptive; add a banned-verdict-verb guard test over the
medications.efficacy namespace across all six locales.
This commit is contained in:
Marc-André Bombeck committed 2026-07-09 13:02:02 +02:00
1 parent 725f69097f
commit 8617fa7129
14 files changed
+1127 -14

No files matched your search

+1
View File
@@ -40,4 +40,5 @@ export { SleepStageStackedBar } from "@/components/insights/sleep-stage-stacked-
export { LabBiomarkerChart } from "@/components/labs/lab-biomarker-chart";
export { DoseStrengthCurve } from "@/components/medications/dose-strength-curve";
export { DrugLevelChart } from "@/components/medications/drug-level-chart";
export { EfficacyChart } from "@/components/medications/detail/efficacy/efficacy-chart";
export { AssessmentHistoryChart } from "@/components/mental-health/assessment-history-chart";
@@ -0,0 +1,126 @@
"use client";
/**
* v1.28 — compact, read-only medication-efficacy summary for the Insights
* medication surface. Reads the SAME server-computed efficacy DTO the detail
* page's "Wirkung" tab reads (no second computation) and shows, per eligible
* medication with enough data, a one-line before/after against its target —
* strictly association-framed, never a verdict. Each row links through to the
* medication's Wirkung tab for the full view.
*
* Card anatomy per UI-STANDARDS §8: TileHeader + the inherited `px-4 md:px-6`
* inset (never re-declared), foreground for content, muted for meta.
*/
import Link from "next/link";
import { Activity, TrendingUp } from "lucide-react";
import { useQueries } from "@tanstack/react-query";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { TileHeader } from "@/components/insights/tile-header";
import { queryKeys } from "@/lib/query-keys";
import { apiGet } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import type {
MedicationEfficacyDTO,
EfficacyTargetView,
} from "@/lib/medications/efficacy/build-efficacy";
function primaryWithDelta(
dto: MedicationEfficacyDTO,
): EfficacyTargetView | null {
const withDelta = dto.targets.find(
(t) => t.primary && t.beforeAfter.present && t.beforeAfter.delta,
);
return withDelta ?? null;
}
export function MedicationEfficacySummary({
medications,
}: {
medications: { id: string; name: string }[];
}) {
const { t } = useTranslations();
const results = useQueries({
queries: medications.map((m) => ({
queryKey: queryKeys.medicationEfficacy(m.id),
queryFn: () =>
apiGet<MedicationEfficacyDTO>(`/api/medications/${m.id}/efficacy`),
staleTime: 60_000,
})),
});
if (medications.length === 0) return null;
const rows = results
.map((r, i) => ({ med: medications[i], dto: r.data }))
.filter(
(
x,
): x is {
med: { id: string; name: string };
dto: MedicationEfficacyDTO;
} => !!x.dto && x.dto.eligible,
)
.map((x) => ({ med: x.med, target: primaryWithDelta(x.dto) }))
.filter((x) => x.target !== null);
return (
<Card data-slot="insights-medication-efficacy">
<CardHeader>
<TileHeader
icon={Activity}
title={t("medications.efficacy.insights.title")}
/>
<p className="text-muted-foreground mt-1 text-xs">
{t("medications.efficacy.insights.subtitle")}
</p>
</CardHeader>
<CardContent className="space-y-3">
{rows.length === 0 ? (
<p className="text-muted-foreground text-sm">
{t("medications.efficacy.insights.empty")}
</p>
) : (
<ul className="divide-border/60 divide-y">
{rows.map(({ med, target }) => {
const ba = target!.beforeAfter;
const unit = target!.unit ? ` ${target!.unit}` : "";
const arrow = (ba.delta?.mean ?? 0) > 0 ? "+" : "";
return (
<li
key={med.id}
className="flex flex-wrap items-center justify-between gap-x-3 gap-y-1 py-2.5 first:pt-0 last:pb-0"
data-slot="insights-medication-efficacy-row"
>
<div className="min-w-0">
<p className="text-foreground text-sm font-medium">
{med.name}
</p>
<p className="text-muted-foreground flex items-center gap-1.5 text-xs">
<TrendingUp aria-hidden="true" className="h-3.5 w-3.5" />
<span>
{target!.label}: {ba.before?.mean}
{unit} → {ba.after?.mean}
{unit} ({arrow}
{ba.delta?.mean}
{unit})
</span>
</p>
</div>
<Link
href={`/medications/${med.id}?tab=wirkung`}
className="text-primary focus-visible:ring-ring shrink-0 rounded-sm text-xs underline-offset-2 hover:underline focus-visible:ring-2 focus-visible:outline-none"
data-slot="insights-medication-efficacy-link"
>
{t("medications.efficacy.insights.link")}
</Link>
</li>
);
})}
</ul>
)}
</CardContent>
</Card>
);
}
@@ -0,0 +1,244 @@
"use client";
/**
* v1.28 — the "Wirkung" target chart. Recharts consumer, so it is exported
* through `chart-runtime` and loaded via `next/dynamic` at the call site (the
* one-shared-chunk rule). Renders the target series over a span around the
* medication's start with:
* - a faint population reference band (context, never a goal line),
* - shaded pause bands,
* - a vertical start marker + quiet dose-change markers,
* - the target line,
* - a faint adherence lane on a hidden right axis.
* Strictly descriptive — no colour-coded verdict, no "good/bad" zones.
*/
import { useMemo } from "react";
import {
ResponsiveContainer,
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ReferenceLine,
ReferenceArea,
} from "recharts";
import { makeFormatters } from "@/lib/format-locale";
import { useTranslations } from "@/lib/i18n/context";
export interface EfficacyChartTarget {
label: string;
unit: string | null;
referenceBand: { low: number; high: number } | null;
series: { t: string; value: number }[];
}
export interface EfficacyChartProps {
target: EfficacyChartTarget;
startMs: number | null;
doseChanges: { at: string; label: string }[];
pauses: { from: string; to: string | null }[];
adherence: { date: string; rate: number }[];
timezone: string;
startLabel: string;
adherenceLabel: string;
typicalRangeLabel: string;
}
const toMs = (iso: string): number => Date.parse(iso);
export function EfficacyChart({
target,
startMs,
doseChanges,
pauses,
adherence,
timezone,
startLabel,
adherenceLabel,
typicalRangeLabel,
}: EfficacyChartProps) {
const { locale } = useTranslations();
const fmt = useMemo(
() => makeFormatters(locale, timezone),
[locale, timezone],
);
const seriesData = useMemo(
() =>
target.series
.map((p) => ({ t: toMs(p.t), value: p.value }))
.filter((p) => Number.isFinite(p.t))
.sort((a, b) => a.t - b.t),
[target.series],
);
const adherenceData = useMemo(
() =>
adherence
.map((p) => ({ t: toMs(p.date), rate: p.rate }))
.filter((p) => Number.isFinite(p.t))
.sort((a, b) => a.t - b.t),
[adherence],
);
const domain = useMemo<[number, number] | null>(() => {
const xs = [
...seriesData.map((p) => p.t),
...adherenceData.map((p) => p.t),
];
if (xs.length === 0) return null;
return [Math.min(...xs), Math.max(...xs)];
}, [seriesData, adherenceData]);
if (seriesData.length === 0 || !domain) return null;
// An open pause runs to the chart's right edge (the latest datum); using the
// domain max keeps the render pure (no `Date.now()` in the render body).
const rightEdge = domain[1];
return (
<div className="touch-pan-y">
<ResponsiveContainer width="100%" height={220}>
<LineChart margin={{ top: 8, right: 8, bottom: 0, left: 0 }}>
<CartesianGrid
strokeDasharray="3 3"
stroke="var(--border)"
opacity={0.5}
/>
<XAxis
dataKey="t"
type="number"
scale="time"
domain={domain}
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
tickLine={false}
axisLine={false}
tickFormatter={(v: number) => fmt.date(new Date(v))}
/>
<YAxis
yAxisId="target"
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
tickLine={false}
axisLine={false}
width={40}
unit={target.unit ? ` ${target.unit}` : undefined}
/>
<YAxis
yAxisId="adherence"
orientation="right"
domain={[0, 100]}
hide
/>
{/* Population reference band — faint context, labelled "typical
range", explicitly NOT a target line. */}
{target.referenceBand ? (
<ReferenceArea
yAxisId="target"
y1={target.referenceBand.low}
y2={target.referenceBand.high}
fill="var(--muted-foreground)"
fillOpacity={0.08}
stroke="none"
label={{
value: typicalRangeLabel,
position: "insideTopLeft",
fill: "var(--muted-foreground)",
fontSize: 10,
}}
/>
) : null}
{/* Shaded pause bands. An open pause runs to now. */}
{pauses.map((p, i) => (
<ReferenceArea
key={`pause-${i}`}
yAxisId="target"
x1={toMs(p.from)}
x2={p.to ? toMs(p.to) : rightEdge}
fill="var(--foreground)"
fillOpacity={0.06}
stroke="none"
/>
))}
{/* Adherence lane — faint, hidden right axis. */}
<Line
yAxisId="adherence"
data={adherenceData}
dataKey="rate"
name={adherenceLabel}
type="stepAfter"
stroke="var(--chart-4)"
strokeWidth={1.5}
strokeOpacity={0.5}
dot={false}
connectNulls
isAnimationActive={false}
/>
{/* The target series. */}
<Line
yAxisId="target"
data={seriesData}
dataKey="value"
name={target.label}
type="monotone"
stroke="var(--chart-1)"
strokeWidth={2}
dot={{ r: 2, fill: "var(--chart-1)" }}
activeDot={{ r: 4 }}
connectNulls
isAnimationActive={false}
/>
{/* Start marker. */}
{startMs !== null ? (
<ReferenceLine
yAxisId="target"
x={startMs}
stroke="var(--primary)"
strokeWidth={1.5}
label={{
value: startLabel,
position: "insideTopRight",
fill: "var(--foreground)",
fontSize: 10,
}}
/>
) : null}
{/* Dose-change markers — quiet, dashed. */}
{doseChanges.map((d, i) => (
<ReferenceLine
key={`dose-${i}`}
yAxisId="target"
x={toMs(d.at)}
stroke="var(--muted-foreground)"
strokeDasharray="4 4"
strokeOpacity={0.6}
/>
))}
<Tooltip
contentStyle={{
backgroundColor: "var(--card)",
border: "1px solid var(--border)",
borderRadius: "0.5rem",
fontSize: "0.8125rem",
}}
labelFormatter={(v) => fmt.date(new Date(Number(v)))}
formatter={(value, name) => {
const num = Number(value);
const text =
name === adherenceLabel
? `${Math.round(num)}%`
: `${num}${target.unit ? ` ${target.unit}` : ""}`;
return [text, String(name)];
}}
/>
</LineChart>
</ResponsiveContainer>
</div>
);
}
@@ -0,0 +1,381 @@
"use client";
/**
* v1.28 — the "Wirkung" tab body: a strictly-descriptive, association-only
* view relating a medication to the outcome metric(s)/lab(s) its class is
* prescribed to move, around its start. Reads the server-authoritative
* efficacy DTO (`GET /api/medications/[id]/efficacy`) and renders it — it
* never recomputes a delta, mean, or adherence rate. No verdict, no score, no
* "working / not working" language, no dose advice: the copy is numbers +
* neutral connective phrasing only, mirroring the adherence-storyline posture.
*/
import { useMemo, useState } from "react";
import dynamic from "next/dynamic";
import { Activity, TrendingUp } from "lucide-react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { MedicationDetailSection } from "@/components/medications/medication-detail-section";
import { TileHeader } from "@/components/insights/tile-header";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { ChartSkeleton } from "@/components/charts/chart-skeleton";
import { queryKeys } from "@/lib/query-keys";
import { apiGet, apiPut } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import { useAuth } from "@/hooks/use-auth";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
import type {
MedicationEfficacyDTO,
EfficacyTargetView,
} from "@/lib/medications/efficacy/build-efficacy";
const EfficacyChart = dynamic(
() =>
import("@/components/charts/chart-runtime").then((mod) => ({
default: mod.EfficacyChart,
})),
{ ssr: false, loading: () => <ChartSkeleton /> },
);
export function EfficacyTab({
medicationId,
active,
}: {
medicationId: string;
active: boolean;
}) {
const { t } = useTranslations();
const { user } = useAuth();
const timezone = user?.timezone || DEFAULT_TIMEZONE;
const queryClient = useQueryClient();
const { data, isLoading } = useQuery({
queryKey: queryKeys.medicationEfficacy(medicationId),
queryFn: () =>
apiGet<MedicationEfficacyDTO>(
`/api/medications/${medicationId}/efficacy`,
),
enabled: active,
staleTime: 60_000,
});
if (isLoading || !data) {
return (
<MedicationDetailSection
titleId="medication-wirkung-heading"
title={t("medications.efficacy.title")}
dataSlot="medication-wirkung-section"
>
<ChartSkeleton />
</MedicationDetailSection>
);
}
// The tab is hidden upstream when ineligible; this is a defensive fallback.
if (!data.eligible) {
return (
<MedicationDetailSection
titleId="medication-wirkung-heading"
title={t("medications.efficacy.title")}
dataSlot="medication-wirkung-section"
>
<p className="text-muted-foreground text-sm">
{t("medications.efficacy.notEligible")}
</p>
</MedicationDetailSection>
);
}
return (
<div className="space-y-6" data-slot="medication-wirkung-section">
<MedicationDetailSection
titleId="medication-wirkung-heading"
title={t("medications.efficacy.title")}
dataSlot="medication-wirkung-intro"
>
<div className="space-y-3">
<p className="text-muted-foreground text-sm">
{t("medications.efficacy.intro")}
</p>
{data.markers.startSource === "firstReading" ? (
<p className="text-muted-foreground text-xs">
{t("medications.efficacy.startFallback")}
</p>
) : null}
{data.markers.start === null ? (
<p className="text-muted-foreground text-xs">
{t("medications.efficacy.noStart")}
</p>
) : null}
<RetargetControl
medicationId={medicationId}
options={data.overrideOptions}
isOverride={data.resolution.tier === "override"}
onChanged={() =>
queryClient.invalidateQueries({
queryKey: queryKeys.medicationEfficacy(medicationId),
})
}
/>
</div>
</MedicationDetailSection>
{data.targets.map((target) => (
<TargetCard
key={`${target.kind}:${target.key}`}
target={target}
dto={data}
timezone={timezone}
/>
))}
<p
className="text-muted-foreground text-xs"
data-slot="medication-wirkung-disclaimer"
>
{t("medications.efficacy.disclaimer")}
</p>
</div>
);
}
function TargetCard({
target,
dto,
timezone,
}: {
target: EfficacyTargetView;
dto: MedicationEfficacyDTO;
timezone: string;
}) {
const { t } = useTranslations();
const startMs = dto.markers.start ? Date.parse(dto.markers.start) : null;
return (
<Card data-slot={`medication-wirkung-target-${target.kind}`}>
<CardHeader>
<TileHeader
icon={TrendingUp}
title={target.label}
right={
target.primary ? (
<span className="text-muted-foreground text-xs">
{t("medications.efficacy.primaryBadge")}
</span>
) : undefined
}
/>
</CardHeader>
<CardContent className="space-y-4">
{target.series.length > 0 ? (
<EfficacyChart
target={{
label: target.label,
unit: target.unit,
referenceBand: target.referenceBand,
series: target.series.map((p) => ({ t: p.t, value: p.value })),
}}
startMs={startMs}
doseChanges={dto.markers.doseChanges}
pauses={dto.markers.pauses}
adherence={dto.adherence.map((a) => ({
date: a.date,
rate: a.rate,
}))}
timezone={timezone}
startLabel={t("medications.efficacy.startMarker")}
adherenceLabel={t("medications.efficacy.adherenceLane")}
typicalRangeLabel={t("medications.efficacy.typicalRange")}
/>
) : (
<p className="text-muted-foreground text-sm">
{t("medications.efficacy.noSeries")}
</p>
)}
<BeforeAfterCard target={target} minWeeks={dto.minWeeksPerSide} />
{target.levelShift?.present && target.levelShift.nearStart ? (
<p
className="text-muted-foreground text-xs"
data-slot="medication-wirkung-levelshift"
>
{t("medications.efficacy.levelShift", {
date: formatShort(target.levelShift.at),
})}
</p>
) : null}
</CardContent>
</Card>
);
}
function BeforeAfterCard({
target,
minWeeks,
}: {
target: EfficacyTargetView;
minWeeks: number;
}) {
const { t } = useTranslations();
const ba = target.beforeAfter;
const unit = target.unit ? ` ${target.unit}` : "";
if (!ba.present || !ba.before || !ba.after || !ba.delta) {
const reasonKey =
ba.reason === "insufficient_before"
? "medications.efficacy.beforeAfter.needBefore"
: ba.reason === "insufficient_after"
? "medications.efficacy.beforeAfter.needAfter"
: ba.reason === "no_start"
? "medications.efficacy.beforeAfter.needStart"
: "medications.efficacy.beforeAfter.needData";
return (
<div
className="border-border/60 rounded-lg border border-dashed p-3"
data-slot="medication-wirkung-beforeafter-empty"
>
<p className="text-muted-foreground text-sm">
{t(reasonKey, { weeks: minWeeks })}
</p>
</div>
);
}
const arrow = ba.delta.mean > 0 ? "+" : "";
return (
<div
className="bg-muted/40 rounded-lg p-3"
data-slot="medication-wirkung-beforeafter"
>
<p className="text-foreground text-sm">
{t("medications.efficacy.beforeAfter.summary", {
before: `${ba.before.mean}${unit}`,
after: `${ba.after.mean}${unit}`,
delta: `${arrow}${ba.delta.mean}${unit}`,
})}
</p>
<p className="text-muted-foreground mt-1 text-xs">
{t("medications.efficacy.beforeAfter.counts", {
beforeCount: ba.before.count,
afterCount: ba.after.count,
})}
</p>
</div>
);
}
function RetargetControl({
medicationId,
options,
isOverride,
onChanged,
}: {
medicationId: string;
options: MedicationEfficacyDTO["overrideOptions"];
isOverride: boolean;
onChanged: () => void;
}) {
const { t } = useTranslations();
const [value, setValue] = useState<string>("");
const [busy, setBusy] = useState(false);
const items = useMemo(
() => [
...options.metrics.map((m) => ({
value: `metric:${m.key}`,
label: m.label,
})),
...options.biomarkers.map((b) => ({
value: `lab:${b.id}`,
label: b.label,
})),
],
[options],
);
const apply = async (clear: boolean) => {
setBusy(true);
try {
const body: {
clear?: boolean;
measurementType?: string;
biomarkerId?: string;
} = {};
if (clear) {
body.clear = true;
} else if (value.startsWith("metric:")) {
body.measurementType = value.slice("metric:".length);
} else if (value.startsWith("lab:")) {
body.biomarkerId = value.slice("lab:".length);
} else {
setBusy(false);
return;
}
await apiPut(`/api/medications/${medicationId}/efficacy/target`, body);
onChanged();
if (clear) setValue("");
} finally {
setBusy(false);
}
};
if (items.length === 0) return null;
return (
<div
className="flex flex-wrap items-center gap-2"
data-slot="medication-wirkung-retarget"
>
<Activity aria-hidden="true" className="text-muted-foreground h-4 w-4" />
<span className="text-muted-foreground text-xs">
{t("medications.efficacy.retarget.label")}
</span>
<Select value={value} onValueChange={setValue}>
<SelectTrigger size="sm" className="w-56">
<SelectValue
placeholder={t("medications.efficacy.retarget.placeholder")}
/>
</SelectTrigger>
<SelectContent>
{items.map((it) => (
<SelectItem key={it.value} value={it.value}>
{it.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
size="sm"
variant="outline"
disabled={busy || value === ""}
onClick={() => apply(false)}
>
{t("medications.efficacy.retarget.apply")}
</Button>
{isOverride ? (
<Button
size="sm"
variant="ghost"
disabled={busy}
onClick={() => apply(true)}
>
{t("medications.efficacy.retarget.reset")}
</Button>
) : null}
</div>
);
}
function formatShort(iso?: string): string {
if (!iso) return "";
const d = new Date(iso);
return Number.isNaN(d.getTime()) ? "" : d.toISOString().slice(0, 10);
}
@@ -46,6 +46,8 @@ import { ArrowLeft, Pencil } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { MedicationDetailSummary } from "@/components/medications/medication-detail-summary";
import { EfficacyTab } from "@/components/medications/detail/efficacy/efficacy-tab";
import { resolveMedicationTargets } from "@/lib/medications/med-target-map";
import { MedicationDetailSection } from "@/components/medications/medication-detail-section";
import { MedicationComplianceBars } from "@/components/medications/card-parts/medication-compliance-bars";
import { DoseHistoryLedger } from "@/components/medications/dose-history-ledger";
@@ -157,6 +159,7 @@ export interface MedicationDetailSnapshot {
// the tab strip render and the `?tab=` round-trip validation.
const TAB_SLUGS = [
"uebersicht",
"wirkung",
"zeitplan",
"bestand",
"verlauf",
@@ -236,6 +239,21 @@ export function MedicationDetailTabs({
const isGlp1 = !oneShot && medication.treatmentClass === "GLP1";
const isInjectable = medication.deliveryForm === "INJECTION";
// v1.28 — the "Wirkung" tab appears only when the medication resolves to a
// known outcome target (ATC class prefix → whole-word name inference) and is
// not a single-dose med. Same pure resolver the server efficacy builder's
// derived tier uses, so the tab's presence agrees with the DTO's eligibility.
const hasEfficacyTarget = useMemo(
() =>
!oneShot &&
resolveMedicationTargets({
name: medication.name,
treatmentClass: medication.treatmentClass,
atcCode: medication.atcCode,
}) !== null,
[oneShot, medication.name, medication.treatmentClass, medication.atcCode],
);
const payload = useMemo(
() => snapshotToWizardPayload(medication),
[medication],
@@ -254,9 +272,10 @@ export function MedicationDetailTabs({
TAB_SLUGS.filter(
(slug) =>
(slug !== "injektion" || isInjectable) &&
(slug !== "zeitplan" || !asNeeded),
(slug !== "zeitplan" || !asNeeded) &&
(slug !== "wirkung" || hasEfficacyTarget),
),
[isInjectable, asNeeded],
[isInjectable, asNeeded, hasEfficacyTarget],
);
const requestedRaw = searchParams?.get("tab");
@@ -535,6 +554,16 @@ export function MedicationDetailTabs({
)}
</TabsContent>
{/* WIRKUNG — the per-medication efficacy view: the outcome metric(s)/
lab(s) the drug's class targets, over the span around its start,
with a before/after comparison + adherence lane. Strictly
descriptive; hidden for one-shot + no-target medications. */}
{hasEfficacyTarget && (
<TabsContent value="wirkung" className="space-y-6 pt-2">
<EfficacyTab medicationId={id} active={activeTab === "wirkung"} />
</TabsContent>
)}
{/* ZEITPLAN — inline edit of the everyday levers: dose times +
each dose's on-time window, then the Erinnerung card (the
notifications switch + the reminder grace, folded in from the