From d88a7ee4b03b50e7fde7163da491e2fdd066ed65 Mon Sep 17 00:00:00 2001 From: Max Wofford Date: Sun, 23 Feb 2025 04:08:22 -0500 Subject: [PATCH] Track sailors log leaderboards in the db --- ..._log_controller.rb => slack_controller.rb} | 41 ++++--- app/jobs/sailors_log_leaderboard_job.rb | 32 ++--- .../sailors_log_set_notification_pref_job.rb | 18 ++- app/models/sailors_log.rb | 87 ------------- app/models/sailors_log_leaderboard.rb | 115 ++++++++++++++++++ config/routes.rb | 2 +- ...3072034_create_sailors_log_leaderboards.rb | 4 + ...leted_at_to_sailors_log_leaderboard_job.rb | 5 + db/schema.rb | 6 +- 9 files changed, 182 insertions(+), 128 deletions(-) rename app/controllers/{sailors_log_controller.rb => slack_controller.rb} (66%) create mode 100644 db/migrate/20250223085114_add_deleted_at_to_sailors_log_leaderboard_job.rb diff --git a/app/controllers/sailors_log_controller.rb b/app/controllers/slack_controller.rb similarity index 66% rename from app/controllers/sailors_log_controller.rb rename to app/controllers/slack_controller.rb index 577c8c8..01f0484 100644 --- a/app/controllers/sailors_log_controller.rb +++ b/app/controllers/slack_controller.rb @@ -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 diff --git a/app/jobs/sailors_log_leaderboard_job.rb b/app/jobs/sailors_log_leaderboard_job.rb index 683e7bd..f07d8b6 100644 --- a/app/jobs/sailors_log_leaderboard_job.rb +++ b/app/jobs/sailors_log_leaderboard_job.rb @@ -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}" diff --git a/app/jobs/sailors_log_set_notification_pref_job.rb b/app/jobs/sailors_log_set_notification_pref_job.rb index 617a140..27ba886 100644 --- a/app/jobs/sailors_log_set_notification_pref_job.rb +++ b/app/jobs/sailors_log_set_notification_pref_job.rb @@ -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 diff --git a/app/models/sailors_log.rb b/app/models/sailors_log.rb index 1bd2207..ca3e712 100644 --- a/app/models/sailors_log.rb +++ b/app/models/sailors_log.rb @@ -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 diff --git a/app/models/sailors_log_leaderboard.rb b/app/models/sailors_log_leaderboard.rb index 69ec7f2..cada055 100644 --- a/app/models/sailors_log_leaderboard.rb +++ b/app/models/sailors_log_leaderboard.rb @@ -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 diff --git a/config/routes.rb b/config/routes.rb index 54ef64c..a63d1e0 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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 diff --git a/db/migrate/20250223072034_create_sailors_log_leaderboards.rb b/db/migrate/20250223072034_create_sailors_log_leaderboards.rb index 00e0ebe..f3dfdae 100644 --- a/db/migrate/20250223072034_create_sailors_log_leaderboards.rb +++ b/db/migrate/20250223072034_create_sailors_log_leaderboards.rb @@ -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 diff --git a/db/migrate/20250223085114_add_deleted_at_to_sailors_log_leaderboard_job.rb b/db/migrate/20250223085114_add_deleted_at_to_sailors_log_leaderboard_job.rb new file mode 100644 index 0000000..c224261 --- /dev/null +++ b/db/migrate/20250223085114_add_deleted_at_to_sailors_log_leaderboard_job.rb @@ -0,0 +1,5 @@ +class AddDeletedAtToSailorsLogLeaderboardJob < ActiveRecord::Migration[8.0] + def change + add_column :sailors_log_leaderboards, :deleted_at, :datetime, default: nil + end +end diff --git a/db/schema.rb b/db/schema.rb index e434861..cbb42ac 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -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|