Show device timestamps in the reader's local time
CI / Test (pull_request) Successful in 48s
Release Drafter / Update release notes draft (pull_request) Successful in 3s

The exact issuance and expiry times in the device rows' hover titles were
rendered in UTC, which reads as wrong to anyone not living there.

- Mark the dates with a <time> element carrying the absolute (UTC) timestamp,
  rather than a custom data attribute whose name claimed to hold local time
- Rewrite those titles in the browser's own zone, keeping the server's UTC
  rendering as the fallback for when JavaScript is unavailable
- Only convert values that carry an explicit offset, so a date-only <time> is
  not read as UTC midnight and shifted onto the previous day
- Give the revoke button a little more room from the device name and dates

Part of #254
This commit is contained in:
raucao committed 2026-11-03 18:28:14 +04:00
1 parent faa7bf6146
commit 17bad40bf4
6 files changed
+68 -11

No files matched your search

@@ -18,16 +18,19 @@
</h5>
<ul class="divide-y divide-gray-100 rounded-lg border border-gray-200" data-turbo-prefetch="false">
<% @authorizations.each do |auth| %>
<li id="<%= rs_device_dom_id(auth) %>" class="flex items-center justify-between gap-4 px-4 py-3">
<li id="<%= rs_device_dom_id(auth) %>" class="flex items-center justify-between gap-6 px-4 py-3">
<div class="min-w-0">
<p class="mb-1 truncate text-base font-medium"><%= auth.device_label %></p>
<p class="truncate text-xs text-gray-500">
<%= tag.span "Connected #{auth.created_at.strftime('%-d %b %Y')}",
title: connected_at_title(auth) %>
Connected <%= timestamp_tag(auth.created_at, title: connected_at_title(auth)) %>
<span class="mx-1">&middot;</span>
<%= tag.span status_label(auth),
title: (expires_at_title(auth) if auth.expire_at.present?),
class: ("text-red-700" if auth.expired?) %>
<%= tag.span class: ("text-red-700" if auth.expired?) do %>
<% if auth.expire_at.present? %>
<%= expiry_prefix(auth) %> <%= timestamp_tag(auth.expire_at, title: expires_at_title(auth)) %>
<% else %>
Never expires
<% end %>
<% end %>
</p>
</div>
<%= button_to "Revoke access", services_storage_rs_auth_path(auth), method: :delete,
@@ -9,13 +9,15 @@ class RsAuthDetailsModalComponent < ViewComponent::Base
@web_app = @auth.web_app
end
def status_label(auth)
return "Never expires" if auth.expire_at.blank?
return "Expired on #{auth.expire_at.strftime('%-d %b %Y')}" if auth.expired?
# "Expires" / "Expired on", or nothing when there is no expiry to describe.
def expiry_prefix(auth)
return nil if auth.expire_at.blank?
"Expires #{auth.expire_at.strftime('%-d %b %Y')}"
auth.expired? ? "Expired on" : "Expires"
end
# Fallbacks for the hover titles, used when JavaScript is unavailable.
# app/javascript/local_time.js replaces them with the reader's own zone.
def connected_at_title(auth)
auth.created_at.strftime("%-d %b %Y, %H:%M")
end
@@ -23,4 +25,10 @@ class RsAuthDetailsModalComponent < ViewComponent::Base
def expires_at_title(auth)
auth.expire_at.strftime("%-d %b %Y, %H:%M")
end
# A <time> element carrying the absolute (UTC) timestamp, so the reader's own
# local time can be shown alongside the date.
def timestamp_tag(time, title:)
tag.time time.strftime("%-d %b %Y"), datetime: time.utc.iso8601, title: title
end
end
+2 -1
View File
@@ -1,4 +1,5 @@
// Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails
import "@hotwired/turbo-rails"
import "controllers"
import "turbo_stream_animations"
import "turbo_stream_animations"
import "local_time"
+32
View File
@@ -0,0 +1,32 @@
// Timestamps are rendered by the server in UTC, next to a <time> element's
// machine-readable value. Rewrite those elements' hover titles so they show the
// reader's own local time instead.
const LOCAL_TIME_FORMAT = new Intl.DateTimeFormat("en-GB", {
day: "numeric",
month: "short",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
hourCycle: "h23",
})
// Only absolute timestamps carry a zone to convert from: a date-only value would
// be read as UTC midnight and shift to the previous day for most readers.
const ABSOLUTE_TIMESTAMP = /T\d{2}:\d{2}(:\d{2})?(\.\d+)?(Z|[+-]\d{2}:\d{2})$/
const localizeTimestamps = () => {
document.querySelectorAll("time[datetime]").forEach((element) => {
const value = element.getAttribute("datetime")
if (!ABSOLUTE_TIMESTAMP.test(value)) return
const timestamp = new Date(value)
if (!Number.isNaN(timestamp.getTime())) {
element.title = LOCAL_TIME_FORMAT.format(timestamp)
}
})
}
document.addEventListener("turbo:load", localizeTimestamps)
+1
View File
@@ -5,5 +5,6 @@ pin "@hotwired/turbo-rails", to: "turbo.min.js", preload: true
pin "@hotwired/stimulus", to: "stimulus.min.js", preload: true
pin "@hotwired/stimulus-loading", to: "stimulus-loading.js", preload: true
pin "turbo_stream_animations"
pin "local_time"
pin_all_from "app/javascript/controllers", under: "controllers"
pin "tailwindcss-stimulus-components" # @4.0.3
+12
View File
@@ -84,6 +84,18 @@ RSpec.describe "Storage connected apps", type: :feature do
"li##{ActionView::RecordIdentifier.dom_id(auth, :device)}", visible: false
)
end
it "marks the device timestamps with machine-readable UTC times" do
visit apps_services_storage_path
timestamps = page.all("time[datetime]", visible: false)
.map { |element| Time.iso8601(element["datetime"]) }
expect(timestamps).to contain_exactly(
be_within(1.second).of(auth.created_at),
be_within(1.second).of(auth.expire_at)
)
end
end
context "with multiple apps" do