Infrastructure & DevOps

Cron Schedule

A scheduled-jobs list with a live next-run countdown, last-run status, and a run-now action per job.

Install

npx shadcn@latest add @paragon/cron-schedule

cron-schedule.tsx

"use client";

import * as React from "react";
import { Check, Loader2, Play, X } from "lucide-react";
import { cn } from "@/lib/utils";

export type LastRunStatus = "success" | "failed" | "running" | "never";

export interface CronJob {
  name: string;
  cron: string;
  /** Seconds until next scheduled run (from mount). */
  nextInSeconds: number;
  lastStatus: LastRunStatus;
  lastRun: string;
}

export interface CronScheduleProps extends React.ComponentProps<"div"> {
  jobs?: CronJob[];
}

const DEFAULT_JOBS: CronJob[] = [
  { name: "nightly-backup", cron: "0 3 * * *", nextInSeconds: 5, lastStatus: "success", lastRun: "22h ago" },
  { name: "sync-billing", cron: "*/15 * * * *", nextInSeconds: 92, lastStatus: "success", lastRun: "8m ago" },
  { name: "reindex-search", cron: "0 * * * *", nextInSeconds: 640, lastStatus: "failed", lastRun: "51m ago" },
  { name: "prune-artifacts", cron: "0 0 * * 0", nextInSeconds: 3720, lastStatus: "running", lastRun: "now" },
];

const STATUS_META: Record<LastRunStatus, { color: string; label: string }> = {
  success: { color: "var(--color-success)", label: "Success" },
  failed: { color: "var(--color-destructive)", label: "Failed" },
  running: { color: "var(--color-warning)", label: "Running" },
  never: { color: "var(--color-muted-foreground)", label: "Never run" },
};

function fmtCountdown(s: number) {
  if (s <= 0) return "now";
  const h = Math.floor(s / 3600);
  const m = Math.floor((s % 3600) / 60);
  const sec = s % 60;
  if (h > 0) return `${h}h ${m}m`;
  if (m > 0) return `${m}m ${sec.toString().padStart(2, "0")}s`;
  return `${sec}s`;
}

function StatusIcon({ status }: { status: LastRunStatus }) {
  const color = STATUS_META[status].color;
  if (status === "running")
    return (
      <Loader2
        className="size-3.5 animate-spin motion-reduce:animate-none"
        style={{ color }}
      />
    );
  if (status === "failed") return <X className="size-3.5" style={{ color }} />;
  if (status === "success") return <Check className="size-3.5" style={{ color }} />;
  return <span className="size-2 rounded-full" style={{ background: color }} />;
}

export function CronSchedule({
  jobs = DEFAULT_JOBS,
  className,
  ...props
}: CronScheduleProps) {
  const [remaining, setRemaining] = React.useState<number[]>(() =>
    jobs.map((j) => j.nextInSeconds),
  );
  const [running, setRunning] = React.useState<Set<number>>(new Set());
  const [inView, setInView] = React.useState(true);
  const ref = React.useRef<HTMLDivElement>(null);
  const timers = React.useRef<Map<number, ReturnType<typeof setTimeout>>>(new Map());

  React.useEffect(() => {
    const el = ref.current;
    if (!el) return;
    const io = new IntersectionObserver(([e]) => setInView(e.isIntersecting), {
      threshold: 0.1,
    });
    io.observe(el);
    return () => io.disconnect();
  }, []);

  React.useEffect(() => {
    if (!inView) return;
    const t = setInterval(() => {
      setRemaining((prev) =>
        prev.map((s, i) => {
          const period = periodOf(jobs[i].cron);
          const next = s - 1;
          return next < 0 ? period : next;
        }),
      );
    }, 1000);
    return () => clearInterval(t);
  }, [inView, jobs]);

  React.useEffect(() => {
    const map = timers.current;
    return () => map.forEach((t) => clearTimeout(t));
  }, []);

  const runNow = (i: number) => {
    setRunning((prev) => new Set(prev).add(i));
    const existing = timers.current.get(i);
    if (existing) clearTimeout(existing);
    const t = setTimeout(() => {
      setRunning((prev) => {
        const next = new Set(prev);
        next.delete(i);
        return next;
      });
    }, 1600);
    timers.current.set(i, t);
  };

  return (
    <div
      ref={ref}
      className={cn(
        "w-full max-w-xl overflow-hidden rounded-xl bg-card text-card-foreground shadow-border",
        className,
      )}
      {...props}
    >
      <div className="grid grid-cols-[1fr_92px_100px_84px] items-center gap-2 border-b border-border px-3 py-2 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
        <span>Job</span>
        <span>Last run</span>
        <span>Next run</span>
        <span className="text-right">Actions</span>
      </div>
      <ul>
        {jobs.map((job, i) => {
          const isRunning = running.has(i);
          const status: LastRunStatus = isRunning ? "running" : job.lastStatus;
          const meta = STATUS_META[status];
          const secs = remaining[i];
          return (
            <li
              key={job.name}
              className="grid grid-cols-[1fr_92px_100px_84px] items-center gap-2 border-b border-border px-3 py-2.5 text-sm last:border-b-0"
            >
              <div className="min-w-0">
                <span className="truncate font-mono text-sm font-medium">
                  {job.name}
                </span>
                <span className="block font-mono text-[11px] text-muted-foreground">
                  {job.cron}
                </span>
              </div>
              <span
                className="flex items-center gap-1.5 text-xs font-medium"
                style={{ color: meta.color }}
              >
                <StatusIcon status={status} />
                <span className="text-muted-foreground">{isRunning ? "now" : job.lastRun}</span>
              </span>
              <span className="text-xs tabular-nums text-muted-foreground">
                in{" "}
                <span
                  className={cn(
                    "font-medium",
                    secs <= 10 ? "text-foreground" : "text-foreground/80",
                  )}
                >
                  {fmtCountdown(secs)}
                </span>
              </span>
              <div className="flex justify-end">
                <button
                  type="button"
                  disabled={isRunning}
                  onClick={() => runNow(i)}
                  aria-label={`Run ${job.name} now`}
                  className="pressable inline-flex items-center gap-1 rounded-md bg-secondary px-2 py-1 text-xs font-medium text-secondary-foreground outline-none transition-[background-color,opacity] duration-150 hover:bg-secondary/80 focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
                >
                  {isRunning ? (
                    <Loader2 className="size-3 animate-spin motion-reduce:animate-none" />
                  ) : (
                    <Play className="size-3" />
                  )}
                  Run
                </button>
              </div>
            </li>
          );
        })}
      </ul>
    </div>
  );
}

/** Rough seconds period from common cron cadences, for the demo countdown reset. */
function periodOf(cron: string): number {
  if (cron.startsWith("*/15")) return 15 * 60;
  if (cron === "0 * * * *") return 3600;
  if (cron === "0 0 * * 0") return 3600; // clamp so the countdown stays lively
  return 3600;
}