AI

Scheduled Tasks

A list of scheduled agent automations — a human cadence next to a live countdown to the next run, the last run's outcome inline, a pause switch, a one-shot manual trigger, the failure printed on the row that broke, and four data states.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import {
  AlertCircle,
  CalendarClock,
  CalendarOff,
  ChevronRight,
  CircleAlert,
  CircleCheck,
  CircleDashed,
  LoaderCircle,
  Play,
  RefreshCcw,

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/scheduled-tasks.json

Prompt

Build a React + TypeScript + Tailwind "ScheduledTasks" panel with zod and
lucide-react — a list of recurring automations you can read, pause and trigger.

Contract
- A zod schema (`scheduledTasksItemSchema` in a sibling contract file) is the
  single source of truth for ONE row: { id, name, scheduleLabel, nextRunAt?:
  string|number|Date|null, lastOutcome, paused, error?, lastRunAt?, durationMs?,
  timezoneLabel? }.
- The split that carries the whole component: `scheduleLabel` is PROSE you
  formatted ("Every weekday at 09:00"), `nextRunAt` is DATA the panel does
  arithmetic on. Prose alone gives a list nobody can triage ("is that about to
  fire?"); an instant alone gives a countdown nobody can verify ("in 14 h" — to
  do what, how often?). Require both. The component never parses a cron
  expression and never formats a wall-clock time in a timezone: that belongs
  where you know the user's locale and zone.
- `lastOutcome` is a FIVE-value union: "running" | "failed" | "skipped" |
  "success" | "never". `running` exists because a long-lived automation is
  occasionally mid-flight and a row claiming "Succeeded" while the job is still
  executing is a lie the reader acts on. `skipped` is its own outcome (the
  previous run overran, the window was missed) and must not be dressed up as a
  failure. `never` is the honest state of a task created five minutes ago.
- `nextRunAt: null` / omitted means NOT SCHEDULED (paused indefinitely, a
  one-shot that already ran, a trigger the backend hasn't computed). It renders
  as "Not scheduled", never as a fabricated countdown.
- A separate envelope status — "loading" | "empty" | "error" | "ready" — is the
  panel's own render state, independent of any row's outcome. `status="error"`
  means listing the schedule failed; a task failing is `lastOutcome: "failed"`,
  and the two read completely differently on screen.
- Props: items: Item[]; status; now? (injected clock); sort? ("next-run" |
  "attention" | "none", default "next-run"); dueSoonMs? (60000); skeletonRows?
  (3); heading?; label?; emptyState?; errorMessage?; onRunNow?(id);
  onTogglePause?(id, paused); onRetry?; formatCountdown?(msUntilRun);
  formatRelative?(msSinceRun); formatDuration?(ms); className plus the rest of
  the element props, ref forwarded to the <section>.
- Omitting `onRunNow` or `onTogglePause` removes that control entirely rather
  than rendering a dead one — a switch that switches nothing is worse than no
  switch. `skeletonRows` and `dueSoonMs` are clamped so 0 or NaN can never
  render an empty or inverted state.

Behavior
- THE PANEL OWNS NO CLOCK. Every countdown is `nextRunAt − now`, recomputed from
  the two instants on every render and never accumulated, so a re-render, a
  paused tab or a replayed fetch cannot drift and the server and the browser
  always produce the same string. Omit `now` and the rows show their cadence
  with no countdown at all — that is the honest degradation, not a fallback to
  Date.now() inside the component. Tick one `now` per second in your app and
  every row in every panel moves together.
- Time wording is prose, not a receipt: "in 45 s", "in 12 m", "in 3 h 20 m", "in
  2 d 4 h", and past a week just "27 d" — the hours are noise at that distance.
  Being late is a fact about the worker, not a failure of the task, so an
  overdue row reads "due now" (under a minute) or "4 m late" in the same voice.
  Inside `dueSoonMs` the countdown takes the primary tone; everything else stays
  muted, so "about to happen" is legible without reading a single number.
- RUN NOW IS ONE-SHOT PER RUN. The guard is a ref read and written synchronously
  inside the click handler: several clicks dispatched in one task all observe
  the same stale state, so a state-only guard would let the extras through and
  fire a billing job four times. The lock is keyed on a token derived from the
  row's observable run identity (`lastOutcome` + `lastRunAt`), which means it
  EXPIRES BY ITSELF the moment the data layer answers — no timer, no reset
  effect, no cleanup. A row already `running` ignores the trigger outright. The
  obligation this puts on the consumer is worth stating in the contract's
  comments: a landing run must move `lastOutcome` or `lastRunAt`, because that
  transition is the signal that unlocks the button.
- The pause switch is controlled by the data and never lies. Pressing it calls
  onTogglePause(id, !paused) and locks on the CURRENT value, so a second press
  before the data moves computes the same token and is dropped (no flip-flop
  storm); the instant `paused` actually changes the token changes and the
  control is live again. While a request is out the switch is `aria-busy` but
  stays focusable and stays in the tab order — the native `disabled` attribute
  would blur it the moment it flipped and drop focus to <body> mid-action.
- Pause is not delete and not disable: a paused row keeps its cadence, its
  history and its manual trigger, shows "Paused" where the countdown was, and
  keeps its error on screen if it is also broken — pausing a broken automation
  does not fix it.
- THE FAILURE LIVES ON THE ROW. A `failed` row prints the first line of `error`
  inline, always, and puts the remainder behind one disclosure that states its
  size ("3 more lines"). Nothing on that path has a max-height or a line clamp;
  the collapsed remainder is UNMOUNTED, not height-zero, so it cannot be reached
  by Tab while invisible. An automation whose reason lives one navigation away
  is an automation that stays broken for a week.
- Ordering is a pure function of the data, so two renders of the same list are
  the same list: "next-run" puts the soonest firing task first, "attention"
  floats failed and running rows above that, and rows the panel cannot place in
  time (paused, unscheduled, unparseable) always sink to the bottom of their
  group instead of being sprayed through it. Ties break on the caller's original
  index, so nothing shuffles between renders.
- Bad time data degrades, never crashes: parsing goes through Date.parse and a
  finite check, so `null`, an omitted field and the string "every other tuesday"
  all land on "Not scheduled" and nothing ever renders NaN.
- Four states are four branches, not `&&` afterthoughts. Loading draws
  skeletonRows placeholders shaped like the real row plus aria-busy and one
  sr-only status line; empty keeps the panel header so the surface still
  explains itself; error is role="alert" with an optional Retry and deliberately
  NO stale rows underneath — you must not be able to pause a task that may no
  longer exist. `status="ready"` with an empty array falls through to the empty
  branch.

Rendering & styling
- Semantic tokens only: bg-card / border / text-muted-foreground for chrome,
  bg-primary + border-primary for the switch when the schedule is live,
  bg-background for its knob, text-primary for an imminent countdown,
  border-destructive/40 + bg-destructive/5 + text-destructive for the inline
  error, and one small outcome→token map (var(--chart-1) running, --destructive
  failed, --chart-2 succeeded, --muted-foreground skipped/never) applied as a
  colour so re-theming the outcomes is six lines. No hard-coded colours.
- Every outcome is a SHAPE as well as a colour — spinning arc, filled alert,
  skip chevrons, check, dashed ring — each next to its word, so the row survives
  a monochrome theme and a colour-blind reader. The spinner and the skeleton
  pulse stop under prefers-reduced-motion; the switch knob's transition is
  motion-reduce-guarded and still moves instantly, because a switch that does
  not move is a broken switch.
- The switch is role="switch" + aria-checked with an accessible name that
  includes the task name; the trigger is a real <button aria-label="Run <name>
  now">; the error disclosure is aria-expanded + aria-controls that only points
  at the panel while it exists.
- The one live region is the header SUMMARY ("6 tasks · 1 failing · 2 paused"),
  which changes when a run starts or a task is paused. The countdowns are
  deliberately outside it: a live region containing a per-second countdown makes
  a screen reader read the panel out loud once a second, forever.
- Long names and long cadence strings use `wrap-anywhere` plus `min-w-0`, NOT
  `break-words`: only overflow-wrap:anywhere lowers the element's min-content
  width, which is what actually stops a 92-character automation name from
  widening the panel. The row is flex-wrap, so the control cluster drops to its
  own line on a narrow surface instead of squeezing the name.
- cn() merges the consumer's className into the root <section>, which also
  spreads the remaining element props and forwards its ref.

Customization levers
- Triage: `sort` decides the story the panel tells — "next-run" for a calendar
  reading, "attention" for an ops reading, "none" when your backend already
  ordered it. `dueSoonMs` moves the line where a countdown becomes urgent (raise
  it to 15 min for hourly jobs, drop it to 10 s for a demo).
- Wording and units: `formatCountdown` / `formatRelative` / `formatDuration`
  replace every time string (localise them, switch to absolute clock times, or
  print "next: 09:00" instead of a countdown) without touching the layout.
- Affordances: pass only the callbacks you can honour — no `onRunNow` means no
  Run button, no `onTogglePause` means no switch, no `onRetry` means no retry.
  This is the intended way to render a read-only audit view.
- Chrome: `heading` and `label` name the panel, `emptyState` replaces the empty
  body with your own CTA ("Create your first automation"), `errorMessage` owns
  the envelope failure text, `skeletonRows` matches the placeholder count to the
  page size you actually fetch.
- Row content: `timezoneLabel` is optional and only appears when supplied;
  `durationMs` and `lastRunAt` each drop out silently when absent, so a backend
  that has no history yet still produces a complete-looking row.
- Density: the row is two stacked text lines plus a control cluster. Drop the
  outcome line for a compact variant, or add an owner/avatar slot on the left —
  the flex-wrap row absorbs both without touching the countdown logic.

Concepts

  • Cadence and instant are two different factsscheduleLabel is prose you formatted where you know the timezone, nextRunAt is an absolute instant the panel does arithmetic on. Ship only the prose and nobody can tell what is about to fire; ship only the instant and nobody can tell what "in 14 h" even means. The component refuses to invent either one from the other.
  • The panel owns no clocknow is injected and every countdown is recomputed as nextRunAt − now, never incremented. One interval in your app drives every row, a screenshot fixture renders the same string a year later, and the server and the browser never disagree. Omit the clock and the countdown disappears rather than lying.
  • Pause is a state, not a delete — a paused automation keeps its cadence, its history and its manual trigger; only the promise of a next run goes away. And pausing a broken task does not repair it, so the error stays on the row.
  • A one-shot trigger whose lock expires by itself — the guard is a ref read synchronously inside the click handler (several clicks in one task all see the same stale state), keyed on a token derived from the row's own run identity. When the data layer answers, the token changes and the button is live again: no timer, no reset effect, no button left permanently dead.
  • The failure lives on the row that failed — first line always visible, the rest one counted disclosure away, nothing clamped and nothing height-zero. A schedule list that only shows a red dot is a list where automations quietly stay broken for weeks.
  • Attention-first ordering, and the unplaceable sink — sorting is a pure function of the data, so the list never shuffles between renders; rows the panel cannot place in time (paused, unscheduled, an unparseable date) fall to the bottom of their group instead of being sprayed through the middle of the timeline.
  • The live region is the summary, never the countdown — counts change rarely and are worth announcing; a per-second countdown inside a live region makes a screen reader read the whole panel out loud once a second, forever.

On This Page