A phased mission plan on an H-relative rail with computed phase positions, go/no-go decision gates, and an interpolating mission-clock cursor.
npx shadcn@latest add @paragon/mission-timeline"use client";
import * as React from "react";
import { Check, Circle, ShieldQuestion } from "lucide-react";
import { cn } from "@/lib/utils";
export interface MissionPhase {
id: string;
name: string;
/** H-relative hour, e.g. -2 for H−2, +4 for H+4. */
hour: number;
/** Whether a go/no-go decision gate precedes this phase. */
gate?: boolean;
gateStatus?: "go" | "nogo" | "pending";
}
export interface MissionTimelineProps extends React.ComponentProps<"div"> {
phases?: MissionPhase[];
/** Current mission clock in H-relative hours. */
currentHour?: number;
static?: boolean;
}
const DEFAULT_PHASES: MissionPhase[] = [
{ id: "p1", name: "Ingress", hour: -2 },
{ id: "p2", name: "FARP", hour: 0, gate: true, gateStatus: "go" },
{ id: "p3", name: "Objective", hour: 3, gate: true, gateStatus: "go" },
{ id: "p4", name: "Exfil", hour: 6, gate: true, gateStatus: "pending" },
{ id: "p5", name: "Recovery", hour: 9 },
];
function hLabel(h: number) {
if (h === 0) return "H";
return `H${h > 0 ? "+" : "−"}${Math.abs(h)}`;
}
/**
* A phased mission timeline. Phases are placed on a single horizontal rail by
* their H-relative hour (position computed from the min/max range, never
* eyeballed); go/no-go gates sit at phase boundaries with a status tone, and a
* mission-clock cursor interpolates to the current hour with a CSS left-offset
* transition inside a fixed track (transform-based, retargetable). Reduced
* motion keeps the cursor without the sweep.
*/
export function MissionTimeline({
phases = DEFAULT_PHASES,
currentHour = 1.4,
static: isStatic = false,
className,
...props
}: MissionTimelineProps) {
const { min, max } = React.useMemo(() => {
const hs = phases.map((p) => p.hour);
return { min: Math.min(...hs), max: Math.max(...hs) };
}, [phases]);
const pct = (h: number) => ((h - min) / (max - min || 1)) * 100;
const cursor = Math.max(0, Math.min(100, pct(currentHour)));
const motion = isStatic ? "" : "transition-transform duration-(--duration-moderate) ease-(--ease-in-out)";
const motionLeft = isStatic ? "" : "transition-[left] duration-(--duration-moderate) ease-(--ease-in-out)";
return (
<div
className={cn(
"w-full max-w-2xl rounded-xl bg-card p-5 text-card-foreground shadow-border",
className,
)}
{...props}
>
<div className="mb-4 flex items-center justify-between">
<p className="text-sm font-semibold">OPORD PARAGON-7 · Timeline</p>
<span className="rounded-md bg-secondary px-2 py-0.5 font-mono text-xs tabular-nums">
{hLabel(Math.round(currentHour))}
</span>
</div>
<div className="relative px-2 pt-6 pb-10">
{/* rail */}
<div className="absolute inset-x-2 top-[calc(1.5rem+5px)] h-0.5 rounded-full bg-border" />
{/* elapsed rail — scaleX transform keeps us off animating width */}
<div
className={cn(
"absolute top-[calc(1.5rem+5px)] right-2 left-2 h-0.5 origin-left rounded-full bg-primary",
motion,
)}
style={{ transform: `scaleX(${cursor / 100})` }}
/>
{/* current cursor */}
<div
className={cn(
"absolute top-[calc(1.5rem-1px)] z-10 -translate-x-1/2",
motionLeft,
)}
style={{ left: `calc(0.5rem + ${cursor}% * ((100% - 1rem) / 100%))` }}
>
<span className="block size-3 rounded-full border-2 border-card bg-primary shadow-border" />
</div>
{phases.map((p) => {
const done = p.hour <= currentHour;
const left = pct(p.hour);
return (
<div
key={p.id}
className="absolute -translate-x-1/2"
style={{
left: `calc(0.5rem + ${left}% * ((100% - 1rem) / 100%))`,
top: "1.5rem",
}}
>
<span
className={cn(
"block size-2.5 -translate-y-1/2 rounded-full border-2 transition-colors duration-(--duration-base)",
done
? "border-primary bg-primary"
: "border-border bg-card",
)}
/>
<div className="mt-2 flex -translate-x-1/2 flex-col items-center whitespace-nowrap">
<span className="font-mono text-[10px] tabular-nums text-muted-foreground">
{hLabel(p.hour)}
</span>
<span
className={cn(
"text-[11px] font-medium",
done ? "text-foreground" : "text-muted-foreground",
)}
>
{p.name}
</span>
{p.gate && (
<span
className={cn(
"mt-0.5 inline-flex items-center gap-0.5 rounded px-1 py-px text-[9px] font-semibold",
p.gateStatus === "go" &&
"bg-success/15 text-success",
p.gateStatus === "nogo" &&
"bg-destructive/15 text-destructive",
p.gateStatus === "pending" &&
"bg-warning/15 text-warning",
)}
>
{p.gateStatus === "go" ? (
<Check className="size-2.5" />
) : p.gateStatus === "pending" ? (
<ShieldQuestion className="size-2.5" />
) : (
<Circle className="size-2.5" />
)}
{p.gateStatus === "go"
? "GO"
: p.gateStatus === "nogo"
? "NO-GO"
: "GATE"}
</span>
)}
</div>
</div>
);
})}
</div>
</div>
);
}