diff --git a/CHANGELOG.md b/CHANGELOG.md
index 33882ef..dec7b91 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
+- Configurable display timezone — `SolidQueueWeb.configure { |c| c.time_zone = "America/New_York" }` renders all dashboard timestamps in the configured zone rather than UTC; uses `in_time_zone` from ActiveSupport; defaults to UTC when not set; a `format_timestamp` helper centralises all timestamp formatting across jobs, failed jobs, history, search, queues, recurring tasks, and the scheduled-job turbo stream update
+
- Sortable table columns — Jobs, Failed Jobs, and History tables now support server-side sorting via `?sort=` and `?direction=` params; click any column header to sort ascending or descending; sort state is preserved across filter changes, status tab switches, and period buttons; a `sort_header_th` helper generates accessible `
` elements with `aria-sort` and direction indicators (↑/↓)
## [1.2.0] - 2026-05-27
diff --git a/README.md b/README.md
index b793036..9063eed 100644
--- a/README.md
+++ b/README.md
@@ -109,6 +109,7 @@ SolidQueueWeb.configure do |config|
config.alert_queue_thresholds = { "critical" => 50, "default" => 200 } # fire when queue depth >= threshold (default: {})
config.alert_webhook_cooldown = 1800 # seconds between repeated alerts per alert type (default: 3600)
config.connects_to = { reading: :reading, writing: :writing } # read replica (default: nil)
+ config.time_zone = "America/New_York" # display timezone for all timestamps (default: nil = UTC)
end
SolidQueueWeb.authenticate do
diff --git a/ROADMAP.md b/ROADMAP.md
index 4a11a0d..3466e38 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -13,7 +13,6 @@ Pull requests for any of these are welcome. See [Contributing](README.md#contrib
| Feature | Notes |
|---|---|
-| **Configurable display timezone** | `config.time_zone = "America/New_York"` — all timestamps rendered in the configured zone rather than UTC. |
| **Sticky filter preferences** | Persist last-used status/period to `localStorage` so filters survive page reloads. |
---
diff --git a/app/helpers/solid_queue_web/application_helper.rb b/app/helpers/solid_queue_web/application_helper.rb
index e4e1f2b..f8ac0d9 100644
--- a/app/helpers/solid_queue_web/application_helper.rb
+++ b/app/helpers/solid_queue_web/application_helper.rb
@@ -19,6 +19,14 @@ def inline_styles
content_tag(:style, css.html_safe)
end
+ def format_timestamp(time, format: "%Y-%m-%d %H:%M:%S")
+ return "—" unless time
+
+ tz = SolidQueueWeb.time_zone
+ displayed = tz ? time.in_time_zone(tz) : time.utc
+ displayed.strftime(format)
+ end
+
def format_duration(seconds)
s = seconds.to_i
return "< 1s" if s < 1
diff --git a/app/views/solid_queue_web/failed_jobs/index.html.erb b/app/views/solid_queue_web/failed_jobs/index.html.erb
index 3ebf2c6..3040e5d 100644
--- a/app/views/solid_queue_web/failed_jobs/index.html.erb
+++ b/app/views/solid_queue_web/failed_jobs/index.html.erb
@@ -120,7 +120,7 @@
—
<% end %>
- | <%= execution.created_at.strftime("%Y-%m-%d %H:%M:%S") %> |
+ <%= format_timestamp(execution.created_at) %> |
<%= button_to "Retry", retry_failed_job_path(execution), method: :post,
class: "sqd-btn sqd-btn--primary sqd-btn--sm" %>
diff --git a/app/views/solid_queue_web/history/index.html.erb b/app/views/solid_queue_web/history/index.html.erb
index 306f46c..bb5085b 100644
--- a/app/views/solid_queue_web/history/index.html.erb
+++ b/app/views/solid_queue_web/history/index.html.erb
@@ -58,7 +58,7 @@
class: "sqd-mono", style: "color: inherit;" %>
|
<%= format_duration(job.finished_at - job.created_at) %> |
- <%= job.finished_at.strftime("%Y-%m-%d %H:%M:%S") %> |
+ <%= format_timestamp(job.finished_at) %> |
<% end %>
diff --git a/app/views/solid_queue_web/jobs/index.html.erb b/app/views/solid_queue_web/jobs/index.html.erb
index 7390be3..a754eff 100644
--- a/app/views/solid_queue_web/jobs/index.html.erb
+++ b/app/views/solid_queue_web/jobs/index.html.erb
@@ -116,9 +116,9 @@
<%= job.priority %> |
- <%= job.scheduled_at ? job.scheduled_at.strftime("%Y-%m-%d %H:%M:%S") : "—" %>
+ <%= format_timestamp(job.scheduled_at) %>
|
- <%= job.created_at.strftime("%Y-%m-%d %H:%M:%S") %> |
+ <%= format_timestamp(job.created_at) %> |
<% if @status == "scheduled" %>
<%= button_to "Run Now", scheduled_job_path(execution),
@@ -183,9 +183,9 @@
|
<%= job.priority %> |
- <%= job.scheduled_at ? job.scheduled_at.strftime("%Y-%m-%d %H:%M:%S") : "—" %>
+ <%= format_timestamp(job.scheduled_at) %>
|
- <%= job.created_at.strftime("%Y-%m-%d %H:%M:%S") %> |
+ <%= format_timestamp(job.created_at) %> |
<% if @status == "claimed" %>
">
<%= time_ago_in_words(execution.created_at) %>
diff --git a/app/views/solid_queue_web/queues/index.html.erb b/app/views/solid_queue_web/queues/index.html.erb
index c2c620b..3abc8d8 100644
--- a/app/views/solid_queue_web/queues/index.html.erb
+++ b/app/views/solid_queue_web/queues/index.html.erb
@@ -26,7 +26,7 @@
<% if (oldest = @oldest_ready[queue.name]) %>
<% age = Time.current - oldest %>
<% latency_color = age > 86_400 ? "var(--danger)" : age > 3_600 ? "var(--warning)" : "inherit" %>
- ">
+
<%= format_duration(age) %>
<% else %>
diff --git a/app/views/solid_queue_web/queues/jobs/index.html.erb b/app/views/solid_queue_web/queues/jobs/index.html.erb
index 0fd38b9..db6d596 100644
--- a/app/views/solid_queue_web/queues/jobs/index.html.erb
+++ b/app/views/solid_queue_web/queues/jobs/index.html.erb
@@ -64,9 +64,9 @@
|
<%= job.priority %> |
- <%= job.scheduled_at ? job.scheduled_at.strftime("%Y-%m-%d %H:%M:%S") : "—" %>
+ <%= format_timestamp(job.scheduled_at) %>
|
- <%= job.created_at.strftime("%Y-%m-%d %H:%M:%S") %> |
+ <%= format_timestamp(job.created_at) %> |
<% if discardable %>
<%= button_to "Discard", queue_job_path(queue_name: @queue, id: execution),
diff --git a/app/views/solid_queue_web/recurring_tasks/index.html.erb b/app/views/solid_queue_web/recurring_tasks/index.html.erb
index 30d236f..abd78b2 100644
--- a/app/views/solid_queue_web/recurring_tasks/index.html.erb
+++ b/app/views/solid_queue_web/recurring_tasks/index.html.erb
@@ -43,7 +43,7 @@
|
<%
next_run = begin
- task.next_time.strftime("%Y-%m-%d %H:%M %Z")
+ format_timestamp(task.next_time, format: "%Y-%m-%d %H:%M")
rescue
nil
end
@@ -52,7 +52,7 @@
|
<% last_run = task.last_enqueued_time %>
- <%= last_run ? last_run.strftime("%Y-%m-%d %H:%M %Z") : "—" %>
+ <%= format_timestamp(last_run, format: "%Y-%m-%d %H:%M") %>
|
<% if task.static? %>
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
index bc870e6..7191a6c 100644
--- a/app/views/solid_queue_web/scheduled_jobs/update.turbo_stream.erb
+++ b/app/views/solid_queue_web/scheduled_jobs/update.turbo_stream.erb
@@ -3,7 +3,7 @@
<% else %>
<%= turbo_stream.replace "scheduled_at_#{@execution.id}" do %>
|
- <%= @execution.scheduled_at.strftime("%Y-%m-%d %H:%M:%S") %>
+ <%= format_timestamp(@execution.scheduled_at) %>
|
<% end %>
<% end %>
\ No newline at end of file
diff --git a/app/views/solid_queue_web/search/index.html.erb b/app/views/solid_queue_web/search/index.html.erb
index 617e722..b37c71d 100644
--- a/app/views/solid_queue_web/search/index.html.erb
+++ b/app/views/solid_queue_web/search/index.html.erb
@@ -52,7 +52,7 @@
| <%= link_to job.class_name, job_path(job), class: "sqd-table-link" %> |
<%= job.queue_name %> |
- <%= job.created_at.strftime("%Y-%m-%d %H:%M:%S") %> |
+ <%= format_timestamp(job.created_at) %> |
<% end %>
diff --git a/lib/solid_queue_web.rb b/lib/solid_queue_web.rb
index 09c19f2..ad8f95b 100644
--- a/lib/solid_queue_web.rb
+++ b/lib/solid_queue_web.rb
@@ -6,7 +6,7 @@ module SolidQueueWeb
class << self
attr_writer :page_size, :dashboard_refresh_interval, :default_refresh_interval, :search_results_limit,
:slow_job_threshold, :alert_webhook_url, :alert_failure_threshold, :alert_webhook_cooldown,
- :alert_queue_thresholds, :connects_to
+ :alert_queue_thresholds, :connects_to, :time_zone
def page_size
@page_size || 25
@@ -48,6 +48,10 @@ def connects_to
@connects_to
end
+ def time_zone
+ @time_zone
+ end
+
def configure
yield self
end
diff --git a/spec/helpers/solid_queue_web/application_helper_spec.rb b/spec/helpers/solid_queue_web/application_helper_spec.rb
index 1b473bb..fd2a097 100644
--- a/spec/helpers/solid_queue_web/application_helper_spec.rb
+++ b/spec/helpers/solid_queue_web/application_helper_spec.rb
@@ -1,6 +1,29 @@
require "rails_helper"
RSpec.describe SolidQueueWeb::ApplicationHelper, type: :helper do
+ describe "#format_timestamp" do
+ let(:time) { Time.utc(2024, 6, 15, 14, 30, 45) }
+
+ after { SolidQueueWeb.time_zone = nil }
+
+ it "returns '—' for nil" do
+ expect(helper.format_timestamp(nil)).to eq("—")
+ end
+
+ it "formats in UTC by default" do
+ expect(helper.format_timestamp(time)).to eq("2024-06-15 14:30:45")
+ end
+
+ it "formats in the configured time zone" do
+ SolidQueueWeb.time_zone = "America/New_York"
+ expect(helper.format_timestamp(time)).to eq("2024-06-15 10:30:45")
+ end
+
+ it "accepts a custom format string" do
+ expect(helper.format_timestamp(time, format: "%Y-%m-%d %H:%M")).to eq("2024-06-15 14:30")
+ end
+ end
+
describe "#format_duration" do
it "returns '< 1s' for sub-second durations" do
expect(helper.format_duration(0.4)).to eq("< 1s")