From f055e54a37873267e97536e7a727841fbdac692f Mon Sep 17 00:00:00 2001 From: Blake Gentry Date: Thu, 9 Apr 2026 21:27:28 -0500 Subject: [PATCH 1/6] Improve job state iconography Replace weak Running icon (ArrowPathRoundedSquareIcon/PlayCircleIcon) with a custom GPU-accelerated animated ring spinner that uses only CSS transform: rotate() for zero layout/paint cost. The spinner animates when a job is actively running and shows a static arc otherwise. Available/Wait icons are unchanged from their original values: PauseCircleIcon in TaskStateIcon, QueueListIcon in JobTimeline. Respects prefers-reduced-motion via motion-reduce:animate-none. --- src/components/JobTimeline.tsx | 15 ++--- src/components/TaskStateIcon.tsx | 4 +- src/components/icons/jobStateIcons.tsx | 88 ++++++++++++++++++++++++++ 3 files changed, 97 insertions(+), 10 deletions(-) create mode 100644 src/components/icons/jobStateIcons.tsx diff --git a/src/components/JobTimeline.tsx b/src/components/JobTimeline.tsx index 41e4a7e7..3cb4fe90 100644 --- a/src/components/JobTimeline.tsx +++ b/src/components/JobTimeline.tsx @@ -1,6 +1,9 @@ import { DurationCompact } from "@components/DurationCompact"; import { - ArrowPathRoundedSquareIcon, + RunningIcon, + RunningSpinnerIcon, +} from "@components/icons/jobStateIcons"; +import { CheckCircleIcon, CircleStackIcon, ClockIcon, @@ -205,7 +208,7 @@ const RunningStep = ({ job }: { job: Job }) => { return ( @@ -216,7 +219,7 @@ const RunningStep = ({ job }: { job: Job }) => { return ( @@ -234,11 +237,7 @@ const RunningStep = ({ job }: { job: Job }) => { const cancelledWhileRunning = Boolean(job.attemptedAt); const state = cancelledWhileRunning ? "complete" : "pending"; return ( - + ( diff --git a/src/components/icons/jobStateIcons.tsx b/src/components/icons/jobStateIcons.tsx new file mode 100644 index 00000000..2dafb5d4 --- /dev/null +++ b/src/components/icons/jobStateIcons.tsx @@ -0,0 +1,88 @@ +import { forwardRef } from "react"; + +type IconProps = { + title?: string; + titleId?: string; +} & React.SVGProps; + +// Circumference of r=9 circle: 2π·9 ≈ 56.549 +// 75% arc (270°): 42.41, 25% gap: 14.14 +const SPINNER_R = 9; +const SPINNER_DASH = "42.41 14.14"; + +/** + * Animated ring spinner for the Running job state. + * + * Uses only CSS `transform: rotate()` for animation, which is composited + * entirely on the GPU — no layout or paint cost per frame. + */ +export const RunningSpinnerIcon = forwardRef( + ({ title, titleId, ...props }, ref) => ( + + ), +); +RunningSpinnerIcon.displayName = "RunningSpinnerIcon"; + +/** + * Static ring indicator for non-active running steps (pending/completed). + * Same shape as RunningSpinnerIcon but without animation. + */ +export const RunningIcon = forwardRef( + ({ title, titleId, ...props }, ref) => ( + + ), +); +RunningIcon.displayName = "RunningIcon"; From e178e196764a62cd291174d009a986de0bdfc3a1 Mon Sep 17 00:00:00 2001 From: Blake Gentry Date: Sat, 11 Apr 2026 20:01:18 -0500 Subject: [PATCH 2/6] Align JobTimeline step colors with diagram Change the "active" step icon background from yellow to blue, reserved for steps where something is actively executing (Running). Add a "waiting" status with amber coloring for time-based wait states: scheduled-in-future, available (waiting for a worker), and awaiting retry. --- src/components/JobTimeline.tsx | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/components/JobTimeline.tsx b/src/components/JobTimeline.tsx index 3cb4fe90..3c6ab16f 100644 --- a/src/components/JobTimeline.tsx +++ b/src/components/JobTimeline.tsx @@ -29,7 +29,7 @@ const useRelativeFormattedTime = (time: Date, addSuffix: boolean): string => { return relative; }; -type StepStatus = "active" | "complete" | "failed" | "pending"; +type StepStatus = "active" | "complete" | "failed" | "pending" | "waiting"; function RelativeTime({ addSuffix = false, @@ -96,6 +96,8 @@ const statusVerticalLineClassesFor = (status: StepStatus): string => { return "before:border-red-200 dark:before:border-red-900"; case "pending": return "before:border-gray-200 dark:before:border-gray-700"; + case "waiting": + return "before:border-gray-200 dark:before:border-gray-700"; } return ""; }; @@ -103,13 +105,15 @@ const statusVerticalLineClassesFor = (status: StepStatus): string => { const statusIconClassesFor = (status: StepStatus): string => { switch (status) { case "active": - return "bg-yellow-200 dark:bg-yellow-600"; + return "bg-blue-200 dark:bg-blue-700"; case "complete": return "bg-green-300 dark:bg-green-700"; case "failed": return "bg-red-200 dark:bg-red-700"; case "pending": return "bg-gray-100 dark:bg-gray-700"; + case "waiting": + return "bg-amber-200 dark:bg-amber-700"; } return ""; }; @@ -121,7 +125,7 @@ const ScheduledStep = ({ job }: { job: Job }) => { descriptionTitle={job.scheduledAt.toUTCString()} Icon={ClockIcon} name="Scheduled" - status="active" + status="waiting" > @@ -184,7 +188,7 @@ const WaitStep = ({ job }: { job: Job }) => { if (job.state === JobState.Available) { return ( - + () ); @@ -282,7 +286,7 @@ const RetryableStep = ({ job }: { job: Job }) => { descriptionTitle={job.scheduledAt.toUTCString()} Icon={ClockIcon} name="Awaiting Retry" - status="active" + status="waiting" > Job errored, retrying From 2a4eeafcf5e5c0bc00e6ab57dbaef3a43a8ae9fe Mon Sep 17 00:00:00 2001 From: Blake Gentry Date: Sat, 11 Apr 2026 21:22:24 -0500 Subject: [PATCH 3/6] Improve job timeline for snoozed jobs Snoozed jobs are a weird edge case as far as the timeline view. They've already run at least once, but their `scheduled_at` reflects the _next_ time they'll run; this means we cannot back out details on how long the job was waiting to be picked up, when it was last scheduled, etc. Until now the `JobTimeline` component really didn't try to deal with this aside from some apparently broken handling (see the "Scheduled Snoozed" story prior to this commit). To improve this, treat inferred snoozes as a distinct timeline phase so the past run, lost wait interval, and next retry each read clearly. Add focused JobTimeline coverage so the snoozed heuristic, step order, and visual states stay stable. --- CHANGELOG.md | 1 + src/components/JobTimeline.test.tsx | 107 ++++++++++++++++++++++++++++ src/components/JobTimeline.tsx | 88 +++++++++++++++++++++-- 3 files changed, 191 insertions(+), 5 deletions(-) create mode 100644 src/components/JobTimeline.test.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index f65032d9..47db9edd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Job state sidebar: only highlight `Running` when the selected jobs state is actually running, even with retained search filters in the URL. [Fixes #526](https://github.com/riverqueue/riverui/issues/526). [PR #527](https://github.com/riverqueue/riverui/pull/527). - Job delete actions: require confirmation before deleting a single job or selected jobs in bulk. [Fixes #545](https://github.com/riverqueue/riverui/issues/545). [PR #546](https://github.com/riverqueue/riverui/pull/546). - Workflow detail: show the backend's not-found message instead of crashing when a workflow ID does not exist. [PR #564](https://github.com/riverqueue/riverui/pull/564). +- Job detail: render a dedicated `Snoozed` timeline step for scheduled jobs with prior attempts so snoozed jobs no longer show negative wait durations. [PR #565](https://github.com/riverqueue/riverui/pull/565). ## [v0.15.0] - 2026-02-26 diff --git a/src/components/JobTimeline.test.tsx b/src/components/JobTimeline.test.tsx new file mode 100644 index 00000000..8c73241e --- /dev/null +++ b/src/components/JobTimeline.test.tsx @@ -0,0 +1,107 @@ +import { jobFactory } from "@test/factories/job"; +import { render, screen, within } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import JobTimeline from "./JobTimeline"; + +const NOW = new Date("2026-04-11T12:00:00.000Z"); + +vi.mock("react-time-sync", () => ({ + useTime: () => NOW.getTime() / 1000, +})); + +const getStepItem = (name: string): HTMLLIElement => { + const stepItem = screen.getByText(name).closest("li"); + if (!(stepItem instanceof HTMLLIElement)) { + throw new Error(`Expected ${name} step list item`); + } + + return stepItem; +}; + +const getStepIcon = (name: string): HTMLSpanElement => { + const stepIcon = getStepItem(name).querySelector("span.absolute"); + if (!(stepIcon instanceof HTMLSpanElement)) { + throw new Error(`Expected ${name} step icon`); + } + + return stepIcon; +}; + +const getStepNames = (): string[] => { + return screen + .getAllByRole("heading", { level: 3 }) + .map((heading) => heading.textContent ?? ""); +}; + +describe("JobTimeline", () => { + it("renders snoozed jobs with a dedicated snoozed step after running", () => { + // This fixture models a job that already ran once, then was snoozed back + // into `scheduled`. In that state, the next retry time is known, but the + // original schedule/wait timing before the prior run is not. + const snoozedJob = jobFactory.scheduledSnoozed().build({ + attemptedAt: new Date("2026-04-11T12:00:03.000Z"), + createdAt: NOW, + errors: [], + finalizedAt: undefined, + scheduledAt: new Date("2026-04-11T12:30:00.000Z"), + }); + + render(); + + expect(getStepNames()).toEqual([ + "Created", + "Scheduled", + "Wait", + "Running", + "Snoozed", + "Complete", + ]); + expect(within(getStepItem("Scheduled")).getByText("—")).toBeInTheDocument(); + expect(within(getStepItem("Wait")).getByText("—")).toBeInTheDocument(); + expect( + within(getStepItem("Running")).queryByText("Not yet started"), + ).toBeNull(); + expect( + within(getStepItem("Snoozed")).getByText(/Retrying/), + ).toBeInTheDocument(); + expect( + within(getStepItem("Snoozed")).queryByText(/Job snoozed/i), + ).toBeNull(); + expect(getStepIcon("Scheduled")).toHaveClass("bg-green-300"); + expect(getStepIcon("Snoozed")).toHaveClass("bg-amber-200"); + expect(screen.queryByText("-1h-30m-57s")).not.toBeInTheDocument(); + }); + + it("keeps the regular scheduled timeline for jobs that have not run yet", () => { + const scheduledJob = jobFactory.scheduled().build({ + createdAt: NOW, + scheduledAt: new Date("2026-04-11T12:30:00.000Z"), + }); + + render(); + + expect(getStepNames()).toEqual([ + "Created", + "Scheduled", + "Wait", + "Running", + "Complete", + ]); + expect(screen.getByText("Scheduled")).toBeInTheDocument(); + expect(screen.getByText("Wait")).toBeInTheDocument(); + expect(screen.queryByText("Snoozed")).not.toBeInTheDocument(); + expect(getStepIcon("Scheduled")).toHaveClass("bg-amber-200"); + }); + + it("keeps retryable jobs on the retry path instead of the snoozed path", () => { + const retryableJob = jobFactory.retryable().build({ + scheduledAt: new Date("2026-04-11T12:30:00.000Z"), + }); + + render(); + + expect(screen.getByText("Awaiting Retry")).toBeInTheDocument(); + expect(screen.queryByText("Snoozed")).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/JobTimeline.tsx b/src/components/JobTimeline.tsx index 3c6ab16f..fed4749c 100644 --- a/src/components/JobTimeline.tsx +++ b/src/components/JobTimeline.tsx @@ -29,6 +29,7 @@ const useRelativeFormattedTime = (time: Date, addSuffix: boolean): string => { return relative; }; +type SnoozedJob = { attemptedAt: Date } & Job; type StepStatus = "active" | "complete" | "failed" | "pending" | "waiting"; function RelativeTime({ @@ -118,8 +119,39 @@ const statusIconClassesFor = (status: StepStatus): string => { return ""; }; +// River jobs do not expose an explicit snooze flag, so we infer a snoozed job +// from a scheduled future run that has already been attempted but neither errored +// nor finalized. Manual retry from the UI transitions jobs to `available`, so it +// should not hit this path. +const isSnoozedJob = (job: Job, now: Date): job is SnoozedJob => { + return ( + job.state === JobState.Scheduled && + job.attemptedAt !== undefined && + job.scheduledAt > now && + job.finalizedAt === undefined && + job.errors.length === 0 + ); +}; + const ScheduledStep = ({ job }: { job: Job }) => { - if (job.state === JobState.Scheduled && job.scheduledAt > new Date()) { + const nowSec = useTime(); + const now = useMemo(() => new Date(nowSec * 1000), [nowSec]); + + if (isSnoozedJob(job, now)) { + return ( + + {/* The next retry time is shown on `Snoozed`; the original schedule is lost. */} + — + + ); + } + + if (job.state === JobState.Scheduled && job.scheduledAt > now) { return ( { const WaitStep = ({ job }: { job: Job }) => { const nowSec = useTime(); - const scheduledAtInFuture = useMemo( - () => job.scheduledAt >= new Date(nowSec * 1000), - [job.scheduledAt, nowSec], - ); + const now = useMemo(() => new Date(nowSec * 1000), [nowSec]); + const scheduledAtInFuture = useMemo(() => job.scheduledAt >= now, [job, now]); + + // A snooze overwrites `scheduledAt` with the next retry time, so the original + // wait duration before the prior run is not available anymore. + if (isSnoozedJob(job, now)) { + return ( + + ); + } if (job.state === JobState.Scheduled && !job.attemptedAt) { return ( @@ -203,6 +246,22 @@ const WaitStep = ({ job }: { job: Job }) => { }; const RunningStep = ({ job }: { job: Job }) => { + const nowSec = useTime(); + const now = useMemo(() => new Date(nowSec * 1000), [nowSec]); + + if (isSnoozedJob(job, now)) { + return ( + + + + ); + } + if ( !job.attemptedAt || job.state === JobState.Available || @@ -279,6 +338,24 @@ const RunningStep = ({ job }: { job: Job }) => { ); }; +const SnoozedStep = ({ job }: { job: Job }) => { + const nowSec = useTime(); + const now = useMemo(() => new Date(nowSec * 1000), [nowSec]); + + if (isSnoozedJob(job, now)) { + return ( + + Retrying + + ); + } +}; + const RetryableStep = ({ job }: { job: Job }) => { if (job.state === JobState.Retryable) { return ( @@ -362,6 +439,7 @@ export default function JobTimeline({ job }: JobTimelineProps) { + From ea35f3cd78e3204cdcd0ca9edd1c57422dec120d Mon Sep 17 00:00:00 2001 From: Blake Gentry Date: Thu, 7 May 2026 21:15:37 -0500 Subject: [PATCH 4/6] polish JobTimeline colors and icons The timeline icons were hard to read at their rendered size, especially on colored status circles and against the dark job detail background. The component also mixed `gray-*` and `slate-*` neutrals, which made it drift from the rest of the UI. Switch the Heroicons steps to their solid variants, add per-status icon foreground colors so each glyph keeps enough contrast without looking harsh, and align the neutral palette with `slate-*`. The connector and ring dark-mode colors are also adjusted so the timeline remains legible on the actual page background. --- src/components/JobTimeline.tsx | 40 +++++++++++++++++++++++----------- 1 file changed, 27 insertions(+), 13 deletions(-) diff --git a/src/components/JobTimeline.tsx b/src/components/JobTimeline.tsx index fed4749c..61a26f0f 100644 --- a/src/components/JobTimeline.tsx +++ b/src/components/JobTimeline.tsx @@ -11,7 +11,7 @@ import { QueueListIcon, TrashIcon, XCircleIcon, -} from "@heroicons/react/24/outline"; +} from "@heroicons/react/24/solid"; import { AttemptError, Job } from "@services/jobs"; import { Heroicon, JobState } from "@services/types"; import clsx from "clsx"; @@ -59,6 +59,7 @@ const StatusStep = ({ }) => { const statusVerticalLineClasses = statusVerticalLineClassesFor(status); const statusIconClasses = statusIconClassesFor(status); + const iconColorClasses = statusIconColorClassesFor(status); return (
  • -

    {name}

    @@ -90,15 +88,15 @@ const StatusStep = ({ const statusVerticalLineClassesFor = (status: StepStatus): string => { switch (status) { case "active": - return "before:border-gray-200 dark:before:border-gray-700"; + return "before:border-slate-200 dark:before:border-slate-700"; case "complete": - return "before:border-green-400 dark:before:border-green-900"; + return "before:border-green-400 dark:before:border-green-800"; case "failed": - return "before:border-red-200 dark:before:border-red-900"; + return "before:border-red-200 dark:before:border-red-800"; case "pending": - return "before:border-gray-200 dark:before:border-gray-700"; + return "before:border-slate-200 dark:before:border-slate-700"; case "waiting": - return "before:border-gray-200 dark:before:border-gray-700"; + return "before:border-slate-200 dark:before:border-slate-700"; } return ""; }; @@ -112,13 +110,29 @@ const statusIconClassesFor = (status: StepStatus): string => { case "failed": return "bg-red-200 dark:bg-red-700"; case "pending": - return "bg-gray-100 dark:bg-gray-700"; + return "bg-slate-100 dark:bg-slate-700"; case "waiting": return "bg-amber-200 dark:bg-amber-700"; } return ""; }; +const statusIconColorClassesFor = (status: StepStatus): string => { + switch (status) { + case "active": + return "text-blue-700 dark:text-blue-200"; + case "complete": + return "text-green-800 dark:text-green-200"; + case "failed": + return "text-red-700 dark:text-red-200"; + case "pending": + return "text-slate-500 dark:text-slate-300"; + case "waiting": + return "text-amber-700 dark:text-amber-200"; + } + return ""; +}; + // River jobs do not expose an explicit snooze flag, so we infer a snoozed job // from a scheduled future run that has already been attempted but neither errored // nor finalized. Manual retry from the UI transitions jobs to `available`, so it @@ -427,7 +441,7 @@ type JobTimelineProps = { export default function JobTimeline({ job }: JobTimelineProps) { return ( -

      +
        Date: Thu, 7 May 2026 21:16:32 -0500 Subject: [PATCH 5/6] distinguish completed running timeline icons Completed jobs used the same check icon for both the `Running` step and the final completion step, which made the timeline visually redundant and hid where the run phase ended. Use the play icon for non-active running phases while keeping the spinner for actively running jobs. Add focused coverage so the running and complete steps remain visually distinct for completed jobs. --- src/components/JobTimeline.test.tsx | 22 ++++++++++++++++++++++ src/components/JobTimeline.tsx | 14 ++++++-------- 2 files changed, 28 insertions(+), 8 deletions(-) diff --git a/src/components/JobTimeline.test.tsx b/src/components/JobTimeline.test.tsx index 8c73241e..d1563057 100644 --- a/src/components/JobTimeline.test.tsx +++ b/src/components/JobTimeline.test.tsx @@ -28,6 +28,15 @@ const getStepIcon = (name: string): HTMLSpanElement => { return stepIcon; }; +const getStepIconSVG = (name: string): SVGElement => { + const stepIconSVG = getStepIcon(name).querySelector("svg"); + if (!(stepIconSVG instanceof SVGElement)) { + throw new Error(`Expected ${name} step icon SVG`); + } + + return stepIconSVG; +}; + const getStepNames = (): string[] => { return screen .getAllByRole("heading", { level: 3 }) @@ -104,4 +113,17 @@ describe("JobTimeline", () => { expect(screen.getByText("Awaiting Retry")).toBeInTheDocument(); expect(screen.queryByText("Snoozed")).not.toBeInTheDocument(); }); + + it("uses distinct icons for completed running and complete steps", () => { + const completedJob = jobFactory.completed().build({ + attemptedAt: new Date("2026-04-11T11:59:50.000Z"), + finalizedAt: new Date("2026-04-11T12:00:00.000Z"), + }); + + render(); + + expect(getStepIconSVG("Running").outerHTML).not.toEqual( + getStepIconSVG("Complete").outerHTML, + ); + }); }); diff --git a/src/components/JobTimeline.tsx b/src/components/JobTimeline.tsx index 61a26f0f..4c6a9ff8 100644 --- a/src/components/JobTimeline.tsx +++ b/src/components/JobTimeline.tsx @@ -1,13 +1,11 @@ import { DurationCompact } from "@components/DurationCompact"; -import { - RunningIcon, - RunningSpinnerIcon, -} from "@components/icons/jobStateIcons"; +import { RunningSpinnerIcon } from "@components/icons/jobStateIcons"; import { CheckCircleIcon, CircleStackIcon, ClockIcon, ExclamationCircleIcon, + PlayCircleIcon, QueueListIcon, TrashIcon, XCircleIcon, @@ -267,7 +265,7 @@ const RunningStep = ({ job }: { job: Job }) => { return ( @@ -285,7 +283,7 @@ const RunningStep = ({ job }: { job: Job }) => { return ( @@ -314,7 +312,7 @@ const RunningStep = ({ job }: { job: Job }) => { const cancelledWhileRunning = Boolean(job.attemptedAt); const state = cancelledWhileRunning ? "complete" : "pending"; return ( - + ( { descriptionTitle={ errored ? lastError?.at.toUTCString() : job.attemptedAt.toUTCString() } - Icon={errored ? ExclamationCircleIcon : CheckCircleIcon} + Icon={errored ? ExclamationCircleIcon : PlayCircleIcon} name={errored ? "Errored" : "Running"} status={errored ? "failed" : "complete"} > From 703d9b3684c9230bc3e748c2dbb2a1a825f56135 Mon Sep 17 00:00:00 2001 From: Blake Gentry Date: Thu, 7 May 2026 22:08:02 -0500 Subject: [PATCH 6/6] fix Vitest path aliases Vitest was not resolving `@test/...` imports, so affected tests failed before running assertions. Anchor the `@*` mapping at `baseUrl: "./src"` so Vite and Vitest map project aliases to the source tree consistently, and opt into the TypeScript 6 deprecation window for that setting. --- tsconfig.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tsconfig.json b/tsconfig.json index 21ebcedf..33445aec 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,6 +13,7 @@ "isolatedModules": true, "noEmit": true, "jsx": "react-jsx", + "ignoreDeprecations": "6.0", /* Linting */ "strict": true, @@ -21,8 +22,9 @@ "noFallthroughCasesInSwitch": true, // allow importing services by @services: + "baseUrl": "./src", "paths": { - "@*": ["./src/*"] + "@*": ["./*"] }, "typeRoots": ["../../node_modules/@heroicons/**/*.d.ts"] },