From b11ecca8d2cb91b1de366ef154cd9db82b9ce80b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 17 Jul 2026 17:17:54 +0200 Subject: [PATCH] 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. --- messages/de.json | 10 ++- messages/en.json | 10 ++- messages/es.json | 10 ++- messages/fr.json | 10 ++- messages/it.json | 10 ++- messages/pl.json | 10 ++- .../detail/efficacy/efficacy-chart.tsx | 60 +++++++++++++++-- .../detail/efficacy/efficacy-tab.tsx | 64 +++++++++++++------ .../detail/medication-detail-tabs.tsx | 58 ++++++++++++----- .../medications/detail/segmented-toggle.tsx | 64 +++++++++++++++++++ 10 files changed, 254 insertions(+), 52 deletions(-) create mode 100644 src/components/medications/detail/segmented-toggle.tsx diff --git a/messages/de.json b/messages/de.json index d37ac34c6..b2e316fd9 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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": { diff --git a/messages/en.json b/messages/en.json index e6046c31f..ba721fa97 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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": { diff --git a/messages/es.json b/messages/es.json index 78dc73526..4159d802a 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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": { diff --git a/messages/fr.json b/messages/fr.json index 26b85adea..7de3b3a90 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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": { diff --git a/messages/it.json b/messages/it.json index 0b879650e..449693af5 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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": { diff --git a/messages/pl.json b/messages/pl.json index 7c09659ab..84fdd49a8 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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": { diff --git a/src/components/medications/detail/efficacy/efficacy-chart.tsx b/src/components/medications/detail/efficacy/efficacy-chart.tsx index e6e8b1d69..1975c0507 100644 --- a/src/components/medications/detail/efficacy/efficacy-chart.tsx +++ b/src/components/medications/detail/efficacy/efficacy-chart.tsx @@ -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({ /> {/* 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 ? ( 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)]; }} diff --git a/src/components/medications/detail/efficacy/efficacy-tab.tsx b/src/components/medications/detail/efficacy/efficacy-tab.tsx index ff04e897f..f3b1bede9 100644 --- a/src/components/medications/detail/efficacy/efficacy-tab.tsx +++ b/src/components/medications/detail/efficacy/efficacy-tab.tsx @@ -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({ {target.series.length > 0 ? ( - ({ 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")} - /> +
+
+ +
+ ({ 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} + /> +
) : (

{t("medications.efficacy.noSeries")} diff --git a/src/components/medications/detail/medication-detail-tabs.tsx b/src/components/medications/detail/medication-detail-tabs.tsx index 1b520fbc7..9454035c6 100644 --- a/src/components/medications/detail/medication-detail-tabs.tsx +++ b/src/components/medications/detail/medication-detail-tabs.tsx @@ -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. */} - - ({ - windowStart: s.windowStart, - label: s.label, - dose: s.dose, - timesOfDay: s.timesOfDay, - }))} +

+ - +
+ {historyView === "recent" ? ( + + ({ + windowStart: s.windowStart, + label: s.label, + dose: s.dose, + timesOfDay: s.timesOfDay, + }))} + /> + + ) : ( + + )} {/* INJEKTION — injectable routes only. Current GLP-1 charts + diff --git a/src/components/medications/detail/segmented-toggle.tsx b/src/components/medications/detail/segmented-toggle.tsx new file mode 100644 index 000000000..e5fc1cfda --- /dev/null +++ b/src/components/medications/detail/segmented-toggle.tsx @@ -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 { + value: T; + label: string; +} + +interface SegmentedToggleProps { + value: T; + options: SegmentedToggleOption[]; + onChange: (value: T) => void; + ariaLabel: string; + dataSlot?: string; +} + +export function SegmentedToggle({ + value, + options, + onChange, + ariaLabel, + dataSlot, +}: SegmentedToggleProps) { + return ( +
+ {options.map(({ value: optionValue, label }) => { + const active = value === optionValue; + return ( + + ); + })} +
+ ); +}