mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
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:
1 parent
725f69097f
commit
8617fa7129
14 files changed
+1127
-14
No files matched your search
@@ -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
|
||||
|
||||
Reference in new issue
Block a user