diff --git a/app/controllers/leaderboards_controller.rb b/app/controllers/leaderboards_controller.rb index db8e676..f1abdc5 100644 --- a/app/controllers/leaderboards_controller.rb +++ b/app/controllers/leaderboards_controller.rb @@ -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 diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 9dd0fe9..7faf7da 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -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 diff --git a/app/jobs/timezone_leaderboard_update_job.rb b/app/jobs/timezone_leaderboard_update_job.rb new file mode 100644 index 0000000..537efe5 --- /dev/null +++ b/app/jobs/timezone_leaderboard_update_job.rb @@ -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 diff --git a/app/models/concerns/timezone_regions.rb b/app/models/concerns/timezone_regions.rb new file mode 100644 index 0000000..0a3837f --- /dev/null +++ b/app/models/concerns/timezone_regions.rb @@ -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 diff --git a/app/models/user.rb b/app/models/user.rb index b0142eb..3c44b3f 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1,4 +1,6 @@ class User < ApplicationRecord + include TimezoneRegions + has_paper_trail encrypts :slack_access_token, :github_access_token diff --git a/app/services/leaderboard_generator.rb b/app/services/leaderboard_generator.rb new file mode 100644 index 0000000..166da02 --- /dev/null +++ b/app/services/leaderboard_generator.rb @@ -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 diff --git a/app/views/leaderboards/index.html.erb b/app/views/leaderboards/index.html.erb index 96d910a..0fa5d93 100644 --- a/app/views/leaderboards/index.html.erb +++ b/app/views/leaderboards/index.html.erb @@ -2,17 +2,38 @@

Leaderboard

- This leaderboard runs in UTC time! + <% if @scope == 'regional' %> + 🧪 Regional Leaderboard: Showing users in <%= @scope_description %> + <% elsif @scope == 'timezone' %> + 🧪 Timezone Leaderboard: Showing users in <%= @scope_description %> + <% elsif @scope == 'global' %> + This leaderboard runs in UTC time! + <% else %> + 🧪 Regional Leaderboard: Showing users in <%= @scope_description %> + <% end %>
- <%= 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' : ''}" %>
+ <% if current_user && Flipper.enabled?(:timezone_leaderboard, current_user) %> +
+ <%= 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' : ''}" %> +
+ <% end %> + + + <% if current_user && current_user.github_uid.blank? %>

<%= 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? %> Updated <%= time_ago_in_words(@leaderboard.updated_at) %> ago. @@ -103,7 +124,7 @@ <%= link_to "updated their wakatime config", my_settings_path, target: "_blank" %>.

<% end %> - <% if @leaderboard.finished_generating? %> + <% if @leaderboard.finished_generating? && @leaderboard.persisted? %> Generated in <%= @leaderboard.finished_generating_at - @leaderboard.created_at %> seconds diff --git a/app/views/users/_timezone_leaderboard_toggle.html.erb b/app/views/users/_timezone_leaderboard_toggle.html.erb new file mode 100644 index 0000000..1ad7b41 --- /dev/null +++ b/app/views/users/_timezone_leaderboard_toggle.html.erb @@ -0,0 +1,12 @@ +
+

+ Regional & Timezone Leaderboards
+ Access regional leaderboards that show users in your timezone region or specific timezone. Choose between timezone-specific, regional (UTC offset), or global competition modes. +

+ <%= 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 %> +
diff --git a/app/views/users/edit.html.erb b/app/views/users/edit.html.erb index a220100..9dacd79 100644 --- a/app/views/users/edit.html.erb +++ b/app/views/users/edit.html.erb @@ -268,6 +268,15 @@ <% end %> +
+
+

🧪 Beta Features

+

Enable experimental features and help us test new functionality.

+
+ + <%= render "timezone_leaderboard_toggle", user: @user %> +
+ <% admin_tool do %>
diff --git a/db/migrate/20250611070646_add_scope_to_leaderboards.rb b/db/migrate/20250611070646_add_scope_to_leaderboards.rb new file mode 100644 index 0000000..c0bc60a --- /dev/null +++ b/db/migrate/20250611070646_add_scope_to_leaderboards.rb @@ -0,0 +1,5 @@ +class AddScopeToLeaderboards < ActiveRecord::Migration[8.0] + def change + add_column :leaderboards, :scope, :string + end +end diff --git a/db/migrate/20250611071124_remove_scope_from_leaderboards.rb b/db/migrate/20250611071124_remove_scope_from_leaderboards.rb new file mode 100644 index 0000000..bf3134d --- /dev/null +++ b/db/migrate/20250611071124_remove_scope_from_leaderboards.rb @@ -0,0 +1,5 @@ +class RemoveScopeFromLeaderboards < ActiveRecord::Migration[8.0] + def change + remove_column :leaderboards, :scope, :string + end +end diff --git a/db/schema.rb b/db/schema.rb index a0f68e8..bba0cfd 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -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