feat(medications): collapsible advanced groups + injection titration timeline

Restructure the medication detail advanced surface (Erweitert + API tabs)
into expand/collapse groups: SettingsGroup gains an opt-in collapsible
mode built on the same Radix Collapsible primitive SheetSection uses, so a
long settings wall reads as scannable sections. Lifecycle and external-API
groups default open; Data and the destructive zone default collapsed.

Add a titration timeline to the injection tab — completed and planned
dose-escalation steps rendered as a vertical timeline with a "you are
here" marker. Reads the existing MedicationDoseChange stream (future-dated
rows are planned steps), so no schema change is needed. Neutral palette,
no state-driven colour.
This commit is contained in:
Marc-André Bombeck committed 2026-06-18 14:41:14 +02:00
1 parent 8920a35d38
commit e6ea27baf2
10 files changed
+424 -6

No files matched your search

+7
View File
@@ -1276,6 +1276,13 @@
"noCurrentDose": "Noch keine Dosis erfasst.",
"empty": "Noch keine Titrations-Historie. Erfasse eine Dosisänderung, um die Stärkekurve zu sehen."
},
"titration": {
"title": "Titrationsplan",
"empty": "Noch keine Dosis-Steigerungsschritte erfasst. Datierte Dosisänderungen hinzufügen, um den Plan abzubilden.",
"youAreHere": "Du bist hier",
"doseStep": "{dose} {unit}",
"plannedFor": "Geplant · {date}"
},
"injectionSiteBodyOutlineAriaLabel": "Körperumriss",
"injectionSitePickerAriaLabel": "Injektionsstelle auswählen",
"injectionSiteRecommendedAriaLabel": "{site} – empfohlene nächste Stelle",
+7
View File
@@ -1276,6 +1276,13 @@
"noCurrentDose": "No dose recorded yet.",
"empty": "No titration history yet. Log a dose change to see the strength curve."
},
"titration": {
"title": "Titration plan",
"empty": "No dose-escalation steps recorded yet. Add dated dose changes to plot the plan.",
"youAreHere": "You are here",
"doseStep": "{dose} {unit}",
"plannedFor": "Planned · {date}"
},
"injectionSiteBodyOutlineAriaLabel": "Body outline",
"injectionSitePickerAriaLabel": "Injection site picker",
"injectionSiteRecommendedAriaLabel": "{site} — recommended next site",
+7
View File
@@ -1276,6 +1276,13 @@
"noCurrentDose": "Aún no hay dosis registrada.",
"empty": "Aún no hay historial de titulación. Registra un cambio de dosis para ver la curva de dosis."
},
"titration": {
"title": "Plan de titulación",
"empty": "Aún no hay pasos de escalada de dosis registrados. Añade cambios de dosis con fecha para trazar el plan.",
"youAreHere": "Estás aquí",
"doseStep": "{dose} {unit}",
"plannedFor": "Previsto · {date}"
},
"injectionSiteBodyOutlineAriaLabel": "Silueta del cuerpo",
"injectionSitePickerAriaLabel": "Selector de sitio de inyección",
"injectionSiteRecommendedAriaLabel": "{site}: sitio siguiente recomendado",
+7
View File
@@ -1276,6 +1276,13 @@
"noCurrentDose": "Aucune dose enregistrée pour l'instant.",
"empty": "Aucun historique de titration. Enregistrez un changement de dose pour voir la courbe de dose."
},
"titration": {
"title": "Plan de titration",
"empty": "Aucune étape d'escalade de dose enregistrée pour le moment. Ajoutez des changements de dose datés pour tracer le plan.",
"youAreHere": "Vous êtes ici",
"doseStep": "{dose} {unit}",
"plannedFor": "Prévu · {date}"
},
"injectionSiteBodyOutlineAriaLabel": "Silhouette du corps",
"injectionSitePickerAriaLabel": "Sélecteur de site d'injection",
"injectionSiteRecommendedAriaLabel": "{site} — prochain site recommandé",
+7
View File
@@ -1276,6 +1276,13 @@
"noCurrentDose": "Nessuna dose ancora registrata.",
"empty": "Nessuna cronologia di titolazione. Registra un cambio di dose per vedere la curva della dose."
},
"titration": {
"title": "Piano di titolazione",
"empty": "Nessuna fase di aumento della dose registrata. Aggiungi variazioni di dose con data per tracciare il piano.",
"youAreHere": "Sei qui",
"doseStep": "{dose} {unit}",
"plannedFor": "Previsto · {date}"
},
"injectionSiteBodyOutlineAriaLabel": "Sagoma del corpo",
"injectionSitePickerAriaLabel": "Selettore del sito di iniezione",
"injectionSiteRecommendedAriaLabel": "{site} — prossimo sito consigliato",
+7
View File
@@ -1276,6 +1276,13 @@
"noCurrentDose": "Nie zapisano jeszcze żadnej dawki.",
"empty": "Brak historii titracji. Zapisz zmianę dawki, aby zobaczyć krzywą dawki."
},
"titration": {
"title": "Plan titracji",
"empty": "Nie zarejestrowano jeszcze kroków zwiększania dawki. Dodaj datowane zmiany dawki, aby nakreślić plan.",
"youAreHere": "Jesteś tutaj",
"doseStep": "{dose} {unit}",
"plannedFor": "Zaplanowano · {date}"
},
"injectionSiteBodyOutlineAriaLabel": "Sylwetka ciała",
"injectionSitePickerAriaLabel": "Wybór miejsca iniekcji",
"injectionSiteRecommendedAriaLabel": "{site} — zalecane następne miejsce",
@@ -0,0 +1,73 @@
/**
* v1.18.5 — titration-timeline step-builder tests.
*
* Pins `buildTitrationSteps`: chronological sort, past/planned split at
* "now", and the single current-step tag (the latest step already in
* effect). The render is exercised indirectly through these seams; the
* pure builder carries the classification logic the component renders.
*/
import { describe, it, expect } from "vitest";
import { buildTitrationSteps } from "../titration-timeline";
const ASOF = new Date("2026-06-18T12:00:00Z");
function change(id: string, iso: string, dose: number) {
return { id, effectiveFrom: iso, doseValue: dose, doseUnit: "mg" };
}
describe("buildTitrationSteps", () => {
it("returns an empty array for no dose changes", () => {
expect(buildTitrationSteps([], ASOF)).toEqual([]);
});
it("sorts by effective date and classifies past vs planned at now", () => {
const steps = buildTitrationSteps(
[
change("c", "2026-08-01T00:00:00Z", 7.5), // future
change("a", "2026-04-01T00:00:00Z", 2.5), // past
change("b", "2026-06-01T00:00:00Z", 5), // past
],
ASOF,
);
expect(steps.map((s) => s.id)).toEqual(["a", "b", "c"]);
expect(steps.map((s) => s.isPast)).toEqual([true, true, false]);
});
it("tags only the latest in-effect step as current", () => {
const steps = buildTitrationSteps(
[
change("a", "2026-04-01T00:00:00Z", 2.5),
change("b", "2026-06-01T00:00:00Z", 5),
change("c", "2026-08-01T00:00:00Z", 7.5),
],
ASOF,
);
expect(steps.map((s) => s.isCurrent)).toEqual([false, true, false]);
});
it("marks no current step when every step is still planned", () => {
const steps = buildTitrationSteps(
[
change("a", "2026-07-01T00:00:00Z", 2.5),
change("b", "2026-08-01T00:00:00Z", 5),
],
ASOF,
);
expect(steps.some((s) => s.isCurrent)).toBe(false);
expect(steps.every((s) => !s.isPast)).toBe(true);
});
it("drops rows with unparseable dates or non-finite doses", () => {
const steps = buildTitrationSteps(
[
change("a", "2026-04-01T00:00:00Z", 2.5),
change("bad", "not-a-date", 5),
{ id: "nan", effectiveFrom: "2026-05-01T00:00:00Z", doseValue: NaN, doseUnit: "mg" },
],
ASOF,
);
expect(steps.map((s) => s.id)).toEqual(["a"]);
});
});
@@ -70,6 +70,7 @@ import { SettingsGroup } from "@/components/medications/settings-group";
import { PhaseConfigSheet } from "@/components/medications/sections/phase-config-sheet";
import { SideEffectsSection } from "@/components/medications/SideEffectsSection";
import { ChartSkeleton } from "@/components/charts/chart-skeleton";
import { TitrationTimeline } from "@/components/medications/titration-timeline";
import { estimateRunwayDays } from "@/components/medications/detail/supply-runway";
import { summariseSupply } from "@/lib/medications/inventory/summary";
import { MedicationWizardDialog } from "@/components/medications/wizard/MedicationWizardDialog";
@@ -661,6 +662,11 @@ export function MedicationDetailTabs({
wave. */}
{isInjectable && (
<TabsContent value="injektion" className="space-y-6 pt-2">
{/* Dose-escalation plan: completed + planned titration steps
with a "you are here" marker. Renders for every injectable
route (not GLP-1-only) since titration is a property of the
injection ladder, not the treatment class. */}
<TitrationTimeline medicationId={id} />
{isGlp1 && <SideEffectsSection medicationId={id} />}
{isGlp1 && (
<div
@@ -689,6 +695,7 @@ export function MedicationDetailTabs({
<SettingsGroup
label={t("medications.detail.erweitert.group.externalApi")}
dataSlot="api-group-external-api"
collapsible
>
<div className="py-3">
<ApiTokensRow
@@ -712,6 +719,7 @@ export function MedicationDetailTabs({
<SettingsGroup
label={t("medications.detail.erweitert.group.lifecycle")}
dataSlot="erweitert-group-lifecycle"
collapsible
>
<div className="py-3">
<LifecycleManageBody
@@ -763,6 +771,8 @@ export function MedicationDetailTabs({
<SettingsGroup
label={t("medications.detail.erweitert.group.data")}
dataSlot="erweitert-group-data"
collapsible
defaultOpen={false}
>
<div className="py-3">
<DataPortabilityRow
@@ -899,6 +909,8 @@ function ComplianceGatedDangerZone({
<SettingsGroup
label={t("medications.detail.erweitert.group.danger")}
dataSlot="erweitert-group-danger"
collapsible
defaultOpen={false}
>
<DangerZoneBody
medicationId={medicationId}
+51 -6
View File
@@ -15,9 +15,19 @@
* title, and the inner switch labels carry their own ids. This avoids
* the duplicate-id axe failure the section-card heading split guards
* against.
*
* v1.18.5 — opt-in collapsible mode. When `collapsible` is set the
* uppercase micro-label becomes a Radix Collapsible trigger (the same
* primitive `SheetSection` uses) with a rotating chevron, so a long
* advanced-settings surface reads as scannable expand/collapse groups
* instead of one wall of stacked rows. The non-collapsible default keeps
* the static `<p>` header for the sheet callers. The settings inside are
* unchanged either way.
*/
import { type ReactNode } from "react";
import { ChevronDown } from "lucide-react";
import { Collapsible as CollapsiblePrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
@@ -26,6 +36,10 @@ export interface SettingsGroupProps {
children: ReactNode;
dataSlot?: string;
className?: string;
/** v1.18.5 — render the group as an expand/collapse section. */
collapsible?: boolean;
/** Whether a collapsible group starts expanded (uncontrolled, default true). */
defaultOpen?: boolean;
}
export function SettingsGroup({
@@ -33,16 +47,47 @@ export function SettingsGroup({
children,
dataSlot,
className,
collapsible = false,
defaultOpen = true,
}: SettingsGroupProps) {
if (!collapsible) {
return (
<section
className={cn("bg-card rounded-lg border px-4 py-1", className)}
{...(dataSlot ? { "data-slot": dataSlot } : {})}
>
<p className="text-muted-foreground border-border/60 border-b py-2.5 text-xs font-medium tracking-wide uppercase">
{label}
</p>
<div className="divide-border/60 divide-y">{children}</div>
</section>
);
}
return (
<section
<CollapsiblePrimitive.Root
defaultOpen={defaultOpen}
className={cn("bg-card rounded-lg border px-4 py-1", className)}
{...(dataSlot ? { "data-slot": dataSlot } : {})}
>
<p className="text-muted-foreground border-border/60 border-b py-2.5 text-xs font-medium tracking-wide uppercase">
{label}
</p>
<div className="divide-border/60 divide-y">{children}</div>
</section>
<CollapsiblePrimitive.Trigger
data-slot="settings-group-trigger"
className="group focus-visible:ring-ring/50 border-border/60 flex w-full items-center gap-2 border-b py-2.5 text-left focus-visible:ring-2 focus-visible:outline-none"
>
<span className="text-muted-foreground flex-1 text-xs font-medium tracking-wide uppercase">
{label}
</span>
<ChevronDown
aria-hidden="true"
className="text-muted-foreground h-4 w-4 shrink-0 transition-transform duration-200 group-data-[state=open]:rotate-180 motion-reduce:transition-none"
/>
</CollapsiblePrimitive.Trigger>
<CollapsiblePrimitive.Content
data-slot="settings-group-content"
className="overflow-hidden data-[state=closed]:animate-collapsible-up data-[state=open]:animate-collapsible-down motion-reduce:animate-none"
>
<div className="divide-border/60 divide-y">{children}</div>
</CollapsiblePrimitive.Content>
</CollapsiblePrimitive.Root>
);
}
@@ -0,0 +1,246 @@
"use client";
/**
* v1.18.5 — dose-escalation (titration) timeline for injectable meds.
*
* Companion to `DoseStrengthCurve`. Where the curve plots the logged
* dose-strength history as a step chart, this surface renders the dose
* STEPS as a vertical timeline — each `MedicationDoseChange` row as one
* step (2.5 mg → 5 mg → 7.5 mg …) with its effective date — and splits
* the list at "now" into completed steps and the planned escalation
* ahead. A "you are here" marker sits at the boundary so the current
* strength reads at a glance against what is still to come.
*
* Data contract: the same `GET /api/medications/[id]/glp1` stream the
* curve consumes (`doseChanges`, ordered `effectiveFrom asc`). A row with
* a future `effectiveFrom` is a PLANNED step; the table already accepts
* future-dated rows, so no schema change is needed to express a plan.
*
* Neutral palette only — completed steps in foreground/muted, the current
* step on the `--primary` accent, planned steps muted with a dashed
* connector. No alarming colour (med-card rule): nothing here turns
* red/green by state.
*/
import { useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { Check, MapPin } from "lucide-react";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiGet } from "@/lib/api/api-fetch";
import { cn } from "@/lib/utils";
import { MedicationDetailSection } from "@/components/medications/medication-detail-section";
interface DoseChange {
id: string;
effectiveFrom: string;
doseValue: number;
doseUnit: string;
note?: string | null;
}
interface Glp1DetailsResponse {
doseChanges: DoseChange[];
}
export interface TitrationTimelineProps {
medicationId: string;
/** Override "now" for deterministic snapshot tests. */
asOf?: Date;
}
export interface TitrationStep {
id: string;
effectiveFrom: number;
doseValue: number;
doseUnit: string;
note?: string | null;
/** Past-or-now effective date. */
isPast: boolean;
/** The latest step whose effective date is at or before "now". */
isCurrent: boolean;
}
/**
* Sort the dose-change stream by effective date, classify each row as
* past / current / planned against `asOf`, and tag the single current
* step (the latest one already in effect). Exported for the unit test.
*/
export function buildTitrationSteps(
doseChanges: readonly DoseChange[],
asOf: Date,
): TitrationStep[] {
const nowMs = asOf.getTime();
const sorted = doseChanges
.map((dc) => ({
id: dc.id,
effectiveFrom: Date.parse(dc.effectiveFrom),
doseValue: dc.doseValue,
doseUnit: dc.doseUnit,
note: dc.note ?? null,
}))
.filter(
(p) => Number.isFinite(p.effectiveFrom) && Number.isFinite(p.doseValue),
)
.sort((a, b) => a.effectiveFrom - b.effectiveFrom);
// The current step is the last one whose effective date is <= now.
let currentIndex = -1;
for (let i = 0; i < sorted.length; i++) {
if (sorted[i].effectiveFrom <= nowMs) currentIndex = i;
}
return sorted.map((p, i) => ({
...p,
isPast: p.effectiveFrom <= nowMs,
isCurrent: i === currentIndex,
}));
}
export function TitrationTimeline({ medicationId, asOf }: TitrationTimelineProps) {
const { t } = useTranslations();
const fmt = useFormatters();
const { data: details } = useQuery<Glp1DetailsResponse | null>({
queryKey: queryKeys.medicationGlp1Details(medicationId),
queryFn: async () => {
try {
return await apiGet<Glp1DetailsResponse>(
`/api/medications/${medicationId}/glp1`,
);
} catch {
return null;
}
},
staleTime: 60 * 1000,
});
const now = useMemo(() => asOf ?? new Date(), [asOf]);
const steps = useMemo(
() => buildTitrationSteps(details?.doseChanges ?? [], now),
[details, now],
);
// Need at least two steps for an escalation to read as a plan; a lone
// step is just the current dose and the curve / status row already
// carry it.
const hasPlan = steps.length >= 2;
const currentIdx = steps.findIndex((s) => s.isCurrent);
// Marker sits after the current step (or at the very top when every
// step is still planned — nothing in effect yet).
const markerAfter = currentIdx;
return (
<MedicationDetailSection
titleId="titration-timeline-title"
title={t("medications.titration.title")}
dataSlot="titration-timeline"
>
{!hasPlan ? (
<p
className="text-muted-foreground bg-muted/40 rounded-md p-4 text-sm"
data-slot="titration-timeline-empty"
>
{t("medications.titration.empty")}
</p>
) : (
<ol className="relative space-y-0" data-slot="titration-timeline-list">
{steps.map((step, i) => (
<li key={step.id} data-slot="titration-step">
{/* "You are here" marker between the current step and the
first planned step (or at the top when nothing is yet
in effect). */}
{markerAfter === i - 1 && i > 0 && (
<TitrationMarker label={t("medications.titration.youAreHere")} />
)}
{markerAfter === -1 && i === 0 && (
<TitrationMarker label={t("medications.titration.youAreHere")} />
)}
<div
className="flex items-start gap-3 py-2.5"
data-slot={
step.isCurrent
? "titration-step-current"
: step.isPast
? "titration-step-past"
: "titration-step-planned"
}
>
{/* Connector node + dashed line for planned segments. */}
<div className="flex flex-col items-center self-stretch pt-1">
<span
className={cn(
"grid h-5 w-5 shrink-0 place-items-center rounded-full border",
step.isCurrent
? "border-primary bg-primary/15 text-primary"
: step.isPast
? "border-border bg-muted text-muted-foreground"
: "border-border/60 border-dashed text-muted-foreground/70",
)}
aria-hidden="true"
>
{step.isPast ? (
<Check className="h-3 w-3" />
) : (
<span className="h-1.5 w-1.5 rounded-full bg-current" />
)}
</span>
{i < steps.length - 1 && (
<span
className={cn(
"mt-1 w-px flex-1",
steps[i + 1].isPast
? "bg-border"
: "border-border/60 border-l border-dashed",
)}
aria-hidden="true"
/>
)}
</div>
<div className="min-w-0 flex-1 space-y-0.5 pb-1">
<div className="flex items-baseline justify-between gap-2">
<p
className={cn(
"text-sm font-medium tabular-nums",
step.isCurrent ? "text-primary" : "text-foreground",
)}
>
{t("medications.titration.doseStep", {
dose: fmt.number(step.doseValue),
unit: step.doseUnit,
})}
</p>
<p className="text-muted-foreground shrink-0 text-xs">
{step.isPast
? fmt.dateShort(new Date(step.effectiveFrom))
: t("medications.titration.plannedFor", {
date: fmt.dateShort(new Date(step.effectiveFrom)),
})}
</p>
</div>
{step.note ? (
<p className="text-muted-foreground text-xs">{step.note}</p>
) : null}
</div>
</div>
</li>
))}
</ol>
)}
</MedicationDetailSection>
);
}
function TitrationMarker({ label }: { label: string }) {
return (
<div
className="text-primary flex items-center gap-1.5 py-1.5 text-xs font-medium"
data-slot="titration-you-are-here"
>
<MapPin className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
{label}
</div>
);
}