Regional leaderboards (#943)

* Regional leaderboards

* Fix the bugs!

* bin/rubocop -A
This commit is contained in:
Mahad Kalam 2026-02-15 08:29:00 +00:00 committed by GitHub
parent 2b178af3e6
commit 8621dfa3ed
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 192 additions and 28 deletions

View file

@ -116,10 +116,14 @@ jobs:
run: |
bin/rails db:create RAILS_ENV=test
bin/rails db:schema:load RAILS_ENV=test
# Create additional test databases
psql -h localhost -U postgres -c "CREATE DATABASE test_wakatime;"
psql -h localhost -U postgres -c "CREATE DATABASE test_sailors_log;"
psql -h localhost -U postgres -c "CREATE DATABASE test_warehouse;"
# Create additional test databases used by multi-db models
psql -h localhost -U postgres -c "CREATE DATABASE test_wakatime;" || true
psql -h localhost -U postgres -c "CREATE DATABASE test_sailors_log;" || true
psql -h localhost -U postgres -c "CREATE DATABASE test_warehouse;" || true
# Mirror schema from primary test DB so cross-db models can query safely in tests
pg_dump -h localhost -U postgres --schema-only --no-owner --no-privileges test_db | psql -h localhost -U postgres test_wakatime
pg_dump -h localhost -U postgres --schema-only --no-owner --no-privileges test_db | psql -h localhost -U postgres test_sailors_log
pg_dump -h localhost -U postgres --schema-only --no-owner --no-privileges test_db | psql -h localhost -U postgres test_warehouse
bin/rails test
- name: Ensure Swagger docs are up to date

View file

@ -1,20 +1,25 @@
class LeaderboardsController < ApplicationController
PER_PAGE = 100
LEADERBOARD_SCOPES = %w[global country].freeze
def index
@period_type = validated_period_type
load_country_context
@leaderboard_scope = validated_leaderboard_scope
@leaderboard = LeaderboardService.get(period: @period_type, date: start_date)
@leaderboard.nil? ? flash.now[:notice] = "Leaderboard is being updated..." : load_metadata
end
def entries
@period_type = validated_period_type
load_country_context
@leaderboard_scope = validated_leaderboard_scope
@leaderboard = LeaderboardService.get(period: @period_type, date: start_date)
return head :no_content unless @leaderboard&.persisted?
page = (params[:page] || 1).to_i
@entries = @leaderboard.entries.includes(:user).order(total_seconds: :desc)
.offset((page - 1) * PER_PAGE).limit(PER_PAGE)
page = [ (params[:page] || 1).to_i, 1 ].max
@entries = leaderboard_entries_scope.includes(:user).order(total_seconds: :desc)
.offset((page - 1) * PER_PAGE).limit(PER_PAGE)
@active_projects = Cache::ActiveProjectsJob.perform_now
@offset = (page - 1) * PER_PAGE
@ -24,8 +29,33 @@ class LeaderboardsController < ApplicationController
private
def validated_period_type
p = (params[:period_type] || "daily").to_sym
%i[daily last_7_days].include?(p) ? p : :daily
p = (params[:period_type] || "daily").to_s
%w[daily last_7_days].include?(p) ? p.to_sym : :daily
end
def validated_leaderboard_scope
requested_scope = params[:scope].to_s
requested_scope = "global" unless LEADERBOARD_SCOPES.include?(requested_scope)
requested_scope = "global" if requested_scope == "country" && !@country_scope_available
requested_scope.to_sym
end
def load_country_context
country = ISO3166::Country.new(current_user&.country_code)
@country_code = country&.alpha2
@country_name = country&.common_name
@country_scope_available = @country_code.present? && @country_name.present?
end
def country_scope?
@leaderboard_scope == :country && @country_scope_available
end
def leaderboard_entries_scope
entries_scope = @leaderboard.entries
return entries_scope unless country_scope?
entries_scope.joins(:user).where(users: { country_code: @country_code })
end
def start_date
@ -35,14 +65,17 @@ class LeaderboardsController < ApplicationController
def load_metadata
return unless @leaderboard.persisted?
ids = @leaderboard.entries.distinct.pluck(:user_id)
entries_scope = leaderboard_entries_scope
ids = entries_scope.distinct.pluck(:user_id)
@user_on_leaderboard = current_user && ids.include?(current_user.id)
@untracked_entries = calculate_untracked_entries(ids) unless @user_on_leaderboard
@total_entries = @leaderboard.entries.count
@untracked_entries = calculate_untracked_entries(ids) unless @user_on_leaderboard || country_scope?
@total_entries = entries_scope.count
end
def calculate_untracked_entries(ids)
r = @period_type == :last_7_days ? ((Date.current - 6.days).beginning_of_day...Date.current.end_of_day) : Date.current.all_day
Hackatime::Heartbeat.where(time: r).distinct.pluck(:user_id).count { |uid| !ids.include?(uid) }
range = @period_type == :last_7_days ? ((Date.current - 6.days).beginning_of_day...Date.current.end_of_day) : Date.current.all_day
ids_set = ids.to_set
Hackatime::Heartbeat.where(time: range).distinct.pluck(:user_id).count { |uid| !ids_set.include?(uid) }
end
end

View file

@ -1,25 +1,48 @@
<div class="max-w-6xl mx-auto px-3 py-4 sm:p-6">
<div class="mb-8">
<h1 class="text-3xl font-bold text-white mb-4">Leaderboard</h1>
<div class="mb-8 space-y-4">
<h1 class="text-3xl font-bold text-white">Leaderboards</h1>
<div class="inline-flex rounded-full p-1 mb-4">
<%= link_to 'Last 24 Hours', leaderboards_path(period_type: 'daily'), class: "px-4 py-2 rounded-full text-sm font-medium transition-all duration-200 #{@period_type == :daily ? 'bg-primary text-white' : 'text-muted bg-darkless hover:text-white'}" %>
<%= link_to 'Last 7 Days', leaderboards_path(period_type: 'last_7_days'), class: "px-4 py-2 rounded-full text-sm font-medium transition-all duration-200 #{@period_type == :last_7_days ? 'bg-primary text-white' : 'text-muted bg-darkless hover:text-white'}" %>
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div class="inline-flex max-w-full overflow-x-auto rounded-full bg-darkless p-1 gap-1">
<%= link_to 'Global', leaderboards_path(period_type: @period_type, scope: 'global'), class: "text-center px-4 py-2 rounded-full text-sm font-medium transition-all duration-200 whitespace-nowrap #{@leaderboard_scope == :global ? 'bg-primary text-white' : 'text-muted hover:text-white'}" %>
<% if @country_scope_available %>
<%= link_to leaderboards_path(period_type: @period_type, scope: 'country'), class: "text-center px-4 py-2 rounded-full text-sm font-medium transition-all duration-200 inline-flex items-center justify-center gap-2 whitespace-nowrap #{@leaderboard_scope == :country ? 'bg-primary text-white' : 'text-muted hover:text-white'}" do %>
<%= country_to_emoji(@country_code) %>
<span class="max-w-[12rem] truncate"><%= @country_name %></span>
<% end %>
<% else %>
<span class="text-center px-4 py-2 rounded-full text-sm font-medium text-muted/60 bg-darker cursor-not-allowed whitespace-nowrap">Country</span>
<% end %>
</div>
<div class="inline-flex max-w-full overflow-x-auto rounded-full bg-darkless p-1 gap-1">
<%= link_to 'Last 24 Hours', leaderboards_path(period_type: 'daily', scope: @leaderboard_scope), class: "text-center px-4 py-2 rounded-full text-sm font-medium transition-all duration-200 whitespace-nowrap #{@period_type == :daily ? 'bg-primary text-white' : 'text-muted hover:text-white'}" %>
<%= link_to 'Last 7 Days', leaderboards_path(period_type: 'last_7_days', scope: @leaderboard_scope), class: "text-center px-4 py-2 rounded-full text-sm font-medium transition-all duration-200 whitespace-nowrap #{@period_type == :last_7_days ? 'bg-primary text-white' : 'text-muted hover:text-white'}" %>
</div>
</div>
<% if current_user && !@country_scope_available %>
<p class="text-xs text-muted">
Set your country in
<%= link_to 'settings', my_settings_path, class: 'text-accent hover:text-cyan-400 transition-colors' %>
to unlock regional leaderboards.
</p>
<% end %>
<% if current_user && current_user.github_uid.blank? %>
<div class="bg-darker border border-primary rounded-lg p-4 mb-6 flex flex-col sm:flex-row sm:items-center gap-3">
<div class="bg-darker border border-primary rounded-lg p-4 flex flex-col sm:flex-row sm:items-center gap-3">
<span class="text-white">Connect your GitHub to qualify for the leaderboard.</span>
<%= link_to 'Connect GitHub', '/auth/github', class: 'bg-primary hover:bg-primary/75 text-white font-medium px-4 py-2 rounded-lg transition-colors duration-200 text-center shrink-0 w-fit' %>
</div>
<% end %>
<div class="text-muted text-sm">
<div class="text-muted text-sm flex flex-wrap items-center gap-x-2 gap-y-1">
<% if @leaderboard %>
<%= @leaderboard.date_range_text %>
<% if @leaderboard.finished_generating? && @leaderboard.persisted? %>
<span class="italic"> - Updated <%= time_ago_in_words(@leaderboard.updated_at) %> ago. </span>
<span class="italic"> Updated <%= time_ago_in_words(@leaderboard.updated_at) %> ago.</span>
<% end %>
<% else %>
<%=
@ -37,16 +60,21 @@
<div class="bg-elevated rounded-xl border border-primary overflow-hidden">
<% if @leaderboard&.persisted? %>
<% if @total_entries && @total_entries > 0 %>
<div id="leaderboard-entries" class="divide-y divide-gray-800" data-controller="infinite-scroll" data-infinite-scroll-url-value="<%= entries_leaderboards_path(period_type: @period_type) %>" data-infinite-scroll-page-value="1" data-infinite-scroll-total-value="<%= @total_entries %>">
<div id="leaderboard-entries" class="divide-y divide-gray-800" data-controller="infinite-scroll" data-infinite-scroll-url-value="<%= entries_leaderboards_path(period_type: @period_type, scope: @leaderboard_scope) %>" data-infinite-scroll-page-value="1" data-infinite-scroll-total-value="<%= @total_entries %>">
<%= render 'loading' %>
</div>
<% else %>
<div class="py-16 text-center">
<div class="py-16 text-center px-3">
<h3 class="text-xl font-medium text-white mb-2">No data available</h3>
<p class="text-muted">Check back later for <%= @period_type == :last_7_days ? 'the last 7 days' : 'the last 24 hours' %> results!</p>
<p class="text-muted">
Check back later for
<%= @period_type == :last_7_days ? 'last 7 days' : 'last 24 hours' %>
results!
</p>
</div>
<% end %>
<% unless @user_on_leaderboard && @untracked_entries != 0 %>
<% if @leaderboard_scope == :global && @untracked_entries.to_i.positive? && !@user_on_leaderboard %>
<div class="px-4 py-3 text-sm text-muted border-t border-primary">
Don't see yourself on the leaderboard? You're probably one of the
<%= pluralize(@untracked_entries, 'user') %>
@ -54,13 +82,19 @@
<%= link_to 'updated their wakatime config', my_settings_path, target: '_blank', class: 'text-accent hover:text-cyan-400 transition-colors' %>.
</div>
<% end %>
<% if @leaderboard.finished_generating? %>
<div class="px-4 py-2 text-xs italic text-muted border-t border-primary">Generated in <%= @leaderboard.finished_generating_at - @leaderboard.created_at %> seconds</div>
<% end %>
<% else %>
<div class="py-16 text-center">
<div class="py-16 text-center px-3">
<h3 class="text-xl font-medium text-white mb-2">No data available</h3>
<p class="text-muted">Check back later for <%= @period_type == :last_7_days ? 'the last 7 days' : 'the last 24 hours' %> results!</p>
<p class="text-muted">
Check back later for
<%= @leaderboard_scope == :country ? "#{@country_name} " : '' %>
<%= @period_type == :last_7_days ? 'last 7 days' : 'last 24 hours' %>
results!
</p>
</div>
<% end %>
</div>

View file

@ -0,0 +1,93 @@
require "test_helper"
class LeaderboardsControllerTest < ActionDispatch::IntegrationTest
setup do
Rails.cache.clear
end
teardown do
Rails.cache.clear
end
test "index renders country tab label and preserves scope in period links" do
us_user = create_user(username: "us_index_user", country_code: "US")
create_boards_for_today(period_type: :last_7_days)
sign_in_as(us_user)
get leaderboards_path(period_type: "last_7_days", scope: "country")
assert_response :success
assert_select "a[href='#{leaderboards_path(period_type: "last_7_days", scope: "global")}']", text: "Global"
assert_select "a[href='#{leaderboards_path(period_type: "last_7_days", scope: "country")}']", text: /United States/
assert_select "a[href='#{leaderboards_path(period_type: "daily", scope: "country")}']", text: "Last 24 Hours"
end
test "index falls back to global selector state when country is missing" do
viewer = create_user(username: "viewer_no_country")
create_boards_for_today(period_type: :daily)
sign_in_as(viewer)
get leaderboards_path(period_type: "daily", scope: "country")
assert_response :success
assert_select "span", text: "Country"
assert_select "a[href='#{leaderboards_path(period_type: "daily", scope: "global")}']"
end
test "entries clamps page=0 to page 1 instead of erroring" do
user = create_user(username: "page_zero_user")
board = create_boards_for_today(period_type: :daily).first
board.entries.create!(user: user, total_seconds: 100)
sign_in_as(user)
get entries_leaderboards_path(period_type: "daily", page: 0), xhr: true
assert_response :success
end
test "entries clamps negative page to page 1 instead of erroring" do
user = create_user(username: "neg_page_user")
board = create_boards_for_today(period_type: :daily).first
board.entries.create!(user: user, total_seconds: 100)
sign_in_as(user)
get entries_leaderboards_path(period_type: "daily", page: -5), xhr: true
assert_response :success
end
test "validated_period_type does not intern arbitrary symbols" do
user = create_user(username: "bad_period_user")
create_boards_for_today(period_type: :daily)
sign_in_as(user)
get leaderboards_path(period_type: "evil_user_input_xyz")
assert_response :success
assert_not Symbol.all_symbols.map(&:to_s).include?("evil_user_input_xyz"),
"Arbitrary user input should not be interned as a symbol"
end
private
def create_user(username:, country_code: nil)
User.create!(username:, country_code:, timezone: "UTC")
end
def create_boards_for_today(period_type:)
[ Date.current, Time.current.in_time_zone("UTC").to_date ].uniq.map do |date|
Leaderboard.create!(
start_date: date,
period_type: period_type,
timezone_utc_offset: nil,
finished_generating_at: Time.current
)
end
end
def sign_in_as(user)
token = user.sign_in_tokens.create!(auth_type: :email)
get auth_token_path(token: token.token)
assert_equal user.id, session[:user_id]
end
end