Component contract
Renders the live status of a single async job. The parent supplies the job ID and a fetcher; the component manages polling cadence and renders status transitions.
jobId: string— required.fetchStatus: (jobId) => Promise<JobStatus>— parent owns the transport. JobStatus = { state: "queued" | "running" | "ready" | "failed" | "expired" | "cancelled", progress?: 0..100, queuePosition?: number, startedAt?, finishedAt?, expiresAt?, downloadUrl?, errorReason?, requestId? }.pollIntervalMs?: number— defaults to 2000. The component clamps to 1000ms minimum and applies exponential backoff after 60s.onStateChange?: (prev, next) => void— fires on transitions, not on every poll.onCancel?: (jobId) => Promise<void>— when provided, "Cancel" button is rendered while state ∈ {queued, running}.onDownload?: (downloadUrl) => void— when provided, "Download" button intercepts; otherwise the URL is rendered as a link.label?: string— short identifier for screen readers + display, e.g. "Guest List Summary report".
Interaction surface
-
Initial render: fetch immediately.
Component does not wait for first poll interval. Show skeleton during the first fetch; transition to whatever state the API returns.
-
Polling cadence.
While state ∈ {queued, running}, poll every pollIntervalMs. After 60s of running, back off: 5s, 10s, 30s, capped at 30s. Stop polling on terminal states (ready, failed, expired, cancelled).
-
Status display by state.
queued: status pill + queue position if known. running: status pill + progress bar (if progress provided) or indeterminate spinner. ready: status pill + download button + expiresAt countdown. failed: status pill + errorReason + requestId for support. expired: status pill + "regenerate" CTA if onCancel-equivalent provided. cancelled: status pill + dismiss.
-
State transition emits one onStateChange.
Polling that returns the same state does not fire the handler. Transitions (queued → running, running → ready) fire exactly once per transition.
-
Cancel mid-flight.
Cancel button calls onCancel; component optimistically transitions to "cancelling" pseudo-state; re-fetches on next poll to confirm. If the next poll returns ready (race window — server completed before cancel landed), the "Cancel" button hides and Download appears.
-
Download.
Renders as a link when no onDownload provided. With handler, renders as a button that calls handler with the signed URL. Either way, expiresAt countdown updates in-place; once expired, the button/link is replaced with "Regenerate" or "Expired" plus a regenerate path if the parent supplies one.
Failure modes
Progress bar shows 100% before the artifact is ready
Trigger: server reports progress=100 in running state but the artifact write has not finished; the user clicks download and gets a 404.
Component shows progress=100 only when state=ready. If state=running and progress=100, render as 99% with "finalizing" sub-text. The "ready" state is the gate. Harness: state=running, progress=100, assert progress bar shows ≤ 99 OR sub-text "finalizing" is rendered.
Stuck-in-queued not surfaced to user
Trigger: job sits in queued for 5 minutes; user thinks the page is broken.
After 60s in queued, render an info banner "Taking longer than expected — queue depth: N". After 5min, escalate to warning banner "Job stuck in queue; contact support with request ID X". Harness: stub fetchStatus to return queued for 60+ seconds, assert info banner visible.
Failed state hides the request ID
Trigger: failed state shows "Job failed" but no requestId; support can't trace it.
Failed state always renders requestId when provided by the API. Copy-to-clipboard button on the requestId is included. Harness: state=failed, requestId="req_abc", assert requestId visible AND a copy button exists adjacent.
Polling continues after terminal state
Trigger: state transitions to ready but the timer keeps polling, hammering the API.
Polling stops on first terminal state. Harness: stub fetchStatus to return ready, count fetch calls over 10s, assert exactly 1 (or 2 — initial + transition).
State transitions fire onStateChange repeatedly
Trigger: every poll while running fires onStateChange(running, running).
Handler fires only when state actually changes (and on the first non-skeleton render with prev=undefined). Harness: state=running for 5 polls, onStateChange called once on initial running entry, not 5 times.
Expired countdown ticks below zero
Trigger: countdown reads "expires in -3 minutes" because the timer didn't transition to expired state at zero.
At expiresAt, the local UI immediately transitions to expired display (server may or may not have caught up; component does this client-side based on Date.now()). Harness: set expiresAt 100ms in future, wait, assert UI shows expired, no negative countdown ever rendered.
Race window: cancel + complete crossover
Trigger: user clicks Cancel; in the same tick, the server finishes the job and returns ready on next poll. UI shows "cancelling" then jumps to ready, confusing user.
When optimistic "cancelling" is overridden by ready from server, render a one-time toast: "Cancel arrived too late — your file is ready". Then show the ready UI. Harness: simulate this race, assert toast visible AND download button visible.
Network error during poll silently retried forever
Trigger: fetchStatus rejects every poll; user sees nothing different.
After 3 consecutive fetch rejections, show an inline error banner: "Couldn't refresh status — retrying every 30s". Continue polling at the backed-off cadence. On first successful response, banner clears. Harness: stub fetch to reject, after 3 attempts assert banner visible.
Download button shown after artifact swept
Trigger: state=ready locally cached but the artifact's R2 row was swept by retention; clicking download 404s.
This is a parent concern (the parent should observe the 404 and re-fetch status) but the component renders "Download" only when downloadUrl is explicitly returned by fetchStatus. If state=ready but no downloadUrl, render "Artifact unavailable — regenerate" instead. Harness: state=ready, downloadUrl=undefined, assert Download button NOT visible AND regenerate visible.
Accessibility
- Uses ui-status-pill for the state display; inherits its a11y.
- State transitions announced via
aria-live="polite"on the status region. - Failed state's requestId is in a region with
role="alert"for screen readers. - Progress bar uses ui-progress-bar with
aria-valuenow,aria-valuemin=0,aria-valuemax=100. - Cancel button has descriptive
aria-label("Cancel [label]"). - Color contrast inherited from ui-status-pill, ui-progress-bar.
- axe-clean at severity ≥ serious.
Stable test attributes
| data-test | Where | Purpose |
|---|---|---|
ui-async-job-tracker | Outer wrapper | data-job-id and data-state attrs |
ui-async-job-status | Status display region | aria-live=polite |
ui-async-job-progress | Progress bar (when applicable) | Visible only in running state with progress provided |
ui-async-job-queue-position | Queue position label | Visible in queued state when known |
ui-async-job-stuck-banner | Stuck-in-queued info banner | Visible after 60s queued |
ui-async-job-error-banner | Polling error banner | Visible after 3 consecutive fetch failures |
ui-async-job-cancel | Cancel button | Visible in queued/running when onCancel provided |
ui-async-job-download | Download button or link | Visible in ready when downloadUrl is set |
ui-async-job-expires-at | Expiry countdown | Visible in ready when expiresAt is set |
ui-async-job-error-reason | Error reason text | Visible in failed state |
ui-async-job-request-id | Request ID + copy button | Visible in failed state when requestId provided |
ui-async-job-regenerate | Regenerate CTA | Visible in expired state OR ready-without-downloadUrl |
Agent test plan
Standalone probes against /admin-test/ui-async-job-tracker-fixture with variants: queued, running-with-progress, running-no-progress, ready, ready-no-url, failed, failed-no-request-id, expired, cancelled, polling-error, stuck-in-queued, race-cancel-then-ready.
Probe list
- initial-fetch-immediate: render, fetchStatus called once before pollInterval elapses
- queued-shows-position: state=queued, queuePosition=12 → "Position 12" visible
- running-progress-clamped-99: state=running, progress=100 → bar shows ≤99 OR "finalizing" subtext
- ready-shows-download: state=ready, downloadUrl set → ui-async-job-download visible
- ready-no-url-shows-regenerate: state=ready, no downloadUrl → ui-async-job-regenerate visible, no download
- failed-shows-error-reason: state=failed → ui-async-job-error-reason visible with reason text
- failed-shows-request-id: state=failed, requestId="req_abc" → text visible AND copy button present
- failed-no-request-id-graceful: state=failed, no requestId → reason still visible, no broken UI
- polling-stops-on-ready: state goes running → ready, after 10s assert poll count limited (1-2)
- on-state-change-fires-once-per-transition: 5 polls in running → onStateChange called once
- on-state-change-prev-arg: queued → running → ready → onStateChange args (queued, running) then (running, ready)
- stuck-banner-after-60s: state=queued for 60s → ui-async-job-stuck-banner visible
- polling-error-banner-after-3-failures: stub fetch reject 3x → ui-async-job-error-banner visible
- expired-countdown-no-negative: expiresAt 100ms future, wait, assert no "-N seconds" text
- expired-transitions-locally: expiresAt now, render, state pill shows expired
- cancel-fires-handler: click cancel, onCancel called with jobId
- cancel-optimistic-then-confirms: cancel + next poll returns cancelled → state=cancelled
- race-cancel-then-ready: cancel + next poll returns ready → toast "Cancel arrived too late" + download visible
- aria-live-status: status region has aria-live=polite
- color-contrast-status: inherited from ui-status-pill
- axe-clean-serious: no serious violations