awa-ui 0.3.0

Web UI and JSON API for the Awa job queue
Documentation
/**
 * Vertical timeline showing a job's lifecycle.
 *
 * Important: this reconstructs history from available timestamps and the
 * errors[] array. AWA does not store a full state-transition log, so the
 * timeline shows what we *know* (timestamps that exist) rather than
 * guessing intermediate states.
 */

import type { JobRow } from "@/lib/api";
import { StateBadge } from "./StateBadge";

interface TimelineEvent {
  timestamp: string;
  label: string;
  state?: string;
  detail?: string;
  isError?: boolean;
}

function buildTimeline(job: JobRow): TimelineEvent[] {
  const events: TimelineEvent[] = [];

  // Created — this is always known
  events.push({
    timestamp: job.created_at,
    label: "Created",
  });

  // Error history — each entry in errors[] has a timestamp and attempt number
  if (job.errors && job.errors.length > 0) {
    for (const err of job.errors) {
      if (err && typeof err === "object" && err !== null) {
        const e = err as Record<string, unknown>;
        const at = typeof e.at === "string" ? e.at : undefined;
        const error = typeof e.error === "string" ? e.error : undefined;
        const attempt = typeof e.attempt === "number" ? e.attempt : undefined;
        if (at) {
          events.push({
            timestamp: at,
            label: attempt != null ? `Attempt ${attempt} failed` : "Error",
            state: "failed",
            detail: error,
            isError: true,
          });
        }
      }
    }
  }

  // Attempt started — shown whenever attempted_at exists
  if (job.attempted_at) {
    const isTerminal = ["completed", "failed", "cancelled"].includes(job.state);
    events.push({
      timestamp: job.attempted_at,
      label: job.state === "waiting_external"
        ? `Attempt ${job.attempt} — waiting for callback`
        : isTerminal
          ? `Attempt ${job.attempt} started`
          : `Attempt ${job.attempt} running`,
      state: isTerminal ? "running" : job.state,
    });
  }

  // Finalized — only shown if we have finalized_at
  if (job.finalized_at) {
    const label =
      job.state === "completed"
        ? `Completed (attempt ${job.attempt})`
        : job.state === "failed"
          ? `Failed after ${job.attempt} attempt${job.attempt !== 1 ? "s" : ""}`
          : job.state === "cancelled"
            ? "Cancelled"
            : "Finalized";
    events.push({
      timestamp: job.finalized_at,
      label,
      state: job.state,
    });
  }

  // Sort chronologically
  events.sort(
    (a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
  );

  return events;
}

const STATE_DOT_COLOR: Record<string, string> = {
  available: "bg-info",
  scheduled: "bg-secondary-fg/30",
  running: "bg-success",
  completed: "bg-success",
  retryable: "bg-warning",
  failed: "bg-danger",
  cancelled: "bg-muted-fg/40",
  waiting_external: "bg-[oklch(0.45_0.15_280)]",
};

export function JobTimeline({ job }: { job: JobRow }) {
  const events = buildTimeline(job);

  if (events.length === 0) return null;

  return (
    <div className="space-y-0">
      {events.map((event, i) => {
        const isLast = i === events.length - 1;
        const dotColor = event.state
          ? STATE_DOT_COLOR[event.state] ?? "bg-muted-fg/30"
          : "bg-muted-fg/30";

        return (
          <div key={`${event.timestamp}-${i}`} className="relative flex gap-4 pb-6 last:pb-0">
            {/* Vertical line */}
            {!isLast && (
              <div className="absolute left-[7px] top-4 bottom-0 w-px bg-border" />
            )}

            {/* Dot */}
            <div className="relative flex-none pt-1">
              <div
                className={`size-[15px] rounded-full border-2 border-bg ${dotColor} ${
                  event.isError ? "ring-2 ring-danger/20" : ""
                }`}
              />
            </div>

            {/* Content */}
            <div className="min-w-0 flex-1">
              <div className="flex flex-wrap items-baseline gap-2">
                <span className="text-sm font-medium">{event.label}</span>
                {event.state && <StateBadge state={event.state} />}
                <time className="ml-auto text-xs tabular-nums text-muted-fg">
                  {new Date(event.timestamp).toLocaleString()}
                </time>
              </div>
              {event.detail && (
                <p className="mt-1 text-sm text-danger">{event.detail}</p>
              )}
            </div>
          </div>
        );
      })}

      {/* Transparency note */}
      <p className="pt-2 text-xs text-muted-fg/60">
        Timeline reconstructed from job timestamps and error history.
      </p>
    </div>
  );
}