Defense & Aerospace

Mission Timeline

A phased mission plan on an H-relative rail with computed phase positions, go/no-go decision gates, and an interpolating mission-clock cursor.

Install

npx shadcn@latest add @paragon/mission-timeline

mission-timeline.tsx

"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>
  );
}