← All stories

COMPONENT · ui-async-job-tracker

ui-async-job-tracker

Component Tier 1 (primitive) Used by report jobs (EF-086), guest messaging async send (EF-053), Salesforce import/export (EF-093), email deliverability rollups (EF-089)

The "this thing is happening in the background, here's where it stands" component. Polls or subscribes for status, surfaces queued/running/ready/failed/expired transitions, doesn't claim more progress than the server reports, and never lies about whether the artifact is downloadable.

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

  1. 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.

  2. 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).

  3. 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.

  4. 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.

  5. 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.

  6. 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-testWherePurpose
ui-async-job-trackerOuter wrapperdata-job-id and data-state attrs
ui-async-job-statusStatus display regionaria-live=polite
ui-async-job-progressProgress bar (when applicable)Visible only in running state with progress provided
ui-async-job-queue-positionQueue position labelVisible in queued state when known
ui-async-job-stuck-bannerStuck-in-queued info bannerVisible after 60s queued
ui-async-job-error-bannerPolling error bannerVisible after 3 consecutive fetch failures
ui-async-job-cancelCancel buttonVisible in queued/running when onCancel provided
ui-async-job-downloadDownload button or linkVisible in ready when downloadUrl is set
ui-async-job-expires-atExpiry countdownVisible in ready when expiresAt is set
ui-async-job-error-reasonError reason textVisible in failed state
ui-async-job-request-idRequest ID + copy buttonVisible in failed state when requestId provided
ui-async-job-regenerateRegenerate CTAVisible 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