Track sailors log leaderboards in the db

This commit is contained in:
Max Wofford 2025-02-23 04:08:22 -05:00
parent 6731eb4316
commit d88a7ee4b0
9 changed files with 182 additions and 128 deletions

View file

@ -1,4 +1,4 @@
class SailorsLogController < ApplicationController
class SlackController < ApplicationController
skip_before_action :verify_authenticity_token
before_action :verify_slack_request
@ -8,24 +8,28 @@ class SailorsLogController < ApplicationController
# Handle slack commands
def create
# Acknowledge receipt
render json: {
response_type: "ephemeral",
blocks: [
{
type: "context",
elements: [
{
type: "mrkdwn",
text: "#{params[:command]} #{params[:text]}"
}
]
}
]
}
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`."
}
SailorsLogSetNotificationPrefJob.perform_later(params[:user_id], params[:channel_id], true, params[:response_url], params[:user_name])
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."
}
SailorsLogSetNotificationPrefJob.perform_later(params[:user_id], params[:channel_id], false, params[:response_url], params[:user_name])
when "leaderboard"
# Acknowledge receipt
head :ok
# Send loading message first
response = HTTP.post(params[:response_url], json: {
response_type: "ephemeral",
@ -33,18 +37,17 @@ class SailorsLogController < ApplicationController
trigger_id: params[:trigger_id]
})
puts "Response: #{response.body}"
# Process in background
SailorsLogLeaderboardJob.perform_later(
params[:channel_id],
params[:user_id],
params[:response_url],
)
else
render json: {
HTTP.post(params[:response_url], json: {
response_type: "ephemeral",
text: "Available commands: `/sailorslog on`, `/sailorslog off`, `/sailorslog leaderboard`"
}
})
end
end

View file

@ -2,31 +2,25 @@ class SailorsLogLeaderboardJob < ApplicationJob
queue_as :default
include ApplicationHelper
def perform(channel_id, response_url)
# Generate leaderboard
leaderboard = SailorsLog.generate_leaderboard(channel_id)
message = "*:boat: Sailor's Log - Today*"
medals = [ "first_place_medal", "second_place_medal", "third_place_medal" ]
def perform(channel_id, slack_uid, response_url)
puts "Performing leaderboard job for channel #{channel_id} and user #{slack_uid}"
# either find a leaderboard for this channel from the last 1 minute or create a new one
leaderboard = SailorsLogLeaderboard.where(slack_channel_id: channel_id, deleted_at: nil)
.where("created_at > ?", 1.minute.ago)
.order(created_at: :desc)
.first
leaderboard.each_with_index do |entry, index|
medal = medals[index] || "white_small_square"
message += "\n:#{medal}: `<@#{entry[:user_id]}>`: #{short_time_simple entry[:duration]}"
message += entry[:projects].map do |project|
language = project[:language_emoji] ? "#{project[:language_emoji]} #{project[:language]}" : project[:language]
project_entry = []
project_entry << "#{project[:name]}"
project_entry << "[#{language}]" unless language.nil?
project_entry << "#{short_time_simple project[:duration]}"
project_entry.join(" ")
end.join(" + ")
end
# Create new leaderboard if none found
leaderboard ||= SailorsLogLeaderboard.create!(
slack_channel_id: channel_id,
slack_uid: slack_uid
)
# Update with final message
response = HTTP.post(response_url, json: {
response_type: "in_channel",
replace_original: true,
text: message
text: leaderboard.message
})
puts "Response: #{response.body}"

View file

@ -1,9 +1,25 @@
class SailorsLogSetNotificationPrefJob < ApplicationJob
queue_as :default
def perform(slack_uid, slack_channel_id, enabled)
def perform(slack_uid, slack_channel_id, enabled, response_url, user_name)
# set preference for the user
slnp = SailorsLogNotificationPreference.find_or_initialize_by(slack_uid: slack_uid, slack_channel_id: slack_channel_id)
slnp.enabled = enabled
slnp.save!
# invalidate the leaderboard cache
SailorsLogLeaderboard.where(slack_channel_id: slack_channel_id, deleted_at: nil).update_all(deleted_at: Time.current)
if enabled
HTTP.post(response_url, json: {
response_type: "in_channel",
text: "@#{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`."
})
else
HTTP.post(response_url, json: {
response_type: "ephemeral",
text: ":white_check_mark: Coding notifications have been turned off in this channel."
})
end
end
end

View file

@ -14,93 +14,6 @@ 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|
print "project: #{project}, duration: #{duration}, language: #{most_common_languages[project]}"
{
name: project,
duration: duration,
language: most_common_languages[project],
language_emoji: self.language_emoji(most_common_languages[project])
}
end
projects = projects.filter { |project| project[:duration] > 1.minute }.sort_by { |project| -project[:duration] }
{
user_id: user_id,
duration: user_durations[user_id],
projects: projects
}
end
end
def self.language_emoji(language)
language = language.downcase
case language
when "ruby"
":#{language}:"
when "javascript"
":js:"
when "typescript"
":ts:"
when "html"
":#{language}:"
when "java"
[ ":java:", ":java_duke:" ].sample
when "unity"
[ ":unity:", ":unity_new:" ].sample
when "c++"
":#{language}:"
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"
":#{language}:"
when "astro"
":#{language}:"
else
nil
end
end
private
def initialize_projects_summary

View file

@ -1,2 +1,117 @@
class SailorsLogLeaderboard < ApplicationRecord
include ApplicationHelper # Add this to get access to short_time_simple
validates :slack_channel_id, :slack_uid, presence: true
after_create :generate_message
private
def generate_message
stats = SailorsLogLeaderboard.generate_leaderboard_stats(slack_channel_id)
message = "*:boat: Sailor's Log - Today*"
medals = [ "first_place_medal", "second_place_medal", "third_place_medal" ]
stats.each_with_index do |entry, index|
medal = medals[index] || "white_small_square"
message += "\n:#{medal}: `<@#{entry[:user_id]}>`: #{short_time_simple entry[:duration]}"
message += entry[:projects].map do |project|
language = project[:language_emoji] ? "#{project[:language_emoji]} #{project[:language]}" : project[:language]
project_entry = []
project_entry << "#{project[:name]}"
project_entry << "[#{language}]" unless language.nil?
project_entry << "#{short_time_simple project[:duration]}"
project_entry.join(" ")
end.join(" + ")
end
# Update the message attribute and save
update_column(:message, message)
end
def self.generate_leaderboard_stats(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|
print "project: #{project}, duration: #{duration}, language: #{most_common_languages[project]}"
{
name: project,
duration: duration,
language: most_common_languages[project],
language_emoji: self.language_emoji(most_common_languages[project])
}
end
projects = projects.filter { |project| project[:duration] > 1.minute }.sort_by { |project| -project[:duration] }
{
user_id: user_id,
duration: user_durations[user_id],
projects: projects
}
end
end
def self.language_emoji(language)
language = language.downcase
case language
when "ruby"
":#{language}:"
when "javascript"
":js:"
when "typescript"
":ts:"
when "html"
":#{language}:"
when "java"
[ ":java:", ":java_duke:" ].sample
when "unity"
[ ":unity:", ":unity_new:" ].sample
when "c++"
":#{language}:"
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"
":#{language}:"
when "astro"
":#{language}:"
else
nil
end
end
end

View file

@ -44,5 +44,5 @@ Rails.application.routes.draw do
get "my/settings", to: "users#edit", as: :my_settings
patch "my/settings", to: "users#update"
post "/slack/commands", to: "sailors_log#create"
post "/slack/commands", to: "slack#create"
end

View file

@ -1,6 +1,10 @@
class CreateSailorsLogLeaderboards < ActiveRecord::Migration[8.0]
def change
create_table :sailors_log_leaderboards do |t|
t.string :slack_channel_id
t.string :slack_uid
t.text :message
t.timestamps
end
end

View file

@ -0,0 +1,5 @@
class AddDeletedAtToSailorsLogLeaderboardJob < ActiveRecord::Migration[8.0]
def change
add_column :sailors_log_leaderboards, :deleted_at, :datetime, default: nil
end
end

6
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_02_23_072034) do
ActiveRecord::Schema[8.0].define(version: 2025_02_23_085114) do
# These are extensions that must be enabled in order to support this database
enable_extension "pg_catalog.plpgsql"
@ -123,8 +123,12 @@ ActiveRecord::Schema[8.0].define(version: 2025_02_23_072034) do
end
create_table "sailors_log_leaderboards", force: :cascade do |t|
t.string "slack_channel_id"
t.string "slack_uid"
t.text "message"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.datetime "deleted_at"
end
create_table "sailors_log_notification_preferences", force: :cascade do |t|