A scheduled-jobs list with a live next-run countdown, last-run status, and a run-now action per job.
npx shadcn@latest add @paragon/cron-schedule"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;
}