Mattias Bodlund 1 week ago
parent
commit
5cc24ca943
10 changed files with 618 additions and 13 deletions
  1. +2
    -2
      Gemfile.lock
  2. +253
    -0
      app/assets/stylesheets/leaderboard.css
  3. +30
    -0
      app/controllers/admin/leaderboard_controller.rb
  4. +1
    -9
      app/controllers/api/v1/leaderboard_controller.rb
  5. +37
    -0
      app/helpers/admin/leaderboard_helper.rb
  6. +45
    -0
      app/services/leaderboard_payload.rb
  7. +242
    -0
      app/views/admin/leaderboard/index.html.erb
  8. +2
    -2
      app/views/layouts/admin.html.erb
  9. +2
    -0
      config/locales/en.yml
  10. +4
    -0
      config/routes.rb

+ 2
- 2
Gemfile.lock View File

@ -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


+ 253
- 0
app/assets/stylesheets/leaderboard.css View File

@ -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;
}

+ 30
- 0
app/controllers/admin/leaderboard_controller.rb View File

@ -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

+ 1
- 9
app/controllers/api/v1/leaderboard_controller.rb View File

@ -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]


+ 37
- 0
app/helpers/admin/leaderboard_helper.rb View File

@ -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

+ 45
- 0
app/services/leaderboard_payload.rb View File

@ -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

+ 242
- 0
app/views/admin/leaderboard/index.html.erb View File

@ -0,0 +1,242 @@
<%= content_for :title, "Leaderboard" %>
<%= turbo_frame_tag 'main' do %>
<%= turbo_stream.append 'flash', partial: 'layouts/flash' %>
<div class="list-title">
<h1><%= yield(:title) %></h1>
<%= link_to "Clear cache",
admin_leaderboard_cache_path,
class: "analytics-period",
data: { turbo_method: :delete, turbo_frame: "main" } %>
</div>
<div class="analytics leaderboard">
<% if DemoActivity::ENABLED %>
<p class="analytics-warning">
<strong>Demo data is on.</strong>
<code>DemoActivity</code> invents players on every cache refresh, so the tomato counts
below are fake. Set <code>DemoActivity::ENABLED = false</code> and wipe the players table
before go-live.
</p>
<% end %>
<p class="analytics-note">
This is the payload served by <code>GET /api/v1/leaderboard</code> — 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.
</p>
<% if @live %>
<p class="analytics-warning">
<strong>Nothing is published at the moment.</strong>
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.
</p>
<% end %>
<section class="analytics-cards">
<%= render "admin/analytics/card",
label: "Tomatoes published",
value: number_with_delimiter(@leaderboard.sum { |row| row[:tomatoes].to_i }),
sub: "finished play sessions" %>
<%= render "admin/analytics/card",
label: "Countries scoring",
value: @leaderboard.count { |row| row[:tomatoes].to_i.positive? },
sub: "of #{@leaderboard.size} listed" %>
<% leader = @leaderboard.first %>
<%= render "admin/analytics/card",
label: "Leading",
value: leader ? "#{country_flag(leader[:country_code])} #{leader[:country_code]}" : "—",
sub: leader ? "#{leader[:score]} pts · #{leaderboard_country_name(leader[:country_code])}" : nil %>
<%= render "admin/analytics/card",
label: @live ? "Computed" : "Published",
value: @updated_at ? "#{time_ago_in_words(@updated_at)} ago" : "—",
sub: @updated_at&.strftime("%-d %b %Y, %H:%M:%S %Z") %>
</section>
<section class="analytics-panel">
<h2>
Badges
<small>rotating banner messages, resampled on every refresh</small>
</h2>
<% if @badges.blank? %>
<p class="analytics-empty">No badges in this payload.</p>
<% else %>
<div class="leaderboard-badges">
<% @badges.each do |badge| %>
<div class="leaderboard-badge <%= badge[:type] %>">
<div class="leaderboard-badge-type"><%= badge_type_label(badge[:type]) %></div>
<div class="leaderboard-badge-message">
<span class="leaderboard-flag"><%= country_flag(badge[:country_code]) %></span>
<%= badge[:message] %>
</div>
<div class="leaderboard-badge-code"><%= badge[:country_code] %></div>
</div>
<% end %>
</div>
<% end %>
</section>
<section class="analytics-panel">
<h2>
Ranking
<small>
<%= (LeaderboardScore::IMPACT_WEIGHT * 100).round %>% impact ·
<%= (LeaderboardScore::EFFICIENCY_WEIGHT * 100).round %>% per-employee efficiency
</small>
</h2>
<p class="analytics-note">
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.
</p>
<% max_score = @leaderboard.map { |row| row[:score].to_i }.max.to_i %>
<div class="leaderboard-table">
<div class="leaderboard-row leaderboard-head">
<div class="leaderboard-rank">#</div>
<div class="leaderboard-country">Country</div>
<div class="leaderboard-track"></div>
<div class="leaderboard-number">Score</div>
<div class="leaderboard-number">Tomatoes</div>
<div class="leaderboard-number">Per employee</div>
</div>
<% @leaderboard.each_with_index do |row, index| %>
<div class="leaderboard-row <%= "is-idle" if row[:tomatoes].to_i.zero? %>">
<div class="leaderboard-rank"><%= index + 1 %></div>
<div class="leaderboard-country">
<span class="leaderboard-flag"><%= country_flag(row[:country_code]) %></span>
<%= leaderboard_country_name(row[:country_code]) %>
<em><%= row[:country_code] %></em>
<% if row[:per_employee].nil? %>
<em class="leaderboard-tag">no headcount</em>
<% end %>
</div>
<div class="leaderboard-track">
<div class="analytics-bar-track">
<div class="analytics-bar-fill"
style="width: <%= max_score.positive? ? (row[:score].to_i * 100.0 / max_score).round(1) : 0 %>%"></div>
</div>
</div>
<div class="leaderboard-number"><strong><%= row[:score] %></strong></div>
<div class="leaderboard-number"><%= number_with_delimiter(row[:tomatoes]) %></div>
<div class="leaderboard-number"><%= row[:per_employee] || "—" %></div>
</div>
<% end %>
</div>
<div class="leaderboard-explainer">
<h3>How the score is calculated</h3>
<p>
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.
</p>
<ol class="leaderboard-steps">
<li>
<strong>Tomatoes</strong>
<span>
How many people in that country finished a game. Only completed sessions count,
and only players tagged with a country we know about.
</span>
</li>
<li>
<strong>Impact index — raw volume</strong>
<span>
<code>tomatoes ÷ highest tomato count × 100</code><br>
The busiest country gets 100, everyone else is scored relative to it. This is the
“how much did you actually do” axis.
</span>
</li>
<li>
<strong>Efficiency — participation rate</strong>
<span>
<code>(tomatoes ÷ employees) ÷ highest ratio × 100</code><br>
Employee headcounts come from <code>config/country_headcounts.yml</code>. This is
the “what share of you turned up” axis, so a 60-person country can beat
a 19,000-person one.
</span>
</li>
<li>
<strong>Blend</strong>
<span>
<code>impact × <%= LeaderboardScore::IMPACT_WEIGHT %> + efficiency × <%= LeaderboardScore::EFFICIENCY_WEIGHT %></code>,
rounded to a whole number. Change the split in
<code>LeaderboardScore::IMPACT_WEIGHT</code> — raising efficiency leans further
toward fairness across team sizes.
</span>
</li>
</ol>
<% example = @leaderboard.find { |row| row[:tomatoes].to_i.positive? } %>
<% if example %>
<% max_tomatoes = @leaderboard.map { |row| row[:tomatoes].to_i }.max.to_f %>
<% max_per_emp = @leaderboard.filter_map { |row| row[:per_employee] }.max.to_f %>
<% impact = max_tomatoes.positive? ? (example[:tomatoes] / max_tomatoes * 100) : 0 %>
<% efficiency = if example[:per_employee] && max_per_emp.positive?
example[:per_employee] / max_per_emp * 100
else
impact
end %>
<p class="leaderboard-worked">
<strong>Right now, <%= country_flag(example[:country_code]) %>
<%= leaderboard_country_name(example[:country_code]) %>:</strong>
<%= 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 <strong><%= example[:score] %></strong>.
</p>
<% end %>
<p class="analytics-note">
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.
</p>
</div>
</section>
<section class="analytics-panel">
<h2>Raw payload <small>what the endpoint returns verbatim</small></h2>
<details class="leaderboard-raw">
<summary>Show JSON</summary>
<pre><%= JSON.pretty_generate(
leaderboard: @leaderboard,
badges: @badges,
updated_at: @updated_at&.iso8601
) %></pre>
</details>
</section>
</div>
<% end %>

+ 2
- 2
app/views/layouts/admin.html.erb View File

@ -9,13 +9,13 @@
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
<%= stylesheet_link_tag "admin", "lexxy", "tom-select", "popup-menu", "forms", "lists", "assets", "nodes", "attachments", "analytics" %>
<%= stylesheet_link_tag "admin", "lexxy", "tom-select", "popup-menu", "forms", "lists", "assets", "nodes", "attachments", "analytics", "leaderboard" %>
<%= javascript_importmap_tags 'admin' %>
</head>
<body>
<nav id="navbar">
<div class="navbar-upper">
<% %i[nodes assets analytics].each do |c| %>
<% %i[nodes assets analytics leaderboard].each do |c| %>
<%= link_to url_for(controller: c, action: 'index'),
class: (controller_name == c.to_s ? 'navbar-link current' : 'navbar-link'),
data: {


+ 2
- 0
config/locales/en.yml View File

@ -253,6 +253,7 @@ en:
address: Address
clear_cache: Clear cache
cache_cleared: The cache is now cleared
leaderboard_expired: The published leaderboard is cleared. The next API call will recompute it.
search: Search
updated_at: Last updated
close: Close
@ -393,6 +394,7 @@ en:
icons:
assets: image
analytics: bar_chart
leaderboard: emoji_events
users: person
nodes: file_copy
tags: sell


+ 4
- 0
config/routes.rb View File

@ -40,6 +40,10 @@ Rails.application.routes.draw do
# Analytics
get "analytics", to: "analytics#index"
# Leaderboard (as published by the API)
get "leaderboard", to: "leaderboard#index"
delete "leaderboard/cache", to: "leaderboard#expire", as: "leaderboard_cache"
# Root
root to: "nodes#index"
end


Loading…
Cancel
Save