diff --git a/README.md b/README.md index a83dc623..d89193c6 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,16 @@ ENCRYPTION_KEY_DERIVATION_SALT=16charssalt1234 Comment out the `LOOPS_API_KEY` for the local letter opener, otherwise the app will try to send out a email and fail. +### Slack Activity Digest Bot Token + +Daily Slack activity digests require a bot token with `chat:write` access. Add the token to your `.env` file: + +``` +SLACK_ACTIVITY_DIGEST_BOT_TOKEN=xoxb-... +``` + +Without this token the digest job will raise an error when it attempts to post to Slack. + ## Build & Run the project ```sh diff --git a/app/controllers/my/activity_digest_subscriptions_controller.rb b/app/controllers/my/activity_digest_subscriptions_controller.rb new file mode 100644 index 00000000..8bfc639e --- /dev/null +++ b/app/controllers/my/activity_digest_subscriptions_controller.rb @@ -0,0 +1,68 @@ +module My + class ActivityDigestSubscriptionsController < ApplicationController + before_action :ensure_current_user + + def show + @channel_id = current_user.slack_neighborhood_channel + @subscription = find_subscription + @timezones = SlackActivityDigestSubscription::TIMEZONE_NAMES + end + + def create + channel_id = current_user.slack_neighborhood_channel + if channel_id.blank? + redirect_to my_activity_digest_subscription_path, alert: "We don't have a Slack team channel on file for you yet." and return + end + + subscription = SlackActivityDigestSubscription.find_or_initialize_by(slack_channel_id: channel_id) + subscription.assign_attributes(create_params) + subscription.timezone ||= current_user.timezone + subscription.delivery_hour ||= 10 + subscription.enabled = true + subscription.created_by_user ||= current_user + subscription.save! + + redirect_to my_activity_digest_subscription_path, notice: "Daily Slack digest enabled for #{subscription.channel_mention}." + end + + def update + subscription = find_subscription + unless subscription + redirect_to my_activity_digest_subscription_path, alert: "Digest is not enabled yet." and return + end + + subscription.update!(update_params) + redirect_to my_activity_digest_subscription_path, notice: "Digest preferences updated." + end + + def destroy + subscription = find_subscription + if subscription + subscription.update!(enabled: false) + end + + redirect_to my_activity_digest_subscription_path, notice: "Daily Slack digest disabled." + end + + private + + def find_subscription + channel_id = current_user.slack_neighborhood_channel + return nil if channel_id.blank? + + SlackActivityDigestSubscription.find_by(slack_channel_id: channel_id) + end + + def create_params + params.fetch(:slack_activity_digest_subscription, {}).permit(:timezone, :delivery_hour, :slack_team_id) + end + + def update_params + params.require(:slack_activity_digest_subscription).permit(:timezone, :delivery_hour) + end + + def ensure_current_user + redirect_to root_path, alert: "You must be logged in to manage the digest." unless current_user + end + end +end diff --git a/app/jobs/slack_activity_digest_job.rb b/app/jobs/slack_activity_digest_job.rb new file mode 100644 index 00000000..030d6da3 --- /dev/null +++ b/app/jobs/slack_activity_digest_job.rb @@ -0,0 +1,47 @@ +class SlackActivityDigestJob < ApplicationJob + queue_as :latency_5m + + include GoodJob::ActiveJobExtensions::Concurrency + + good_job_control_concurrency_with( + total_limit: 1, + key: -> { "slack_activity_digest_job_#{arguments.first}" } + ) + + def perform(subscription_id, as_of_epoch = nil) + subscription = SlackActivityDigestSubscription.find_by(id: subscription_id) + return unless subscription&.enabled? + + as_of = as_of_epoch ? Time.at(as_of_epoch).utc : Time.current + + result = SlackActivityDigestService.new(subscription: subscription, as_of: as_of).build + + deliver(subscription, result) + subscription.mark_delivered!(as_of) + end + + private + + def deliver(subscription, result) + token = ENV["SLACK_ACTIVITY_DIGEST_BOT_TOKEN"] + raise "Missing SLACK_ACTIVITY_DIGEST_BOT_TOKEN" if token.blank? + + payload = { + channel: subscription.slack_channel_id, + text: result.fallback_text, + blocks: result.blocks + } + + response = HTTP.auth("Bearer #{token}") + .post("https://slack.com/api/chat.postMessage", json: payload) + + body = JSON.parse(response.body) + if body["ok"] != true + Rails.logger.error("SlackActivityDigestJob failed for #{subscription.slack_channel_id}: #{body['error'] || 'unknown error'}") + raise "Slack API error: #{body['error'] || 'unknown error'}" + end + rescue StandardError => e + Rails.logger.error("SlackActivityDigestJob encountered error: #{e.message}") + raise + end +end diff --git a/app/jobs/slack_activity_digest_scheduler_job.rb b/app/jobs/slack_activity_digest_scheduler_job.rb new file mode 100644 index 00000000..1d0f1663 --- /dev/null +++ b/app/jobs/slack_activity_digest_scheduler_job.rb @@ -0,0 +1,18 @@ +class SlackActivityDigestSchedulerJob < ApplicationJob + queue_as :literally_whenever + + include GoodJob::ActiveJobExtensions::Concurrency + + good_job_control_concurrency_with( + total_limit: 1, + key: "slack_activity_digest_scheduler" + ) + + def perform(reference_time = Time.current) + SlackActivityDigestSubscription.enabled.find_each do |subscription| + next unless subscription.due_for_delivery?(reference_time) + + SlackActivityDigestJob.perform_later(subscription.id, reference_time.to_i) + end + end +end diff --git a/app/models/slack_activity_digest_subscription.rb b/app/models/slack_activity_digest_subscription.rb new file mode 100644 index 00000000..aed7fcdd --- /dev/null +++ b/app/models/slack_activity_digest_subscription.rb @@ -0,0 +1,37 @@ +class SlackActivityDigestSubscription < ApplicationRecord + TIMEZONE_NAMES = ActiveSupport::TimeZone.all.map(&:name).freeze + + belongs_to :created_by_user, class_name: "User", optional: true + + validates :slack_channel_id, presence: true, uniqueness: true + validates :timezone, presence: true, inclusion: { in: TIMEZONE_NAMES } + validates :delivery_hour, presence: true, inclusion: { in: 0..23 } + + scope :enabled, -> { where(enabled: true) } + + def due_for_delivery?(reference_time = Time.current) + return false unless enabled? + + tz = active_time_zone + local_reference = reference_time.in_time_zone(tz) + + return local_reference.hour >= delivery_hour if last_delivered_at.blank? + + last_local = last_delivered_at.in_time_zone(tz) + return false if last_local.to_date == local_reference.to_date + + local_reference.hour >= delivery_hour + end + + def mark_delivered!(delivered_at = Time.current) + update!(last_delivered_at: delivered_at) + end + + def channel_mention + "<##{slack_channel_id}>" + end + + def active_time_zone + ActiveSupport::TimeZone[timezone] || ActiveSupport::TimeZone["UTC"] + end +end diff --git a/app/services/slack_activity_digest_service.rb b/app/services/slack_activity_digest_service.rb new file mode 100644 index 00000000..f5193f9e --- /dev/null +++ b/app/services/slack_activity_digest_service.rb @@ -0,0 +1,123 @@ +class SlackActivityDigestService + Result = Struct.new(:blocks, :fallback_text, :period_start, :period_end, :total_seconds, :active_user_ids, keyword_init: true) + + def initialize(subscription:, as_of: Time.current) + @subscription = subscription + @reference_time = as_of + @helpers = ApplicationController.helpers + end + + def build + users = users_in_channel + user_records = users.to_a + user_ids = user_records.map(&:id) + period = digest_period + + heartbeat_scope = Heartbeat.where(user_id: user_ids) + .where("time >= ? AND time < ?", period[:start_epoch], period[:end_epoch]) + + total_seconds = Heartbeat.duration_seconds(heartbeat_scope) + + per_user = Heartbeat.duration_seconds(heartbeat_scope.group(:user_id)) + per_project = Heartbeat.duration_seconds(heartbeat_scope.where.not(project: [ nil, "" ]).group(:project)) + + active_user_ids = per_user.keys + + Result.new( + blocks: build_blocks(user_records.index_by(&:id), period, total_seconds, per_user, per_project), + fallback_text: fallback_text(period), + period_start: period[:start_time], + period_end: period[:end_time], + total_seconds: total_seconds, + active_user_ids: active_user_ids + ) + end + + private + + def users_in_channel + User.where(slack_neighborhood_channel: @subscription.slack_channel_id) + end + + def digest_period + tz = @subscription.active_time_zone + reference_local = @reference_time.in_time_zone(tz) + period_end = reference_local.beginning_of_day + period_start = period_end - 1.day + + { + start_time: period_start, + end_time: period_end, + start_epoch: period_start.to_i, + end_epoch: period_end.to_i, + timezone: tz + } + end + + def build_blocks(users_map, period, total_seconds, per_user, per_project) + channel_mention = @subscription.channel_mention + day_label = period[:start_time].strftime("%B %d") + + blocks = [] + + header_text = "*Daily coding highlights for #{channel_mention} — #{day_label}*" + blocks << { type: "section", text: { type: "mrkdwn", text: header_text } } + + if total_seconds.positive? + active_count = per_user.length + summary_text = "*Team total:* #{@helpers.short_time_detailed(total_seconds)} across #{active_count} #{'hacker'.pluralize(active_count)}" + blocks << { type: "section", text: { type: "mrkdwn", text: summary_text } } + blocks << { type: "divider" } + + add_top_users_block(blocks, users_map, per_user) + add_top_projects_block(blocks, per_project) + else + blocks << { type: "section", text: { type: "mrkdwn", text: no_activity_text(period, channel_mention) } } + end + + blocks + end + + def add_top_users_block(blocks, users_map, per_user) + return if per_user.blank? + + lines = per_user.sort_by { |_, seconds| -seconds }.first(5).each_with_index.map do |(user_id, seconds), index| + user = users_map[user_id] + handle = user&.slack_uid.present? ? "<@#{user.slack_uid}>" : user&.display_name || "User ##{user_id}" + "#{index + 1}. #{handle} – #{@helpers.short_time_simple(seconds)}" + end + + return if lines.empty? + + blocks << { + type: "section", + text: { type: "mrkdwn", text: "*Top hackers yesterday*\n" + lines.join("\n") } + } + end + + def add_top_projects_block(blocks, per_project) + return if per_project.blank? + + lines = per_project.sort_by { |_, seconds| -seconds }.first(5).map do |project, seconds| + project_name = project.presence || "Untitled" + "• *#{project_name}* – #{@helpers.short_time_simple(seconds)}" + end + + return if lines.empty? + + blocks << { + type: "section", + text: { type: "mrkdwn", text: "*Focus projects*\n" + lines.join("\n") } + } + end + + def no_activity_text(period, channel_mention) + day_label = period[:start_time].strftime("%B %d") + ":zzz: No coding activity recorded for #{channel_mention} on #{day_label}. Let’s ship something today!" + end + + def fallback_text(period) + day_label = period[:start_time].strftime("%B %d") + "Daily coding highlights for #{day_label}" + end +end diff --git a/app/views/my/activity_digest_subscriptions/show.html.erb b/app/views/my/activity_digest_subscriptions/show.html.erb new file mode 100644 index 00000000..ce4bbacc --- /dev/null +++ b/app/views/my/activity_digest_subscriptions/show.html.erb @@ -0,0 +1,54 @@ +
+

Slack activity digest

+ + <% if @channel_id.blank? %> +

We could not detect a Slack neighborhood channel for your account yet. Once your account is linked to a team channel, you can enable the daily digest here.

+ <% else %> +
+

+ We'll post a daily coding recap for <%= @subscription&.channel_mention || "<##{@channel_id}>" %> + covering the previous day once the local clock hits your chosen delivery hour. +

+ + <% if @subscription&.enabled? %> +
+

Digest is currently enabled.

+

Last delivered: <%= @subscription.last_delivered_at ? l(@subscription.last_delivered_at, format: :long) : "pending" %>

+
+ + <%= form_with model: @subscription, url: my_activity_digest_subscription_path, method: :patch, class: "space-y-4" do |f| %> +
+ <%= f.label :timezone, "Timezone", class: "block text-sm font-medium" %> + <%= f.select :timezone, options_for_select(@timezones, @subscription.timezone), {}, class: "mt-1 block w-full rounded-md border-gray-300" %> +
+ +
+ <%= f.label :delivery_hour, "Delivery hour (0-23)", class: "block text-sm font-medium" %> + <%= f.number_field :delivery_hour, in: 0..23, class: "mt-1 block w-full rounded-md border-gray-300" %> +
+ +
+ <%= f.submit "Save changes", class: "btn btn-primary" %> + <%= button_to "Disable digest", my_activity_digest_subscription_path, method: :delete, class: "btn btn-secondary" %> +
+ <% end %> + <% else %> + <%= form_with model: SlackActivityDigestSubscription.new, url: my_activity_digest_subscription_path, method: :post, class: "space-y-4" do |f| %> +
+ <%= f.label :timezone, "Timezone", class: "block text-sm font-medium" %> + <%= f.select :timezone, options_for_select(@timezones, current_user.timezone), {}, class: "mt-1 block w-full rounded-md border-gray-300" %> +
+ +
+ <%= f.label :delivery_hour, "Delivery hour (0-23)", class: "block text-sm font-medium" %> + <%= f.number_field :delivery_hour, in: 0..23, value: 10, class: "mt-1 block w-full rounded-md border-gray-300" %> +
+ + <%= f.hidden_field :slack_team_id, value: params[:slack_team_id] %> + + <%= f.submit "Enable daily digest", class: "btn btn-primary" %> + <% end %> + <% end %> +
+ <% end %> +
diff --git a/app/views/users/edit.html.erb b/app/views/users/edit.html.erb index c457ad54..0a3bfcff 100644 --- a/app/views/users/edit.html.erb +++ b/app/views/users/edit.html.erb @@ -142,6 +142,10 @@

You can enable notifications for specific channels by running /sailorslog on in the Slack channel.

+
+ <%= link_to "Open daily digest preferences", my_activity_digest_subscription_path, + class: "inline-flex items-center gap-2 px-3 py-2 bg-gray-700 hover:bg-gray-600 text-gray-200 text-sm font-medium rounded transition-colors duration-200" %> +
diff --git a/config/initializers/good_job.rb b/config/initializers/good_job.rb index 56cadb17..d7c29326 100644 --- a/config/initializers/good_job.rb +++ b/config/initializers/good_job.rb @@ -32,6 +32,10 @@ args: [ :daily ], kwargs: { force_update: true } }, + slack_activity_digest_scheduler: { + cron: "15 * * * *", + class: "SlackActivityDigestSchedulerJob" + }, last_7_days_leaderboard_update: { cron: "*/7 * * * *", class: "LeaderboardUpdateJob", diff --git a/config/routes.rb b/config/routes.rb index d2f9d46e..bc65a8fd 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -111,6 +111,7 @@ def matches?(request) namespace :my do resources :project_repo_mappings, param: :project_name, only: [ :edit, :update ], constraints: { project_name: /.+/ } resource :mailing_address, only: [ :show, :edit ] + resource :activity_digest_subscription, only: [ :show, :create, :update, :destroy ] get "mailroom", to: "mailroom#index" resources :heartbeats, only: [] do collection do diff --git a/db/migrate/20251114120000_create_slack_activity_digest_subscriptions.rb b/db/migrate/20251114120000_create_slack_activity_digest_subscriptions.rb new file mode 100644 index 00000000..da8c4d21 --- /dev/null +++ b/db/migrate/20251114120000_create_slack_activity_digest_subscriptions.rb @@ -0,0 +1,18 @@ +class CreateSlackActivityDigestSubscriptions < ActiveRecord::Migration[8.0] + def change + create_table :slack_activity_digest_subscriptions do |t| + t.string :slack_channel_id, null: false + t.string :slack_team_id + t.string :timezone, null: false, default: "UTC" + t.integer :delivery_hour, null: false, default: 17 + t.boolean :enabled, null: false, default: true + t.datetime :last_delivered_at + t.references :created_by_user, foreign_key: { to_table: :users } + + t.timestamps + end + + add_index :slack_activity_digest_subscriptions, :slack_channel_id, unique: true + add_index :slack_activity_digest_subscriptions, :enabled + end +end diff --git a/lib/wakatime_service.rb b/lib/wakatime_service.rb index 5fc336d7..5e852862 100644 --- a/lib/wakatime_service.rb +++ b/lib/wakatime_service.rb @@ -57,16 +57,25 @@ def generate_summary end def generate_summary_chunk(group_by) + helpers = ApplicationController.helpers + total_seconds = @total_seconds.to_f + result = [] @scope.group(group_by).duration_seconds.each do |key, value| + percent_value = if total_seconds.positive? + (100.0 * value / total_seconds).round(2) + else + 0.0 + end + result << { name: transform_display_name(group_by, key), total_seconds: value, - text: ApplicationController.helpers.short_time_simple(value), + text: helpers.short_time_simple(value), hours: value / 3600, minutes: (value % 3600) / 60, - percent: (100.0 * value / @total_seconds).round(2), - digital: ApplicationController.helpers.digital_time(value) + percent: percent_value, + digital: helpers.digital_time(value) } end result = result.sort_by { |item| -item[:total_seconds] } diff --git a/test/jobs/slack_activity_digest_job_test.rb b/test/jobs/slack_activity_digest_job_test.rb new file mode 100644 index 00000000..1d83b2da --- /dev/null +++ b/test/jobs/slack_activity_digest_job_test.rb @@ -0,0 +1,83 @@ +require "test_helper" + +class SlackActivityDigestJobTest < ActiveJob::TestCase + def setup + @original_timeout = Heartbeat.heartbeat_timeout_duration + Heartbeat.heartbeat_timeout_duration(1.hour) + + @user = User.create!( + slack_uid: "U999", + username: "digest-user", + timezone: "UTC", + slack_neighborhood_channel: "C999" + ) + + @subscription = SlackActivityDigestSubscription.create!( + slack_channel_id: "C999", + timezone: "UTC", + delivery_hour: 10, + enabled: true + ) + + create_heartbeat(@user, seconds: 600, occurred_at: Time.utc(2024, 5, 1, 16, 0, 0)) + end + + def teardown + Heartbeat.heartbeat_timeout_duration(@original_timeout) + Heartbeat.delete_all + SlackActivityDigestSubscription.delete_all + User.delete_all + end + + def test_job_posts_to_slack + Time.use_zone("UTC") do + travel_to Time.utc(2024, 5, 2, 11, 0, 0) do + captured = {} + fake_response = Struct.new(:body).new({ ok: true }.to_json) + + client = Class.new do + def initialize(captured, response) + @captured = captured + @response = response + end + + def post(url, json:) + @captured[:url] = url + @captured[:json] = json + @response + end + end.new(captured, fake_response) + + ENV["SLACK_ACTIVITY_DIGEST_BOT_TOKEN"] = "xoxb-test" + + HTTP.stub :auth, ->(*) { client } do + SlackActivityDigestJob.perform_now(@subscription.id, Time.current.to_i) + end + + assert_equal "https://slack.com/api/chat.postMessage", captured[:url] + assert_kind_of Array, captured[:json][:blocks] + assert captured[:json][:blocks].any? + assert_not_nil @subscription.reload.last_delivered_at + ensure + ENV.delete("SLACK_ACTIVITY_DIGEST_BOT_TOKEN") + end + end + end + + private + + def create_heartbeat(user, seconds:, occurred_at:) + steps = [ (seconds / 60).to_i, 1 ].max + step_seconds = seconds / steps.to_f + + (steps + 1).times do |index| + ts = occurred_at.to_i + (index * step_seconds).round + Heartbeat.create!( + user: user, + time: ts, + project: "DigestProject", + source_type: :direct_entry + ) + end + end +end diff --git a/test/lib/wakatime_service_test.rb b/test/lib/wakatime_service_test.rb index cb5d07ee..200628be 100644 --- a/test/lib/wakatime_service_test.rb +++ b/test/lib/wakatime_service_test.rb @@ -12,7 +12,7 @@ def test_parse_user_agent_with_vscode_wakatime_client result = WakatimeService.parse_user_agent(user_agent) assert_equal "darwin", result[:os] assert_equal "vscode", result[:editor] - assert_nil result[:error] + assert_nil result[:err] end def test_parse_user_agent_with_GitHub_Desktop @@ -20,7 +20,7 @@ def test_parse_user_agent_with_GitHub_Desktop result = WakatimeService.parse_user_agent(user_agent) assert_equal "darwin", result[:os] assert_equal "github-desktop", result[:editor] - assert_nil result[:error] + assert_nil result[:err] end def test_parse_user_agent_with_Figma @@ -28,7 +28,7 @@ def test_parse_user_agent_with_Figma result = WakatimeService.parse_user_agent(user_agent) assert_equal "darwin", result[:os] assert_equal "figma", result[:editor] - assert_nil result[:error] + assert_nil result[:err] end def test_parse_user_agent_with_Terminal @@ -36,7 +36,7 @@ def test_parse_user_agent_with_Terminal result = WakatimeService.parse_user_agent(user_agent) assert_equal "darwin", result[:os] assert_equal "terminal", result[:editor] - assert_nil result[:error] + assert_nil result[:err] end def test_parse_user_agent_with_vim @@ -44,7 +44,7 @@ def test_parse_user_agent_with_vim result = WakatimeService.parse_user_agent(user_agent) assert_equal "darwin", result[:os] assert_equal "vim", result[:editor] - assert_nil result[:error] + assert_nil result[:err] end def test_parse_user_agent_with_Windows @@ -52,7 +52,7 @@ def test_parse_user_agent_with_Windows result = WakatimeService.parse_user_agent(user_agent) assert_equal "windows", result[:os] assert_equal "vscode", result[:editor] - assert_nil result[:error] + assert_nil result[:err] end def test_parse_user_agent_with_Cursor @@ -60,7 +60,7 @@ def test_parse_user_agent_with_Cursor result = WakatimeService.parse_user_agent(user_agent) assert_equal "darwin", result[:os] assert_equal "cursor", result[:editor] - assert_nil result[:error] + assert_nil result[:err] end def test_parse_user_agent_with_Firefox @@ -68,7 +68,7 @@ def test_parse_user_agent_with_Firefox result = WakatimeService.parse_user_agent(user_agent) assert_equal "linux", result[:os] assert_equal "firefox", result[:editor] - assert_nil result[:error] + assert_nil result[:err] end def test_parse_user_agent_with_invalid_user_agent @@ -78,4 +78,25 @@ def test_parse_user_agent_with_invalid_user_agent assert_equal "", result[:editor] assert_equal "failed to parse user agent string", result[:err] end + + def test_generate_summary_chunk_handles_zero_total_seconds + service = WakatimeService.new(scope: Heartbeat.none) + service.instance_variable_set(:@total_seconds, 0) + + grouped_scope = Minitest::Mock.new + grouped_scope.expect(:duration_seconds, { "Hackatime" => 0 }) + + scope = Minitest::Mock.new + scope.expect(:group, grouped_scope, [ :project ]) + + service.instance_variable_set(:@scope, scope) + + chunk = service.generate_summary_chunk(:project) + + assert_equal 1, chunk.length + assert_equal 0.0, chunk.first[:percent] + + scope.verify + grouped_scope.verify + end end diff --git a/test/models/slack_activity_digest_subscription_test.rb b/test/models/slack_activity_digest_subscription_test.rb new file mode 100644 index 00000000..bd18603a --- /dev/null +++ b/test/models/slack_activity_digest_subscription_test.rb @@ -0,0 +1,39 @@ +require "test_helper" + +class SlackActivityDigestSubscriptionTest < ActiveSupport::TestCase + def setup + @subscription = SlackActivityDigestSubscription.new( + slack_channel_id: "C123", + timezone: "UTC", + delivery_hour: 10 + ) + end + + def test_due_for_delivery_when_never_delivered + travel_to Time.utc(2024, 5, 1, 11, 0, 0) do + assert @subscription.due_for_delivery? + end + end + + def test_not_due_before_delivery_hour + travel_to Time.utc(2024, 5, 1, 9, 59, 0) do + refute @subscription.due_for_delivery? + end + end + + def test_not_due_when_already_sent_today + @subscription.last_delivered_at = Time.utc(2024, 5, 1, 10, 0, 0) + + travel_to Time.utc(2024, 5, 1, 15, 0, 0) do + refute @subscription.due_for_delivery? + end + end + + def test_due_next_day_after_hour + @subscription.last_delivered_at = Time.utc(2024, 4, 30, 10, 5, 0) + + travel_to Time.utc(2024, 5, 1, 10, 1, 0) do + assert @subscription.due_for_delivery? + end + end +end diff --git a/test/services/slack_activity_digest_service_test.rb b/test/services/slack_activity_digest_service_test.rb new file mode 100644 index 00000000..fc44461c --- /dev/null +++ b/test/services/slack_activity_digest_service_test.rb @@ -0,0 +1,72 @@ +require "test_helper" + +class SlackActivityDigestServiceTest < ActiveSupport::TestCase + def setup + @original_timeout = Heartbeat.heartbeat_timeout_duration + Heartbeat.heartbeat_timeout_duration(1.hour) + + @user = User.create!( + slack_uid: "U123TEST", + username: "testuser", + timezone: "UTC", + slack_neighborhood_channel: "C123TEST" + ) + + @subscription = SlackActivityDigestSubscription.create!( + slack_channel_id: "C123TEST", + timezone: "UTC", + delivery_hour: 10, + enabled: true + ) + end + + def teardown + Heartbeat.heartbeat_timeout_duration(@original_timeout) + Heartbeat.delete_all + SlackActivityDigestSubscription.delete_all + User.delete_all + end + + def test_build_includes_top_user_and_project + travel_to Time.utc(2024, 5, 2, 12, 0, 0) do + create_heartbeat(@user, project: "ShipIt", seconds: 3600, occurred_at: Time.utc(2024, 5, 1, 18, 0, 0)) + create_heartbeat(@user, project: "ShipIt", seconds: 1200, occurred_at: Time.utc(2024, 5, 1, 19, 0, 0)) + + result = SlackActivityDigestService.new(subscription: @subscription, as_of: Time.current).build + + assert_equal 4800, result.total_seconds + assert_equal [ @user.id ], result.active_user_ids + + blocks_text = result.blocks.map { |block| block.dig(:text, :text) }.compact.join("\n") + assert_includes blocks_text, "ShipIt" + assert_includes blocks_text, "testuser" + end + end + + def test_build_handles_no_activity + travel_to Time.utc(2024, 5, 2, 12, 0, 0) do + result = SlackActivityDigestService.new(subscription: @subscription, as_of: Time.current).build + + assert_equal 0, result.total_seconds + fallback = result.blocks.last.dig(:text, :text) + assert_match(/No coding activity/, fallback) + end + end + + private + + def create_heartbeat(user, project:, seconds:, occurred_at:) + steps = [ (seconds / 60).to_i, 1 ].max + step_seconds = seconds / steps.to_f + + (steps + 1).times do |index| + ts = occurred_at.to_i + (index * step_seconds).round + Heartbeat.create!( + user: user, + time: ts, + project: project, + source_type: :direct_entry + ) + end + end +end