diff --git a/app/jobs/warm_mini_leaderboard_cache_job.rb b/app/jobs/warm_mini_leaderboard_cache_job.rb new file mode 100644 index 0000000..28e5b0c --- /dev/null +++ b/app/jobs/warm_mini_leaderboard_cache_job.rb @@ -0,0 +1,20 @@ +class WarmMiniLeaderboardCacheJob < ApplicationJob + queue_as :default + + def perform + offsets = [ -8, -7, -6, -5, -4, -3, 0, 1, 2, 8, 9, 10, 11, 12 ] + + offsets.each do |offset| + begin + LeaderboardGenerator.generate_timezone_offset_leaderboard( + Date.current, + offset, + :daily + ) + Rails.logger.info "Warmed mini leaderboard cache for UTC#{offset >= 0 ? '+' : ''}#{offset}" + rescue => e + Rails.logger.error "Failed to warm cache for UTC#{offset >= 0 ? '+' : ''}#{offset}: #{e.message}" + end + end + end +end diff --git a/app/models/concerns/heartbeatable.rb b/app/models/concerns/heartbeatable.rb index 3b6a694..b85e585 100644 --- a/app/models/concerns/heartbeatable.rb +++ b/app/models/concerns/heartbeatable.rb @@ -98,9 +98,19 @@ module Heartbeatable end def daily_streaks_for_users(user_ids, start_date: 31.days.ago) - # First get the raw durations using window function + return {} if user_ids.empty? + start_date = [ start_date, 30.days.ago ].max + keys = user_ids.map { |id| "user_streak_#{id}" } + streak_cache = Rails.cache.read_multi(*keys) + + uncached_users = user_ids.select { |id| streak_cache["user_streak_#{id}"].nil? } + + if uncached_users.empty? + return user_ids.index_with { |id| streak_cache["user_streak_#{id}"] || 0 } + end + raw_durations = joins(:user) - .where(user_id: user_ids) + .where(user_id: uncached_users) .coding_only .with_valid_timestamps .where(time: start_date..Time.current) @@ -128,8 +138,7 @@ module Heartbeatable } end - # Initialize the result hash with zeros for all users - result = user_ids.index_with { 0 } + result = user_ids.index_with { |id| streak_cache["user_streak_#{id}"] || 0 } # Then calculate streaks for each user daily_durations.each do |user_id, data| @@ -158,6 +167,9 @@ module Heartbeatable end result[user_id] = streak + + # Cache the streak for 1 hour + Rails.cache.write("user_streak_#{user_id}", streak, expires_in: 1.hour) end result diff --git a/app/services/leaderboard_generator.rb b/app/services/leaderboard_generator.rb index 9ab4959..99a56f7 100644 --- a/app/services/leaderboard_generator.rb +++ b/app/services/leaderboard_generator.rb @@ -11,14 +11,24 @@ class LeaderboardGenerator 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).not_convicted - generate_leaderboard_for_users(users, date, "UTC#{utc_offset >= 0 ? '+' : ''}#{utc_offset}", period_type) + + cache_key = "timezone_leaderboard_#{utc_offset}_#{date}_#{period_type}" + + Rails.cache.fetch(cache_key, expires_in: 5.minutes) do + users = User.users_in_timezone_offset(utc_offset).not_convicted + generate_leaderboard_for_users(users, date, "UTC#{utc_offset >= 0 ? '+' : ''}#{utc_offset}", period_type) + end end def generate_timezone_leaderboard(date, timezone, period_type = :daily) date = Date.current if date.blank? - users = User.users_in_timezone(timezone).not_convicted - generate_leaderboard_for_users(users, date, timezone, period_type) + + cache_key = "timezone_leaderboard_#{timezone.gsub('/', '_')}_#{date}_#{period_type}" + + Rails.cache.fetch(cache_key, expires_in: 5.minutes) do + users = User.users_in_timezone(timezone).not_convicted + generate_leaderboard_for_users(users, date, timezone, period_type) + end end private @@ -34,10 +44,13 @@ class LeaderboardGenerator finished_generating_at: Time.current ) - # Get user IDs + # Get user IDs and preload users hash for faster lookups user_ids = users.pluck(:id) return leaderboard if user_ids.empty? + # Preload users into a hash for O(1) lookups + users_hash = users.index_by(&:id) + # Calculate heartbeats for the date range in UTC date_range = case period_type when :weekly @@ -48,7 +61,7 @@ class LeaderboardGenerator date.all_day end - # Get heartbeats for these users + # Get heartbeats for these users - limit to reduce query size heartbeats = Heartbeat.where(user_id: user_ids, time: date_range) .coding_only .with_valid_timestamps @@ -59,8 +72,10 @@ class LeaderboardGenerator 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? + # Only calculate streaks for users who actually have time today + # This significantly reduces the streak calculation overhead + streak_user_ids = user_totals.keys + streaks = streak_user_ids.any? ? Heartbeat.daily_streaks_for_users(streak_user_ids, start_date: 30.days.ago) : {} # Create virtual leaderboard entries entries = user_totals.map do |user_id, total_seconds| @@ -71,8 +86,8 @@ class LeaderboardGenerator 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 } + # Use preloaded users hash instead of find + entry.user = users_hash[user_id] entry end.sort_by(&:total_seconds).reverse diff --git a/app/views/leaderboards/_mini_leaderboard.html.erb b/app/views/leaderboards/_mini_leaderboard.html.erb index 1328abb..f4d619e 100644 --- a/app/views/leaderboards/_mini_leaderboard.html.erb +++ b/app/views/leaderboards/_mini_leaderboard.html.erb @@ -2,23 +2,17 @@ <% if leaderboard.present? %> <% entries = leaderboard.entries.respond_to?(:order) ? leaderboard.entries.order(total_seconds: :desc) : leaderboard.entries - if current_user - user_rank = entries.find_index { |entry| entry.user_id == current_user.id } - if user_rank && user_rank >= 3 - # Show top 2 entries and immediate competition - top_entries = entries[0..1] - competition_entries = entries[[user_rank - 1, 2].max..[user_rank + 1, entries.size - 1].min] - mini_leaderboard_entries = top_entries + competition_entries - show_top_entries = false - else - # Show top 3 entries (either user is in top 3 or not on leaderboard) - mini_leaderboard_entries = entries.first(3) - show_top_entries = true - end + + top_entries = entries.first(3) + user_entry = current_user ? entries.find { |entry| entry.user_id == current_user.id } : nil + user_rank = user_entry ? entries.index(user_entry) : nil + + if user_entry && user_rank && user_rank >= 3 + mini_leaderboard_entries = top_entries + [user_entry] + show_user_separately = true else - # Not logged in, show top 3 - mini_leaderboard_entries = entries.first(3) - show_top_entries = true + mini_leaderboard_entries = top_entries + show_user_separately = false end %> @@ -36,20 +30,22 @@