Merge branch 'main' into feat/wakatime-sync

This commit is contained in:
2025-06-11 17:28:07 +02:00
committed by GitHub
29 changed files with 664 additions and 107 deletions

View File

@@ -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 <specific-file>` or `git add <directory>/` for targeted commits
## Code Style (rubocop-rails-omakase)
- **Naming**: snake_case files/methods/vars, PascalCase classes, 2-space indent

View File

@@ -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"

View File

@@ -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

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

@@ -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

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

@@ -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

View File

@@ -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

View File

@@ -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

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

@@ -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? %>
<div class="mini-leaderboard">
<p class="super">
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) %>
🧪 <strong><%= link_to "Regional Leaderboard", my_settings_path(anchor: "user_beta_features") %>:</strong> 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 %>
</p>
<div class="leaderboard-entries">

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

@@ -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 %>
</ul>
</aside>

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

@@ -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 %>
</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,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

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1,5 @@
class AddBetaFeaturesToUsers < ActiveRecord::Migration[8.0]
def change
add_column :users, :beta_features, :text, default: '[]'
end
end

View File

@@ -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

View File

@@ -0,0 +1,5 @@
class RemoveBetaFeaturesFromUsers < ActiveRecord::Migration[8.0]
def change
remove_column :users, :beta_features, :text
end
end

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

18
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_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

View File

@@ -19,6 +19,7 @@ services:
db:
image: postgres:16
shm_size: '2gb'
volumes:
- harbor_postgres_data:/var/lib/postgresql/data
environment:

View File

@@ -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} <span id='init-time-ago'>#{Time.now.to_i - Time.parse("Sun Feb 16 03:21:30 2025 -0500").to_i}</span> seconds ago!<script>setInterval(()=>{document.getElementById('init-time-ago').innerHTML=parseInt(document.getElementById('init-time-ago').innerHTML)+1},1000)</script>".html_safe,
"uptime: <span id='uptime'>#{Time.now.to_i - Rails.application.config.server_start_time.to_i}</span> seconds!<script>setInterval(()=>{document.getElementById('uptime').innerHTML=parseInt(document.getElementById('uptime').innerHTML)+1},1000)</script>".html_safe,
"It takes a long time to build something good: <a href='https://github.com/hackclub/hackatime#readme' target='_blank'><img src='https://hackatime-badge.hackclub.com/U0C7B14Q3/harbor'></a>".html_safe,
"If you're seeing this, the page is currently <a href='https://status.hackatime.hackclub.com/status/hackatime' target='_blank'><img src='https://status.hackatime.hackclub.com/api/badge/1/status'></a>.".html_safe,
"time is money!",
"in soviet russia, time tracks you!"
]
end