mirror of
https://github.com/SrIzan10/hc-harbor.git
synced 2026-05-01 10:45:21 +00:00
Regional leaderboards persist to database (#494)
This commit is contained in:
@@ -10,51 +10,78 @@ class LeaderboardUpdateJob < ApplicationJob
|
||||
drop: true
|
||||
)
|
||||
|
||||
def perform(period = :daily, date = Date.current)
|
||||
def perform(period = :daily, date = Date.current, force_update: false)
|
||||
date = LeaderboardDateRange.normalize_date(date, period)
|
||||
|
||||
Rails.logger.info "Starting leaderboard generation for #{period} on #{date}"
|
||||
# global
|
||||
build_leaderboard(date, period, nil, nil, force_update)
|
||||
|
||||
board = build_global(date, period)
|
||||
build_timezones(date, period)
|
||||
|
||||
Rails.logger.info "Completed leaderboard generation for #{period} on #{date}"
|
||||
|
||||
board
|
||||
rescue => e
|
||||
Rails.logger.error "Failed to update leaderboard: #{e.message}"
|
||||
Honeybadger.notify(e, context: { period: period, date: date })
|
||||
raise
|
||||
# Build timezone leaderboards
|
||||
range = LeaderboardDateRange.calculate(date, period)
|
||||
timezones_for_users_in(range).each do |timezone|
|
||||
offset = User.timezone_to_utc_offset(timezone)
|
||||
build_leaderboard(date, period, offset, timezone, force_update)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def build_global(date, period)
|
||||
range = LeaderboardDateRange.calculate(date, period)
|
||||
def timezones_for_users_in(range)
|
||||
# Expand range by 1 day in both directions to catch users in all timezones
|
||||
expanded_range = (range.begin - 1.day)...(range.end + 1.day)
|
||||
|
||||
User.joins(:heartbeats)
|
||||
.where(heartbeats: { time: expanded_range })
|
||||
.where.not(timezone: nil)
|
||||
.distinct
|
||||
.pluck(:timezone)
|
||||
.compact
|
||||
end
|
||||
|
||||
|
||||
def build_leaderboard(date, period, timezone_offset = nil, timezone = nil, force_update = false)
|
||||
board = ::Leaderboard.find_or_create_by!(
|
||||
start_date: date,
|
||||
period_type: period,
|
||||
timezone_utc_offset: nil
|
||||
) do |lb|
|
||||
lb.finished_generating_at = nil
|
||||
timezone_utc_offset: timezone_offset
|
||||
)
|
||||
|
||||
return board if board.finished_generating_at.present? && !force_update
|
||||
|
||||
if timezone_offset
|
||||
Rails.logger.info "Building timezone leaderboard for #{timezone} (UTC#{timezone_offset >= 0 ? '+' : ''}#{timezone_offset})"
|
||||
else
|
||||
Rails.logger.info "Building global leaderboard"
|
||||
end
|
||||
|
||||
return board if board.finished_generating_at.present?
|
||||
# Calculate timezone-aware range
|
||||
range = if timezone
|
||||
Time.use_zone(timezone) { LeaderboardDateRange.calculate(date, period) }
|
||||
else
|
||||
LeaderboardDateRange.calculate(date, period)
|
||||
end
|
||||
|
||||
ActiveRecord::Base.transaction do
|
||||
board.entries.delete_all
|
||||
data = Heartbeat.where(time: range)
|
||||
.with_valid_timestamps
|
||||
.joins(:user)
|
||||
.coding_only
|
||||
.where.not(users: { github_uid: nil })
|
||||
.group(:user_id)
|
||||
.duration_seconds
|
||||
|
||||
data = data.filter { |_, seconds| seconds > 60 }
|
||||
# Build the base heartbeat query
|
||||
heartbeat_query = Heartbeat.where(time: range)
|
||||
.with_valid_timestamps
|
||||
.joins(:user)
|
||||
.coding_only
|
||||
.where.not(users: { github_uid: nil })
|
||||
.where.not(users: { trust_level: User.trust_levels[:red] })
|
||||
|
||||
convicted = User.where(trust_level: User.trust_levels[:red]).pluck(:id)
|
||||
data = data.reject { |user_id, _| convicted.include?(user_id) }
|
||||
# Filter by timezone if specified
|
||||
if timezone_offset
|
||||
users_in_tz = User.users_in_timezone_offset(timezone_offset).not_convicted
|
||||
user_ids = users_in_tz.pluck(:id)
|
||||
return board if user_ids.empty?
|
||||
heartbeat_query = heartbeat_query.where(user_id: user_ids)
|
||||
end
|
||||
|
||||
data = heartbeat_query.group(:user_id).duration_seconds
|
||||
.filter { |_, seconds| seconds > 60 }
|
||||
|
||||
streaks = Heartbeat.daily_streaks_for_users(data.keys)
|
||||
|
||||
@@ -73,41 +100,15 @@ class LeaderboardUpdateJob < ApplicationJob
|
||||
board.update!(finished_generating_at: Time.current)
|
||||
end
|
||||
|
||||
key = LeaderboardCache.global_key(period, date)
|
||||
LeaderboardCache.write(key, board)
|
||||
# Cache the board
|
||||
cache_key = timezone_offset ?
|
||||
LeaderboardCache.timezone_key(timezone_offset, date, period) :
|
||||
LeaderboardCache.global_key(period, date)
|
||||
|
||||
LeaderboardCache.write(cache_key, board)
|
||||
|
||||
Rails.logger.debug "Persisted #{timezone_offset ? 'timezone' : 'global'} leaderboard with #{board.entries.count} entries"
|
||||
|
||||
board
|
||||
end
|
||||
|
||||
def build_timezones(date, period)
|
||||
range = LeaderboardDateRange.calculate(date, period)
|
||||
|
||||
user_timezones = User.joins(:heartbeats)
|
||||
.where(heartbeats: { time: range })
|
||||
.where.not(timezone: nil)
|
||||
.distinct
|
||||
.pluck(:timezone)
|
||||
.compact
|
||||
|
||||
offsets = user_timezones.map { |tz| User.timezone_to_utc_offset(tz) }.compact.uniq
|
||||
|
||||
Rails.logger.info "Generating timezone leaderboards for #{offsets.size} active UTC offsets"
|
||||
|
||||
offsets.each do |offset|
|
||||
build_timezone(date, period, offset)
|
||||
end
|
||||
end
|
||||
|
||||
def build_timezone(date, period, offset)
|
||||
key = LeaderboardCache.timezone_key(offset, date, period)
|
||||
|
||||
data = LeaderboardCache.fetch(key) do
|
||||
users = User.users_in_timezone_offset(offset).not_convicted
|
||||
LeaderboardBuilder.build_for_users(users, date, "UTC#{offset >= 0 ? '+' : ''}#{offset}", period)
|
||||
end
|
||||
|
||||
Rails.logger.debug "Cached timezone leaderboard for UTC#{offset >= 0 ? '+' : ''}#{offset} with #{data&.entries&.size || 0} entries"
|
||||
|
||||
data
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
class TimezoneLeaderboardJob < ApplicationJob
|
||||
queue_as :latency_5m
|
||||
|
||||
include GoodJob::ActiveJobExtensions::Concurrency
|
||||
|
||||
# Limits concurrency to 1 job per timezone/period/date combination
|
||||
good_job_control_concurrency_with(
|
||||
key: -> { "timezone_#{arguments[0]}_#{arguments[1]}_#{arguments[2]}" },
|
||||
total: 1,
|
||||
drop: true
|
||||
)
|
||||
|
||||
def perform(period = :daily, date = Date.current, offset = 0)
|
||||
date = LeaderboardDateRange.normalize_date(date, period)
|
||||
|
||||
Rails.logger.info "Generating timezone leaderboard for UTC#{offset >= 0 ? '+' : ''}#{offset} (#{period}, #{date})"
|
||||
|
||||
key = LeaderboardCache.timezone_key(offset, date, period)
|
||||
|
||||
# Generate the leaderboard
|
||||
board = build_timezone(date, period, offset)
|
||||
|
||||
# Cache it for 10 minutes
|
||||
LeaderboardCache.write(key, board)
|
||||
|
||||
Rails.logger.info "Cached timezone leaderboard for UTC#{offset >= 0 ? '+' : ''}#{offset} with #{board&.entries&.size || 0} entries"
|
||||
|
||||
board
|
||||
rescue => e
|
||||
Rails.logger.error "Failed to generate timezone leaderboard for UTC#{offset}: #{e.message}"
|
||||
Honeybadger.notify(e, context: { period: period, date: date, offset: offset })
|
||||
raise
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def build_timezone(date, period, offset)
|
||||
users = User.users_in_timezone_offset(offset).not_convicted
|
||||
LeaderboardBuilder.build_for_users(users, date, "UTC#{offset >= 0 ? '+' : ''}#{offset}", period)
|
||||
end
|
||||
end
|
||||
@@ -1,80 +0,0 @@
|
||||
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
|
||||
@@ -18,18 +18,21 @@ class LeaderboardService
|
||||
private
|
||||
|
||||
def get_timezone(date, period, offset)
|
||||
date = LeaderboardDateRange.normalize_date(date, period)
|
||||
key = LeaderboardCache.timezone_key(offset, date, period)
|
||||
board = LeaderboardCache.read(key)
|
||||
|
||||
return board if board.present?
|
||||
|
||||
board = ::Leaderboard.where.not(finished_generating_at: nil)
|
||||
.find_by(start_date: date, period_type: period, timezone_utc_offset: offset, deleted_at: nil)
|
||||
|
||||
if board.present?
|
||||
Rails.logger.debug "Cache HIT for timezone leaderboard UTC#{offset >= 0 ? '+' : ''}#{offset}"
|
||||
LeaderboardCache.write(key, board)
|
||||
return board
|
||||
end
|
||||
|
||||
Rails.logger.debug "Cache MISS for timezone leaderboard UTC#{offset >= 0 ? '+' : ''}#{offset}"
|
||||
|
||||
TimezoneLeaderboardJob.perform_later(period, date, offset)
|
||||
Rails.logger.info "Falling back to global leaderboard for UTC#{offset >= 0 ? '+' : ''}#{offset}"
|
||||
::LeaderboardUpdateJob.perform_later(period, date)
|
||||
get_global(date, period)
|
||||
end
|
||||
|
||||
@@ -37,7 +40,9 @@ class LeaderboardService
|
||||
date = LeaderboardDateRange.normalize_date(date, period)
|
||||
key = LeaderboardCache.global_key(period, date)
|
||||
board = LeaderboardCache.read(key)
|
||||
|
||||
return board if board.present?
|
||||
|
||||
board = ::Leaderboard.where.not(finished_generating_at: nil)
|
||||
.find_by(start_date: date, period_type: period, timezone_utc_offset: nil, deleted_at: nil)
|
||||
|
||||
@@ -46,8 +51,7 @@ class LeaderboardService
|
||||
return board
|
||||
end
|
||||
|
||||
Rails.logger.info "No leaderboard found for #{period} #{date}, triggering background generation"
|
||||
LeaderboardUpdateJob.perform_later(period, date)
|
||||
::LeaderboardUpdateJob.perform_later(period, date)
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
@@ -23,11 +23,11 @@
|
||||
|
||||
<div class="inline-flex rounded-full p-1 mb-4 ml-4">
|
||||
<%= link_to "Timezone", leaderboards_path(period_type: @period_type, scope: 'timezone'),
|
||||
class: "px-4 py-2 rounded-full text-sm font-medium transition-all duration-200 #{@scope == 'timezone' ? ' text-white' : 'text-muted hover:text-white'}", style: "background:none; border:none;" %>
|
||||
class: "px-4 py-2 rounded-full text-sm font-medium transition-all duration-200 #{@scope == 'timezone' ? ' bg-primary text-white' : 'text-muted hover:text-white'}", style: "background:none; border:none;" %>
|
||||
<%= link_to "Regional", leaderboards_path(period_type: @period_type, scope: 'regional'),
|
||||
class: "px-4 py-2 rounded-full text-sm font-medium transition-all duration-200 #{@scope == 'regional' ? ' text-white' : 'text-muted hover:text-white'}", style: "background:none; border:none;" %>
|
||||
class: "px-4 py-2 rounded-full text-sm font-medium transition-all duration-200 #{@scope == 'regional' ? ' bg-primary text-white' : 'text-muted hover:text-white'}", style: "background:none; border:none;" %>
|
||||
<%= link_to "Global", leaderboards_path(period_type: @period_type, scope: 'global'),
|
||||
class: "px-4 py-2 rounded-full text-sm font-medium transition-all duration-200 #{@scope == 'global' ? ' text-white' : 'text-muted hover:text-white'}", style: "background:none; border:none;" %>
|
||||
class: "px-4 py-2 rounded-full text-sm font-medium transition-all duration-200 #{@scope == 'global' ? ' bg-primary text-white' : 'text-muted hover:text-white'}", style: "background:none; border:none;" %>
|
||||
</div>
|
||||
|
||||
<% if current_user && current_user.github_uid.blank? %>
|
||||
|
||||
@@ -30,17 +30,20 @@ Rails.application.configure do
|
||||
daily_leaderboard_update: {
|
||||
cron: "* * * * *",
|
||||
class: "LeaderboardUpdateJob",
|
||||
args: [ :daily ]
|
||||
args: [ :daily ],
|
||||
kwargs: { force_update: true }
|
||||
},
|
||||
weekly_leaderboard_update: {
|
||||
cron: "*/2 * * * *",
|
||||
class: "LeaderboardUpdateJob",
|
||||
args: [ :weekly ]
|
||||
args: [ :weekly ],
|
||||
kwargs: { force_update: true }
|
||||
},
|
||||
last_7_days_leaderboard_update: {
|
||||
cron: "*/7 * * * *",
|
||||
class: "LeaderboardUpdateJob",
|
||||
args: [ :last_7_days ]
|
||||
args: [ :last_7_days ],
|
||||
kwargs: { force_update: true }
|
||||
},
|
||||
# sailors_log_poll: {
|
||||
# cron: "*/2 * * * *",
|
||||
|
||||
Reference in New Issue
Block a user