diff --git a/CHANGELOG.md b/CHANGELOG.md
index 349068c..8f584d1 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
+
+- Scheduled job management — "Run Now" promotes a scheduled job to run immediately by back-dating its `scheduled_at`; "+1h", "+24h", and "+7d" buttons push `scheduled_at` forward by the chosen offset; both actions update the execution and the underlying job record; Turbo Stream responses remove the row on "Run Now" and update the `scheduled_at` cell in place on postpone
+
## [0.9.0] - 2026-05-20
### Added
diff --git a/README.md b/README.md
index de8efd9..034e2d4 100644
--- a/README.md
+++ b/README.md
@@ -36,6 +36,7 @@ SolidQueueWeb surfaces all of this in a browser UI available at any route you ch
- **Dashboard** — stat cards showing counts for ready, scheduled, running, blocked, and failed jobs, plus queues, recurring tasks, and processes; "Done (1h)" and "Done (24h)" throughput cards; a "Throughput — Last 12 Hours" bar chart (blue) and a "Queue Depth — Last 12 Hours" bar chart (purple) showing hourly snapshots of active job count; pure CSS, no charting library; auto-refreshes every 5 seconds
- **Queues** — all queues sorted by name with size; oldest ready job latency (color-coded, with UTC timestamp tooltip); Done (24h) and Failed (24h) throughput counts; a mini 12-bar failure rate sparkline per queue showing failure % per hour over the last 12 hours; pause/resume controls
- **Jobs** — filterable by status (ready, scheduled, claimed, blocked, failed) and by queue; search by job class name with dynamic auto-submit; time-based period filter (1 h / 24 h / 7 d); discard individual or all jobs; Turbo Frame navigation so only the table updates on filter or search; auto-refreshes every 10 seconds
+- **Scheduled job management** — reschedule a scheduled job to run immediately ("Run Now") or push its `scheduled_at` forward by 1 h, 24 h, or 7 d; Turbo Stream responses update the row in place
- **Failed jobs** — list of failed executions with error details; search by class name; filter by queue; time-based period filter; retry or discard individually or in bulk
- **Job detail** — full arguments, timestamps, blocked-until date, and error backtrace; action buttons based on job status
- **Queue management** — pause and resume individual queues; queue-scoped job list with status filter, search, and discard
diff --git a/app/controllers/solid_queue_web/scheduled_jobs_controller.rb b/app/controllers/solid_queue_web/scheduled_jobs_controller.rb
new file mode 100644
index 0000000..b48dc5e
--- /dev/null
+++ b/app/controllers/solid_queue_web/scheduled_jobs_controller.rb
@@ -0,0 +1,34 @@
+module SolidQueueWeb
+ class ScheduledJobsController < ApplicationController
+ OFFSETS = { "1h" => 1.hour, "24h" => 24.hours, "7d" => 7.days }.freeze
+
+ def update
+ @execution = SolidQueue::ScheduledExecution.find(params[:id])
+ @period = params[:period].presence_in(PERIOD_DURATIONS.keys)
+ @run_now = params[:offset] == "now"
+
+ new_time = if @run_now
+ 1.second.ago
+ elsif OFFSETS.key?(params[:offset])
+ @execution.scheduled_at + OFFSETS[params[:offset]]
+ else
+ raise ArgumentError, "Invalid offset."
+ end
+
+ @execution.update!(scheduled_at: new_time)
+ @execution.job.update!(scheduled_at: new_time)
+
+ respond_to do |format|
+ format.turbo_stream
+ format.html do
+ notice = @run_now ? "Job scheduled to run immediately." : "Job rescheduled by +#{params[:offset]}."
+ redirect_to jobs_path(status: "scheduled", period: @period), notice: notice
+ end
+ end
+ rescue ArgumentError => e
+ redirect_to jobs_path(status: "scheduled"), alert: e.message
+ rescue => e
+ redirect_to jobs_path(status: "scheduled"), alert: "Could not reschedule job: #{e.message}"
+ end
+ end
+end
diff --git a/app/views/solid_queue_web/jobs/index.html.erb b/app/views/solid_queue_web/jobs/index.html.erb
index e8e1967..e9c8f43 100644
--- a/app/views/solid_queue_web/jobs/index.html.erb
+++ b/app/views/solid_queue_web/jobs/index.html.erb
@@ -94,11 +94,24 @@
class: "sqd-mono", style: "color: inherit;" %>
<%= job.priority %> |
-
+ |
<%= job.scheduled_at ? job.scheduled_at.strftime("%Y-%m-%d %H:%M:%S") : "—" %>
|
<%= job.created_at.strftime("%Y-%m-%d %H:%M:%S") %> |
+ <% if @status == "scheduled" %>
+ <%= button_to "Run Now", scheduled_job_path(execution),
+ method: :patch,
+ params: { offset: "now", period: @period },
+ class: "sqd-btn sqd-btn--primary sqd-btn--sm",
+ data: { confirm: "Run this job immediately?" } %>
+ <% %w[1h 24h 7d].each do |offset| %>
+ <%= button_to "+#{offset}", scheduled_job_path(execution),
+ method: :patch,
+ params: { offset: offset, period: @period },
+ class: "sqd-btn sqd-btn--muted sqd-btn--sm" %>
+ <% end %>
+ <% end %>
<%= button_to "Discard", job_path(execution),
method: :delete,
params: { status: @status, period: @period },
diff --git a/app/views/solid_queue_web/scheduled_jobs/update.turbo_stream.erb b/app/views/solid_queue_web/scheduled_jobs/update.turbo_stream.erb
new file mode 100644
index 0000000..bc870e6
--- /dev/null
+++ b/app/views/solid_queue_web/scheduled_jobs/update.turbo_stream.erb
@@ -0,0 +1,9 @@
+<% if @run_now %>
+ <%= turbo_stream.remove "execution_#{@execution.id}" %>
+<% else %>
+ <%= turbo_stream.replace "scheduled_at_#{@execution.id}" do %>
+ |
+ <%= @execution.scheduled_at.strftime("%Y-%m-%d %H:%M:%S") %>
+ |
+ <% end %>
+<% end %>
\ No newline at end of file
diff --git a/config/routes.rb b/config/routes.rb
index a78c2a1..325b428 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -18,6 +18,8 @@
# Singular selection resources must be defined before the member routes of their
# parent resources, otherwise DELETE /list/selection matches /list/:id first.
+ resources :scheduled_jobs, only: [:update]
+
resource :job_selection, path: "list/selection", only: [:destroy], controller: "jobs/selections"
resources :jobs, path: "list", only: [:index, :show, :destroy] do
collection do
diff --git a/spec/requests/solid_queue_web/scheduled_jobs_spec.rb b/spec/requests/solid_queue_web/scheduled_jobs_spec.rb
new file mode 100644
index 0000000..3a0960e
--- /dev/null
+++ b/spec/requests/solid_queue_web/scheduled_jobs_spec.rb
@@ -0,0 +1,110 @@
+require "rails_helper"
+
+RSpec.describe "ScheduledJobs", type: :request do
+ let!(:job) do
+ SolidQueue::Job.create!(
+ queue_name: "default",
+ class_name: "TestJob",
+ arguments: {},
+ active_job_id: SecureRandom.uuid,
+ scheduled_at: 2.hours.from_now
+ )
+ end
+
+ let(:execution) { job.scheduled_execution }
+
+ describe "PATCH /scheduled_jobs/:id" do
+ context "with offset=now" do
+ it "sets scheduled_at to the past and redirects" do
+ patch "/jobs/scheduled_jobs/#{execution.id}", params: { offset: "now" }
+ expect(response).to redirect_to("/jobs/list?status=scheduled")
+ execution.reload
+ expect(execution.scheduled_at).to be <= Time.current
+ end
+
+ it "also updates the job's scheduled_at" do
+ patch "/jobs/scheduled_jobs/#{execution.id}", params: { offset: "now" }
+ job.reload
+ expect(job.scheduled_at).to be <= Time.current
+ end
+
+ it "removes the row via turbo stream" do
+ patch "/jobs/scheduled_jobs/#{execution.id}",
+ params: { offset: "now" },
+ headers: { "Accept" => "text/vnd.turbo-stream.html, text/html" }
+ expect(response.content_type).to include("text/vnd.turbo-stream.html")
+ expect(response.body).to include("execution_#{execution.id}")
+ expect(response.body).to include("remove")
+ end
+ end
+
+ context "with offset=1h" do
+ it "postpones scheduled_at by 1 hour and redirects" do
+ original = execution.scheduled_at
+ patch "/jobs/scheduled_jobs/#{execution.id}", params: { offset: "1h" }
+ expect(response).to redirect_to("/jobs/list?status=scheduled")
+ execution.reload
+ expect(execution.scheduled_at).to be_within(5.seconds).of(original + 1.hour)
+ end
+
+ it "updates the cell via turbo stream" do
+ patch "/jobs/scheduled_jobs/#{execution.id}",
+ params: { offset: "1h" },
+ headers: { "Accept" => "text/vnd.turbo-stream.html, text/html" }
+ expect(response.content_type).to include("text/vnd.turbo-stream.html")
+ expect(response.body).to include("scheduled_at_#{execution.id}")
+ expect(response.body).to include("replace")
+ end
+ end
+
+ context "with offset=24h" do
+ it "postpones scheduled_at by 24 hours" do
+ original = execution.scheduled_at
+ patch "/jobs/scheduled_jobs/#{execution.id}", params: { offset: "24h" }
+ execution.reload
+ expect(execution.scheduled_at).to be_within(5.seconds).of(original + 24.hours)
+ end
+ end
+
+ context "with offset=7d" do
+ it "postpones scheduled_at by 7 days" do
+ original = execution.scheduled_at
+ patch "/jobs/scheduled_jobs/#{execution.id}", params: { offset: "7d" }
+ execution.reload
+ expect(execution.scheduled_at).to be_within(5.seconds).of(original + 7.days)
+ end
+ end
+
+ context "with an invalid offset" do
+ it "redirects with an alert" do
+ patch "/jobs/scheduled_jobs/#{execution.id}", params: { offset: "bogus" }
+ expect(response).to redirect_to("/jobs/list?status=scheduled")
+ follow_redirect!
+ expect(response.body).to include("Invalid offset")
+ end
+
+ it "does not modify scheduled_at" do
+ original = execution.scheduled_at
+ patch "/jobs/scheduled_jobs/#{execution.id}", params: { offset: "bogus" }
+ execution.reload
+ expect(execution.scheduled_at).to be_within(1.second).of(original)
+ end
+ end
+
+ context "with a missing execution" do
+ it "redirects with an alert" do
+ patch "/jobs/scheduled_jobs/0", params: { offset: "now" }
+ expect(response).to redirect_to("/jobs/list?status=scheduled")
+ follow_redirect!
+ expect(response.body).to include("Could not reschedule job")
+ end
+ end
+
+ context "when period param is present" do
+ it "preserves period in the redirect" do
+ patch "/jobs/scheduled_jobs/#{execution.id}", params: { offset: "now", period: "24h" }
+ expect(response).to redirect_to("/jobs/list?period=24h&status=scheduled")
+ end
+ end
+ end
+end