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"] },