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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/scheduled-tasks.jsonPrompt
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 facts —
scheduleLabelis prose you formatted where you know the timezone,nextRunAtis 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 clock —
nowis injected and every countdown is recomputed asnextRunAt − 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.
Human Handoff
A bot-to-human escalation card that walks one ticket through request, queue, connecting, connected and an offline leave-your-address fallback.
Retrieval Results
The RAG retrieval step opened up — ranked chunk cards with source and page, a similarity bar carrying the pipeline cutoff, whole-word query emphasis, a used-in-answer badge wired to its citation number, and a threshold divider that refuses to lie.