mirror of
https://github.com/System-End/hackatime.git
synced 2026-04-19 16:38:23 +00:00
make leaderboards go vrooom (#1120)
* make leaderboards go vrooom * goog
This commit is contained in:
parent
91a0daf23f
commit
8ce245d8c4
7 changed files with 151 additions and 71 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -56,3 +56,6 @@ pg-dump*
|
|||
# Generated by `rails docs:generate_llms`
|
||||
/public/llms.txt
|
||||
/public/llms-full.txt
|
||||
|
||||
rust-stats-server/target/
|
||||
scratch/
|
||||
|
|
|
|||
|
|
@ -29,20 +29,18 @@ class LeaderboardUpdateJob < ApplicationJob
|
|||
Rails.logger.info "Building leaderboard for #{period} on #{date}"
|
||||
|
||||
range = LeaderboardDateRange.calculate(date, period)
|
||||
timestamp = Time.current
|
||||
eligible_users = User.where.not(github_uid: nil)
|
||||
.where.not(trust_level: User.trust_levels[:red])
|
||||
|
||||
ActiveRecord::Base.transaction do
|
||||
# Build the base heartbeat query
|
||||
heartbeat_query = Heartbeat.where(time: range)
|
||||
.with_valid_timestamps
|
||||
.joins(:user)
|
||||
.coding_only
|
||||
.where.not(users: { github_uid: nil })
|
||||
.where.not(users: { trust_level: User.trust_levels[:red] })
|
||||
heartbeat_query = Heartbeat.where(user_id: eligible_users.select(:id), time: range)
|
||||
.leaderboard_eligible
|
||||
|
||||
data = heartbeat_query.group(:user_id).duration_seconds
|
||||
.filter { |_, seconds| seconds > 60 }
|
||||
|
||||
streaks = Heartbeat.daily_streaks_for_users(data.keys)
|
||||
streaks = Heartbeat.daily_streaks_for_users(data.keys, exclude_browser_time: true)
|
||||
|
||||
entries = data.map do |user_id, seconds|
|
||||
{
|
||||
|
|
@ -50,8 +48,8 @@ class LeaderboardUpdateJob < ApplicationJob
|
|||
user_id: user_id,
|
||||
total_seconds: seconds,
|
||||
streak_count: streaks[user_id] || 0,
|
||||
created_at: Time.current,
|
||||
updated_at: Time.current
|
||||
created_at: timestamp,
|
||||
updated_at: timestamp
|
||||
}
|
||||
end
|
||||
|
||||
|
|
@ -63,7 +61,7 @@ class LeaderboardUpdateJob < ApplicationJob
|
|||
board.entries.delete_all
|
||||
end
|
||||
|
||||
board.update!(finished_generating_at: Time.current)
|
||||
board.update!(finished_generating_at: timestamp)
|
||||
end
|
||||
|
||||
# Cache the board
|
||||
|
|
|
|||
|
|
@ -1,9 +1,31 @@
|
|||
module Heartbeatable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
BROWSER_EDITORS = %w[
|
||||
arc
|
||||
brave
|
||||
chrome
|
||||
chromium
|
||||
edge
|
||||
firefox
|
||||
floorp
|
||||
librewolf
|
||||
microsoft-edge
|
||||
opera
|
||||
opera-gx
|
||||
safari
|
||||
vivaldi
|
||||
waterfox
|
||||
zen
|
||||
].freeze
|
||||
|
||||
included do
|
||||
# Filter heartbeats to only include those with category equal to "coding"
|
||||
scope :coding_only, -> { where(category: "coding") }
|
||||
scope :excluding_browser_time, -> {
|
||||
where("editor IS NULL OR LOWER(editor) NOT IN (?)", BROWSER_EDITORS)
|
||||
}
|
||||
scope :leaderboard_eligible, -> { coding_only.excluding_browser_time.with_valid_timestamps }
|
||||
|
||||
# This is to prevent PG timestamp overflow errors if someones gives us a
|
||||
# heartbeat with a time that is enormously far in the future.
|
||||
|
|
@ -97,19 +119,27 @@ module Heartbeatable
|
|||
end
|
||||
end
|
||||
|
||||
def daily_streaks_for_users(user_ids, start_date: 31.days.ago)
|
||||
def daily_streaks_for_users(user_ids, start_date: 31.days.ago, exclude_browser_time: false)
|
||||
return {} if user_ids.empty?
|
||||
start_date = [ start_date, 30.days.ago ].max
|
||||
keys = user_ids.map { |id| "user_streak_#{id}" }
|
||||
cache_prefix = exclude_browser_time ? "user_streak_without_browser" : "user_streak"
|
||||
keys = user_ids.map { |id| "#{cache_prefix}_#{id}" }
|
||||
streak_cache = Rails.cache.read_multi(*keys)
|
||||
|
||||
uncached_users = user_ids.select { |id| streak_cache["user_streak_#{id}"].nil? }
|
||||
uncached_users = user_ids.select { |id| streak_cache["#{cache_prefix}_#{id}"].nil? }
|
||||
|
||||
if uncached_users.empty?
|
||||
return user_ids.index_with { |id| streak_cache["user_streak_#{id}"] || 0 }
|
||||
return user_ids.index_with { |id| streak_cache["#{cache_prefix}_#{id}"] || 0 }
|
||||
end
|
||||
|
||||
timeout = heartbeat_timeout_duration.to_i
|
||||
day_group_sql = "DATE_TRUNC('day', to_timestamp(time) AT TIME ZONE users.timezone)"
|
||||
streak_diff_sql = <<~SQL.squish
|
||||
LEAST(
|
||||
time - LAG(time) OVER (PARTITION BY user_id, #{day_group_sql} ORDER BY time),
|
||||
#{timeout}
|
||||
) as diff
|
||||
SQL
|
||||
raw_durations = joins(:user)
|
||||
.where(user_id: uncached_users)
|
||||
.coding_only
|
||||
|
|
@ -118,9 +148,10 @@ module Heartbeatable
|
|||
.select(
|
||||
:user_id,
|
||||
"users.timezone as user_timezone",
|
||||
Arel.sql("DATE_TRUNC('day', to_timestamp(time) AT TIME ZONE users.timezone) as day_group"),
|
||||
Arel.sql("LEAST(time - LAG(time) OVER (PARTITION BY user_id, DATE_TRUNC('day', to_timestamp(time) AT TIME ZONE users.timezone) ORDER BY time), #{timeout}) as diff")
|
||||
Arel.sql("#{day_group_sql} as day_group"),
|
||||
Arel.sql(streak_diff_sql)
|
||||
)
|
||||
raw_durations = raw_durations.excluding_browser_time if exclude_browser_time
|
||||
|
||||
# Then aggregate the results
|
||||
daily_durations = connection.select_all(
|
||||
|
|
@ -152,7 +183,7 @@ module Heartbeatable
|
|||
}
|
||||
end
|
||||
|
||||
result = user_ids.index_with { |id| streak_cache["user_streak_#{id}"] || 0 }
|
||||
result = user_ids.index_with { |id| streak_cache["#{cache_prefix}_#{id}"] || 0 }
|
||||
|
||||
# Then calculate streaks for each user
|
||||
daily_durations.each do |user_id, data|
|
||||
|
|
@ -183,7 +214,7 @@ module Heartbeatable
|
|||
result[user_id] = streak
|
||||
|
||||
# Cache the streak for 1 hour
|
||||
Rails.cache.write("user_streak_#{user_id}", streak, expires_in: 1.hour)
|
||||
Rails.cache.write("#{cache_prefix}_#{user_id}", streak, expires_in: 1.hour)
|
||||
end
|
||||
|
||||
result
|
||||
|
|
|
|||
|
|
@ -1,49 +0,0 @@
|
|||
module LeaderboardBuilder
|
||||
module_function
|
||||
|
||||
def build_for_users(users, date, scope, period)
|
||||
date = Date.current if date.blank?
|
||||
|
||||
board = ::Leaderboard.new(
|
||||
start_date: date,
|
||||
period_type: period,
|
||||
finished_generating_at: Time.current
|
||||
)
|
||||
|
||||
ids = users.pluck(:id)
|
||||
return board if ids.empty?
|
||||
|
||||
users_map = users.index_by(&:id)
|
||||
|
||||
range = LeaderboardDateRange.calculate(date, period)
|
||||
|
||||
beats = Heartbeat.where(user_id: ids, time: range)
|
||||
.coding_only
|
||||
.with_valid_timestamps
|
||||
.joins(:user)
|
||||
.where.not(users: { github_uid: nil })
|
||||
|
||||
totals = beats.group(:user_id).duration_seconds
|
||||
totals = totals.filter { |_, seconds| seconds > 60 }
|
||||
|
||||
streak_ids = totals.keys
|
||||
streaks = streak_ids.any? ? Heartbeat.daily_streaks_for_users(streak_ids, start_date: 30.days.ago) : {}
|
||||
|
||||
entries = totals.map do |user_id, seconds|
|
||||
entry = LeaderboardEntry.new(
|
||||
leaderboard: board,
|
||||
user_id: user_id,
|
||||
total_seconds: seconds,
|
||||
streak_count: streaks[user_id] || 0
|
||||
)
|
||||
|
||||
entry.user = users_map[user_id]
|
||||
entry
|
||||
end.sort_by(&:total_seconds).reverse
|
||||
|
||||
board.define_singleton_method(:entries) { entries }
|
||||
board.define_singleton_method(:scope_name) { scope }
|
||||
|
||||
board
|
||||
end
|
||||
end
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
CI.run do
|
||||
step "Setup: Rails", "bin/setup --skip-server"
|
||||
step "Setup: Frontend", "bun install --frozen-lockfile"
|
||||
step "Style: Ruby", "bin/rubocop"
|
||||
|
||||
step "Zeitwerk", "bin/rails zeitwerk:check"
|
||||
|
|
@ -19,7 +18,7 @@ CI.run do
|
|||
step "Frontend: Lint", "bun run format:svelte:check"
|
||||
|
||||
if success?
|
||||
step "Signoff: All systems go. Ready for merge and deploy."
|
||||
heading "Signoff: All systems go. Ready for merge and deploy.", type: :success
|
||||
else
|
||||
failure "Signoff: CI failed. Do not merge or deploy.", "Fix the issues and try again."
|
||||
end
|
||||
|
|
|
|||
66
test/jobs/leaderboard_update_job_test.rb
Normal file
66
test/jobs/leaderboard_update_job_test.rb
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
require "test_helper"
|
||||
|
||||
class LeaderboardUpdateJobTest < ActiveJob::TestCase
|
||||
setup do
|
||||
Rails.cache.clear
|
||||
end
|
||||
|
||||
teardown do
|
||||
Rails.cache.clear
|
||||
end
|
||||
|
||||
test "perform excludes browser editor heartbeats from persisted leaderboard entries" do
|
||||
coded_user = create_user(username: "lb_job_coded", github_uid: "GH_LEADERBOARD_JOB_CODED")
|
||||
browser_only_user = create_user(username: "lb_job_browser", github_uid: "GH_LEADERBOARD_JOB_BROWSER")
|
||||
|
||||
create_heartbeat_pair(user: coded_user, started_at: today_at(9, 0), editor: "vscode")
|
||||
create_heartbeat_pair(user: coded_user, started_at: today_at(11, 0), editor: "firefox")
|
||||
create_heartbeat_pair(user: browser_only_user, started_at: today_at(13, 0), editor: "firefox")
|
||||
|
||||
LeaderboardUpdateJob.perform_now(:daily, Date.current, force_update: true)
|
||||
|
||||
board = Leaderboard.find_by!(
|
||||
start_date: Date.current,
|
||||
period_type: :daily,
|
||||
timezone_utc_offset: nil
|
||||
)
|
||||
|
||||
assert_equal [ coded_user.id ], board.entries.order(:user_id).pluck(:user_id)
|
||||
assert_equal 120, board.entries.find_by!(user_id: coded_user.id).total_seconds
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def create_user(username:, github_uid:)
|
||||
User.create!(
|
||||
username: username,
|
||||
github_uid: github_uid,
|
||||
timezone: "UTC"
|
||||
)
|
||||
end
|
||||
|
||||
def create_heartbeat_pair(user:, started_at:, editor:)
|
||||
user.heartbeats.create!(
|
||||
entity: "src/#{editor}.rb",
|
||||
type: "file",
|
||||
category: "coding",
|
||||
editor: editor,
|
||||
time: started_at.to_f,
|
||||
project: "leaderboard-job-test",
|
||||
source_type: :test_entry
|
||||
)
|
||||
user.heartbeats.create!(
|
||||
entity: "src/#{editor}.rb",
|
||||
type: "file",
|
||||
category: "coding",
|
||||
editor: editor,
|
||||
time: (started_at + 2.minutes).to_f,
|
||||
project: "leaderboard-job-test",
|
||||
source_type: :test_entry
|
||||
)
|
||||
end
|
||||
|
||||
def today_at(hour, minute)
|
||||
Time.current.change(hour: hour, min: minute, sec: 0)
|
||||
end
|
||||
end
|
||||
|
|
@ -1,8 +1,16 @@
|
|||
require "test_helper"
|
||||
|
||||
class HeartbeatTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
Rails.cache.clear
|
||||
end
|
||||
|
||||
teardown do
|
||||
Rails.cache.clear
|
||||
end
|
||||
|
||||
test "soft delete hides record from default scope and restore brings it back" do
|
||||
user = User.create!
|
||||
user = User.create!(timezone: "UTC")
|
||||
heartbeat = user.heartbeats.create!(
|
||||
entity: "src/main.rb",
|
||||
type: "file",
|
||||
|
|
@ -23,4 +31,28 @@ class HeartbeatTest < ActiveSupport::TestCase
|
|||
|
||||
assert_includes Heartbeat.all, heartbeat
|
||||
end
|
||||
|
||||
test "daily streak cache is separated for browser-filtered leaderboard streaks" do
|
||||
user = User.create!(timezone: "UTC", username: "hb_streak_cache")
|
||||
create_heartbeat_sequence(user: user, started_at: Time.current.beginning_of_day + 9.hours, editor: "firefox")
|
||||
|
||||
assert_equal 1, Heartbeat.daily_streaks_for_users([ user.id ])[user.id]
|
||||
assert_equal 0, Heartbeat.daily_streaks_for_users([ user.id ], exclude_browser_time: true)[user.id]
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def create_heartbeat_sequence(user:, started_at:, editor:, count: 9)
|
||||
count.times do |offset|
|
||||
user.heartbeats.create!(
|
||||
entity: "src/#{editor}.rb",
|
||||
type: "file",
|
||||
category: "coding",
|
||||
editor: editor,
|
||||
time: (started_at + (offset * 2).minutes).to_f,
|
||||
project: "heartbeat-test",
|
||||
source_type: :test_entry
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue