mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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:
1 parent
8920a35d38
commit
e6ea27baf2
10 files changed
+424
-6
No files matched your search
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user