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..d1563057
--- /dev/null
+++ b/src/components/JobTimeline.test.tsx
@@ -0,0 +1,129 @@
+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 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 })
+ .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();
+ });
+
+ 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 41e4a7e7..4c6a9ff8 100644
--- a/src/components/JobTimeline.tsx
+++ b/src/components/JobTimeline.tsx
@@ -1,14 +1,15 @@
import { DurationCompact } from "@components/DurationCompact";
+import { RunningSpinnerIcon } from "@components/icons/jobStateIcons";
import {
- ArrowPathRoundedSquareIcon,
CheckCircleIcon,
CircleStackIcon,
ClockIcon,
ExclamationCircleIcon,
+ PlayCircleIcon,
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";
@@ -26,7 +27,8 @@ const useRelativeFormattedTime = (time: Date, addSuffix: boolean): string => {
return relative;
};
-type StepStatus = "active" | "complete" | "failed" | "pending";
+type SnoozedJob = { attemptedAt: Date } & Job;
+type StepStatus = "active" | "complete" | "failed" | "pending" | "waiting";
function RelativeTime({
addSuffix = false,
@@ -55,6 +57,7 @@ const StatusStep = ({
}) => {
const statusVerticalLineClasses = statusVerticalLineClassesFor(status);
const statusIconClasses = statusIconClassesFor(status);
+ const iconColorClasses = statusIconColorClassesFor(status);
return (
-
+
{name}
@@ -86,13 +86,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-slate-200 dark:before:border-slate-700";
}
return "";
};
@@ -100,25 +102,74 @@ 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";
+ 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
+// 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 (
+
@@ -139,10 +190,21 @@ const ScheduledStep = ({ job }: { job: Job }) => {
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 (
@@ -181,7 +243,7 @@ const WaitStep = ({ job }: { job: Job }) => {
if (job.state === JobState.Available) {
return (
-
+
()
);
@@ -196,6 +258,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 ||
@@ -205,7 +283,7 @@ const RunningStep = ({ job }: { job: Job }) => {
return (
@@ -216,7 +294,7 @@ const RunningStep = ({ job }: { job: Job }) => {
return (
@@ -234,11 +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"}
>
@@ -276,6 +350,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 (
@@ -283,7 +375,7 @@ const RetryableStep = ({ job }: { job: Job }) => {
descriptionTitle={job.scheduledAt.toUTCString()}
Icon={ClockIcon}
name="Awaiting Retry"
- status="active"
+ status="waiting"
>
Job errored, retrying
@@ -347,7 +439,7 @@ type JobTimelineProps = {
export default function JobTimeline({ job }: JobTimelineProps) {
return (
-
+
+
diff --git a/src/components/TaskStateIcon.tsx b/src/components/TaskStateIcon.tsx
index 2a961733..b88d0142 100644
--- a/src/components/TaskStateIcon.tsx
+++ b/src/components/TaskStateIcon.tsx
@@ -1,10 +1,10 @@
+import { RunningSpinnerIcon } from "@components/icons/jobStateIcons";
import {
CheckCircleIcon,
ClockIcon,
EllipsisHorizontalCircleIcon,
ExclamationTriangleIcon,
PauseCircleIcon,
- PlayCircleIcon,
QuestionMarkCircleIcon,
XCircleIcon,
} from "@heroicons/react/20/solid";
@@ -66,7 +66,7 @@ export const TaskStateIcon = ({
);
case JobState.Running:
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";
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"]
},