Mattias Bodlund 2 months ago
parent
commit
2cad4069d0
18 changed files with 449 additions and 36 deletions
  1. +3
    -0
      Gemfile
  2. +34
    -27
      Gemfile.lock
  3. +13
    -4
      app/controllers/api/v1/leaderboard_controller.rb
  4. +10
    -1
      app/controllers/game_controller.rb
  5. +15
    -0
      app/jobs/resolve_player_country_job.rb
  6. +8
    -0
      app/models/player.rb
  7. +22
    -0
      app/services/demo_activity.rb
  8. +102
    -0
      app/services/leaderboard_badges.rb
  9. +74
    -0
      app/services/leaderboard_score.rb
  10. +8
    -0
      app/views/api/v1/leaderboard/index.json.jbuilder
  11. +73
    -0
      config/country_headcounts.yml
  12. +1
    -1
      config/importmap.rb
  13. +18
    -0
      config/initializers/geocoder.rb
  14. +6
    -0
      db/migrate/20260703000000_add_country_to_players.rb
  15. +3
    -1
      db/schema.rb
  16. +57
    -0
      docs/leaderboard_api.md
  17. BIN
      docs/leaderboard_api.pdf
  18. +2
    -2
      vendor/javascript/stimulus-use.js

+ 3
- 0
Gemfile View File

@ -36,6 +36,9 @@ gem "acts_as_list"
gem "date_validator"
# Resolve a visitor's country from their request IP [https://github.com/alexreisner/geocoder]
gem "geocoder"
gem "premailer-rails"
gem "dkim"


+ 34
- 27
Gemfile.lock View File

@ -93,10 +93,11 @@ GEM
builder (3.3.0)
concurrent-ruby (1.3.7)
connection_pool (3.0.2)
crass (1.0.6)
crass (1.0.7)
css_parser (3.0.0)
addressable
ssrf_filter (~> 1.5)
csv (3.3.5)
date (3.5.1)
date_validator (0.12.0)
activemodel (>= 3)
@ -117,6 +118,9 @@ GEM
ffi (1.17.4-x86_64-darwin)
ffi (1.17.4-x86_64-linux-gnu)
ffi (1.17.4-x86_64-linux-musl)
geocoder (1.8.6)
base64 (>= 0.1.0)
csv (>= 3.0.0)
globalid (1.4.0)
activesupport (>= 6.1)
htmlentities (4.4.2)
@ -152,14 +156,14 @@ GEM
kaminari-core (= 1.2.2)
kaminari-core (1.2.2)
language_server-protocol (3.17.0.5)
lexxy (0.9.20)
lexxy (0.9.22)
rails (>= 8.0.2)
lint_roller (1.1.0)
logger (1.7.0)
loofah (2.25.1)
crass (~> 1.0.2)
nokogiri (>= 1.12.0)
mail (2.9.0)
mail (2.9.1)
logger
mini_mime (>= 0.1.1)
net-imap
@ -233,9 +237,6 @@ GEM
actionpack (>= 7.0.0)
activesupport (>= 7.0.0)
rack
psych (5.4.0)
date
stringio
public_suffix (7.0.5)
puma (8.0.2)
nio4r (~> 2.0)
@ -280,9 +281,14 @@ GEM
zeitwerk (~> 2.6)
rainbow (3.1.1)
rake (13.4.2)
rdoc (7.2.0)
rbs (4.0.3)
logger
prism (>= 1.6.0)
tsort
rdoc (8.0.0)
erb
psych (>= 4.0.0)
prism (>= 1.6.0)
rbs (>= 4.0.0)
tsort
redis (5.4.1)
redis-client (>= 0.22.0)
@ -293,7 +299,7 @@ GEM
io-console (~> 0.5)
request_store (1.7.0)
rack (>= 1.4)
rubocop (1.88.0)
rubocop (1.88.1)
json (~> 2.3)
language_server-protocol (~> 3.17.0.2)
lint_roller (~> 1.1.0)
@ -335,13 +341,12 @@ GEM
ssrf_filter (1.5.0)
stimulus-rails (1.3.4)
railties (>= 6.0.0)
stringio (3.2.0)
thor (1.5.0)
thruster (0.1.21)
thruster (0.1.21-aarch64-linux)
thruster (0.1.21-arm64-darwin)
thruster (0.1.21-x86_64-darwin)
thruster (0.1.21-x86_64-linux)
thruster (0.1.22)
thruster (0.1.22-aarch64-linux)
thruster (0.1.22-arm64-darwin)
thruster (0.1.22-x86_64-darwin)
thruster (0.1.22-x86_64-linux)
timeout (0.6.1)
tsort (0.2.0)
turbo-rails (2.0.23)
@ -384,6 +389,7 @@ DEPENDENCIES
date_validator
dkim
dotenv-rails
geocoder
image_processing (~> 1.2)
importmap-rails
jbuilder
@ -431,8 +437,9 @@ CHECKSUMS
bundler (4.0.14) sha256=d09a0a965cf772266a7e49e83610be7c2f4e49e61134c42a56804bb383cc24b8
concurrent-ruby (1.3.7) sha256=4412caec3a5ea2e5fdc52076724c071a81f2c0593d83b2ac8cbb8ca63b3151b0
connection_pool (3.0.2) sha256=33fff5ba71a12d2aa26cb72b1db8bba2a1a01823559fb01d29eb74c286e62e0a
crass (1.0.6) sha256=dc516022a56e7b3b156099abc81b6d2b08ea1ed12676ac7a5657617f012bd45d
crass (1.0.7) sha256=94868719948664c89ddcaf0a37c65048413dfcb1c869470a5f7a7ceb5390b295
css_parser (3.0.0) sha256=eaf0e9283fd581d06e815235ceef4f0910c0b394c606355dbc69f93e84443885
csv (3.3.5) sha256=6e5134ac3383ef728b7f02725d9872934f523cb40b961479f69cf3afa6c8e73f
date (3.5.1) sha256=750d06384d7b9c15d562c76291407d89e368dda4d4fff957eb94962d325a0dc0
date_validator (0.12.0) sha256=68c9834da240347b9c17441c553a183572508617ebfbe8c020020f3192ce3058
dkim (1.1.0) sha256=74f2e3075c89cd2967995f3ee293da9dccccffee21ee593715a068ab39a717f8
@ -449,6 +456,7 @@ CHECKSUMS
ffi (1.17.4-x86_64-darwin) sha256=aa70390523cf3235096cf64962b709b4cfbd5c082a2cb2ae714eb0fe2ccda496
ffi (1.17.4-x86_64-linux-gnu) sha256=9d3db14c2eae074b382fa9c083fe95aec6e0a1451da249eab096c34002bc752d
ffi (1.17.4-x86_64-linux-musl) sha256=3fdf9888483de005f8ef8d1cf2d3b20d86626af206cbf780f6a6a12439a9c49e
geocoder (1.8.6) sha256=e0ca1554b499f466de9b003f7dff70f89a5888761c2ca68ed9f86b6e5e24e74c
globalid (1.4.0) sha256=037f12fbf1d9d7a014d501c2d5c77356fd4ddd96d7a7991d6700bba96706f427
htmlentities (4.4.2) sha256=bbafbdf69f2eca9262be4efef7e43e6a1de54c95eb600f26984f71d2fe96c5c3
i18n (1.15.2) sha256=00f9eb62412fe593b2a65a97daa75300d37abb8f7202ec748e94b6d46a9dd1b5
@ -463,11 +471,11 @@ CHECKSUMS
kaminari-activerecord (1.2.2) sha256=0dd3a67bab356a356f36b3b7236bcb81cef313095365befe8e98057dd2472430
kaminari-core (1.2.2) sha256=3bd26fec7370645af40ca73b9426a448d09b8a8ba7afa9ba3c3e0d39cdbb83ff
language_server-protocol (3.17.0.5) sha256=fd1e39a51a28bf3eec959379985a72e296e9f9acfce46f6a79d31ca8760803cc
lexxy (0.9.20) sha256=31b4e50b68302e9a4d00b5a3e104f3b27a0682d43510a1e9e6673da695038e6e
lexxy (0.9.22) sha256=976c9c96e73078937b97942962ec80f32adfb59315921acaaeb30a4cb8a7306e
lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87
logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203
loofah (2.25.1) sha256=d436c73dbd0c1147b16c4a41db097942d217303e1f7728704b37e4df9f6d2e04
mail (2.9.0) sha256=6fa6673ecd71c60c2d996260f9ee3dd387d4673b8169b502134659ece6d34941
mail (2.9.1) sha256=06574eca475253d6c18145dd70af80d0eb970182d55053497c5f4d797ea160e8
marcel (1.2.1) sha256=1678e9360e32f9eafa917c80029e2f6d10b2715c66a4b87b6d0da9b9cd1f859f
mini_magick (5.3.1) sha256=29395dfd76badcabb6403ee5aff6f681e867074f8f28ce08d78661e9e4a351c4
mini_mime (1.1.5) sha256=8681b7e2e4215f2a159f9400b5816d85e9d8c6c6b491e96a12797e798f8bccef
@ -504,7 +512,6 @@ CHECKSUMS
prettyprint (0.2.0) sha256=2bc9e15581a94742064a3cc8b0fb9d45aae3d03a1baa6ef80922627a0766f193
prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85
propshaft (1.3.2) sha256=1d56a3e56a92c21bfc29caf07406b5386b00d4c47ddf357cf989a5a234b1389e
psych (5.4.0) sha256=14f72d69a611af663d7d70e4a7b67d9eb1f3ae9f8d916b478961d5a0075ba5b7
public_suffix (7.0.5) sha256=1a8bb08f1bbea19228d3bed6e5ed908d1cb4f7c2726d18bd9cadf60bc676f623
puma (8.0.2) sha256=c8ed871dfbbe66448ea9ffd46692342d9804d4071522b52b5331b7b6e7b686fb
racc (1.8.1) sha256=4a7f6929691dbec8b5209a0b373bc2614882b55fc5d2e447a21aaa691303d62f
@ -518,13 +525,14 @@ CHECKSUMS
railties (8.1.3) sha256=913eb0e0cb520aac687ffd74916bd726d48fa21f47833c6292576ef6a286de22
rainbow (3.1.1) sha256=039491aa3a89f42efa1d6dec2fc4e62ede96eb6acd95e52f1ad581182b79bc6a
rake (13.4.2) sha256=cb825b2bd5f1f8e91ca37bddb4b9aaf345551b4731da62949be002fa89283701
rdoc (7.2.0) sha256=8650f76cd4009c3b54955eb5d7e3a075c60a57276766ebf36f9085e8c9f23192
rbs (4.0.3) sha256=5a7bf70e2628549d9a1f44eae447b2cfe55968a9c60cfff52693a4bdcc020e14
rdoc (8.0.0) sha256=03bf8c08a9639658855a0cfd77c0abca8325c227693f7f33f82957811348c469
redis (5.4.1) sha256=b5e675b57ad22b15c9bcc765d5ac26f60b675408af916d31527af9bd5a81faae
redis-client (0.30.0) sha256=743f11ed42f0a41a0341554087b077479fec7e2d47a7c123fd90a12c0db5e477
regexp_parser (2.12.0) sha256=35a916a1d63190ab5c9009457136ae5f3c0c7512d60291d0d1378ba18ce08ebb
reline (0.6.3) sha256=1198b04973565b36ec0f11542ab3f5cfeeec34823f4e54cebde90968092b1835
request_store (1.7.0) sha256=e1b75d5346a315f452242a68c937ef8e48b215b9453a77a6c0acdca2934c88cb
rubocop (1.88.0) sha256=e420ddf1662d0ef34bc8a2910ac4b396a7ddda0b51a708264405241734b08e0b
rubocop (1.88.1) sha256=726af773d6bc169ed3ff852f3ca020b7c58d39c34e7a8d879a8e0147cc994f26
rubocop-ast (1.49.1) sha256=4412f3ee70f6fe4546cc489548e0f6fcf76cafcfa80fa03af67098ffed755035
rubocop-performance (1.26.1) sha256=cd19b936ff196df85829d264b522fd4f98b6c89ad271fa52744a8c11b8f71834
rubocop-rails (2.35.5) sha256=f00b3c936002ba8e9ac62e8607c54bb24cda44b36e41b9c7e4f3872e1b0f3fe3
@ -535,13 +543,12 @@ CHECKSUMS
sidekiq (8.1.6) sha256=be20cd051124b1a16cf97ea9157137abbd30a515c16a5ae9312d2eadd045e40f
ssrf_filter (1.5.0) sha256=e03dcdb9d1730d7f6710532a606b3543df2a448a0293ce04a2d995523c5a97f6
stimulus-rails (1.3.4) sha256=765676ffa1f33af64ce026d26b48e8ffb2e0b94e0f50e9119e11d6107d67cb06
stringio (3.2.0) sha256=c37cb2e58b4ffbd33fe5cd948c05934af997b36e0b6ca6fdf43afa234cf222e1
thor (1.5.0) sha256=e3a9e55fe857e44859ce104a84675ab6e8cd59c650a49106a05f55f136425e73
thruster (0.1.21) sha256=dc67928f36e5894844579a95e45637a5091db7a7ea05468ee8c2c6eb0a3f77cf
thruster (0.1.21-aarch64-linux) sha256=f5aff78fb7a6431ed3d6ab4bde03a89c461e9a73981dbc97d6990d85c3db235c
thruster (0.1.21-arm64-darwin) sha256=bd8db9f57fae2cbb3fe08ebab49cb47fe49608122dac23daf0ce709adfb9bfc8
thruster (0.1.21-x86_64-darwin) sha256=ccd6acd144fad27856800edfa0573944018333fac8e10a2e5d09726b70c8b0db
thruster (0.1.21-x86_64-linux) sha256=6e2fbcf826540a72d3710ae4db072c2333287ac2ee57e7e52f35bc10900d74a7
thruster (0.1.22) sha256=f34fb82bd3e31570ae8f8b930e484f9e1db7b080cbc993d49090b161879c3e05
thruster (0.1.22-aarch64-linux) sha256=6c306241e8ff8912fb26705a15cbb7b44a4dfcedd59fa890b2c72811f15cfa0e
thruster (0.1.22-arm64-darwin) sha256=d0506142832b1b020ab687dbfb938ec7cb07eeaabebc7f8682a73e8f0636917b
thruster (0.1.22-x86_64-darwin) sha256=2598d4122176bebd9d2debfcc5d330886168ca95e1e4705a5f38e80734c4e959
thruster (0.1.22-x86_64-linux) sha256=b96436be7f0854db632f421b4a53b795429a0bc95c15c2e1c1a7a6fdd1ed090f
timeout (0.6.1) sha256=78f57368a7e7bbadec56971f78a3f5ecbcfb59b7fcbb0a3ed6ddc08a5094accb
tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f
turbo-rails (2.0.23) sha256=ee0d90733aafff056cf51ff11e803d65e43cae258cc55f6492020ec1f9f9315f


+ 13
- 4
app/controllers/api/v1/leaderboard_controller.rb View File

@ -9,10 +9,19 @@ class Api::V1::LeaderboardController < ApplicationController
def index
request.format = :json
@leaderboard = I18n.t("countries").keys.map do |code|
{ country_code: code.upcase, score: rand(1000..9999) }
end.sort_by { |e| -e[:score] }
@updated_at = Time.current
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
@leaderboard = data[:leaderboard]
@badges = data[:badges]
@updated_at = data[:updated_at]
end
private


+ 10
- 1
app/controllers/game_controller.rb View File

@ -18,7 +18,8 @@ class GameController < ApplicationController
# POST
def start
player = Player.create(locale: I18n.locale.to_s)
player = Player.create(locale: I18n.locale.to_s, country: country_from_param)
ResolvePlayerCountryJob.perform_later(player.id, request.remote_ip) if player.country.blank?
session[:player_id] = player.id
redirect_to action: "facts"
end
@ -134,6 +135,14 @@ private
# An explicit ?country= override (handy for testing or a fixed-country kiosk).
# When absent, IP-based detection runs asynchronously in
# ResolvePlayerCountryJob so it never blocks the start of the game.
def country_from_param
Player.valid_country_code(params[:country])
end
def url_from_param
return "" unless params[:url]


+ 15
- 0
app/jobs/resolve_player_country_job.rb View File

@ -0,0 +1,15 @@
# Resolves a player's country from their request IP in the background, so the
# external geolocation call never delays the start of the game. Enqueued by
# GameController#start only when no explicit ?country= override was given.
class ResolvePlayerCountryJob < ApplicationJob
queue_as :default
def perform(player_id, ip)
player = Player.find_by(id: player_id)
return if player.nil? || player.country.present?
code = Geocoder.search(ip).first&.country_code
country = Player.valid_country_code(code)
player.update(country: country) if country
end
end

+ 8
- 0
app/models/player.rb View File

@ -8,6 +8,14 @@ class Player < ApplicationRecord
SCORE_KEYS = %w[food_waste emissions income].freeze
# Normalises an arbitrary country code to one of the leaderboard's known
# countries, or nil if it isn't one we track. Shared by the game controller
# (?country= override) and ResolvePlayerCountryJob (IP geolocation).
def self.valid_country_code(code)
code.to_s.downcase.presence_in(I18n.t("countries").keys.map(&:to_s))
end
def record_answer(stage, answer_id)
stage_data(stage)["answer_id"] = answer_id
save


+ 22
- 0
app/services/demo_activity.rb View File

@ -0,0 +1,22 @@
# Fakes live gameplay so the banner developer has moving numbers to build
# against. Called on each leaderboard cache refresh (~once a minute); each call
# adds a handful of random players across random countries, most of them done.
#
# TEMPORARY: set ENABLED = false (or delete this) and wipe the players table
# before the game goes live.
class DemoActivity
ENABLED = true
def self.simulate!
return unless ENABLED
countries = I18n.t("countries").keys.map(&:to_s)
rand(5..20).times do
Player.create!(
locale: "en",
country: countries.sample,
is_done: rand < 0.7
)
end
end
end

+ 102
- 0
app/services/leaderboard_badges.rb View File

@ -0,0 +1,102 @@
# Builds the rotating banner "badges" that sit alongside the ranked leaderboard.
# The banner picks one at a time or cycles through the whole list.
#
# Three kinds, matching the client's brief:
# #1 encouragement "Ketch-up <Country>!" bottom 10 featured countries
# #2 new_players "Yay! New players in <Country>" countries with recent sign-ups
# "<N> new players in <Country>!" (benchmark variant)
# #3 participation "<Country>, every tomato counts!" everyone not covered above
#
# Tunables — the client's example numbers, adjust freely:
class LeaderboardBadges
NEW_PLAYER_WINDOW = 24.hours # #2: "new players in the last X hours"
BENCHMARKS = [100, 50, 10] # #2: highest crossed threshold wins
ENCOURAGEMENT_COUNT = 10 # #1: rotate across the bottom N
# leaderboard: the array returned by LeaderboardScore.call (already ranked).
def self.call(leaderboard)
new(leaderboard).call
end
def initialize(leaderboard)
@leaderboard = leaderboard
end
# At most three badges — one random pick of each type. The pick changes on
# each cache refresh, so the banner naturally rotates through countries.
def call
[
new_player_badges.sample,
encouragement_badges.sample,
participation_badges.sample
].compact
end
private
# #2 — countries that gained new players (started sessions) within the window.
def new_player_badges
new_player_counts.map do |code, count|
benchmark = BENCHMARKS.find { |b| count >= b }
message = if benchmark
"#{benchmark} new players in #{country_name(code)}!"
else
"Yay! New players in #{country_name(code)}"
end
badge(:new_players, code, message)
end
end
# #1 — encouragement for the bottom N countries that are actually playing.
def encouragement_badges
bottom_featured.map do |code|
badge(:encouragement, code, "Ketch-up #{country_name(code)}!")
end
end
# #3 — everyone not featured on the leaderboard and not covered by #1 or #2.
def participation_badges
covered = (new_player_counts.keys + bottom_featured).to_set
unfeatured_codes.reject { |code| covered.include?(code) }.map do |code|
badge(:participation, code, "#{country_name(code)}, every tomato counts!")
end
end
# Countries with at least one tomato — the ones "on" the leaderboard.
def featured
@featured ||= @leaderboard.select { |row| row[:tomatoes].to_i.positive? }
end
# @leaderboard is ranked high→low, so the last N featured are the bottom N.
def bottom_featured
@bottom_featured ||= featured.last(ENCOURAGEMENT_COUNT).map { |row| code_of(row) }
end
def unfeatured_codes
featured_set = featured.map { |row| code_of(row) }.to_set
@leaderboard.map { |row| code_of(row) }.reject { |code| featured_set.include?(code) }
end
def new_player_counts
@new_player_counts ||= Player
.where(country: known_codes)
.where(created_at: NEW_PLAYER_WINDOW.ago..)
.group(:country).count
end
def known_codes
@known_codes ||= I18n.t("countries").keys.map(&:to_s)
end
def code_of(row)
row[:country_code].to_s.downcase
end
def country_name(code)
I18n.t("countries.#{code}", default: code.to_s.upcase)
end
def badge(type, code, message)
{ type: type.to_s, country_code: code.to_s.upcase, message: message }
end
end

+ 74
- 0
app/services/leaderboard_score.rb View File

@ -0,0 +1,74 @@
# Computes the country leaderboard using a blended score that balances overall
# impact with per-employee efficiency, so bigger teams don't win on size alone.
#
# For each country:
# tomatoes = number of finished play sessions (players where is_done)
# per_employee = tomatoes / employee headcount (nil if headcount unknown)
# impact_index = tomatoes / max(tomatoes) * 100
# efficiency = per_employee / max(per_employee) * 100
# score = impact_index * IMPACT_WEIGHT + efficiency * EFFICIENCY_WEIGHT
#
# Tune the balance here. The proposal's default is an even 50/50 split; raise
# EFFICIENCY_WEIGHT to lean more toward fairness across team sizes.
class LeaderboardScore
IMPACT_WEIGHT = 0.5
EFFICIENCY_WEIGHT = 1.0 - IMPACT_WEIGHT
def self.call
new.call
end
def call
rows = build_rows
max_tomatoes = rows.map { |r| r[:tomatoes] }.max.to_f
max_per_emp = rows.filter_map { |r| r[:per_employee] }.max.to_f
rows.map { |r| score_row(r, max_tomatoes, max_per_emp) }
.sort_by { |r| [ -r[:score], r[:country_code] ] }
end
private
# One row per known country, so the API always lists every country. Only
# players that are done AND tagged with a known country contribute tomatoes;
# countries with no finished players still appear, with 0 tomatoes / 0 score.
def build_rows
known = I18n.t("countries").keys.map(&:to_s)
counts = Player.where(is_done: true, country: known).group(:country).count
headcounts = self.class.headcounts
known.map do |country|
tomatoes = counts[country].to_i
employees = headcounts[country].to_i
per_employee = employees.positive? ? tomatoes.to_f / employees : nil
{ country: country, tomatoes: tomatoes, per_employee: per_employee }
end
end
def score_row(row, max_tomatoes, max_per_emp)
impact = max_tomatoes.positive? ? row[:tomatoes] / max_tomatoes * 100 : 0.0
efficiency = if row[:per_employee] && max_per_emp.positive?
row[:per_employee] / max_per_emp * 100
else
# No headcount for this country: fall back to impact only rather than
# penalising it with a zero efficiency score.
impact
end
blended = impact * IMPACT_WEIGHT + efficiency * EFFICIENCY_WEIGHT
{
country_code: row[:country].to_s.upcase,
score: blended.round,
tomatoes: row[:tomatoes],
per_employee: row[:per_employee]&.round(2)
}
end
def self.headcounts
@headcounts ||= YAML.safe_load_file(
Rails.root.join("config", "country_headcounts.yml"),
permitted_classes: [], aliases: false
) || {}
end
end

+ 8
- 0
app/views/api/v1/leaderboard/index.json.jbuilder View File

@ -1,6 +1,14 @@
json.leaderboard @leaderboard do |entry|
json.country_code entry[:country_code]
json.score entry[:score]
json.tomatoes entry[:tomatoes]
json.per_employee entry[:per_employee]
end
json.badges @badges do |badge|
json.type badge[:type]
json.country_code badge[:country_code]
json.message badge[:message]
end
json.updated_at @updated_at.iso8601

+ 73
- 0
config/country_headcounts.yml View File

@ -0,0 +1,73 @@
# Employee headcount per country, used to compute the leaderboard's
# per-employee (efficiency) score. Keys are ISO country codes and MUST match
# the keys under `countries:` in config/locales/en.yml.
#
# The numbers below are PLACEHOLDER TEST VALUES — update as HR confirms them.
# Any country left blank (nil) or with a non-positive value is excluded from the
# efficiency axis so we never divide by zero — it still counts toward total
# tomatoes / impact.
#
# NOTE: Balearic Islands and Canary Islands are folded into Spain (es) in
# en.yml, so they have no separate key here.
---
au: 1200 # Australia
at: 950 # Austria
bh: 180 # Bahrain
es: 3100 # Spain (incl. Balearic & Canary Islands)
be: 1400 # Belgium
bg: 600 # Bulgaria
ca: 2600 # Canada
cl: 700 # Chile
cn: 5200 # China
co: 850 # Colombia
hr: 400 # Croatia
cy: 150 # Cyprus
cz: 1100 # Czech Republic
dk: 1300 # Denmark
do: 220 # Dominican Republic
eg: 900 # Egypt
ee: 300 # Estonia
fi: 1050 # Finland
fr: 3800 # France
de: 4600 # Germany
gr: 750 # Greece
hk: 500 # Hong Kong
hu: 950 # Hungary
is: 160 # Iceland
in: 6100 # India
id: 2400 # Indonesia
ie: 1150 # Ireland
il: 680 # Israel
it: 3300 # Italy
jp: 3900 # Japan
jo: 260 # Jordan
kw: 210 # Kuwait
lv: 320 # Latvia
lt: 360 # Lithuania
my: 1600 # Malaysia
mx: 2200 # Mexico
ma: 540 # Morocco
nl: 2100 # Netherlands
nz: 480 # New Zealand
"no": 1250 # Norway
om: 190 # Oman
ph: 1900 # Philippines
pl: 800 # Poland
pt: 1000 # Portugal
pr: 240 # Puerto Rico
qa: 230 # Qatar
ro: 1150 # Romania
sa: 1050 # Saudi Arabia
rs: 560 # Serbia
sg: 900 # Singapore
sk: 620 # Slovakia
si: 340 # Slovenia
kr: 1800 # South Korea
se: 2900 # Sweden
ch: 1500 # Switzerland
tw: 1350 # Taiwan
th: 1700 # Thailand
tr: 2000 # Turkey
ae: 700 # United Arab Emirates
gb: 4100 # United Kingdom
us: 4280 # United States of America

+ 1
- 1
config/importmap.rb View File

@ -6,7 +6,7 @@ pin "@hotwired/stimulus-loading", to: "stimulus-loading.js"
pin_all_from "app/javascript/controllers", under: "controllers"
pin "sortablejs" # @1.15.7
pin "stimulus-use" # @0.52.3
pin "stimulus-use" # @0.53.0
pin "@rails/request.js", to: "@rails--request.js.js" # @0.0.13
pin "@rails/activestorage", to: "@rails--activestorage.js" # @8.1.300
pin "lexxy", to: "lexxy.js"


+ 18
- 0
config/initializers/geocoder.rb View File

@ -0,0 +1,18 @@
Geocoder.configure(
# IP-address geolocation service used by `request.location`.
# ipinfo.io works without an API key at low volume; add an ipinfo_token to the
# Rails credentials to raise the rate limit. For a no-rate-limit /
# privacy-friendly setup, swap this for an offline MaxMind GeoLite2 lookup
# (ip_lookup: :geoip2 + the .mmdb file).
ip_lookup: :ipinfo_io,
ipinfo_io: { api_key: Rails.application.credentials.ipinfo_token.presence },
# A slow or unreachable geo service must never hold up the start of the game.
timeout: 2,
always_raise: [], # swallow lookup errors -> request.location returns nil
# Employees behind the same office gateway share an egress IP, so cache each
# lookup and reuse it instead of hammering the API once per player.
cache: Rails.cache,
cache_options: { expiration: 1.day }
)

+ 6
- 0
db/migrate/20260703000000_add_country_to_players.rb View File

@ -0,0 +1,6 @@
class AddCountryToPlayers < ActiveRecord::Migration[8.1]
def change
add_column :players, :country, :string
add_index :players, :country
end
end

+ 3
- 1
db/schema.rb View File

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.1].define(version: 2026_06_05_000001) do
ActiveRecord::Schema[8.1].define(version: 2026_07_03_000000) do
# These are extensions that must be enabled in order to support this database
enable_extension "pg_catalog.plpgsql"
@ -115,6 +115,7 @@ ActiveRecord::Schema[8.1].define(version: 2026_06_05_000001) do
end
create_table "players", force: :cascade do |t|
t.string "country"
t.datetime "created_at", null: false
t.boolean "is_done", default: false, null: false
t.string "locale", null: false
@ -122,6 +123,7 @@ ActiveRecord::Schema[8.1].define(version: 2026_06_05_000001) do
t.integer "score", default: 0, null: false
t.jsonb "scores", default: {}, null: false
t.datetime "updated_at", null: false
t.index ["country"], name: "index_players_on_country"
t.index ["is_done"], name: "index_players_on_is_done"
t.index ["locale"], name: "index_players_on_locale"
end


+ 57
- 0
docs/leaderboard_api.md View File

@ -0,0 +1,57 @@
# Tomato Challenge — Leaderboard API
Provides the data for the intranet score banner: a ranked list of countries plus
up to three rotating "badge" messages.
## Endpoint
```
GET https://savethetomato.ikeafoundation.org/api/v1/leaderboard
```
## Authentication
Send a Bearer token in the header:
```
Authorization: Bearer ikea-tomato-2026
```
Missing/wrong token returns `401 { "error": "Unauthorized" }`.
## Response
```json
{
"leaderboard": [
{ "country_code": "DK", "score": 100, "tomatoes": 5, "per_employee": 1.2 },
{ "country_code": "PL", "score": 63, "tomatoes": 3, "per_employee": 0.9 }
],
"badges": [
{ "type": "new_players", "country_code": "FR", "message": "10 new players in France!" },
{ "type": "encouragement", "country_code": "SE", "message": "Ketch-up Sweden!" },
{ "type": "participation", "country_code": "CN", "message": "China, every tomato counts!" }
],
"updated_at": "2026-07-03T12:00:00Z"
}
```
### `leaderboard`
Always contains **all countries**, sorted best-to-worst by `score`.
- `country_code` — ISO 2-letter code, uppercase.
- `score` — blended 0–100 ranking score.
- `tomatoes` — total saved (whole number).
- `per_employee` — tomatoes per employee (decimal, may be `null`).
### `badges`
Zero to three messages, **at most one of each `type`**. Render `message` as-is.
- `new_players` — a country just gained players.
- `encouragement` — nudge for a trailing country.
- `participation` — a country not yet on the board.
`country_code` is included if you want to show a flag alongside the message.
### `updated_at`
ISO-8601 timestamp of the data.
## Notes
- Response is **cached for ~1 minute**, so numbers/badges change about once a
minute — safe to poll on that cadence.
- The **field/structure is stable**; exact scoring and badge wording may still be
tuned. Don't hard-code specific messages.
- Data is currently **simulated** for development and will be reset before go-live.

BIN
docs/leaderboard_api.pdf View File


+ 2
- 2
vendor/javascript/stimulus-use.js
File diff suppressed because it is too large
View File


Loading…
Cancel
Save