diff --git a/messages/de.json b/messages/de.json index 7cac0883a..95af909c0 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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", diff --git a/messages/en.json b/messages/en.json index 1f42141f5..afaeb1e0b 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/es.json b/messages/es.json index abfe65977..f36fc5296 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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", diff --git a/messages/fr.json b/messages/fr.json index 358419bb8..47a61720c 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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é", diff --git a/messages/it.json b/messages/it.json index 773c0a809..c1eac2476 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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", diff --git a/messages/pl.json b/messages/pl.json index e316f5b1f..9df765a80 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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", diff --git a/src/components/medications/__tests__/titration-timeline.test.ts b/src/components/medications/__tests__/titration-timeline.test.ts new file mode 100644 index 000000000..4ffb3669c --- /dev/null +++ b/src/components/medications/__tests__/titration-timeline.test.ts @@ -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"]); + }); +}); diff --git a/src/components/medications/detail/MedicationDetailTabs.tsx b/src/components/medications/detail/MedicationDetailTabs.tsx index 42fc719d9..03bf145e9 100644 --- a/src/components/medications/detail/MedicationDetailTabs.tsx +++ b/src/components/medications/detail/MedicationDetailTabs.tsx @@ -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 && ( + {/* 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. */} + {isGlp1 && } {isGlp1 && (
` 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 ( +
+

+ {label} +

+
{children}
+
+ ); + } + return ( -
-

- {label} -

-
{children}
-
+ + + {label} + + + +
{children}
+
+ ); } diff --git a/src/components/medications/titration-timeline.tsx b/src/components/medications/titration-timeline.tsx new file mode 100644 index 000000000..32dab66d3 --- /dev/null +++ b/src/components/medications/titration-timeline.tsx @@ -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({ + queryKey: queryKeys.medicationGlp1Details(medicationId), + queryFn: async () => { + try { + return await apiGet( + `/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 ( + + {!hasPlan ? ( +

+ {t("medications.titration.empty")} +

+ ) : ( +
    + {steps.map((step, i) => ( +
  1. + {/* "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 && ( + + )} + {markerAfter === -1 && i === 0 && ( + + )} +
    + {/* Connector node + dashed line for planned segments. */} +
    +
    + +
    +
    +

    + {t("medications.titration.doseStep", { + dose: fmt.number(step.doseValue), + unit: step.doseUnit, + })} +

    +

    + {step.isPast + ? fmt.dateShort(new Date(step.effectiveFrom)) + : t("medications.titration.plannedFor", { + date: fmt.dateShort(new Date(step.effectiveFrom)), + })} +

    +
    + {step.note ? ( +

    {step.note}

    + ) : null} +
    +
    +
  2. + ))} +
+ )} +
+ ); +} + +function TitrationMarker({ label }: { label: string }) { + return ( +
+
+ ); +}