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

+8 -2
View File
@@ -1403,7 +1403,10 @@
"confirmAction": "Löschen",
"toast": "Einnahme gelöscht",
"failed": "Einnahme konnte nicht gelöscht werden."
}
},
"viewRecent90": "Letzte 90 Tage",
"viewAll": "Komplette Historie",
"viewToggleLabel": "Verlaufsbereich"
},
"verlauf": {
"loading": "Verlauf wird geladen…",
@@ -1806,7 +1809,10 @@
"subtitle": "Was mit den Zielwerten deiner Medikamente zeitlich zusammenfällt – nur ein Zusammenhang, kein Urteil.",
"link": "Wirkung öffnen",
"empty": "Noch nicht genug Daten, um deine Medikamente mit ihren Zielwerten in Beziehung zu setzen."
}
},
"scaleAbsolute": "Absolut",
"scalePercentSinceStart": "% seit Start",
"scaleToggleLabel": "Diagrammskala"
}
},
"charts": {
+8 -2
View File
@@ -1403,7 +1403,10 @@
"confirmAction": "Delete",
"toast": "Intake deleted",
"failed": "Could not delete the intake."
}
},
"viewRecent90": "Last 90 days",
"viewAll": "Full history",
"viewToggleLabel": "History range"
},
"verlauf": {
"loading": "Loading history…",
@@ -1806,7 +1809,10 @@
"subtitle": "What lines up with the measurements your medications target — association only, never a verdict.",
"link": "Open the effect view",
"empty": "Not enough data yet to relate your medications to their target measurements."
}
},
"scaleAbsolute": "Absolute",
"scalePercentSinceStart": "% since start",
"scaleToggleLabel": "Chart scale"
}
},
"charts": {
+8 -2
View File
@@ -1403,7 +1403,10 @@
"confirmAction": "Eliminar",
"toast": "Toma eliminada",
"failed": "No se pudo eliminar la toma."
}
},
"viewRecent90": "Últimos 90 días",
"viewAll": "Historial completo",
"viewToggleLabel": "Rango del historial"
},
"verlauf": {
"loading": "Cargando historial …",
@@ -1806,7 +1809,10 @@
"subtitle": "Lo que coincide con las mediciones que persiguen tus medicamentos: solo asociación, nunca un veredicto.",
"link": "Abrir la vista de efecto",
"empty": "Aún no hay suficientes datos para relacionar tus medicamentos con sus mediciones objetivo."
}
},
"scaleAbsolute": "Absoluto",
"scalePercentSinceStart": "% desde el inicio",
"scaleToggleLabel": "Escala del gráfico"
}
},
"charts": {
+8 -2
View File
@@ -1403,7 +1403,10 @@
"confirmAction": "Supprimer",
"toast": "Prise supprimée",
"failed": "Impossible de supprimer la prise."
}
},
"viewRecent90": "90 derniers jours",
"viewAll": "Historique complet",
"viewToggleLabel": "Plage de l'historique"
},
"verlauf": {
"loading": "Chargement de l'historique …",
@@ -1806,7 +1809,10 @@
"subtitle": "Ce qui coïncide avec les mesures visées par vos médicaments — association seulement, jamais un verdict.",
"link": "Ouvrir la vue Effet",
"empty": "Pas encore assez de données pour relier vos médicaments à leurs mesures cibles."
}
},
"scaleAbsolute": "Absolu",
"scalePercentSinceStart": "% depuis le début",
"scaleToggleLabel": "Échelle du graphique"
}
},
"charts": {
+8 -2
View File
@@ -1403,7 +1403,10 @@
"confirmAction": "Elimina",
"toast": "Assunzione eliminata",
"failed": "Impossibile eliminare l’assunzione."
}
},
"viewRecent90": "Ultimi 90 giorni",
"viewAll": "Storico completo",
"viewToggleLabel": "Intervallo dello storico"
},
"verlauf": {
"loading": "Caricamento cronologia …",
@@ -1806,7 +1809,10 @@
"subtitle": "Ciò che coincide con le misurazioni a cui mirano i tuoi farmaci: solo associazione, mai un verdetto.",
"link": "Apri la vista Effetto",
"empty": "Ancora non ci sono dati sufficienti per collegare i tuoi farmaci alle loro misurazioni obiettivo."
}
},
"scaleAbsolute": "Assoluto",
"scalePercentSinceStart": "% dall'inizio",
"scaleToggleLabel": "Scala del grafico"
}
},
"charts": {
+8 -2
View File
@@ -1403,7 +1403,10 @@
"confirmAction": "Usuń",
"toast": "Przyjęcie usunięte",
"failed": "Nie udało się usunąć przyjęcia."
}
},
"viewRecent90": "Ostatnie 90 dni",
"viewAll": "Pełna historia",
"viewToggleLabel": "Zakres historii"
},
"verlauf": {
"loading": "Wczytywanie historii …",
@@ -1806,7 +1809,10 @@
"subtitle": "Co zbiega się w czasie z pomiarami, na które celują Twoje leki — tylko powiązanie, nigdy werdykt.",
"link": "Otwórz widok działania",
"empty": "Za mało danych, aby powiązać Twoje leki z docelowymi pomiarami."
}
},
"scaleAbsolute": "Bezwzględne",
"scalePercentSinceStart": "% od początku",
"scaleToggleLabel": "Skala wykresu"
}
},
"charts": {
@@ -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>
);
}