feat(medications): scale efficacy y-axis and reach older doses

- Efficacy target chart: replace the implicit zero-based y-axis with a
  padded data-range domain so a small relative trend fills the plot
  instead of compressing into the top band.
- Add a "% since start" chart mode that reindexes the target series to a
  percentage of the start-anchored baseline; the absolute reference band
  is hidden in that mode.
- Intake history tab: toggle between the trailing 90-day dose ledger and
  the full paginated intake history so older doses stay reachable.
This commit is contained in:
Marc-André Bombeck committed 2026-07-17 17:17:54 +02:00
1 parent 546c5c7d8f
commit b11ecca8d2
10 files changed
+254 -52

No files matched your search

@@ -44,6 +44,12 @@ export interface EfficacyChartProps {
startLabel: string;
adherenceLabel: string;
typicalRangeLabel: string;
/**
* "absolute" plots the raw target values on the target's own unit;
* "percent" reindexes the series to a percentage of a start-anchored
* baseline and hides the (now meaningless) population reference band.
*/
mode?: "absolute" | "percent";
}
const toMs = (iso: string): number => Date.parse(iso);
@@ -58,6 +64,7 @@ export function EfficacyChart({
startLabel,
adherenceLabel,
typicalRangeLabel,
mode = "absolute",
}: EfficacyChartProps) {
const { locale } = useTranslations();
const fmt = useMemo(
@@ -91,8 +98,44 @@ export function EfficacyChart({
return [Math.min(...xs), Math.max(...xs)];
}, [seriesData, adherenceData]);
// The plotted target series. In "percent" mode each point is reindexed to a
// percentage of the baseline value at/nearest the start (first point when
// the start is unknown), so a small relative trend reads at full scale.
const displaySeries = useMemo(() => {
if (mode !== "percent" || seriesData.length === 0) return seriesData;
const baselinePoint =
startMs === null
? seriesData[0]
: seriesData.reduce((best, p) =>
Math.abs(p.t - startMs) < Math.abs(best.t - startMs) ? p : best,
);
const baseline = baselinePoint.value;
// A zero/non-finite baseline can't anchor a ratio; leave the series
// untouched rather than emit Infinity/NaN (target values are ~never 0).
if (!Number.isFinite(baseline) || baseline === 0) return seriesData;
return seriesData.map((p) => ({
t: p.t,
value: Math.round((p.value / baseline) * 1000) / 10,
}));
}, [seriesData, mode, startMs]);
// Padded data-range domain for the target axis, computed from the currently
// displayed series (never the reference band) so a real trend fills the plot
// instead of compressing against a zero-based axis. The band may clip.
const targetDomain = useMemo<[number, number]>(() => {
const values = displaySeries.map((p) => p.value);
if (values.length === 0) return [0, 1];
const min = Math.min(...values);
const max = Math.max(...values);
if (min === max) return [min - 1, max + 1];
const pad = Math.max((max - min) * 0.12, 1);
return [Math.floor(min - pad), Math.ceil(max + pad)];
}, [displaySeries]);
if (seriesData.length === 0 || !domain) return null;
const unitSuffix = mode === "percent" ? "%" : target.unit;
// 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];
@@ -117,11 +160,12 @@ export function EfficacyChart({
/>
<YAxis
yAxisId="target"
domain={targetDomain}
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
tickLine={false}
axisLine={false}
width={40}
unit={target.unit ? ` ${target.unit}` : undefined}
unit={unitSuffix ? ` ${unitSuffix}` : undefined}
/>
<YAxis
yAxisId="adherence"
@@ -131,8 +175,9 @@ export function EfficacyChart({
/>
{/* Population reference band — faint context, labelled "typical
range", explicitly NOT a target line. */}
{target.referenceBand ? (
range", explicitly NOT a target line. Hidden in percent mode:
an absolute band is meaningless once the series is normalized. */}
{mode === "absolute" && target.referenceBand ? (
<ReferenceArea
yAxisId="target"
y1={target.referenceBand.low}
@@ -180,7 +225,7 @@ export function EfficacyChart({
{/* The target series. */}
<Line
yAxisId="target"
data={seriesData}
data={displaySeries}
dataKey="value"
name={target.label}
type="monotone"
@@ -230,9 +275,12 @@ export function EfficacyChart({
labelFormatter={(v) => fmt.date(new Date(Number(v)))}
formatter={(value, name) => {
const num = Number(value);
if (name === adherenceLabel) {
return [`${Math.round(num)}%`, String(name)];
}
const text =
name === adherenceLabel
? `${Math.round(num)}%`
mode === "percent"
? `${num}%`
: `${num}${target.unit ? ` ${target.unit}` : ""}`;
return [text, String(name)];
}}
@@ -15,6 +15,7 @@ import { Activity, TrendingUp } from "lucide-react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { MedicationDetailSection } from "@/components/medications/medication-detail-section";
import { SegmentedToggle } from "@/components/medications/detail/segmented-toggle";
import { TileHeader } from "@/components/insights/tile-header";
import { Glp1PlateauNote } from "@/components/insights/glp1-plateau-note";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
@@ -206,6 +207,9 @@ function TargetCard({
timezone: string;
}) {
const { t } = useTranslations();
const [scaleMode, setScaleMode] = useState<"absolute" | "percent">(
"absolute",
);
const startMs = dto.markers.start ? Date.parse(dto.markers.start) : null;
return (
@@ -225,25 +229,47 @@ function TargetCard({
</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")}
/>
<div className="space-y-3">
<div className="flex justify-end">
<SegmentedToggle
value={scaleMode}
options={[
{
value: "absolute",
label:
target.unit ?? t("medications.efficacy.scaleAbsolute"),
},
{
value: "percent",
label: t("medications.efficacy.scalePercentSinceStart"),
},
]}
onChange={setScaleMode}
ariaLabel={t("medications.efficacy.scaleToggleLabel")}
dataSlot="medication-wirkung-scale-toggle"
/>
</div>
<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")}
mode={scaleMode}
/>
</div>
) : (
<p className="text-muted-foreground text-sm">
{t("medications.efficacy.noSeries")}
@@ -51,6 +51,8 @@ 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";
import { IntakeHistoryListV2 } from "@/components/medications/intake-history-list-v2";
import { SegmentedToggle } from "@/components/medications/detail/segmented-toggle";
import { ScheduleTimesEditor } from "@/components/medications/scheduling/schedule-times-editor";
import { ScheduleHistoryTimeline } from "@/components/medications/scheduling/schedule-history-timeline";
import type { DoseWindowEntry } from "@/components/medications/scheduling/dose-window";
@@ -232,6 +234,10 @@ export function MedicationDetailTabs({
const [editOpen, setEditOpen] = useState(shouldOpenEditFromUrl);
const [importOpen, setImportOpen] = useState(false);
const [phaseSheetOpen, setPhaseSheetOpen] = useState(false);
// Verlauf tab: the schedule-expanded ledger covers only a trailing 90-day
// window; "all" swaps in the full paginated intake history so older doses
// stay reachable.
const [historyView, setHistoryView] = useState<"recent" | "all">("recent");
const id = medication.id;
const oneShot = medication.oneShot === true;
@@ -682,22 +688,44 @@ export function MedicationDetailTabs({
Slot zuordnen?" nudge). CSV import lives under Erweitert →
Daten (the DataPortabilityRow), not in this header. */}
<TabsContent value="verlauf" className="space-y-6 pt-2">
<MedicationDetailSection
titleId="medication-verlauf-heading"
title={t("medications.detail.intake.title")}
dataSlot="medication-verlauf-section"
>
<DoseHistoryLedger
medicationId={id}
medicationName={medication.name}
schedules={medication.schedules.map((s) => ({
windowStart: s.windowStart,
label: s.label,
dose: s.dose,
timesOfDay: s.timesOfDay,
}))}
<div className="flex justify-end">
<SegmentedToggle
value={historyView}
options={[
{
value: "recent",
label: t("medications.detail.intake.viewRecent90"),
},
{
value: "all",
label: t("medications.detail.intake.viewAll"),
},
]}
onChange={setHistoryView}
ariaLabel={t("medications.detail.intake.viewToggleLabel")}
dataSlot="medication-verlauf-view-toggle"
/>
</MedicationDetailSection>
</div>
{historyView === "recent" ? (
<MedicationDetailSection
titleId="medication-verlauf-heading"
title={t("medications.detail.intake.title")}
dataSlot="medication-verlauf-section"
>
<DoseHistoryLedger
medicationId={id}
medicationName={medication.name}
schedules={medication.schedules.map((s) => ({
windowStart: s.windowStart,
label: s.label,
dose: s.dose,
timesOfDay: s.timesOfDay,
}))}
/>
</MedicationDetailSection>
) : (
<IntakeHistoryListV2 medicationId={id} />
)}
</TabsContent>
{/* INJEKTION — injectable routes only. Current GLP-1 charts +
@@ -0,0 +1,64 @@
"use client";
import { cn } from "@/lib/utils";
/**
* Small two-plus-segment text toggle for medication-detail surfaces. Mirrors
* the icon-only `MedicationViewToggle` chrome (bordered pill group, filled
* active segment, ghost inactive) but carries text labels. `aria-pressed`
* announces the state per segment; the group is named for screen readers.
* Segments keep the 40-px mobile tap floor, shrinking to the 32-px control
* height on desktop.
*/
export interface SegmentedToggleOption<T extends string> {
value: T;
label: string;
}
interface SegmentedToggleProps<T extends string> {
value: T;
options: SegmentedToggleOption<T>[];
onChange: (value: T) => void;
ariaLabel: string;
dataSlot?: string;
}
export function SegmentedToggle<T extends string>({
value,
options,
onChange,
ariaLabel,
dataSlot,
}: SegmentedToggleProps<T>) {
return (
<div
role="group"
aria-label={ariaLabel}
className="border-border bg-background/30 inline-flex shrink-0 items-center rounded-md border p-0.5"
{...(dataSlot ? { "data-slot": dataSlot } : {})}
>
{options.map(({ value: optionValue, label }) => {
const active = value === optionValue;
return (
<button
key={optionValue}
type="button"
onClick={() => {
if (!active) onChange(optionValue);
}}
aria-pressed={active}
data-slot={`${dataSlot ?? "segment"}-${optionValue}`}
className={cn(
"focus-visible:ring-ring inline-flex min-h-10 items-center justify-center rounded-[5px] px-3 text-xs font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none motion-reduce:transition-none sm:min-h-8",
active
? "bg-secondary text-secondary-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
{label}
</button>
);
})}
</div>
);
}