mirror of
https://github.com/System-End/hackatime.git
synced 2026-04-19 21:05:15 +00:00
Add leaderboard for sailors log
This commit is contained in:
parent
61dd5153fd
commit
2cd2fe1aec
11 changed files with 233 additions and 19 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
4
Gemfile
4
Gemfile
|
|
@ -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
|
||||
|
|
|
|||
29
Gemfile.lock
29
Gemfile.lock
|
|
@ -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
|
||||
|
|
|
|||
68
app/controllers/sailors_log_controller.rb
Normal file
68
app/controllers/sailors_log_controller.rb
Normal 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
|
||||
9
app/jobs/sailors_log_set_notification_pref_job.rb
Normal file
9
app/jobs/sailors_log_set_notification_pref_job.rb
Normal 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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
3
config/initializers/slack.rb
Normal file
3
config/initializers/slack.rb
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
Slack.configure do |config|
|
||||
config.token = ENV["SLACK_BOT_OAUTH_TOKEN"] # Using the existing env variable name
|
||||
end
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,3 +0,0 @@
|
|||
every 1.hour do
|
||||
runner "UpdateLeaderboardJob.perform_later"
|
||||
end
|
||||
Loading…
Add table
Reference in a new issue