You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

242 lines
9.8 KiB

<%= 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 %>