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