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