diff --git a/CHANGELOG.md b/CHANGELOG.md
index fa46e80c..064a1c21 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+### Added
+
+- Add sort order toggle (ASC/DESC) and time range filter (created after/before) to the job list dashboard. [PR #720](https://github.com/riverqueue/riverui/pull/720).
+
## [v0.22.0] - 2026-10-24
### Added
diff --git a/handler_api_endpoint.go b/handler_api_endpoint.go
index 947b6ea8..ff26406e 100644
--- a/handler_api_endpoint.go
+++ b/handler_api_endpoint.go
@@ -421,16 +421,35 @@ func (*jobListEndpoint[TTx]) Meta() *apiendpoint.EndpointMeta {
}
type jobListRequest struct {
- IDs []int64 `json:"-" validate:"omitempty,min=1,max=1000"` // from ExtractRaw
- Kinds []string `json:"-" validate:"omitempty,max=100"` // from ExtractRaw
- Limit *int `json:"-" validate:"omitempty,min=0,max=1000"` // from ExtractRaw
- Priorities []int16 `json:"-" validate:"omitempty,min=0,max=10"` // from ExtractRaw
- Queues []string `json:"-" validate:"omitempty,max=100"` // from ExtractRaw
- State *rivertype.JobState `json:"-" validate:"omitempty,oneof=available cancelled completed discarded pending retryable running scheduled"` // from ExtractRaw
- Tags []string `json:"-" validate:"omitempty,max=100"` // from ExtractRaw
+ CreatedAfter *time.Time `json:"-"` // from ExtractRaw
+ CreatedBefore *time.Time `json:"-"` // from ExtractRaw
+ IDs []int64 `json:"-" validate:"omitempty,min=1,max=1000"` // from ExtractRaw
+ Kinds []string `json:"-" validate:"omitempty,max=100"` // from ExtractRaw
+ Limit *int `json:"-" validate:"omitempty,min=0,max=1000"` // from ExtractRaw
+ Priorities []int16 `json:"-" validate:"omitempty,min=0,max=10"` // from ExtractRaw
+ Queues []string `json:"-" validate:"omitempty,max=100"` // from ExtractRaw
+ SortOrder *string `json:"-" validate:"omitempty,oneof=asc desc"` // from ExtractRaw
+ State *rivertype.JobState `json:"-" validate:"omitempty,oneof=available cancelled completed discarded pending retryable running scheduled"` // from ExtractRaw
+ Tags []string `json:"-" validate:"omitempty,max=100"` // from ExtractRaw
}
func (req *jobListRequest) ExtractRaw(r *http.Request) error {
+ if createdAfter := r.URL.Query().Get("created_after"); createdAfter != "" {
+ t, err := time.Parse(time.RFC3339, createdAfter)
+ if err != nil {
+ return apierror.NewBadRequestf("Invalid `created_after` timestamp: %s.", err)
+ }
+ req.CreatedAfter = &t
+ }
+
+ if createdBefore := r.URL.Query().Get("created_before"); createdBefore != "" {
+ t, err := time.Parse(time.RFC3339, createdBefore)
+ if err != nil {
+ return apierror.NewBadRequestf("Invalid `created_before` timestamp: %s.", err)
+ }
+ req.CreatedBefore = &t
+ }
+
if ids := r.URL.Query()["ids"]; len(ids) > 0 {
req.IDs = sliceutil.Map(ids, func(id string) int64 {
value, err := strconv.ParseInt(id, 10, 64)
@@ -464,14 +483,18 @@ func (req *jobListRequest) ExtractRaw(r *http.Request) error {
})
}
- if state := r.URL.Query().Get("state"); state != "" {
- req.State = (*rivertype.JobState)(&state)
- }
-
if queues := r.URL.Query()["queues"]; len(queues) > 0 {
req.Queues = queues
}
+ if sortOrder := r.URL.Query().Get("sort_order"); sortOrder != "" {
+ req.SortOrder = &sortOrder
+ }
+
+ if state := r.URL.Query().Get("state"); state != "" {
+ req.State = (*rivertype.JobState)(&state)
+ }
+
if tags := r.URL.Query()["tags"]; len(tags) > 0 {
req.Tags = tags
}
@@ -505,17 +528,45 @@ func (a *jobListEndpoint[TTx]) Execute(ctx context.Context, req *jobListRequest)
params = params.TagsAny(req.Tags...)
}
+ // Determine sort direction: use explicit request value, or fall back
+ // to a sensible default per state.
+ sortOrder := river.SortOrderAsc
+ if req.SortOrder != nil {
+ switch *req.SortOrder {
+ case "desc":
+ sortOrder = river.SortOrderDesc
+ default:
+ sortOrder = river.SortOrderAsc
+ }
+ } else {
+ // Preserve original default: finalized states sort descending.
+ if req.State != nil {
+ switch *req.State {
+ case rivertype.JobStateCancelled, rivertype.JobStateCompleted, rivertype.JobStateDiscarded:
+ sortOrder = river.SortOrderDesc
+ }
+ }
+ }
+
if req.State == nil {
- params = params.States(rivertype.JobStateRunning).OrderBy(river.JobListOrderByTime, river.SortOrderAsc)
+ params = params.States(rivertype.JobStateRunning).OrderBy(river.JobListOrderByTime, sortOrder)
} else {
switch *req.State {
case rivertype.JobStateCancelled, rivertype.JobStateCompleted, rivertype.JobStateDiscarded:
- params = params.States(*req.State).OrderBy(river.JobListOrderByTime, river.SortOrderDesc)
+ params = params.States(*req.State).OrderBy(river.JobListOrderByTime, sortOrder)
case rivertype.JobStateAvailable, rivertype.JobStateRetryable, rivertype.JobStatePending, rivertype.JobStateRunning, rivertype.JobStateScheduled:
- params = params.States(*req.State)
+ params = params.States(*req.State).OrderBy(river.JobListOrderByTime, sortOrder)
}
}
+ // Time range filters on created_at.
+ if req.CreatedAfter != nil {
+ params = params.Where("created_at >= @created_after", river.NamedArgs{"created_after": *req.CreatedAfter})
+ }
+ if req.CreatedBefore != nil {
+ params = params.Where("created_at <= @created_before", river.NamedArgs{"created_before": *req.CreatedBefore})
+ }
+
result, err := a.Client.JobListTx(ctx, tx, params)
if err != nil {
return nil, fmt.Errorf("error listing jobs: %w", err)
diff --git a/handler_api_endpoint_test.go b/handler_api_endpoint_test.go
index 74b4a28d..bd2a23c9 100644
--- a/handler_api_endpoint_test.go
+++ b/handler_api_endpoint_test.go
@@ -25,6 +25,7 @@ import (
"github.com/riverqueue/river/rivershared/riversharedtest"
"github.com/riverqueue/river/rivershared/startstop"
"github.com/riverqueue/river/rivershared/uniquestates"
+ "github.com/riverqueue/river/rivershared/util/ptrutil"
"github.com/riverqueue/river/rivertype"
"riverqueue.com/riverui/internal/apibundle"
@@ -767,6 +768,74 @@ func TestAPIHandlerJobList(t *testing.T) {
require.Len(t, resp.Data, 1)
require.Equal(t, job.ID, resp.Data[0].ID)
})
+
+ t.Run("SortOrderDesc", func(t *testing.T) {
+ t.Parallel()
+
+ endpoint, bundle := setupEndpoint(ctx, t, newJobListEndpoint)
+
+ job1 := testfactory.Job(ctx, t, bundle.exec, &testfactory.JobOpts{
+ Kind: ptrutil.Ptr("kind1"),
+ State: ptrutil.Ptr(rivertype.JobStateRunning),
+ })
+ job2 := testfactory.Job(ctx, t, bundle.exec, &testfactory.JobOpts{
+ Kind: ptrutil.Ptr("kind2"),
+ State: ptrutil.Ptr(rivertype.JobStateRunning),
+ })
+
+ // Default order should be ascending (job1 first)
+ resp, err := apitest.InvokeHandler(ctx, endpoint.Execute, testMountOpts(t), &jobListRequest{})
+ require.NoError(t, err)
+ require.Len(t, resp.Data, 2)
+ require.Equal(t, job1.ID, resp.Data[0].ID)
+ require.Equal(t, job2.ID, resp.Data[1].ID)
+
+ // Explicit descending order should reverse
+ desc := "desc"
+ resp, err = apitest.InvokeHandler(ctx, endpoint.Execute, testMountOpts(t), &jobListRequest{
+ SortOrder: &desc,
+ })
+ require.NoError(t, err)
+ require.Len(t, resp.Data, 2)
+ require.Equal(t, job2.ID, resp.Data[0].ID)
+ require.Equal(t, job1.ID, resp.Data[1].ID)
+ })
+
+ t.Run("CreatedAfterAndBefore", func(t *testing.T) {
+ t.Parallel()
+
+ endpoint, bundle := setupEndpoint(ctx, t, newJobListEndpoint)
+
+ now := time.Now().UTC()
+ oldTime := now.Add(-48 * time.Hour)
+ recentTime := now.Add(-1 * time.Hour)
+
+ job1 := testfactory.Job(ctx, t, bundle.exec, &testfactory.JobOpts{
+ CreatedAt: &oldTime,
+ State: ptrutil.Ptr(rivertype.JobStateRunning),
+ })
+ job2 := testfactory.Job(ctx, t, bundle.exec, &testfactory.JobOpts{
+ CreatedAt: &recentTime,
+ State: ptrutil.Ptr(rivertype.JobStateRunning),
+ })
+
+ // Filter to only recent jobs (created after 24 hours ago)
+ cutoff := now.Add(-24 * time.Hour)
+ resp, err := apitest.InvokeHandler(ctx, endpoint.Execute, testMountOpts(t), &jobListRequest{
+ CreatedAfter: &cutoff,
+ })
+ require.NoError(t, err)
+ require.Len(t, resp.Data, 1)
+ require.Equal(t, job2.ID, resp.Data[0].ID)
+
+ // Filter to only old jobs (created before 24 hours ago)
+ resp, err = apitest.InvokeHandler(ctx, endpoint.Execute, testMountOpts(t), &jobListRequest{
+ CreatedBefore: &cutoff,
+ })
+ require.NoError(t, err)
+ require.Len(t, resp.Data, 1)
+ require.Equal(t, job1.ID, resp.Data[0].ID)
+ })
}
func TestAPIHandlerJobListCustomSchema(t *testing.T) {
diff --git a/src/components/JobList.test.tsx b/src/components/JobList.test.tsx
index 242ec352..900a5e05 100644
--- a/src/components/JobList.test.tsx
+++ b/src/components/JobList.test.tsx
@@ -3,7 +3,14 @@ import { StatesAndCounts } from "@services/states";
import { JobState } from "@services/types";
import { jobMinimalFactory } from "@test/factories/job";
import { createFeatures } from "@test/utils/features";
-import { act, render, screen, waitFor, within } from "@testing-library/react";
+import {
+ act,
+ fireEvent,
+ render,
+ screen,
+ waitFor,
+ within,
+} from "@testing-library/react";
import { type ReactNode } from "react";
import { userEvent } from "storybook/test";
import {
@@ -119,6 +126,51 @@ describe("JobList", () => {
},
);
+ it("reports sort order and time range filter changes", async () => {
+ mockUseSettings.mockReturnValue(settingsMock({}));
+ const onSortOrderChange = vi.fn();
+ const onTimeRangeChange = vi.fn();
+ const features = createFeatures({});
+
+ render(
+
+
+ ,
+ );
+
+ // Running defaults to ascending, so the control offers descending next.
+ await userEvent.click(
+ screen.getByRole("button", { name: "Sort ascending" }),
+ );
+ expect(onSortOrderChange).toHaveBeenCalledWith("desc");
+
+ await userEvent.click(
+ screen.getByRole("button", { name: "Filter by time range" }),
+ );
+ fireEvent.change(screen.getByLabelText("Created after"), {
+ target: { value: "2026-03-01T10:00" },
+ });
+ expect(onTimeRangeChange).toHaveBeenCalledWith(
+ new Date("2026-03-01T10:00").toISOString(),
+ undefined,
+ );
+ });
+
it("shows job args by default", () => {
const job = jobMinimalFactory.build({
argsRaw: '{"z":2,"id":1970670598291982290,"a":1}',
diff --git a/src/components/JobList.tsx b/src/components/JobList.tsx
index 024ef1da..4f7e0b7a 100644
--- a/src/components/JobList.tsx
+++ b/src/components/JobList.tsx
@@ -14,9 +14,13 @@ import { MenuButton as HeadlessMenuButton } from "@headlessui/react";
import { ExclamationTriangleIcon } from "@heroicons/react/20/solid";
import {
ArrowUturnLeftIcon,
+ BarsArrowDownIcon,
+ BarsArrowUpIcon,
ChevronUpDownIcon,
+ FunnelIcon,
TrashIcon,
XCircleIcon,
+ XMarkIcon,
} from "@heroicons/react/24/outline";
import { useSelected } from "@hooks/use-selected";
import { useSettings } from "@hooks/use-settings";
@@ -157,15 +161,23 @@ export type JobRowsProps = {
cancelJobs: (jobIDs: bigint[]) => void;
canShowFewer: boolean;
canShowMore: boolean;
+ createdAfter?: string;
+ createdBefore?: string;
deleteJobs: (jobIDs: bigint[]) => void;
hideArgs: boolean;
initialFilters?: Filter[];
jobs: JobMinimal[];
onFiltersChange?: (filters: Filter[]) => void;
+ onSortOrderChange?: (sortOrder: "asc" | "desc" | undefined) => void;
+ onTimeRangeChange?: (
+ createdAfter: string | undefined,
+ createdBefore: string | undefined,
+ ) => void;
retryJobs: (jobIDs: bigint[]) => void;
setJobRefetchesPaused: (value: boolean) => void;
showFewer: () => void;
showMore: () => void;
+ sortOrder?: "asc" | "desc";
state: JobState;
};
@@ -173,15 +185,23 @@ type JobListProps = {
cancelJobs: (jobIDs: bigint[]) => void;
canShowFewer: boolean;
canShowMore: boolean;
+ createdAfter?: string;
+ createdBefore?: string;
deleteJobs: (jobIDs: bigint[]) => void;
initialFilters?: Filter[];
jobs: JobMinimal[];
loading?: boolean;
onFiltersChange?: (filters: Filter[]) => void;
+ onSortOrderChange?: (sortOrder: "asc" | "desc" | undefined) => void;
+ onTimeRangeChange?: (
+ createdAfter: string | undefined,
+ createdBefore: string | undefined,
+ ) => void;
retryJobs: (jobIDs: bigint[]) => void;
setJobRefetchesPaused: (value: boolean) => void;
showFewer: () => void;
showMore: () => void;
+ sortOrder?: "asc" | "desc";
state: JobState;
statesAndCounts: StatesAndCounts | undefined;
};
@@ -284,19 +304,206 @@ function JobListActionButtons({
);
}
+const formatDateTimeLocal = (isoString: string | undefined): string => {
+ if (!isoString) return "";
+ try {
+ const d = new Date(isoString);
+ // Format as YYYY-MM-DDTHH:MM for datetime-local input
+ const year = d.getFullYear();
+ const month = String(d.getMonth() + 1).padStart(2, "0");
+ const day = String(d.getDate()).padStart(2, "0");
+ const hours = String(d.getHours()).padStart(2, "0");
+ const minutes = String(d.getMinutes()).padStart(2, "0");
+ return `${year.toString()}-${month}-${day}T${hours}:${minutes}`;
+ } catch {
+ return "";
+ }
+};
+
+const dateTimeLocalToISO = (value: string): string | undefined => {
+ if (!value) return undefined;
+ try {
+ return new Date(value).toISOString();
+ } catch {
+ return undefined;
+ }
+};
+
+const TimeRangeFilter = ({
+ createdAfter,
+ createdBefore,
+ onTimeRangeChange,
+}: {
+ createdAfter?: string;
+ createdBefore?: string;
+ onTimeRangeChange?: (
+ createdAfter: string | undefined,
+ createdBefore: string | undefined,
+ ) => void;
+}) => {
+ const [isOpen, setIsOpen] = useState(false);
+ const hasFilter = !!createdAfter || !!createdBefore;
+
+ return (
+
+
+ {isOpen && (
+
+
+
+
+ {
+ onTimeRangeChange?.(
+ dateTimeLocalToISO(e.target.value),
+ createdBefore,
+ );
+ }}
+ type="datetime-local"
+ value={formatDateTimeLocal(createdAfter)}
+ />
+
+
+
+ {
+ onTimeRangeChange?.(
+ createdAfter,
+ dateTimeLocalToISO(e.target.value),
+ );
+ }}
+ type="datetime-local"
+ value={formatDateTimeLocal(createdBefore)}
+ />
+
+ {hasFilter && (
+
{
+ onTimeRangeChange?.(undefined, undefined);
+ setIsOpen(false);
+ }}
+ type="button"
+ >
+ Clear
+
+ )}
+
+
+ )}
+
+ );
+};
+
+const SortOrderButton = ({
+ onSortOrderChange,
+ sortOrder,
+ state,
+}: {
+ onSortOrderChange?: (sortOrder: "asc" | "desc" | undefined) => void;
+ sortOrder?: "asc" | "desc";
+ state: JobState;
+}) => {
+ // Default sort direction per state (matches backend defaults)
+ const defaultIsDesc = [
+ JobState.Cancelled,
+ JobState.Completed,
+ JobState.Discarded,
+ ].includes(state);
+ const effectiveSortOrder = sortOrder ?? (defaultIsDesc ? "desc" : "asc");
+ const isDesc = effectiveSortOrder === "desc";
+ const isCustom = sortOrder !== undefined;
+
+ const toggle = () => {
+ const newOrder = isDesc ? "asc" : "desc";
+ // If toggling back to default, clear the param
+ const defaultOrder = defaultIsDesc ? "desc" : "asc";
+ onSortOrderChange?.(newOrder === defaultOrder ? undefined : newOrder);
+ };
+
+ return (
+
+ {isDesc ? (
+
+ ) : (
+
+ )}
+
+ {isDesc ? "Newest" : "Oldest"}
+
+
+ );
+};
+
const JobRows = ({
cancelJobs,
canShowFewer,
canShowMore,
+ createdAfter,
+ createdBefore,
deleteJobs,
hideArgs,
initialFilters,
jobs,
onFiltersChange,
+ onSortOrderChange,
+ onTimeRangeChange,
retryJobs,
setJobRefetchesPaused,
showFewer,
showMore,
+ sortOrder,
state,
}: JobRowsProps) => {
const {
@@ -356,6 +563,21 @@ const JobRows = ({
onFiltersChange={onFiltersChange}
/>
+ {selectedJobs.length === 0 && (
+
+
+
+
+ )}
+
{selectedJobs.length > 0 && (
{
cancelJobs,
canShowFewer,
canShowMore,
+ createdAfter,
+ createdBefore,
deleteJobs,
initialFilters,
jobs,
loading = false,
onFiltersChange,
+ onSortOrderChange,
+ onTimeRangeChange,
retryJobs,
setJobRefetchesPaused,
showFewer,
showMore,
+ sortOrder,
state,
statesAndCounts,
} = props;
@@ -520,15 +747,20 @@ const JobList = (props: JobListProps) => {
cancelJobs={cancelJobs}
canShowFewer={canShowFewer}
canShowMore={canShowMore}
+ createdAfter={createdAfter}
+ createdBefore={createdBefore}
deleteJobs={deleteJobs}
hideArgs={hideArgs}
initialFilters={initialFilters}
jobs={jobs}
onFiltersChange={onFiltersChange}
+ onSortOrderChange={onSortOrderChange}
+ onTimeRangeChange={onTimeRangeChange}
retryJobs={retryJobs}
setJobRefetchesPaused={setJobRefetchesPaused}
showFewer={showFewer}
showMore={showMore}
+ sortOrder={sortOrder}
state={state}
/>
)}
diff --git a/src/routes/jobs/index.schema.ts b/src/routes/jobs/index.schema.ts
index f4978cf7..4b7c2ea1 100644
--- a/src/routes/jobs/index.schema.ts
+++ b/src/routes/jobs/index.schema.ts
@@ -5,14 +5,19 @@ export const minimumLimit = 20;
export const defaultLimit = 20;
export const maximumLimit = 200;
+export type SortOrder = "asc" | "desc";
+
// Search params kept when moving between job states. Other panes clear
// them when they link to Jobs.
export const retainedJobSearchKeys = [
+ "createdAfter",
+ "createdBefore",
"id",
"kind",
"limit",
"priority",
"queue",
+ "sortOrder",
"tags",
] as const;
@@ -23,6 +28,8 @@ export const defaultValues = {
};
export const jobSearchSchema = z.object({
+ createdAfter: z.string().optional(),
+ createdBefore: z.string().optional(),
id: z
.union([z.string(), z.array(z.string().min(1))])
.optional()
@@ -59,6 +66,10 @@ export const jobSearchSchema = z.object({
.union([z.string(), z.array(z.string().min(1))])
.optional()
.transform((v) => (Array.isArray(v) ? v : v ? [v] : undefined)),
+ sortOrder: z
+ .enum(["asc", "desc"])
+ .optional()
+ .transform((v) => v as SortOrder | undefined),
state: z.nativeEnum(JobState).default(defaultValues.state),
tags: z
.union([z.string(), z.array(z.string().min(1))])
diff --git a/src/routes/jobs/index.test.tsx b/src/routes/jobs/index.test.tsx
index 46a827a0..bd34f133 100644
--- a/src/routes/jobs/index.test.tsx
+++ b/src/routes/jobs/index.test.tsx
@@ -9,10 +9,18 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
type JobListHarnessProps = {
cancelJobs: (jobIDs: bigint[]) => void;
+ createdAfter?: string;
+ createdBefore?: string;
deleteJobs: (jobIDs: bigint[]) => void;
initialFilters?: Filter[];
onFiltersChange?: (filters: Filter[]) => void;
+ onSortOrderChange?: (sortOrder: "asc" | "desc" | undefined) => void;
+ onTimeRangeChange?: (
+ createdAfter: string | undefined,
+ createdBefore: string | undefined,
+ ) => void;
retryJobs: (jobIDs: bigint[]) => void;
+ sortOrder?: "asc" | "desc";
};
type NavigateOptions = {
@@ -80,21 +88,27 @@ vi.mock("@services/toast", () => ({
}));
const loaderDeps = {
+ createdAfter: "2026-01-01T00:00:00.000Z",
+ createdBefore: "2026-02-01T00:00:00.000Z",
id: undefined,
kind: ["email"],
limit: 20,
priority: [1],
queue: ["default"],
+ sortOrder: "desc" as const,
state: JobState.Running,
tags: ["customer:123", "urgent"],
};
const activeJobsKey = listJobsKey({
+ createdAfter: loaderDeps.createdAfter,
+ createdBefore: loaderDeps.createdBefore,
ids: loaderDeps.id,
kinds: loaderDeps.kind,
limit: loaderDeps.limit,
priorities: loaderDeps.priority,
queues: loaderDeps.queue,
+ sortOrder: loaderDeps.sortOrder,
state: loaderDeps.state,
tags: loaderDeps.tags,
});
@@ -189,6 +203,18 @@ describe("Jobs Route Search Schema", () => {
tags: ["customer:123", "urgent"],
});
});
+
+ it("validates sort order and time range parameters", () => {
+ expect(jobSearchSchema.parse({ sortOrder: "asc" }).sortOrder).toBe("asc");
+ expect(jobSearchSchema.parse({ sortOrder: "desc" }).sortOrder).toBe("desc");
+ expect(() => jobSearchSchema.parse({ sortOrder: "newest" })).toThrow();
+
+ const createdAfter = "2026-01-01T00:00:00.000Z";
+ const createdBefore = "2026-01-02T00:00:00.000Z";
+ expect(
+ jobSearchSchema.parse({ createdAfter, createdBefore }),
+ ).toMatchObject({ createdAfter, createdBefore });
+ });
});
describe("JobsIndexComponent", () => {
@@ -260,6 +286,40 @@ describe("JobsIndexComponent", () => {
});
});
+ it("round trips sort order and time range into route search", async () => {
+ renderJobsIndex();
+
+ await waitFor(() => expect(mockListJobs).toHaveBeenCalled());
+ const props = latestJobListProps();
+ expect(props.createdAfter).toBe(loaderDeps.createdAfter);
+ expect(props.createdBefore).toBe(loaderDeps.createdBefore);
+ expect(props.sortOrder).toBe(loaderDeps.sortOrder);
+
+ act(() => props.onSortOrderChange?.("asc"));
+ let navigateOpts = mockNavigate.mock.calls.at(-1)?.[0] as
+ NavigateOptions | undefined;
+ expect(navigateOpts?.search({ state: JobState.Running })).toMatchObject({
+ sortOrder: "asc",
+ });
+
+ act(() => props.onSortOrderChange?.(undefined));
+ navigateOpts = mockNavigate.mock.calls.at(-1)?.[0] as
+ NavigateOptions | undefined;
+ expect(navigateOpts?.search({ state: JobState.Running })).toMatchObject({
+ sortOrder: undefined,
+ });
+
+ const createdAfter = "2026-03-01T00:00:00.000Z";
+ const createdBefore = "2026-04-01T00:00:00.000Z";
+ act(() => props.onTimeRangeChange?.(createdAfter, createdBefore));
+ navigateOpts = mockNavigate.mock.calls.at(-1)?.[0] as
+ NavigateOptions | undefined;
+ expect(navigateOpts?.search({ state: JobState.Running })).toMatchObject({
+ createdAfter,
+ createdBefore,
+ });
+ });
+
it("refreshes the active tag-filtered query after mutations", async () => {
const { queryClient } = renderJobsIndex();
const invalidateQueries = vi.spyOn(queryClient, "invalidateQueries");
diff --git a/src/routes/jobs/index.tsx b/src/routes/jobs/index.tsx
index 268afbce..9fc91840 100644
--- a/src/routes/jobs/index.tsx
+++ b/src/routes/jobs/index.tsx
@@ -10,6 +10,7 @@ import {
defaultValues,
jobSearchSchema,
retainedJobSearchKeys,
+ SortOrder,
} from "@routes/jobs/index.schema";
import {
cancelJobs,
@@ -62,13 +63,27 @@ export const Route = createFileRoute("/jobs/")({
return context;
},
loaderDeps: ({
- search: { limit, state, kind, queue, priority, id, tags },
+ search: {
+ createdAfter,
+ createdBefore,
+ limit,
+ state,
+ kind,
+ queue,
+ priority,
+ id,
+ sortOrder,
+ tags,
+ },
}) => {
return {
+ createdAfter,
+ createdBefore,
kind,
limit: limit || defaultValues.limit,
priority: priority?.map((p) => parseInt(p, 10)),
queue,
+ sortOrder,
state,
id,
tags,
@@ -76,11 +91,31 @@ export const Route = createFileRoute("/jobs/")({
},
loader: async ({
context: { queryClient },
- deps: { limit, state, kind, queue, id, tags },
+ deps: {
+ createdAfter,
+ createdBefore,
+ limit,
+ state,
+ kind,
+ queue,
+ id,
+ sortOrder,
+ tags,
+ },
}) => {
await Promise.all([
queryClient.ensureQueryData({
- ...jobsQueryOptions({ limit, state, kind, queue, id, tags }),
+ ...jobsQueryOptions({
+ createdAfter,
+ createdBefore,
+ limit,
+ state,
+ kind,
+ queue,
+ id,
+ sortOrder,
+ tags,
+ }),
}),
queryClient.ensureQueryData(statesQueryOptions()),
]);
@@ -91,8 +126,18 @@ export const Route = createFileRoute("/jobs/")({
export function JobsIndexComponent() {
const navigate = Route.useNavigate();
- const { id, limit, state, kind, queue, priority, tags } =
- Route.useLoaderDeps();
+ const {
+ createdAfter,
+ createdBefore,
+ id,
+ limit,
+ state,
+ kind,
+ queue,
+ priority,
+ sortOrder,
+ tags,
+ } = Route.useLoaderDeps();
const refreshSettings = useRefreshSetting();
const refreshOptions = refreshQueryOptions(refreshSettings.intervalMs);
const [pauseRefetches, setJobRefetchesPaused] = useState(false);
@@ -101,12 +146,15 @@ export function JobsIndexComponent() {
const jobsQuery = useQuery(
jobsQueryOptions(
{
+ createdAfter,
+ createdBefore,
id,
limit,
state,
kind,
queue,
priority,
+ sortOrder,
tags,
},
{
@@ -291,12 +339,15 @@ export function JobsIndexComponent() {
});
queryClient.invalidateQueries({
queryKey: listJobsKey({
+ createdAfter,
+ createdBefore,
limit,
state,
kinds: kind,
queues: queue,
priorities: priority,
ids: id,
+ sortOrder,
tags,
}),
});
@@ -315,12 +366,15 @@ export function JobsIndexComponent() {
});
await queryClient.removeQueries({
queryKey: listJobsKey({
+ createdAfter,
+ createdBefore,
limit,
state,
kinds: kind,
queues: queue,
priorities: priority,
ids: id,
+ sortOrder,
tags,
}),
});
@@ -332,12 +386,15 @@ export function JobsIndexComponent() {
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: listJobsKey({
+ createdAfter,
+ createdBefore,
limit,
state,
kinds: kind,
queues: queue,
priorities: priority,
ids: id,
+ sortOrder,
tags,
}),
});
@@ -346,20 +403,73 @@ export function JobsIndexComponent() {
successMessage: "Jobs enqueued for retry",
});
+ const handleSortOrderChange = useCallback(
+ (newSortOrder: SortOrder | undefined) => {
+ navigate({
+ replace: true,
+ search: (old) =>
+ ({ ...old, sortOrder: newSortOrder }) as {
+ id?: string[];
+ kind?: string[];
+ limit: number;
+ priority?: string[];
+ queue?: string[];
+ sortOrder?: SortOrder;
+ state: JobState;
+ tags?: string[];
+ },
+ });
+ },
+ [navigate],
+ );
+
+ const handleTimeRangeChange = useCallback(
+ (
+ newCreatedAfter: string | undefined,
+ newCreatedBefore: string | undefined,
+ ) => {
+ navigate({
+ replace: true,
+ search: (old) =>
+ ({
+ ...old,
+ createdAfter: newCreatedAfter,
+ createdBefore: newCreatedBefore,
+ }) as {
+ createdAfter?: string;
+ createdBefore?: string;
+ id?: string[];
+ kind?: string[];
+ limit: number;
+ priority?: string[];
+ queue?: string[];
+ state: JobState;
+ tags?: string[];
+ },
+ });
+ },
+ [navigate],
+ );
+
return (
@@ -368,19 +478,25 @@ export function JobsIndexComponent() {
const jobsQueryOptions = (
{
+ createdAfter,
+ createdBefore,
id,
limit,
state,
kind,
queue,
priority,
+ sortOrder,
tags,
}: {
+ createdAfter?: string;
+ createdBefore?: string;
id?: bigint[];
kind?: string[];
limit: number;
priority?: number[];
queue?: string[];
+ sortOrder?: SortOrder;
state: JobState;
tags?: string[];
},
@@ -399,12 +515,15 @@ const jobsQueryOptions = (
};
return queryOptions({
queryKey: listJobsKey({
+ createdAfter,
+ createdBefore,
limit,
state,
kinds: kind,
queues: queue,
priorities: priority,
ids: id,
+ sortOrder,
tags,
}),
queryFn: listJobs,
diff --git a/src/services/jobs.ts b/src/services/jobs.ts
index 26d6396b..a35e8120 100644
--- a/src/services/jobs.ts
+++ b/src/services/jobs.ts
@@ -185,22 +185,28 @@ export const deleteJobs: MutationFunction = async ({
export type ListJobsKey = [
"listJobs",
{
+ createdAfter: string | undefined;
+ createdBefore: string | undefined;
ids: bigint[] | undefined;
kinds: string[] | undefined;
limit: number;
priorities: number[] | undefined;
queues: string[] | undefined;
+ sortOrder: "asc" | "desc" | undefined;
state: JobState | undefined;
tags: string[] | undefined;
},
];
type ListJobsFilters = {
+ createdAfter?: string;
+ createdBefore?: string;
ids?: bigint[];
kinds?: string[];
limit: number;
priorities?: number[];
queues?: string[];
+ sortOrder?: "asc" | "desc";
state?: JobState;
tags?: string[];
};
@@ -212,11 +218,14 @@ export const listJobsKey = (args: ListJobsFilters): ListJobsKey => {
return [
"listJobs",
{
+ createdAfter: args.createdAfter,
+ createdBefore: args.createdBefore,
ids: args.ids,
kinds: args.kinds,
limit: args.limit,
priorities: args.priorities,
queues: args.queues,
+ sortOrder: args.sortOrder,
state: args.state,
tags: args.tags,
},
@@ -227,16 +236,33 @@ export const listJobs: QueryFunction = async ({
queryKey,
signal,
}) => {
- const [, { ids, kinds, limit, priorities, queues, state, tags }] = queryKey;
+ const [
+ ,
+ {
+ createdAfter,
+ createdBefore,
+ ids,
+ kinds,
+ limit,
+ priorities,
+ queues,
+ sortOrder,
+ state,
+ tags,
+ },
+ ] = queryKey;
// Build query params object with only defined values
const params: Record = {
limit: String(limit),
};
+ if (createdAfter) params.created_after = createdAfter;
+ if (createdBefore) params.created_before = createdBefore;
if (ids?.length) params.ids = ids.map(String);
if (kinds?.length) params.kinds = kinds;
if (priorities?.length) params.priorities = priorities.map(String);
if (queues?.length) params.queues = queues;
+ if (sortOrder) params.sort_order = sortOrder;
if (state) params.state = state;
if (tags?.length) params.tags = tags;