{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "cron-1",
  "title": "Cron 1: scheduled jobs",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "The scheduled jobs of a product: each schedule in plain words beside its cron expression, an on and off switch, the last run written out, the next runs, and a Run now button that asks first.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/alert-dialog.json",
    "https://ui.ballmac.com/r/button.json",
    "https://ui.ballmac.com/r/skeleton.json",
    "https://ui.ballmac.com/r/state-view.json",
    "https://ui.ballmac.com/r/switch.json",
    "https://ui.ballmac.com/r/return-focus.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/blocks/cron-1/cron-1.tsx",
      "content": "// Ballmac UI: Cron 1. https://ui.ballmac.com/blocks/cron-1\n\"use client\"\n\nimport * as React from \"react\"\nimport { CircleCheck, CircleX, Clock, Loader2, Play } from \"lucide-react\"\n\nimport { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from \"@/components/ballmac/alert-dialog\"\nimport { Button } from \"@/components/ballmac/button\"\nimport { Skeleton } from \"@/components/ballmac/skeleton\"\nimport { frameProps, StateView, type StateProps } from \"@/components/ballmac/state-view\"\nimport { Switch } from \"@/components/ballmac/switch\"\nimport { useReturnFocus } from \"@/lib/ballmac/return-focus\"\nimport { cn } from \"@/lib/utils\"\n\ntype Cron1Job = {\n  id: string\n  name: string\n  /** A five-field cron expression, for example \"0 9 * * 1-5\". */\n  schedule: string\n  /** Time zone the schedule is read in, such as \"UTC\". */\n  timezone?: string\n  enabled: boolean\n  owner?: string\n  /** What happened last time. */\n  last?: { status: \"success\" | \"failed\" | \"running\"; at: string; duration?: string }\n  /** The next few times it will run, already formatted. */\n  next?: string[]\n}\n\ntype Cron1Props = Omit<React.ComponentProps<\"section\">, \"title\"> &\n  StateProps & {\n    jobs?: Cron1Job[]\n    title?: string\n    /** Called when a job is switched on or off. Reject and it goes back with the message. */\n    onToggle?: (job: Cron1Job, enabled: boolean) => void | Promise<void>\n    /** Called when someone confirms \"Run now\". */\n    onRunNow?: (job: Cron1Job) => void | Promise<void>\n  }\n\nconst DAYS = [\"Sunday\", \"Monday\", \"Tuesday\", \"Wednesday\", \"Thursday\", \"Friday\", \"Saturday\"]\nconst two = (n: number) => String(n).padStart(2, \"0\")\n\n/**\n * Turns the common five-field cron expressions into a sentence, and says \"A custom schedule\" for the rest instead of guessing. Handles every N minutes or\n * hours, daily, weekdays, one weekday, and monthly on a day. Fixed text, so the server and the browser agree.\n */\nfunction describeCron(expr: string): string {\n  const f = expr.trim().split(/\\s+/)\n  if (f.length !== 5) return \"A custom schedule\"\n  const [min, hour, dom, mon, dow] = f\n  const at = (h: string, m: string) => `${two(Number(h))}:${two(Number(m))}`\n  const num = /^\\d+$/\n  if (mon !== \"*\") return \"A custom schedule\"\n  let m = min.match(/^\\*\\/(\\d+)$/)\n  if (m && hour === \"*\" && dom === \"*\" && dow === \"*\") return Number(m[1]) === 1 ? \"Every minute\" : `Every ${m[1]} minutes`\n  m = hour.match(/^\\*\\/(\\d+)$/)\n  if (m && num.test(min) && dom === \"*\" && dow === \"*\") return `Every ${m[1]} hours at :${two(Number(min))}`\n  if (min === \"0\" && hour === \"*\" && dom === \"*\" && dow === \"*\") return \"Every hour\"\n  if (num.test(min) && num.test(hour) && dom === \"*\") {\n    if (dow === \"*\") return `Every day at ${at(hour, min)}`\n    if (dow === \"1-5\") return `Weekdays at ${at(hour, min)}`\n    if (dow === \"0,6\" || dow === \"6,0\") return `Weekends at ${at(hour, min)}`\n    if (num.test(dow)) return `Every ${DAYS[Number(dow) % 7]} at ${at(hour, min)}`\n  }\n  if (num.test(min) && num.test(hour) && num.test(dom) && dow === \"*\") return `On day ${dom} of every month at ${at(hour, min)}`\n  return \"A custom schedule\"\n}\n\nconst sample: Cron1Job[] = [\n  { id: \"c1\", name: \"Send the weekly digest\", schedule: \"0 9 * * 1\", timezone: \"UTC\", enabled: true, owner: \"Growth\", last: { status: \"success\", at: \"Mon 09:00\", duration: \"4 min 12 s\" }, next: [\"Mon 27 Oct, 09:00\", \"Mon 3 Nov, 09:00\", \"Mon 10 Nov, 09:00\"] },\n  { id: \"c2\", name: \"Clean up expired sessions\", schedule: \"*/15 * * * *\", timezone: \"UTC\", enabled: true, owner: \"Platform\", last: { status: \"success\", at: \"14:45\", duration: \"3 s\" }, next: [\"15:00\", \"15:15\", \"15:30\"] },\n  { id: \"c3\", name: \"Rebuild the search index\", schedule: \"30 2 * * *\", timezone: \"UTC\", enabled: true, owner: \"Search\", last: { status: \"failed\", at: \"Today 02:30\", duration: \"11 min\" }, next: [\"Tomorrow 02:30\", \"Sat 25 Oct, 02:30\"] },\n  { id: \"c4\", name: \"Back up the database\", schedule: \"0 */6 * * *\", timezone: \"UTC\", enabled: true, owner: \"Platform\", last: { status: \"running\", at: \"12:00\" }, next: [\"18:00\", \"Tomorrow 00:00\"] },\n  { id: \"c5\", name: \"Send invoices\", schedule: \"0 8 1 * *\", timezone: \"UTC\", enabled: false, owner: \"Finance\", last: { status: \"success\", at: \"1 Oct, 08:00\", duration: \"6 min\" }, next: [] },\n]\n\nfunction Skel() {\n  return (\n    <div aria-hidden=\"true\" className=\"grid gap-3\">\n      {[0, 1, 2, 3, 4].map((i) => (\n        <Skeleton key={i} className=\"h-20 rounded-xl\" />\n      ))}\n    </div>\n  )\n}\n\n/**\n * The scheduled jobs of a product: each with its schedule in plain words (and the cron expression beside it), a switch, what happened last time written out,\n * the next runs, and a Run now button that asks first. A switch that fails to save goes back with the reason.\n */\nfunction Cron1({ jobs: jobsProp = sample, title = \"Scheduled jobs\", onToggle, onRunNow, state = \"ready\", onRetry, emptyState, errorState, className, ...props }: Cron1Props) {\n  const id = React.useId()\n  const [jobs, setJobs] = React.useState<Cron1Job[]>(jobsProp)\n  React.useEffect(() => setJobs(jobsProp), [jobsProp])\n  const [confirm, setConfirm] = React.useState<Cron1Job | null>(null)\n  const [busy, setBusy] = React.useState<string | null>(null)\n  const [note, setNote] = React.useState<{ id: string; ok: boolean; text: string } | null>(null)\n  const ret = useReturnFocus(!!confirm)\n  const view = state === \"ready\" && jobs.length === 0 ? \"empty\" : state\n\n  async function toggle(j: Cron1Job, enabled: boolean) {\n    setBusy(j.id)\n    setNote(null)\n    setJobs((l) => l.map((x) => (x.id === j.id ? { ...x, enabled } : x)))\n    try {\n      await onToggle?.(j, enabled)\n    } catch (e) {\n      setJobs((l) => l.map((x) => (x.id === j.id ? j : x)))\n      setNote({ id: j.id, ok: false, text: e instanceof Error ? e.message : `${j.name} could not be changed, so it was put back as it was.` })\n    } finally {\n      setBusy(null)\n    }\n  }\n  async function runNow(j: Cron1Job) {\n    setBusy(j.id)\n    setNote(null)\n    try {\n      await onRunNow?.(j)\n      setJobs((l) => l.map((x) => (x.id === j.id ? { ...x, last: { status: \"running\", at: \"now\" } } : x)))\n      setNote({ id: j.id, ok: true, text: `${j.name} started.` })\n    } catch (e) {\n      setNote({ id: j.id, ok: false, text: e instanceof Error ? e.message : `${j.name} could not be started.` })\n    } finally {\n      setBusy(null)\n    }\n  }\n\n  return (\n    <section data-slot=\"cron-1\" data-state={state} aria-labelledby={`${id}-title`} className={cn(\"mx-auto w-full max-w-4xl px-4 py-10 sm:px-6\", className)} {...frameProps(props)}>\n      <header className=\"mb-8\">\n        <h1 id={`${id}-title`} className=\"text-3xl font-semibold tracking-[-0.035em] text-balance\">\n          {title}\n        </h1>\n        <p className=\"text-muted-foreground mt-2 max-w-xl text-pretty\">Everything that runs on a timer, when it runs next, and how the last run went.</p>\n      </header>\n      <StateView state={view} onRetry={onRetry} label=\"Loading scheduled jobs\" loading={<Skel />} empty={{ title: \"No scheduled jobs\", description: \"Jobs that run on a schedule, like digests and backups, will be listed here.\", ...emptyState }} error={{ title: \"Scheduled jobs could not be loaded\", description: \"Nothing was changed or started. Check your connection and try again.\", ...errorState }}>\n        <ul className=\"grid gap-3\">\n          {jobs.map((j) => {\n            const l = j.last\n            return (\n              <li key={j.id} data-enabled={j.enabled} className={cn(\"bg-card rounded-xl border p-4 sm:p-5\", !j.enabled && \"bg-muted/30\")}>\n                <div className=\"flex flex-wrap items-start justify-between gap-x-6 gap-y-3\">\n                  <div className=\"min-w-0\">\n                    <p className=\"font-medium\">{j.name}</p>\n                    <p className=\"text-muted-foreground mt-0.5 flex flex-wrap items-center gap-x-2 text-sm\">\n                      <Clock aria-hidden=\"true\" className=\"size-3.5\" />\n                      {describeCron(j.schedule)}\n                      <code className=\"bg-muted rounded px-1.5 py-0.5 font-mono text-xs\" dir=\"ltr\">{j.schedule}</code>\n                      {j.timezone && <span className=\"text-xs\">{j.timezone}</span>}\n                    </p>\n                    <p className=\"mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs\">\n                      {l ? (\n                        <span className={cn(\"inline-flex items-center gap-1.5\", l.status === \"failed\" && \"text-destructive font-medium\")}>\n                          {l.status === \"success\" ? <CircleCheck aria-hidden=\"true\" className=\"size-3.5\" /> : l.status === \"failed\" ? <CircleX aria-hidden=\"true\" className=\"size-3.5\" /> : <Loader2 aria-hidden=\"true\" className=\"size-3.5 motion-safe:animate-spin\" style={{ animationPlayState: \"var(--bm-ambient-state, running)\" }} />}\n                          {l.status === \"success\" ? \"Last run succeeded\" : l.status === \"failed\" ? \"Last run failed\" : \"Running now\"} · {l.at}\n                          {l.duration && ` · ${l.duration}`}\n                        </span>\n                      ) : (\n                        <span className=\"text-muted-foreground\">Never run</span>\n                      )}\n                      {j.owner && <span className=\"text-muted-foreground\">Owner: {j.owner}</span>}\n                    </p>\n                    {j.enabled && j.next && j.next.length > 0 && <p className=\"text-muted-foreground mt-1 text-xs\">Next: {j.next.slice(0, 3).join(\" · \")}</p>}\n                    {!j.enabled && <p className=\"text-muted-foreground mt-1 text-xs\">Switched off. It will not run until it is turned back on.</p>}\n                  </div>\n                  <div className=\"flex items-center gap-3\">\n                    <Button size=\"sm\" variant=\"outline\" disabled={busy === j.id || l?.status === \"running\"} onClick={() => setConfirm(j)} aria-label={`Run ${j.name} now`}>\n                      <Play aria-hidden=\"true\" className=\"rtl:-scale-x-100\" /> Run now\n                    </Button>\n                    <Switch aria-label={`${j.name} is on`} checked={j.enabled} disabled={busy === j.id} onCheckedChange={(v) => void toggle(j, v)} />\n                  </div>\n                </div>\n                {note?.id === j.id && <p role={note.ok ? \"status\" : \"alert\"} className={cn(\"mt-3 text-sm font-medium\", !note.ok && \"text-destructive\")}>{note.text}</p>}\n              </li>\n            )\n          })}\n        </ul>\n      </StateView>\n\n      <AlertDialog open={!!confirm} onOpenChange={(o) => !o && setConfirm(null)}>\n        <AlertDialogContent {...ret}>\n          <AlertDialogHeader>\n            <AlertDialogTitle>Run {confirm?.name} now?</AlertDialogTitle>\n            <AlertDialogDescription>It starts straight away, in addition to its schedule ({confirm ? describeCron(confirm.schedule).toLowerCase() : \"\"}).</AlertDialogDescription>\n          </AlertDialogHeader>\n          <AlertDialogFooter>\n            <AlertDialogCancel>Cancel</AlertDialogCancel>\n            <AlertDialogAction onClick={() => confirm && void runNow(confirm)}>Run now</AlertDialogAction>\n          </AlertDialogFooter>\n        </AlertDialogContent>\n      </AlertDialog>\n    </section>\n  )\n}\n\nexport { Cron1, describeCron, type Cron1Job, type Cron1Props }\n",
      "type": "registry:component",
      "target": "@components/ballmac/blocks/cron-1/cron-1.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "cron",
      "scheduled jobs",
      "scheduler",
      "automation",
      "backups",
      "tasks",
      "devops",
      "timers"
    ],
    "version": "1.0.0",
    "updated": "2026-10-24",
    "tests": true,
    "ai": {
      "summary": "Scheduled jobs. <Cron1 jobs={[{ id, name, schedule: '0 9 * * 1-5', timezone?, enabled, owner?, last?: { status: 'success' | 'failed' | 'running', at, duration? }, next?: string[] }]} onToggle={(job, enabled) => Promise} onRunNow={(job) => Promise} />. Exports describeCron(expr), which turns common five-field expressions into a sentence (every N minutes or hours, daily, weekdays, weekends, one weekday, monthly) and says 'A custom schedule' for the rest instead of guessing.",
      "whenToUse": [
        "An admin page for cron jobs and background schedules",
        "Letting a team pause a job or run it once by hand"
      ],
      "whenNotToUse": [
        "The workers and their failed jobs; use queues-1",
        "Automations built by users with triggers and steps; use workflow-1"
      ],
      "composesWith": [
        "queues-1",
        "audit-1",
        "pipeline-1",
        "slo-1"
      ],
      "a11y": [
        {
          "keys": "Schedule",
          "action": "Written as a sentence and also shown as code, which stays left to right"
        },
        {
          "keys": "Switch",
          "action": "Named '<job> is on'; a failure is an alert and the switch goes back"
        },
        {
          "keys": "Run now",
          "action": "A button named for the job that opens an alert dialog, and is off while the job is already running"
        },
        {
          "keys": "Last run",
          "action": "Written in words with an icon (Last run failed), never colour alone"
        }
      ],
      "customization": [
        "jobs with last and next runs",
        "onToggle and onRunNow",
        "describeCron is exported for your own lists"
      ]
    },
    "states": [
      {
        "id": "loading",
        "title": "Loading",
        "example": "cron-1-loading",
        "trigger": "state=\"loading\"",
        "note": "Skeleton rows under the real heading, announced as loading."
      },
      {
        "id": "empty",
        "title": "No scheduled jobs",
        "example": "cron-1-empty",
        "trigger": "state=\"empty\"",
        "note": "Says no scheduled jobs; emptyState sets the copy."
      },
      {
        "id": "error",
        "title": "Could not load",
        "example": "cron-1-error",
        "trigger": "state=\"error\"",
        "note": "An alert saying nothing was changed or started, with Try again (onRetry). A switch that fails goes back and says why."
      }
    ],
    "examples": [
      "cron-1-demo",
      "cron-1-loading",
      "cron-1-empty",
      "cron-1-error"
    ],
    "url": "https://ui.ballmac.com/components/cron-1"
  },
  "categories": [
    "blocks",
    "cron"
  ],
  "type": "registry:block"
}