mirror of
https://github.com/System-End/hackatime.git
synced 2026-04-19 22:15:14 +00:00
Regional leaderboards (#943)
* Regional leaderboards * Fix the bugs! * bin/rubocop -A
This commit is contained in:
parent
2b178af3e6
commit
8621dfa3ed
4 changed files with 192 additions and 28 deletions
12
.github/workflows/ci.yml
vendored
12
.github/workflows/ci.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
93
test/controllers/leaderboards_controller_test.rb
Normal file
93
test/controllers/leaderboards_controller_test.rb
Normal 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
|
||||
Loading…
Add table
Reference in a new issue