Run Timeline

Multi-lane execution timeline showing run phases over time, with optional cursor.

Report a bug

Preview

Switch between light and dark to inspect the embedded Storybook preview.

Installation

pnpm dlx shadcn@latest add https://ui.vllnt.ai/r/run-timeline.json
bash

Storybook

Explore all variants, controls, and accessibility checks in the interactive Storybook playground.

View in Storybook

Code

"use client";

import {
  type ComponentPropsWithoutRef,
  forwardRef,
  type ReactNode,
} from "react";

import { cn } from "../../lib/utils";

/**
 * Phase state — drives the bar tone.
 *
 * @public
 */
export type RunPhaseState =
  | "complete"
  | "failed"
  | "queued"
  | "running"
  | "stopped";

const STATE_FILL: Record<RunPhaseState, string> = {
  complete: "bg-emerald-500/70",
  failed: "bg-red-500/70",
  queued: "bg-amber-500/70",
  running: "bg-blue-500/70",
  stopped: "bg-muted-foreground/40",
};

const STATE_LABEL: Record<RunPhaseState, string> = {
  complete: "Complete",
  failed: "Failed",
  queued: "Queued",
  running: "Running",
  stopped: "Stopped",
};

/**
 * One lane in a multi-lane run timeline.
 *
 * @public
 */
export type RunTimelineLane = {
  /** Stable identifier — used as the React key + phase routing. */
  id: string;
  /** Display label rendered to the left of the lane. */
  label: ReactNode;
};

/**
 * One phase block in the run timeline.
 *
 * @public
 */
export type RunTimelinePhase = {
  /** End timestamp in the same units as `start` / `end`. */
  end: number;
  /** Stable identifier — used as the React key + analytics hook. */
  id: string;
  /** Optional label rendered inside the phase bar. */
  label?: ReactNode;
  /** Lane id this phase belongs to. Defaults to `"default"` (single-lane mode). */
  laneId?: string;
  /** Optional click handler — when provided, the phase becomes a button. */
  onActivate?: () => void;
  /** Start timestamp `>= timeline start`. */
  start: number;
  /** Phase state. Defaults to `"running"`. */
  state?: RunPhaseState;
};

/**
 * Localizable strings.
 *
 * @public
 */
export type RunTimelineLabels = {
  /** Empty-state copy. Defaults to `"No phases"`. */
  empty?: string;
  /** Aria-label override. Defaults to `"Run timeline"`. */
  region?: string;
};

const DEFAULT_LABELS = {
  empty: "No phases",
  region: "Run timeline",
} as const satisfies Required<RunTimelineLabels>;

/**
 * Props for {@link RunTimeline}.
 *
 * @public
 */
export type RunTimelineProps = {
  /** Optional cursor position in the same units as the range. Renders a vertical line. */
  cursor?: number;
  /** End of the time range. Must be `> start`. */
  end: number;
  /** Optional formatter for the start / cursor / end labels. */
  formatValue?: (value: number) => ReactNode;
  /** Localizable strings. */
  labels?: RunTimelineLabels;
  /** Optional explicit lane definitions in render order. Required for multi-lane mode. */
  lanes?: RunTimelineLane[];
  /** Phase blocks — order is irrelevant; routed to lanes by `laneId`. */
  phases: RunTimelinePhase[];
  /** Start of the time range. */
  start: number;
} & ComponentPropsWithoutRef<"section">;

const clamp = (v: number, min: number, max: number): number => {
  if (v < min) {
    return min;
  }
  if (v > max) {
    return max;
  }
  return v;
};

const Endpoints = (props: {
  cursor?: number;
  end: number;
  format?: (v: number) => ReactNode;
  start: number;
}): React.ReactElement => {
  const fmt = props.format;
  const showCursor = typeof props.cursor === "number";
  return (
    <div className="flex items-baseline justify-between gap-2 text-[11px] text-muted-foreground">
      <span data-run-timeline-start>
        {fmt ? fmt(props.start) : props.start}
      </span>
      {showCursor ? (
        <span
          className="font-semibold text-foreground"
          data-run-timeline-cursor-label
        >
          {fmt ? fmt(props.cursor ?? 0) : props.cursor}
        </span>
      ) : null}
      <span data-run-timeline-end>{fmt ? fmt(props.end) : props.end}</span>
    </div>
  );
};

const PhaseBar = (props: {
  laneIndex: number;
  laneTotal: number;
  phase: RunTimelinePhase;
  span: number;
  start: number;
}): React.ReactElement => {
  const { laneIndex, laneTotal, phase, span, start } = props;
  const left = clamp((phase.start - start) / span, 0, 1) * 100;
  const right = clamp((phase.end - start) / span, 0, 1) * 100;
  const width = Math.max(right - left, 0.5);
  const top = (laneIndex / laneTotal) * 100;
  const height = 100 / laneTotal;
  const state = phase.state ?? "running";
  const sharedStyle = {
    height: `${height}%`,
    left: `${left}%`,
    top: `${top}%`,
    width: `${width}%`,
  };
  const ariaLabel = `${STATE_LABEL[state]} ${phase.start}${phase.end}`;
  if (phase.onActivate) {
    const handleClick = (): void => {
      phase.onActivate?.();
    };
    return (
      <button
        aria-label={ariaLabel}
        className={cn(
          "absolute flex items-center justify-start overflow-hidden truncate rounded-sm border border-border/50 px-1 text-left text-[10px] text-foreground transition-opacity hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
          STATE_FILL[state],
        )}
        data-run-phase={phase.id}
        data-run-phase-state={state}
        onClick={handleClick}
        style={sharedStyle}
        type="button"
      >
        {phase.label}
      </button>
    );
  }
  return (
    <span
      aria-label={ariaLabel}
      className={cn(
        "absolute flex items-center justify-start overflow-hidden truncate rounded-sm border border-border/50 px-1 text-[10px] text-foreground",
        STATE_FILL[state],
      )}
      data-run-phase={phase.id}
      data-run-phase-state={state}
      role="img"
      style={sharedStyle}
    >
      {phase.label}
    </span>
  );
};

type LaneViewInput = {
  cursor?: number;
  end: number;
  lanes: RunTimelineLane[];
  phases: RunTimelinePhase[];
  start: number;
};

const TrackBody = (props: LaneViewInput): React.ReactElement => {
  const { cursor, end, lanes, phases, start } = props;
  const span = end - start;
  const cursorRatio =
    typeof cursor === "number" ? clamp((cursor - start) / span, 0, 1) : null;
  return (
    <div className="flex items-stretch">
      <div className="flex w-24 flex-col gap-px text-[10px] uppercase tracking-wide text-muted-foreground">
        {lanes.map((lane) => (
          <div
            className="flex h-7 items-center px-2"
            data-run-timeline-lane={lane.id}
            key={lane.id}
          >
            {lane.label}
          </div>
        ))}
      </div>
      <div
        className="relative flex-1 overflow-hidden rounded-md border border-border bg-muted/20"
        data-run-timeline-track
        style={{ height: `${lanes.length * 28}px` }}
      >
        {phases.map((phase) => {
          const index = lanes.findIndex(
            (lane) => lane.id === (phase.laneId ?? "default"),
          );
          if (index === -1) {
            return null;
          }
          return (
            <PhaseBar
              key={phase.id}
              laneIndex={index}
              laneTotal={lanes.length}
              phase={phase}
              span={span}
              start={start}
            />
          );
        })}
        {cursorRatio === null ? null : (
          <span
            aria-hidden="true"
            className="absolute top-0 bottom-0 w-px bg-foreground"
            data-run-timeline-cursor
            style={{ left: `${cursorRatio * 100}%` }}
          />
        )}
      </div>
    </div>
  );
};

const resolveLanes = (
  explicit: RunTimelineLane[] | undefined,
): RunTimelineLane[] => {
  if (explicit && explicit.length > 0) {
    return explicit;
  }
  return [{ id: "default", label: "Run" }];
};

/**
 * Multi-lane execution timeline showing run phases over time. Each
 * phase renders as a colored bar positioned by its start / end and
 * routed to a lane by `laneId`. Optional cursor draws a thin vertical
 * line for the current playback position.
 *
 * Pure presentation; the host computes the phase list from the run's
 * execution history. Pair with {@link "../timeline-scrubber/timeline-scrubber".TimelineScrubber}
 * to drive the cursor.
 *
 * Distinct from `MapTimeline` (geo-aware), `Stepper` (sequential
 * steps), and the timeline family `#32`–`#35`: this primitive is
 * specifically the run history surface inside the canvas.
 *
 * @example
 * ```tsx
 * <RunTimeline
 *   start={0} end={3600}
 *   cursor={1800}
 *   lanes={[
 *     { id: "ingest", label: "Ingest" },
 *     { id: "rank",   label: "Rank" },
 *   ]}
 *   phases={[
 *     { id: "1", laneId: "ingest", start: 0,    end: 600,  state: "complete", label: "load" },
 *     { id: "2", laneId: "rank",   start: 600,  end: 2400, state: "running",  label: "score" },
 *   ]}
 * />
 * ```
 *
 * @public
 */
export const RunTimeline = forwardRef<HTMLElement, RunTimelineProps>(
  (props, ref) => {
    const {
      className,
      cursor,
      end,
      formatValue,
      labels,
      lanes,
      phases,
      start,
      ...rest
    } = props;
    const resolvedLabels = { ...DEFAULT_LABELS, ...labels };
    const safeEnd = end <= start ? start + 1 : end;
    const resolvedLanes = resolveLanes(lanes);
    return (
      <section
        aria-label={resolvedLabels.region}
        className={cn("flex w-full flex-col gap-2", className)}
        data-run-timeline
        ref={ref}
        {...rest}
      >
        <Endpoints
          cursor={cursor}
          end={safeEnd}
          format={formatValue}
          start={start}
        />
        {phases.length === 0 ? (
          <p
            className="rounded-md border border-border bg-muted/20 px-2 py-3 text-center text-[11px] text-muted-foreground"
            data-run-timeline-state="empty"
          >
            {resolvedLabels.empty}
          </p>
        ) : (
          <TrackBody
            cursor={cursor}
            end={safeEnd}
            lanes={resolvedLanes}
            phases={phases}
            start={start}
          />
        )}
      </section>
    );
  },
);
RunTimeline.displayName = "RunTimeline";
typescript

Dependencies

  • @vllnt/ui@^0.2.1