From e0eb7bce87d8e5c25574f0eb151f5404ab398ece Mon Sep 17 00:00:00 2001 From: Lewis Buckley Date: Wed, 1 Jul 2026 15:45:00 +0100 Subject: [PATCH] Nicer uptime tooltips; make service description optional MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the plain native `title` on each uptime bar with a styled hover tooltip (a floating card matching the app's tokens: canvas surface, border, shadow, a status dot, and a caret). It now also reports minutes down on a degraded day, derived from the day's uptime fraction — e.g. "Jun 15 · 99.93% uptime · 1 min down". The bar keeps an aria-label so screen readers still get the text. Also make a service's description truly optional: read it via `try` so the public page renders even when no service defines one, and drop the sample descriptions from the dummy app to cover that path. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../stylesheets/upright/public_status.css | 70 +++++++++++++++++++ app/models/upright/service/daily_status.rb | 31 ++++---- .../upright/public/services/_service.html.erb | 4 +- .../public/services/_uptime_bar.html.erb | 10 ++- test/dummy/config/services.yml | 2 - .../upright/service/daily_status_test.rb | 43 ++++++++++++ 6 files changed, 143 insertions(+), 17 deletions(-) create mode 100644 test/models/upright/service/daily_status_test.rb diff --git a/app/assets/stylesheets/upright/public_status.css b/app/assets/stylesheets/upright/public_status.css index 660b5ae..be868bd 100644 --- a/app/assets/stylesheets/upright/public_status.css +++ b/app/assets/stylesheets/upright/public_status.css @@ -140,6 +140,7 @@ border-radius: 1px; flex: 1 1 0; min-width: 2px; + position: relative; transition: filter 100ms ease-out; } @@ -163,4 +164,73 @@ color: var(--color-ink-dark); font-weight: 500; } + + .uptime__tooltip { + background: var(--color-canvas); + border: var(--border); + border-radius: 0.5rem; + bottom: calc(100% + 0.5rem); + box-shadow: var(--shadow); + display: flex; + flex-direction: column; + font-family: var(--font-sans); + font-size: var(--text-x-small); + gap: 0.2rem; + left: 50%; + line-height: 1.35; + opacity: 0; + padding: 0.45rem 0.6rem; + pointer-events: none; + position: absolute; + transform: translate(-50%, 0.25rem); + transition: opacity 120ms ease-out, transform 120ms ease-out; + visibility: hidden; + white-space: nowrap; + z-index: 10; + } + + .uptime__bar:hover .uptime__tooltip { + opacity: 1; + transform: translate(-50%, 0); + visibility: visible; + } + + /* Caret, sitting on the tooltip's bottom edge and matching its border. */ + .uptime__tooltip::after { + background: var(--color-canvas); + border-bottom: var(--border); + border-right: var(--border); + content: ""; + height: 0.5rem; + left: 50%; + position: absolute; + top: calc(100% - 0.25rem); + transform: translateX(-50%) rotate(45deg); + width: 0.5rem; + } + + .uptime__tooltip-date { + color: var(--color-ink); + font-weight: 600; + } + + .uptime__tooltip-detail { + align-items: center; + color: var(--color-ink-medium); + display: flex; + gap: 0.4rem; + } + + .uptime__tooltip-dot { + border-radius: 50%; + flex-shrink: 0; + height: 0.5rem; + width: 0.5rem; + } + + .uptime__tooltip-dot--operational { background: var(--status-operational); } + .uptime__tooltip-dot--degraded { background: var(--status-degraded); } + .uptime__tooltip-dot--partial_outage { background: var(--status-partial); } + .uptime__tooltip-dot--major_outage { background: var(--status-major); } + .uptime__tooltip-dot--none { background: var(--status-none); } } diff --git a/app/models/upright/service/daily_status.rb b/app/models/upright/service/daily_status.rb index 89d7c37..7d23325 100644 --- a/app/models/upright/service/daily_status.rb +++ b/app/models/upright/service/daily_status.rb @@ -11,22 +11,29 @@ def operational? status == :operational end - def tooltip - "#{date_label}: #{body}" + def date_label + date == Date.current ? "Today" : date.to_fs(:month_day) end - private - def date_label - date == Date.current ? "Today" : date.to_fs(:month_day) + def detail + if uptime_fraction + [ "%.2f%% uptime" % (uptime_fraction * 100), downtime ].compact.join(" · ") + elsif status + status.to_s.humanize.downcase + else + "no data" end + end - def body - if uptime_fraction - "%.2f%% uptime" % (uptime_fraction * 100) - elsif status - status.to_s.humanize.downcase - else - "no data" + def aria_label + "#{date_label}: #{detail}" + end + + private + def downtime + if uptime_fraction && uptime_fraction < 1 + minutes = ((1 - uptime_fraction) * 24 * 60).round + minutes.zero? ? "<1 min down" : "#{minutes} #{'min'.pluralize(minutes)} down" end end end diff --git a/app/views/upright/public/services/_service.html.erb b/app/views/upright/public/services/_service.html.erb index 37f7fef..15b3511 100644 --- a/app/views/upright/public/services/_service.html.erb +++ b/app/views/upright/public/services/_service.html.erb @@ -8,8 +8,8 @@ - <% if service.description.present? %> -

<%= service.description %>

+ <% if description = service.try(:description).presence %> +

<%= description %>

<% end %>
diff --git a/app/views/upright/public/services/_uptime_bar.html.erb b/app/views/upright/public/services/_uptime_bar.html.erb index 4562c45..e1c17e2 100644 --- a/app/views/upright/public/services/_uptime_bar.html.erb +++ b/app/views/upright/public/services/_uptime_bar.html.erb @@ -1 +1,9 @@ -" title="<%= day.tooltip %>"> + + + diff --git a/test/dummy/config/services.yml b/test/dummy/config/services.yml index 34524d6..be1f281 100644 --- a/test/dummy/config/services.yml +++ b/test/dummy/config/services.yml @@ -1,9 +1,7 @@ - code: "example_app" name: "Example App" - description: "Public-facing services for the Example App" public: true - code: "internal_tools" name: "Internal Tools" - description: "Tooling used by staff" public: false diff --git a/test/models/upright/service/daily_status_test.rb b/test/models/upright/service/daily_status_test.rb new file mode 100644 index 0000000..edf5efd --- /dev/null +++ b/test/models/upright/service/daily_status_test.rb @@ -0,0 +1,43 @@ +require "test_helper" + +class Upright::Service::DailyStatusTest < ActiveSupport::TestCase + test "an operational day states uptime without a downtime clause" do + day = day_with(uptime_fraction: 1.0) + assert_equal "100.00% uptime", day.detail + end + + test "a downtime clause reports whole minutes" do + day = day_with(uptime_fraction: 1 - 5.0 / 1440) + assert_equal "99.65% uptime · 5 mins down", day.detail + end + + test "one minute is singular" do + day = day_with(uptime_fraction: 1 - 1.0 / 1440) + assert_equal "99.93% uptime · 1 min down", day.detail + end + + test "sub-minute downtime reads as less than a minute" do + day = day_with(uptime_fraction: 0.9999) + assert_equal "99.99% uptime · <1 min down", day.detail + end + + test "today carries a live status but no fraction" do + day = Upright::Service::DailyStatus.new(date: Date.current, status: :degraded) + assert_equal "Today", day.date_label + assert_equal "degraded", day.detail + end + + test "a day with no measurement reads as no data" do + assert_equal "no data", day_with.detail + end + + test "aria label combines the date and detail" do + day = day_with(uptime_fraction: 1.0) + assert_equal "#{day.date_label}: 100.00% uptime", day.aria_label + end + + private + def day_with(**attributes) + Upright::Service::DailyStatus.new(date: Date.new(2026, 6, 15), **attributes) + end +end