Show device timestamps in the reader's local time
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:
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">·</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
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user