Add leaderboard for sailors log

This commit is contained in:
Max Wofford 2025-02-23 01:57:22 -05:00
parent 61dd5153fd
commit 2cd2fe1aec
11 changed files with 233 additions and 19 deletions

View file

@ -3,8 +3,10 @@ PORT=4000
SLACK_CLIENT_ID=your_client_id_here
SLACK_CLIENT_SECRET=your_client_secret_here
SLACK_SIGNING_SECRET=your_signing_secret_here
SLACK_TEAM_ID=your_slack_workspace_id
SLACK_USER_OAUTH_TOKEN=your_user_oauth_token_here
SLACK_BOT_OAUTH_TOKEN=your_bot_oauth_token_here
WAKATIME_DATABASE_URL=your_wakatime_database_url_here

View file

@ -35,8 +35,8 @@ gem "solid_cable"
gem "good_job"
# Job scheduler
gem "whenever", require: false
# Slack client
gem "slack-ruby-client"
# Reduces boot times through caching; required in config/boot.rb
gem "bootsnap", require: false

View file

@ -115,7 +115,6 @@ GEM
rack-test (>= 0.6.3)
regexp_parser (>= 1.5, < 3.0)
xpath (~> 3.2)
chronic (0.10.2)
concurrent-ruby (1.3.5)
connection_pool (2.5.0)
crass (1.0.6)
@ -134,6 +133,17 @@ GEM
erubi (1.13.1)
et-orbi (1.2.11)
tzinfo
faraday (2.12.2)
faraday-net_http (>= 2.0, < 3.5)
json
logger
faraday-mashify (0.1.1)
faraday (~> 2.0)
hashie
faraday-multipart (1.1.0)
multipart-post (~> 2.0)
faraday-net_http (3.4.0)
net-http (>= 0.5.0)
ffi (1.17.1-aarch64-linux-gnu)
ffi (1.17.1-aarch64-linux-musl)
ffi (1.17.1-arm-linux-gnu)
@ -147,6 +157,8 @@ GEM
fugit (1.11.1)
et-orbi (~> 1, >= 1.2.11)
raabro (~> 1.4)
gli (2.22.2)
ostruct
globalid (1.2.1)
activesupport (>= 6.1)
good_job (4.9.0)
@ -156,6 +168,7 @@ GEM
fugit (>= 1.11.0)
railties (>= 6.1.0)
thor (>= 1.0.0)
hashie (5.0.0)
http (5.2.0)
addressable (~> 2.8)
base64 (~> 0.1)
@ -217,6 +230,9 @@ GEM
mini_mime (1.1.5)
minitest (5.25.4)
msgpack (1.8.0)
multipart-post (2.4.1)
net-http (0.6.0)
uri
net-imap (0.5.6)
date
net-protocol
@ -367,6 +383,13 @@ GEM
rexml (~> 3.2, >= 3.2.5)
rubyzip (>= 1.2.2, < 3.0)
websocket (~> 1.0)
slack-ruby-client (2.5.2)
faraday (>= 2.0)
faraday-mashify
faraday-multipart
gli
hashie
logger
solid_cable (3.0.7)
actioncable (>= 7.2)
activejob (>= 7.2)
@ -426,8 +449,6 @@ GEM
base64
websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5)
whenever (1.0.0)
chronic (>= 0.6.3)
xpath (3.2.0)
nokogiri (~> 1.8)
zeitwerk (2.7.2)
@ -466,6 +487,7 @@ DEPENDENCIES
rails (~> 8.0.1)
rubocop-rails-omakase
selenium-webdriver
slack-ruby-client
solid_cable
solid_cache
sqlite3 (>= 2.1)
@ -475,7 +497,6 @@ DEPENDENCIES
turbo-rails
tzinfo-data
web-console
whenever
BUNDLED WITH
2.6.2

View file

@ -0,0 +1,68 @@
class SailorsLogController < ApplicationController
skip_before_action :verify_authenticity_token
before_action :verify_slack_request
# Handle slack commands
def create
case params[:text].downcase.strip
when "on"
puts "Turning on notifications for #{params[:user_id]} in #{params[:channel_id]}"
SailorsLogSetNotificationPrefJob.perform_later(params[:user_id], params[:channel_id], true)
render json: {
response_type: "in_channel",
text: "@#{params[:user_name]} ran `/sailorslog on` to turn on High Seas notifications in this channel. Every time they code an hour on a project, a short message celebrating will be posted to this channel. They will also show on `/sailorslog leaderboard`."
}
when "off"
SailorsLogSetNotificationPrefJob.perform_later(params[:user_id], params[:channel_id], false)
render json: {
response_type: "ephemeral",
text: ":white_check_mark: Coding notifications have been turned off in this channel."
}
when "leaderboard"
leaderboard = SailorsLog.generate_leaderboard(params[:channel_id])
message = "*:boat: Sailor's Log - Today*"
medals = [ "first_place_medal", "second_place_medal", "third_place_medal" ]
# ex.
# :first_place_medal: @Irtaza: 2h 6m → Farmworks [C#]: 125m
# :second_place_medal: @Cigan: 1h 33m → Gitracker-1 [JAVA]: 49m + Dupe [JAVA]: 41m + Lovac-Integration [JAVA]: 2m
leaderboard.each_with_index do |entry, index|
medal = medals[index] || "white_small_square"
message += "\n:#{medal}: `<@#{entry[:user_id]}>`: #{entry[:duration]}"
message += entry[:projects].map do |project|
language = project[:language_emoji] ? "#{project[:language_emoji]} #{project[:language]}" : project[:language]
"#{project[:name]} [#{language}]"
end.join(" + ")
end
puts message
render json: {
response_type: "in_channel",
text: message
}
else
render json: {
response_type: "ephemeral",
text: "Available commands: `/sailorslog on`, `/sailorslog off`, `/sailorslog leaderboard`"
}
end
end
private
def verify_slack_request
timestamp = request.headers["X-Slack-Request-Timestamp"]
signature = request.headers["X-Slack-Signature"]
# Skip verification in development
return true if Rails.env.development?
slack_signing_secret = ENV["SLACK_SIGNING_SECRET"]
sig_basestring = "v0:#{timestamp}:#{request.raw_post}"
my_signature = "v0=" + OpenSSL::HMAC.hexdigest("SHA256", slack_signing_secret, sig_basestring)
unless ActiveSupport::SecurityUtils.secure_compare(my_signature, signature)
head :unauthorized
nil
end
end
end

View file

@ -0,0 +1,9 @@
class SailorsLogSetNotificationPrefJob < ApplicationJob
queue_as :default
def perform(slack_uid, slack_channel_id, enabled)
slnp = SailorsLogNotificationPreference.find_or_initialize_by(slack_uid: slack_uid, slack_channel_id: slack_channel_id)
slnp.enabled = enabled
slnp.save!
end
end

View file

@ -16,15 +16,38 @@ class Heartbeat < WakatimeRecord
self.ignored_columns += [ "hash" ]
def self.duration_seconds(scope = all)
capped_diffs = scope
.select("CASE
WHEN LAG(time) OVER (ORDER BY time) IS NULL THEN 0
ELSE LEAST(EXTRACT(EPOCH FROM (time - LAG(time) OVER (ORDER BY time))), #{TIMEOUT_DURATION.to_i})
END as diff")
.where.not(time: nil)
.order(time: :asc)
if scope.group_values.any?
# when grouped, return hash of group key => duration
group_column = scope.group_values.first
connection.select_value("SELECT COALESCE(SUM(diff), 0)::integer FROM (#{capped_diffs.to_sql}) AS diffs").to_i
capped_diffs = scope
.select("#{group_column}, CASE
WHEN LAG(time) OVER (PARTITION BY #{group_column} ORDER BY time) IS NULL THEN 0
ELSE LEAST(EXTRACT(EPOCH FROM (time - LAG(time) OVER (PARTITION BY #{group_column} ORDER BY time))), #{TIMEOUT_DURATION.to_i})
END as diff")
.where.not(time: nil)
.order(time: :asc)
.unscope(:group)
connection.select_all(
"SELECT #{group_column}, COALESCE(SUM(diff), 0)::integer as duration
FROM (#{capped_diffs.to_sql}) AS diffs
GROUP BY #{group_column}"
).each_with_object({}) do |row, hash|
hash[row[group_column.to_s]] = row["duration"].to_i
end
else
# when not grouped, return a single value
capped_diffs = scope
.select("CASE
WHEN LAG(time) OVER (ORDER BY time) IS NULL THEN 0
ELSE LEAST(EXTRACT(EPOCH FROM (time - LAG(time) OVER (ORDER BY time))), #{TIMEOUT_DURATION.to_i})
END as diff")
.where.not(time: nil)
.order(time: :asc)
connection.select_value("SELECT COALESCE(SUM(diff), 0)::integer FROM (#{capped_diffs.to_sql}) AS diffs").to_i
end
end
def self.duration_formatted(scope = all)

View file

@ -14,6 +14,87 @@ class SailorsLog < ApplicationRecord
foreign_key: :slack_uid,
primary_key: :slack_uid
def self.generate_leaderboard(channel)
# Get all users with enabled preferences in the channel
users_in_channel = SailorsLogNotificationPreference.where(enabled: true, slack_channel_id: channel)
.distinct
.pluck(:slack_uid)
# Get all durations for users in channel
user_durations = Heartbeat.where(user_id: users_in_channel)
.today
.group(:user_id)
.duration_seconds
# Sort and take top 10 users
top_user_ids = user_durations.sort_by { |_, duration| -duration }.first(10).map(&:first)
# Now get detailed project info only for top 10 users
top_user_ids.map do |user_id|
user_heartbeats = Heartbeat.where(user_id: user_id).today
# Get most common language per project using ActiveRecord
most_common_languages = user_heartbeats
.group(:project, :language)
.count
.group_by { |k, _| k[0] } # Group by project
.transform_values { |langs| langs.max_by { |_, count| count }&.first&.last } # Get most common language
# Get all project durations in one query
project_durations = user_heartbeats
.group(:project)
.duration_seconds
projects = project_durations.map do |project, duration|
{
name: project,
duration: duration,
language: most_common_languages[project],
language_emoji: self.language_emoji(most_common_languages[project])
}
end
{
user_id: user_id,
duration: user_durations[user_id],
projects: projects
}
end
end
def self.language_emoji(language)
case language.downcase
when "ruby"
":ruby:"
when "javascript"
":js:"
when "typescript"
":ts:"
when "html"
":html:"
when "java"
[ ":java:", ":java_duke:" ].sample
when "unity"
[ ":unity:", ":unity_new:" ].sample
when "c++"
":c++:"
when "c"
[ ":c:", ":c_1:" ].sample
when "rust"
[ ":ferris:", ":crab:", ":ferrisowo:" ].sample
when "python"
[ ":snake:", ":python:", ":pf:", ":tw_snake:" ].sample
when "nix"
[ ":nix:", ":parrot-nix:" ].sample
when "go"
[ ":golang:", ":gopher:", ":gothonk:" ].sample
when "kotlin"
":kotlin:"
else
nil
end
end
private
def initialize_projects_summary

View file

@ -73,4 +73,8 @@ Rails.application.configure do
# Apply autocorrection by RuboCop to files generated by `bin/rails generate`.
# config.generators.apply_rubocop_autocorrect_after_generate!
# Allow any host in development
# https://www.fngtps.com/2019/rails6-blocked-host/
config.hosts.clear
end

View file

@ -0,0 +1,3 @@
Slack.configure do |config|
config.token = ENV["SLACK_BOT_OAUTH_TOKEN"] # Using the existing env variable name
end

View file

@ -21,10 +21,14 @@ Rails.application.routes.draw do
# get "manifest" => "rails/pwa#manifest", as: :pwa_manifest
# get "service-worker" => "rails/pwa#service_worker", as: :pwa_service_worker
# Defines the root path route ("/")
# root "posts#index"
root "static_pages#index"
resources :static_pages, only: [ :index ] do
collection do
get :project_durations
end
end
get "/auth/slack", to: "sessions#new", as: :slack_auth
get "/auth/slack/callback", to: "sessions#create"
delete "signout", to: "sessions#destroy", as: "signout"
@ -39,4 +43,6 @@ Rails.application.routes.draw do
# Namespace for current user actions
get "my/settings", to: "users#edit", as: :my_settings
patch "my/settings", to: "users#update"
post "/slack/commands", to: "sailors_log#create"
end

View file

@ -1,3 +0,0 @@
every 1.hour do
runner "UpdateLeaderboardJob.perform_later"
end