diff --git a/Gemfile.lock b/Gemfile.lock index 61e9555..2e2fe6b 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -338,7 +338,7 @@ GEM logger (>= 1.7.0) rack (>= 3.2.0) redis-client (>= 0.29.0) - ssrf_filter (1.5.0) + ssrf_filter (1.6.0) stimulus-rails (1.3.4) railties (>= 6.0.0) thor (1.5.0) @@ -542,7 +542,7 @@ CHECKSUMS ruby-vips (2.3.0) sha256=e685ec02c13969912debbd98019e50492e12989282da5f37d05f5471442f5374 securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1 sidekiq (8.1.7) sha256=91ad48c7e6c32e1846bf1d5b74c27948758049b93fed0cb539f8d586c7de3415 - ssrf_filter (1.5.0) sha256=e03dcdb9d1730d7f6710532a606b3543df2a448a0293ce04a2d995523c5a97f6 + ssrf_filter (1.6.0) sha256=78d15bc26d0385c931f46f12a9c1cbb90e6556bd96b95f25e5a5f786c8dcf999 stimulus-rails (1.3.4) sha256=765676ffa1f33af64ce026d26b48e8ffb2e0b94e0f50e9119e11d6107d67cb06 thor (1.5.0) sha256=e3a9e55fe857e44859ce104a84675ab6e8cd59c650a49106a05f55f136425e73 thruster (0.1.26) sha256=6e45e807086b29d51404841bd1ad493b67cd95892fd65dc5afcdd32e82e94ce8 diff --git a/app/assets/stylesheets/leaderboard.css b/app/assets/stylesheets/leaderboard.css new file mode 100644 index 0000000..aa9f886 --- /dev/null +++ b/app/assets/stylesheets/leaderboard.css @@ -0,0 +1,253 @@ +.leaderboard { + max-width: 1200px; +} + +.leaderboard > .analytics-note:first-of-type { + margin-top: 1rem; + max-width: 75ch; + + & code { + font-family: var(--font-mono); + font-size: 0.85em; + background: var(--clr-grey-200); + border-radius: 4px; + padding: 0.1em 0.35em; + } +} + + +.leaderboard-flag { + font-size: 1.05em; + line-height: 1; +} + + +.leaderboard-badges { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + gap: 1rem; + margin-top: 1.25rem; +} + +.leaderboard-badge { + position: relative; + border: 1px solid var(--border); + border-left: 3px solid var(--action); + border-radius: 10px; + padding: 0.9rem 1rem; + background: #fff; + + &.new_players { border-left-color: var(--analytics-positive); } + &.encouragement { border-left-color: var(--analytics-neutral); } + &.participation { border-left-color: var(--action); } +} + +.leaderboard-badge-type { + font-size: 0.7rem; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--secondary); +} + +.leaderboard-badge-message { + margin-top: 0.4rem; + font-size: 0.95rem; + line-height: 1.35; + display: flex; + align-items: baseline; + gap: 0.4rem; +} + +.leaderboard-badge-code { + position: absolute; + top: 0.9rem; + right: 1rem; + font-size: 0.7rem; + font-variant-numeric: tabular-nums; + color: var(--clr-grey-400); +} + + +.leaderboard-table { + margin-top: 1.25rem; + display: flex; + flex-direction: column; +} + +.leaderboard-row { + display: grid; + grid-template-columns: 2.5rem minmax(180px, 20rem) 1fr 5rem 6rem 8rem; + align-items: center; + gap: 0.85rem; + font-size: 0.85rem; + padding: 0.4rem 0; + border-bottom: 1px solid var(--border); + + &:hover:not(.leaderboard-head) { + background: var(--hover); + } + + &.is-idle { + opacity: 0.5; + } +} + +.leaderboard-head { + font-size: 0.72rem; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--secondary); + padding-bottom: 0.5rem; +} + +.leaderboard-rank { + font-variant-numeric: tabular-nums; + color: var(--secondary); + text-align: right; +} + +.leaderboard-country { + display: flex; + align-items: baseline; + gap: 0.45rem; + overflow: hidden; + white-space: nowrap; + + & em { + font-style: normal; + font-size: 0.72rem; + color: var(--clr-grey-400); + } +} + +.leaderboard-tag { + border: 1px solid var(--border); + border-radius: 999px; + padding: 0.05em 0.5em; +} + +.leaderboard-number { + font-variant-numeric: tabular-nums; + text-align: right; +} + +.leaderboard-head .leaderboard-number { + text-align: right; +} + + +.leaderboard-raw { + margin-top: 1.25rem; + + & summary { + cursor: pointer; + font-size: 0.85rem; + color: var(--secondary); + } + + & pre { + margin-top: 0.85rem; + max-height: 30rem; + overflow: auto; + background: var(--clr-grey-200); + border-radius: 8px; + padding: 1rem; + font-family: var(--font-mono); + font-size: 0.75rem; + line-height: 1.5; + } +} + + +.leaderboard-explainer { + margin-top: 2rem; + border: 1px solid var(--border); + border-radius: 10px; + padding: 1.25rem 1.5rem 1.5rem; + background: var(--hover); + max-width: 80ch; + + & > h3 { + font-size: 0.95rem; + font-weight: 500; + margin: 0 0 0.6rem; + } + + & > p { + font-size: 0.85rem; + line-height: 1.55; + color: var(--secondary); + margin: 0; + max-width: none; + } + + & code { + font-family: var(--font-mono); + font-size: 0.78em; + background: #fff; + border: 1px solid var(--border); + border-radius: 4px; + padding: 0.1em 0.4em; + white-space: nowrap; + } +} + +.leaderboard-steps { + margin: 1.1rem 0 0; + padding: 0; + list-style: none; + counter-reset: leaderboard-step; + display: flex; + flex-direction: column; + gap: 0.9rem; + + & > li { + counter-increment: leaderboard-step; + display: grid; + grid-template-columns: 1.5rem 1fr; + gap: 0.15rem 0.75rem; + align-items: baseline; + + &::before { + content: counter(leaderboard-step); + grid-row: span 2; + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.5rem; + aspect-ratio: 1; + border-radius: 50%; + background: var(--clr-grey-200); + font-size: 0.72rem; + font-variant-numeric: tabular-nums; + align-self: start; + } + } + + & strong { + font-size: 0.85rem; + font-weight: 500; + } + + & span { + font-size: 0.82rem; + line-height: 1.6; + color: var(--secondary); + } +} + +.leaderboard-worked { + margin-top: 1.25rem !important; + padding-top: 1rem; + border-top: 1px solid var(--border); + color: var(--clr-black) !important; + + & strong { + font-weight: 500; + } +} + +.leaderboard-explainer > .analytics-note { + margin-top: 1rem; + font-size: 0.8rem; +} diff --git a/app/controllers/admin/leaderboard_controller.rb b/app/controllers/admin/leaderboard_controller.rb new file mode 100644 index 0000000..f1949f0 --- /dev/null +++ b/app/controllers/admin/leaderboard_controller.rb @@ -0,0 +1,30 @@ +# Shows admins the leaderboard exactly as the API publishes it. +# +# The endpoint serves a cached payload, so this reads that same cache entry +# rather than recomputing -- otherwise the screen would show numbers nobody is +# actually being served. When the cache has expired the screen falls back to a +# preview of what the next API call will publish, and says so. +class Admin::LeaderboardController < Admin::AdminController + + # GET /admin/:locale/leaderboard + def index + @published = LeaderboardPayload.published + @live = @published.nil? + @payload = @published || LeaderboardPayload.build + + @leaderboard = @payload[:leaderboard] + @badges = @payload[:badges] + @updated_at = @payload[:updated_at] + end + + + # DELETE /admin/:locale/leaderboard/cache + # + # Drops the published payload so the next API call recomputes. Handy when + # headcounts or weights change and you don't want to wait out the TTL. + def expire + LeaderboardPayload.expire! + + redirect_to admin_leaderboard_path, notice: t(:leaderboard_expired, scope: 'utils') + end +end diff --git a/app/controllers/api/v1/leaderboard_controller.rb b/app/controllers/api/v1/leaderboard_controller.rb index d43a176..d67ab4b 100644 --- a/app/controllers/api/v1/leaderboard_controller.rb +++ b/app/controllers/api/v1/leaderboard_controller.rb @@ -9,15 +9,7 @@ class Api::V1::LeaderboardController < ApplicationController def index request.format = :json - data = Rails.cache.fetch("api/v1/leaderboard", expires_in: 1.minute) do - DemoActivity.simulate! # TEMP: fake gameplay so the banner has moving numbers - leaderboard = LeaderboardScore.call - { - leaderboard: leaderboard, - badges: LeaderboardBadges.call(leaderboard), - updated_at: Time.current - } - end + data = LeaderboardPayload.fetch @leaderboard = data[:leaderboard] @badges = data[:badges] diff --git a/app/helpers/admin/leaderboard_helper.rb b/app/helpers/admin/leaderboard_helper.rb new file mode 100644 index 0000000..d37cb4f --- /dev/null +++ b/app/helpers/admin/leaderboard_helper.rb @@ -0,0 +1,37 @@ +module Admin::LeaderboardHelper + + # Turns "SE" into the flag emoji by mapping each letter onto its regional + # indicator symbol. Cheaper than shipping 34 flag SVGs for one admin screen, + # and it degrades to the two letters wherever the font has no flag. + def country_flag(country_code) + code = country_code.to_s.upcase + return "" unless code.match?(/\A[A-Z]{2}\z/) + + code.codepoints.map { |c| (c - 65 + 0x1F1E6) }.pack("U*") + end + + + def leaderboard_country_name(country_code) + t("countries.#{country_code.to_s.downcase}", default: country_code.to_s.upcase) + end + + + # The badge types the API emits, named the way the brief describes them. + def badge_type_label(type) + { + "new_players" => "New players", + "encouragement" => "Encouragement", + "participation" => "Participation" + }[type.to_s] || type.to_s.humanize + end + + + # How long the published payload still has before the next API call + # recomputes it. The cache entry carries no expiry we can read back, so this + # is derived from when it was built. + def leaderboard_cache_age(updated_at) + return nil if updated_at.blank? + + Time.current - updated_at + end +end diff --git a/app/services/leaderboard_payload.rb b/app/services/leaderboard_payload.rb new file mode 100644 index 0000000..8b24c3c --- /dev/null +++ b/app/services/leaderboard_payload.rb @@ -0,0 +1,45 @@ +# The leaderboard payload as published by Api::V1::LeaderboardController. +# +# The API serves this out of a short cache, so what a consumer of the endpoint +# sees is whatever landed in `CACHE_KEY` on the last refresh -- not a live read +# of the players table. The admin screen reads that same cached entry, which is +# why the key, the TTL and the shape all live here rather than in the +# controller: one place publishes, both places agree on what "published" means. +class LeaderboardPayload + CACHE_KEY = "api/v1/leaderboard".freeze + TTL = 1.minute + + # What the API returns. Recomputes and republishes when the cache is cold. + def self.fetch + Rails.cache.fetch(CACHE_KEY, expires_in: TTL) do + DemoActivity.simulate! # TEMP: fake gameplay so the banner has moving numbers + build + end + end + + + # The currently published payload, or nil when the cache has expired and no + # one has called the endpoint since. Never computes, never publishes -- the + # admin screen uses this to show what consumers are actually being served. + def self.published + Rails.cache.read(CACHE_KEY) + end + + + # A fresh payload that is NOT written to the cache and does not invent demo + # players. This is what the next API call would publish. + def self.build + leaderboard = LeaderboardScore.call + + { + leaderboard: leaderboard, + badges: LeaderboardBadges.call(leaderboard), + updated_at: Time.current + } + end + + + def self.expire! + Rails.cache.delete(CACHE_KEY) + end +end diff --git a/app/views/admin/leaderboard/index.html.erb b/app/views/admin/leaderboard/index.html.erb new file mode 100644 index 0000000..037bb10 --- /dev/null +++ b/app/views/admin/leaderboard/index.html.erb @@ -0,0 +1,242 @@ +<%= content_for :title, "Leaderboard" %> + +<%= turbo_frame_tag 'main' do %> + + <%= turbo_stream.append 'flash', partial: 'layouts/flash' %> + +
+ Demo data is on.
+ DemoActivity invents players on every cache refresh, so the tomato counts
+ below are fake. Set DemoActivity::ENABLED = false and wipe the players table
+ before go-live.
+
+ This is the payload served by GET /api/v1/leaderboard — the banner reads it,
+ not the players table directly. The endpoint caches for
+ <%= distance_of_time_in_words(LeaderboardPayload::TTL) %>, so these are the numbers
+ consumers are actually being served right now.
+
+ Nothing is published at the moment. + The cache has expired and no one has called the endpoint since. Below is a preview of + what the next call will publish — badges are sampled per refresh, so the ones that go + out will be a different pick. +
+ <% end %> + +No badges in this payload.
+ <% else %> ++ Score blends total tomatoes against tomatoes per employee, so a big country cannot win on + headcount alone. Countries with no headcount on file fall back to their impact score + rather than being penalised — those rows are marked. +
+ + <% max_score = @leaderboard.map { |row| row[:score].to_i }.max.to_i %> + ++ A country's score is a blend of two numbers, so a big country cannot win on headcount + alone and a small one cannot win on a single enthusiastic office. +
+ +tomatoes ÷ highest tomato count × 100(tomatoes ÷ employees) ÷ highest ratio × 100config/country_headcounts.yml. This is
+ the “what share of you turned up” axis, so a 60-person country can beat
+ a 19,000-person one.
+
+ impact × <%= LeaderboardScore::IMPACT_WEIGHT %> + efficiency × <%= LeaderboardScore::EFFICIENCY_WEIGHT %>,
+ rounded to a whole number. Change the split in
+ LeaderboardScore::IMPACT_WEIGHT — raising efficiency leans further
+ toward fairness across team sizes.
+
+ + Right now, <%= country_flag(example[:country_code]) %> + <%= leaderboard_country_name(example[:country_code]) %>: + <%= number_with_delimiter(example[:tomatoes]) %> + <%= "tomato".pluralize(example[:tomatoes]) %> + against a best of <%= number_with_delimiter(max_tomatoes.to_i) %> gives an impact of + <%= impact.round(1) %>; + <% if example[:per_employee] %> + <%= example[:per_employee] %> per employee against a best of <%= max_per_emp.round(2) %> + gives an efficiency of <%= efficiency.round(1) %>. + <% else %> + with no headcount on file, efficiency falls back to the impact score + (<%= efficiency.round(1) %>). + <% end %> + Blended, that lands on <%= example[:score] %>. +
+ <% end %> + ++ Two details worth knowing: every country we hold a name for is listed even with zero + tomatoes, so the banner can always name anyone; and ties are broken alphabetically by + country code, not randomly, so the order is stable between refreshes. +
+<%= JSON.pretty_generate( + leaderboard: @leaderboard, + badges: @badges, + updated_at: @updated_at&.iso8601 + ) %>+