mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +00:00
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:
1 parent
546c5c7d8f
commit
b11ecca8d2
10 files changed
+254
-52
No files matched your search
+8
-2
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user