Add regional leaderboard beta opt-in (#305)

This commit is contained in:
Max Wofford
2025-06-11 04:12:18 -04:00
committed by GitHub
parent 4bc0dd0ea8
commit c70c961431
12 changed files with 463 additions and 65 deletions

View File

@@ -1,61 +1,137 @@
class LeaderboardsController < ApplicationController
def index
@period_type = (params[:period_type] || "daily").to_sym
@period_type = :daily unless Leaderboard.period_types
.keys
.map(&:to_sym)
.include?(@period_type)
set_params
validate_timezone_requirements
start_date = case @period_type
when :weekly
Date.current.beginning_of_week
when :last_7_days
Date.current
else
Date.current
end
cache_key = "leaderboard_#{@period_type}_#{start_date}"
@leaderboard = Rails.cache.fetch(cache_key, expires_in: 1.minute) do
Leaderboard.where.not(finished_generating_at: nil)
.find_by(
start_date: start_date,
period_type: @period_type,
deleted_at: nil
)
end
Rails.cache.delete(cache_key) if @leaderboard.nil?
@leaderboard = find_or_generate_leaderboard
if @leaderboard.nil?
LeaderboardUpdateJob.perform_later @period_type
flash.now[:notice] = "Leaderboard is being updated..."
else
# Load entries with users and their project repo mappings in a single query
@entries = @leaderboard.entries
.includes(:user)
.order(total_seconds: :desc)
tracked_user_ids = @leaderboard.entries.distinct.pluck(:user_id)
@user_on_leaderboard = current_user && tracked_user_ids.include?(current_user.id)
unless @user_on_leaderboard
time_range = case @period_type
when :weekly
(start_date.beginning_of_day...(start_date + 7.days).beginning_of_day)
when :last_7_days
((start_date - 6.days).beginning_of_day...start_date.end_of_day)
else
start_date.all_day
end
@untracked_entries = Hackatime::Heartbeat
.where(time: time_range)
.distinct
.pluck(:user_id)
.count { |user_id| !tracked_user_ids.include?(user_id) }
end
@active_projects = Cache::ActiveProjectsJob.perform_now
load_entries_and_metadata
end
end
private
def set_params
@use_timezone_leaderboard = current_user && Flipper.enabled?(:timezone_leaderboard, current_user)
@period_type = validated_period_type
@scope = params[:scope] || (@use_timezone_leaderboard ? "regional" : "global")
@scope_description = scope_description
end
def validated_period_type
period = (params[:period_type] || "daily").to_sym
valid_periods = [ :daily, :weekly, :last_7_days ]
valid_periods.include?(period) ? period : :daily
end
def scope_description
case @scope
when "regional" then current_user&.timezone_offset_name
when "timezone" then current_user&.timezone
end
end
def validate_timezone_requirements
return unless regional_or_timezone_scope?
unless current_user&.timezone
flash[:error] = "Please set your timezone in settings to view regional leaderboards"
redirect_to my_settings_path
return
end
if @scope == "regional" && current_user.timezone_utc_offset.nil?
flash[:error] = "Unable to determine UTC offset for your timezone: #{current_user.timezone}"
redirect_to leaderboards_path
end
end
def regional_or_timezone_scope?
%w[regional timezone].include?(@scope)
end
def find_or_generate_leaderboard
case @scope
when "regional" then generate_regional_leaderboard
when "timezone" then generate_timezone_leaderboard
else find_or_generate_global_leaderboard
end
end
def generate_regional_leaderboard
LeaderboardGenerator.generate_timezone_offset_leaderboard(
start_date, current_user.timezone_utc_offset, @period_type
)
end
def generate_timezone_leaderboard
LeaderboardGenerator.generate_timezone_leaderboard(
start_date, current_user.timezone, @period_type
)
end
def find_or_generate_global_leaderboard
cache_key = "leaderboard_#{@period_type}_#{start_date}"
leaderboard = Rails.cache.fetch(cache_key, expires_in: 1.minute) do
Leaderboard.where.not(finished_generating_at: nil)
.find_by(start_date: start_date, period_type: @period_type, deleted_at: nil)
end
Rails.cache.delete(cache_key) if leaderboard.nil?
if leaderboard.nil?
LeaderboardUpdateJob.perform_later(@period_type)
nil
else
leaderboard
end
end
def start_date
@start_date ||= case @period_type
when :weekly then Date.current.beginning_of_week
when :last_7_days then Date.current - 6.days
else Date.current
end
end
def load_entries_and_metadata
@entries = @leaderboard.entries
if @leaderboard.persisted?
@entries = @entries.includes(:user).order(total_seconds: :desc)
load_user_tracking_data
end
@active_projects = Cache::ActiveProjectsJob.perform_now
end
def load_user_tracking_data
tracked_user_ids = @leaderboard.entries.distinct.pluck(:user_id)
@user_on_leaderboard = current_user && tracked_user_ids.include?(current_user.id)
unless @user_on_leaderboard || regional_or_timezone_scope?
@untracked_entries = calculate_untracked_entries(tracked_user_ids)
end
end
def calculate_untracked_entries(tracked_user_ids)
time_range = case @period_type
when :weekly
(start_date.beginning_of_day...(start_date + 7.days).beginning_of_day)
when :last_7_days
((start_date - 6.days).beginning_of_day...start_date.end_of_day)
else
start_date.all_day
end
Hackatime::Heartbeat.where(time: time_range)
.distinct
.pluck(:user_id)
.count { |user_id| !tracked_user_ids.include?(user_id) }
end
end

View File

@@ -20,15 +20,47 @@ class UsersController < ApplicationController
end
def update
if @user.update(user_params)
if @user.uses_slack_status?
@user.update_slack_status
# Handle timezone leaderboard toggle
if params[:toggle_timezone_leaderboard] == "1"
if Flipper.enabled?(:timezone_leaderboard, @user)
Flipper.disable(:timezone_leaderboard, @user)
message = "Regional & Timezone Leaderboards disabled"
else
Flipper.enable(:timezone_leaderboard, @user)
message = "Regional & Timezone Leaderboards enabled"
end
respond_to do |format|
format.turbo_stream do
render turbo_stream: turbo_stream.replace(
"timezone_leaderboard_toggle",
partial: "timezone_leaderboard_toggle",
locals: { user: @user }
)
end
format.html do
redirect_to is_own_settings? ? my_settings_path : settings_user_path(@user),
notice: message
end
end
return
end
# Handle regular user settings updates
if params[:user].present?
if @user.update(user_params)
if @user.uses_slack_status?
@user.update_slack_status
end
redirect_to is_own_settings? ? my_settings_path : settings_user_path(@user),
notice: "Settings updated successfully"
else
flash[:error] = "Failed to update settings"
render :settings, status: :unprocessable_entity
end
else
redirect_to is_own_settings? ? my_settings_path : settings_user_path(@user),
notice: "Settings updated successfully"
else
flash[:error] = "Failed to update settings"
render :settings, status: :unprocessable_entity
end
end

View File

@@ -0,0 +1,80 @@
class TimezoneLeaderboardUpdateJob < ApplicationJob
queue_as :latency_10s
include GoodJob::ActiveJobExtensions::Concurrency
# Limits concurrency to 1 job per date
good_job_control_concurrency_with(
key: -> { "timezone_daily_#{arguments[0] || Date.current.to_s}" },
total: 1,
drop: true
)
def perform(date = Date.current)
parsed_date = date.is_a?(Date) ? date : Date.parse(date.to_s)
leaderboard = Leaderboard.create!(
start_date: parsed_date,
period_type: :daily_timezone_normalized
)
Rails.logger.info "Starting timezone-normalized leaderboard generation for #{parsed_date}"
ActiveRecord::Base.transaction do
# Get all unique timezones
timezones = User.where.not(timezone: nil).distinct.pluck(:timezone)
entries_data = []
timezones.each do |timezone|
# Calculate the date range for this timezone
timezone_date_range = Time.use_zone(timezone) do
parsed_date.in_time_zone(timezone).all_day
end
# Get all heartbeats for users in this timezone during their local day
timezone_heartbeats = Heartbeat.joins(:user)
.where(users: { timezone: timezone })
.where(time: timezone_date_range)
.coding_only
.with_valid_timestamps
.where.not(users: { github_uid: nil })
# Group by user and calculate totals
user_totals = timezone_heartbeats.group(:user_id).duration_seconds
user_totals = user_totals.filter { |_, total_seconds| total_seconds > 60 }
# Get streaks for all users at once
user_ids = user_totals.keys
streaks = Heartbeat.daily_streaks_for_users(user_ids) if user_ids.any?
# Build entries data
user_totals.each do |user_id, total_seconds|
entries_data << {
leaderboard_id: leaderboard.id,
user_id: user_id,
total_seconds: total_seconds,
streak_count: streaks[user_id] || 0
}
end
end
LeaderboardEntry.insert_all!(entries_data) if entries_data.any?
end
leaderboard.finished_generating_at = Time.current
leaderboard.save!
# Clean up old timezone-normalized leaderboards for this date
Leaderboard.where.not(id: leaderboard.id)
.where(start_date: parsed_date, period_type: :daily_timezone_normalized)
.where(deleted_at: nil)
.update_all(deleted_at: Time.current)
leaderboard
rescue => e
Rails.logger.error "Failed to update timezone-normalized leaderboard: #{e.message}"
raise
rescue Date::Error
raise ArgumentError, "Invalid date format provided"
end
end

View File

@@ -0,0 +1,71 @@
module TimezoneRegions
extend ActiveSupport::Concern
class_methods do
def timezone_to_utc_offset(timezone)
return nil if timezone.blank?
begin
tz = Time.find_zone(timezone)
return nil unless tz
tz.now.utc_offset / 3600 # Convert seconds to hours
rescue
nil
end
end
def users_in_timezone_offset(utc_offset)
# Get all users whose timezone has the same UTC offset
user_timezones = User.where.not(timezone: nil).distinct.pluck(:timezone)
matching_timezones = user_timezones.select do |tz|
timezone_to_utc_offset(tz) == utc_offset
end
User.where(timezone: matching_timezones)
end
def users_in_timezone(timezone)
User.where(timezone: timezone)
end
def available_timezone_offsets
# Get all unique UTC offsets that have users
user_timezones = User.where.not(timezone: nil).distinct.pluck(:timezone)
offsets = user_timezones.map { |tz| timezone_to_utc_offset(tz) }.compact.uniq.sort
offsets
end
def available_timezones
# Only return timezones that have users
User.where.not(timezone: nil).distinct.pluck(:timezone).sort
end
def offset_to_name(utc_offset)
case utc_offset
when -8 then "PST (UTC-8)"
when -7 then "MST (UTC-7)"
when -6 then "CST (UTC-6)"
when -5 then "EST (UTC-5)"
when -4 then "AST (UTC-4)"
when 0 then "GMT (UTC+0)"
when 1 then "CET (UTC+1)"
when 2 then "EET (UTC+2)"
when 8 then "CST Asia (UTC+8)"
when 9 then "JST (UTC+9)"
when 10 then "AEST (UTC+10)"
else "UTC#{utc_offset >= 0 ? '+' : ''}#{utc_offset}"
end
end
end
included do
def timezone_utc_offset
self.class.timezone_to_utc_offset(timezone)
end
def timezone_offset_name
offset = timezone_utc_offset
return "Unknown" unless offset
self.class.offset_to_name(offset)
end
end
end

View File

@@ -1,4 +1,6 @@
class User < ApplicationRecord
include TimezoneRegions
has_paper_trail
encrypts :slack_access_token, :github_access_token

View File

@@ -0,0 +1,85 @@
class LeaderboardGenerator
include TimezoneRegions
def self.generate_timezone_offset_leaderboard(date, utc_offset, period_type = :daily)
new.generate_timezone_offset_leaderboard(date, utc_offset, period_type)
end
def self.generate_timezone_leaderboard(date, timezone, period_type = :daily)
new.generate_timezone_leaderboard(date, timezone, period_type)
end
def generate_timezone_offset_leaderboard(date, utc_offset, period_type = :daily)
date = Date.current if date.blank?
users = User.users_in_timezone_offset(utc_offset)
generate_leaderboard_for_users(users, date, "UTC#{utc_offset >= 0 ? '+' : ''}#{utc_offset}", period_type)
end
def generate_timezone_leaderboard(date, timezone, period_type = :daily)
date = Date.current if date.blank?
users = User.users_in_timezone(timezone)
generate_leaderboard_for_users(users, date, timezone, period_type)
end
private
def generate_leaderboard_for_users(users, date, scope_name, period_type = :daily)
# Ensure date is valid
date = Date.current if date.blank?
# Create a virtual leaderboard object (not saved to DB)
leaderboard = Leaderboard.new(
start_date: date,
period_type: period_type,
finished_generating_at: Time.current
)
# Get user IDs
user_ids = users.pluck(:id)
return leaderboard if user_ids.empty?
# Calculate heartbeats for the date range in UTC
date_range = case period_type
when :weekly
date.beginning_of_week.beginning_of_day..date.end_of_week.end_of_day
when :last_7_days
(date - 6.days).beginning_of_day..Date.current.end_of_day
else
date.all_day
end
# Get heartbeats for these users
heartbeats = Heartbeat.where(user_id: user_ids, time: date_range)
.coding_only
.with_valid_timestamps
.joins(:user)
.where.not(users: { github_uid: nil })
# Group by user and calculate totals
user_totals = heartbeats.group(:user_id).duration_seconds
user_totals = user_totals.filter { |_, total_seconds| total_seconds > 60 }
# Get streaks for all users at once
streaks = Heartbeat.daily_streaks_for_users(user_totals.keys) if user_totals.any?
# Create virtual leaderboard entries
entries = user_totals.map do |user_id, total_seconds|
entry = LeaderboardEntry.new(
leaderboard: leaderboard,
user_id: user_id,
total_seconds: total_seconds,
streak_count: streaks[user_id] || 0
)
# Manually set the user association to avoid N+1 queries
entry.user = users.find { |u| u.id == user_id }
entry
end.sort_by(&:total_seconds).reverse
# Attach entries to leaderboard
leaderboard.define_singleton_method(:entries) { entries }
leaderboard.define_singleton_method(:scope_name) { scope_name }
leaderboard
end
end

View File

@@ -2,17 +2,38 @@
<div class="header">
<h1>Leaderboard</h1>
<em>This leaderboard runs in UTC time!</em>
<% if @scope == 'regional' %>
<em>🧪 <strong>Regional Leaderboard:</strong> Showing users in <%= @scope_description %></em>
<% elsif @scope == 'timezone' %>
<em>🧪 <strong>Timezone Leaderboard:</strong> Showing users in <%= @scope_description %></em>
<% elsif @scope == 'global' %>
<em>This leaderboard runs in UTC time!</em>
<% else %>
<em>🧪 <strong>Regional Leaderboard:</strong> Showing users in <%= @scope_description %></em>
<% end %>
<div class="period-toggle">
<%= link_to "Daily", leaderboards_path(period_type: 'daily'),
class: "period-toggle-btn #{@period_type == :daily ? 'active' : ''}" %>
<%= link_to "Weekly", leaderboards_path(period_type: 'weekly'),
<%= link_to "Daily", leaderboards_path(period_type: 'daily', scope: @scope),
class: "period-toggle-btn #{(@period_type == :daily || @period_type == :daily_timezone_normalized) ? 'active' : ''}" %>
<%= link_to "Weekly", leaderboards_path(period_type: 'weekly', scope: @scope),
class: "period-toggle-btn #{@period_type == :weekly ? 'active' : ''}" %>
<%= link_to "Last 7 Days", leaderboards_path(period_type: 'last_7_days'),
<%= link_to "Last 7 Days", leaderboards_path(period_type: 'last_7_days', scope: @scope),
class: "period-toggle-btn #{@period_type == :last_7_days ? 'active' : ''}" %>
</div>
<% if current_user && Flipper.enabled?(:timezone_leaderboard, current_user) %>
<div class="period-toggle">
<%= link_to "Timezone", leaderboards_path(period_type: @period_type, scope: 'timezone'),
class: "period-toggle-btn #{@scope == 'timezone' ? 'active' : ''}" %>
<%= link_to "Regional", leaderboards_path(period_type: @period_type, scope: 'regional'),
class: "period-toggle-btn #{@scope == 'regional' ? 'active' : ''}" %>
<%= link_to "Global", leaderboards_path(period_type: @period_type, scope: 'global'),
class: "period-toggle-btn #{@scope == 'global' ? 'active' : ''}" %>
</div>
<% end %>
<% if current_user && current_user.github_uid.blank? %>
<p>
<%= link_to "Connect your GitHub", "/auth/github", class: "button" %> to qualify for the leaderboard.
@@ -23,7 +44,7 @@
<% if @leaderboard %>
<%= @leaderboard.date_range_text %>
<% if @leaderboard.finished_generating? %>
<% if @leaderboard.finished_generating? && @leaderboard.persisted? %>
<span class="super">
Updated <%= time_ago_in_words(@leaderboard.updated_at) %> ago.
</span>
@@ -103,7 +124,7 @@
<%= link_to "updated their wakatime config", my_settings_path, target: "_blank" %>.
</p>
<% end %>
<% if @leaderboard.finished_generating? %>
<% if @leaderboard.finished_generating? && @leaderboard.persisted? %>
<span class="super">
Generated in <%= @leaderboard.finished_generating_at - @leaderboard.created_at %> seconds
</span>

View File

@@ -0,0 +1,12 @@
<div id="timezone_leaderboard_toggle">
<p>
<strong>Regional & Timezone Leaderboards</strong><br>
<small>Access regional leaderboards that show users in your timezone region or specific timezone. Choose between timezone-specific, regional (UTC offset), or global competition modes.</small>
</p>
<%= form_with url: (@is_own_settings ? my_settings_path : settings_user_path(user)), method: :patch, local: false do |f| %>
<%= hidden_field_tag :toggle_timezone_leaderboard, "1" %>
<%= f.submit Flipper.enabled?(:timezone_leaderboard, user) ? "Disable Feature" : "Enable Feature",
role: "button",
class: Flipper.enabled?(:timezone_leaderboard, user) ? "secondary" : "" %>
<% end %>
</div>

View File

@@ -268,6 +268,15 @@
<% end %>
</article>
<article>
<header>
<h2 id="user_beta_features">🧪 Beta Features</h2>
<p>Enable experimental features and help us test new functionality.</p>
</header>
<%= render "timezone_leaderboard_toggle", user: @user %>
</article>
<% admin_tool do %>
<article>
<header>

View File

@@ -0,0 +1,5 @@
class AddScopeToLeaderboards < ActiveRecord::Migration[8.0]
def change
add_column :leaderboards, :scope, :string
end
end

View File

@@ -0,0 +1,5 @@
class RemoveScopeFromLeaderboards < ActiveRecord::Migration[8.0]
def change
remove_column :leaderboards, :scope, :string
end
end

2
db/schema.rb generated
View File

@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.0].define(version: 2025_06_11_062456) do
ActiveRecord::Schema[8.0].define(version: 2025_06_11_071124) do
create_schema "pganalyze"
# These are extensions that must be enabled in order to support this database