diff --git a/AGENT.md b/AGENT.md index 042f930..533be86 100644 --- a/AGENT.md +++ b/AGENT.md @@ -24,6 +24,8 @@ ## Git Practices - **NEVER commit `config/database.yml`** unless explicitly asked to - contains sensitive local/production database credentials +- **NEVER use `git add .`** - always add files individually to avoid accidentally committing unwanted files +- Use `git add ` or `git add /` for targeted commits ## Code Style (rubocop-rails-omakase) - **Naming**: snake_case files/methods/vars, PascalCase classes, 2-space indent diff --git a/Gemfile b/Gemfile index 99a5fe6..ab24c7b 100644 --- a/Gemfile +++ b/Gemfile @@ -94,6 +94,11 @@ gem "countries" # Markdown parsing gem "redcarpet" +# Feature flags +gem "flipper" +gem "flipper-active_record" +gem "flipper-ui" + group :development, :test do # See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem gem "debug", platforms: %i[ mri windows ], require: "debug/prelude" diff --git a/Gemfile.lock b/Gemfile.lock index ea8f9bc..307bb5a 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -175,6 +175,18 @@ GEM ffi (>= 1.15.5) rake flamegraph (0.9.5) + flipper (1.3.4) + concurrent-ruby (< 2) + flipper-active_record (1.3.4) + activerecord (>= 4.2, < 9) + flipper (~> 1.3.4) + flipper-ui (1.3.4) + erubi (>= 1.0.0, < 2.0.0) + flipper (~> 1.3.4) + rack (>= 1.4, < 4) + rack-protection (>= 1.5.3, < 5.0.0) + rack-session (>= 1.0.2, < 3.0.0) + sanitize (< 8) fugit (1.11.1) et-orbi (~> 1, >= 1.2.11) raabro (~> 1.4) @@ -339,6 +351,10 @@ GEM rack (>= 3.0.14) rack-mini-profiler (3.3.1) rack (>= 1.2.0) + rack-protection (4.1.1) + base64 (>= 0.1.0) + logger (>= 1.6.0) + rack (>= 3.0.0, < 4) rack-session (2.1.1) base64 (>= 0.1.0) rack (>= 3.0.0) @@ -422,6 +438,9 @@ GEM ruby-progressbar (1.13.0) rubyzip (2.4.1) safely_block (0.5.0) + sanitize (7.0.0) + crass (~> 1.0.2) + nokogiri (>= 1.16.8) securerandom (0.4.1) selenium-webdriver (4.33.0) base64 (~> 0.2) @@ -533,6 +552,9 @@ DEPENDENCIES doorkeeper (~> 5.8) dotenv-rails flamegraph + flipper + flipper-active_record + flipper-ui geocoder good_job honeybadger 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/static_pages_controller.rb b/app/controllers/static_pages_controller.rb index f079d15..aa5275e 100644 --- a/app/controllers/static_pages_controller.rb +++ b/app/controllers/static_pages_controller.rb @@ -82,12 +82,22 @@ class StaticPagesController < ApplicationController end def mini_leaderboard - @leaderboard = Leaderboard.where.associated(:entries) - .where(start_date: Date.current) - .where(deleted_at: nil) - .where(period_type: :daily) - .distinct - .first + @use_timezone_leaderboard = current_user && Flipper.enabled?(:timezone_leaderboard, current_user) + + if @use_timezone_leaderboard && current_user&.timezone_utc_offset + # Use regional leaderboard for beta participants + @leaderboard = LeaderboardGenerator.generate_timezone_offset_leaderboard( + Date.current, current_user.timezone_utc_offset, :daily + ) + else + # Use global leaderboard + @leaderboard = Leaderboard.where.associated(:entries) + .where(start_date: Date.current) + .where(deleted_at: nil) + .where(period_type: :daily) + .distinct + .first + end @active_projects = Cache::ActiveProjectsJob.perform_now diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 3b3c31e..b18462d 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/geocode_users_without_country_job.rb b/app/jobs/geocode_users_without_country_job.rb index c12ae52..4a039ac 100644 --- a/app/jobs/geocode_users_without_country_job.rb +++ b/app/jobs/geocode_users_without_country_job.rb @@ -6,13 +6,11 @@ class GeocodeUsersWithoutCountryJob < ApplicationJob enqueue_limit 1 def perform - return unless geocodable_users.exists? + return unless geocodable_users.present? - description = "Geocoding #{geocodable_users.count} user(s) at #{Time.current.iso8601}" - - GoodJob::Batch.enqueue(description: description) do - geocodable_users.find_each do |user| - SetUserCountryCodeJob.perform_later(user.id) + GoodJob::Bulk.enqueue do + geocodable_users.each do |user_id| + SetUserCountryCodeJob.perform_later(user_id) end end end @@ -24,5 +22,6 @@ class GeocodeUsersWithoutCountryJob < ApplicationJob .joins(:heartbeats) .where.not(heartbeats: { ip_address: nil }) .distinct + .pluck(:id) end end diff --git a/app/jobs/set_user_country_code_job.rb b/app/jobs/set_user_country_code_job.rb index 4eee253..d671dd8 100644 --- a/app/jobs/set_user_country_code_job.rb +++ b/app/jobs/set_user_country_code_job.rb @@ -3,6 +3,7 @@ class SetUserCountryCodeJob < ApplicationJob queue_as :literally_whenever def perform(user_id) + @user_id = user_id ips = Heartbeat.where(user_id: user_id) .where.not(ip_address: nil) .distinct @@ -10,40 +11,48 @@ class SetUserCountryCodeJob < ApplicationJob # Try IP geocoding first ips.each do |ip| - begin - puts "Getting country code for IP #{ip}" - result = Geocoder.search(ip).first - next unless result&.country_code.present? - - country_code = result.country_code.upcase - puts "Found country code: #{country_code}" - - if ISO3166::Country.codes.include?(country_code) - User.find(user_id).update!(country_code: country_code) - return - end - rescue => e - Rails.logger.error "Error getting country code for IP #{ip}: #{e.message}" - next - end + country_code = ip_to_country_code(ip) + next unless country_code.present? + return user.update!(country_code: country_code) end # Fallback to timezone if IP geocoding failed - user = User.find(user_id) return unless user.timezone.present? return if user.timezone == "UTC" # avoid anyone in the default timezone begin - puts "Falling back to timezone-based country detection for timezone #{user.timezone}" - country_code = timezone_to_country(user.timezone) + puts "Falling back to timezone-based country detection for timezone #{@user.timezone}" + country_code = timezone_to_country(@user.timezone) - if country_code.present? && ISO3166::Country.codes.include?(country_code.upcase) - country_code = country_code.upcase + if country_code.present? puts "Found country code from timezone: #{country_code}" user.update!(country_code: country_code) end rescue => e - Rails.logger.error "Error getting country code from timezone #{user.timezone}: #{e.message}" + Rails.logger.error "Error getting country code from timezone #{@user.timezone}: #{e.message}" + end + end + + private + + def user + @user ||= User.find(@user_id) + end + + def timezone_to_country(timezone) + ApplicationHelper.timezone_to_country(timezone) + end + + def ip_to_country_code(ip) + begin + puts "Getting country code for IP #{ip}" + result = Geocoder.search(ip).first + return unless result&.country_code.present? + + result.country_code.upcase + + rescue => e + Rails.logger.error "Error getting country code for IP #{ip}: #{e.message}" end end end diff --git a/app/jobs/sync_repo_metadata_job.rb b/app/jobs/sync_repo_metadata_job.rb index db00006..c270452 100644 --- a/app/jobs/sync_repo_metadata_job.rb +++ b/app/jobs/sync_repo_metadata_job.rb @@ -1,5 +1,5 @@ class SyncRepoMetadataJob < ApplicationJob - queue_as :default + queue_as :literally_whenever retry_on HTTP::TimeoutError, HTTP::ConnectionError, wait: :exponentially_longer, attempts: 3 retry_on JSON::ParserError, wait: 10.seconds, attempts: 2 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 2647d56..1d14d26 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/_mini_leaderboard.html.erb b/app/views/leaderboards/_mini_leaderboard.html.erb index 1e74d77..12eaae3 100644 --- a/app/views/leaderboards/_mini_leaderboard.html.erb +++ b/app/views/leaderboards/_mini_leaderboard.html.erb @@ -1,7 +1,7 @@ <%= turbo_frame_tag "mini_leaderboard" do %> <% if leaderboard.present? %> <% - entries = leaderboard.entries.order(total_seconds: :desc) + 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 @@ -25,9 +25,13 @@ <% if mini_leaderboard_entries&.any? %>

- This leaderboard is in <%= Leaderboard::GLOBAL_TIMEZONE %>. - <% if current_user && timezone_difference_in_seconds(Leaderboard::GLOBAL_TIMEZONE, current_user.timezone) != 0 %> - <%= timezone_difference_in_words(Leaderboard::GLOBAL_TIMEZONE, current_user.timezone) %> + <% if leaderboard.respond_to?(:scope_name) %> + 🧪 <%= link_to "Regional Leaderboard", my_settings_path(anchor: "user_beta_features") %>: Showing others in <%= link_to "your timezone", my_settings_path(anchor: "user_timezone") %> + <% else %> + This leaderboard is in <%= Leaderboard::GLOBAL_TIMEZONE %>. + <% if current_user && timezone_difference_in_seconds(Leaderboard::GLOBAL_TIMEZONE, current_user.timezone) != 0 %> + <%= timezone_difference_in_words(Leaderboard::GLOBAL_TIMEZONE, current_user.timezone) %> + <% 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/shared/_nav.html.erb b/app/views/shared/_nav.html.erb index 13aa5c5..77f5fe7 100644 --- a/app/views/shared/_nav.html.erb +++ b/app/views/shared/_nav.html.erb @@ -99,5 +99,10 @@ OAuth2 apps <% end %> <% end %> + <% admin_tool(nil, "li") do %> + <%= link_to flipper_path, class: "nav-item #{current_page?(flipper_path) ? 'active' : ''}", data: { action: "click->nav#clickLink" } do %> + Feature Flags + <% end %> + <% end %> 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 5c229c8..a22e53b 100644 --- a/app/views/users/edit.html.erb +++ b/app/views/users/edit.html.erb @@ -95,8 +95,10 @@ <% if @user.github_access_token.present? %> <%= link_to "Relink GitHub Account", github_auth_path, data: { turbo: "false" }, role: "button" %> <%= link_to "Unlink GitHub Account", github_unlink_path, - method: :delete, - data: { confirm: "Are you sure? This will remove access to your GitHub data." }, + data: { + turbo_method: :delete, + confirm: "Are you sure? This will remove access to your GitHub data." + }, role: "button", class: "outline" %> <% else %> @@ -266,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/config/initializers/app_metrics.rb b/config/initializers/app_metrics.rb new file mode 100644 index 0000000..23c8b99 --- /dev/null +++ b/config/initializers/app_metrics.rb @@ -0,0 +1,4 @@ +# Application metrics calculated at startup + +lines_of_code = `find . -name "*.rb" -not -path "./vendor/*" -not -path "./tmp/*" -exec wc -l {} + | tail -1 | awk '{print $1}'`.strip.to_i rescue 0 +Rails.application.config.lines_of_code = lines_of_code diff --git a/config/initializers/flipper.rb b/config/initializers/flipper.rb new file mode 100644 index 0000000..188735e --- /dev/null +++ b/config/initializers/flipper.rb @@ -0,0 +1,40 @@ +Rails.application.configure do + ## Memoization ensures that only one adapter call is made per feature per request. + ## For more info, see https://www.flippercloud.io/docs/optimization#memoization + config.flipper.memoize = true + + ## Flipper preloads all features before each request, which is recommended if: + ## * you have a limited number of features (< 100?) + ## * most of your requests depend on most of your features + ## * you have limited gate data combined across all features (< 1k enabled gates, like individual actors, across all features) + ## + ## For more info, see https://www.flippercloud.io/docs/optimization#preloading + # config.flipper.preload = true + + ## Warn or raise an error if an unknown feature is checked + ## Can be set to `:warn`, `:raise`, or `false` + # config.flipper.strict = Rails.env.development? && :warn + config.flipper.strict = false + + ## Show Flipper checks in logs + # config.flipper.log = true + + ## Reconfigure Flipper to use the Memory adapter and disable Cloud in tests + # config.flipper.test_help = true + + ## The path that Flipper Cloud will use to sync features + # config.flipper.cloud_path = "_flipper" + + ## The instrumenter that Flipper will use. Defaults to ActiveSupport::Notifications. + # config.flipper.instrumenter = ActiveSupport::Notifications +end + +## Register a group that can be used for enabling features. +## +## Flipper.enable_group :my_feature, :admins +## +## See https://www.flippercloud.io/docs/features#enablement-group + +Flipper.register(:admins) do |actor| + actor.respond_to?(:admin?) && actor.admin? +end diff --git a/config/routes.rb b/config/routes.rb index bacdb83..d6c0920 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -13,6 +13,7 @@ Rails.application.routes.draw do constraints AdminConstraint do mount GoodJob::Engine => "good_job" mount AhoyCaptain::Engine => "/ahoy_captain" + mount Flipper::UI.app(Flipper) => "flipper", as: :flipper get "/impersonate/:id", to: "sessions#impersonate", as: :impersonate_user end diff --git a/db/migrate/20250611061502_add_beta_features_to_users.rb b/db/migrate/20250611061502_add_beta_features_to_users.rb new file mode 100644 index 0000000..60caddc --- /dev/null +++ b/db/migrate/20250611061502_add_beta_features_to_users.rb @@ -0,0 +1,5 @@ +class AddBetaFeaturesToUsers < ActiveRecord::Migration[8.0] + def change + add_column :users, :beta_features, :text, default: '[]' + end +end diff --git a/db/migrate/20250611062306_create_flipper_tables.rb b/db/migrate/20250611062306_create_flipper_tables.rb new file mode 100644 index 0000000..a9d6d1d --- /dev/null +++ b/db/migrate/20250611062306_create_flipper_tables.rb @@ -0,0 +1,22 @@ +class CreateFlipperTables < ActiveRecord::Migration[8.0] + def up + create_table :flipper_features do |t| + t.string :key, null: false + t.timestamps null: false + end + add_index :flipper_features, :key, unique: true + + create_table :flipper_gates do |t| + t.string :feature_key, null: false + t.string :key, null: false + t.text :value + t.timestamps null: false + end + add_index :flipper_gates, [ :feature_key, :key, :value ], unique: true, length: { value: 255 } + end + + def down + drop_table :flipper_gates + drop_table :flipper_features + end +end diff --git a/db/migrate/20250611062456_remove_beta_features_from_users.rb b/db/migrate/20250611062456_remove_beta_features_from_users.rb new file mode 100644 index 0000000..f38ee31 --- /dev/null +++ b/db/migrate/20250611062456_remove_beta_features_from_users.rb @@ -0,0 +1,5 @@ +class RemoveBetaFeaturesFromUsers < ActiveRecord::Migration[8.0] + def change + remove_column :users, :beta_features, :text + end +end 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 ccaa9e9..cf5624c 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_09_203830) 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 @@ -107,6 +107,22 @@ ActiveRecord::Schema[8.0].define(version: 2025_06_09_203830) do t.index ["user_id"], name: "index_email_verification_requests_on_user_id" end + create_table "flipper_features", force: :cascade do |t| + t.string "key", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["key"], name: "index_flipper_features_on_key", unique: true + end + + create_table "flipper_gates", force: :cascade do |t| + t.string "feature_key", null: false + t.string "key", null: false + t.text "value" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["feature_key", "key", "value"], name: "index_flipper_gates_on_feature_key_and_key_and_value", unique: true + end + create_table "good_job_batches", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.datetime "created_at", null: false t.datetime "updated_at", null: false diff --git a/docker-compose.yml b/docker-compose.yml index 232f1c2..0c6dd4d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,6 +19,7 @@ services: db: image: postgres:16 + shm_size: '2gb' volumes: - harbor_postgres_data:/var/lib/postgresql/data environment: diff --git a/lib/flavor_text.rb b/lib/flavor_text.rb index 0e5f5ae..0569dba 100644 --- a/lib/flavor_text.rb +++ b/lib/flavor_text.rb @@ -215,7 +215,14 @@ class FlavorText "a minute saved is a minute earned", "how did it get so late so soon?", # dr. seuss "You can have it all. Just not all at once.", # oprah i think? - "from the #{%w[makers inventor].sample} of #{%w[clocks time hackatime].sample}" + "from the #{%w[makers inventor].sample} of #{%w[clocks time hackatime].sample}", + "written in #{Rails.application.config.lines_of_code} lines of code!", + "#{%w[est created inited].sample} #{Time.now.to_i - Time.parse("Sun Feb 16 03:21:30 2025 -0500").to_i} seconds ago!".html_safe, + "uptime: #{Time.now.to_i - Rails.application.config.server_start_time.to_i} seconds!".html_safe, + "It takes a long time to build something good: ".html_safe, + "If you're seeing this, the page is currently .".html_safe, + "time is money!", + "in soviet russia, time tracks you!" ] end