diff --git a/.env.example b/.env.example index 363b28892..88b4b5cbb 100644 --- a/.env.example +++ b/.env.example @@ -8,7 +8,7 @@ SLACK_USER_OAUTH_TOKEN=your_user_oauth_token_here # Sailors' log slack app for slack channel notifications SAILORS_LOG_SLACK_SIGNING_SECRET=your_signing_secret_here -SLACK_SAILORS_LOG_BOT_OAUTH_TOKEN=your_bot_oauth_token_here +SAILORS_LOG_SLACK_BOT_OAUTH_TOKEN=your_bot_oauth_token_here # You can leave this alone if you're using the provided docker setup! DATABASE_URL=your_database_url_here diff --git a/app/controllers/settings/goals_controller.rb b/app/controllers/settings/goals_controller.rb index 83f7ac84f..e45f39bc1 100644 --- a/app/controllers/settings/goals_controller.rb +++ b/app/controllers/settings/goals_controller.rb @@ -4,6 +4,7 @@ def show = render_goals def create @goal = @user.goals.build(goal_params) if @goal.save + GoalCompletionCheckJob.schedule_for(@user.id) redirect_to my_settings_goals_path, notice: "Goal created." else flash.now[:error] = @goal.errors.full_messages.to_sentence @@ -14,6 +15,7 @@ def create def update @goal = @user.goals.find(params[:goal_id]) if @goal.update(goal_params) + GoalCompletionCheckJob.schedule_for(@user.id) redirect_to my_settings_goals_path, notice: "Goal updated." else flash.now[:error] = @goal.errors.full_messages.to_sentence @@ -34,15 +36,32 @@ def render_goals(status: :ok, goal_form: nil) end def section_props - { programming_goals: programming_goals_props, options: { goals: goal_options } } + { + programming_goals: programming_goals_props, + options: { goals: goal_options }, + notification_options: { + email_available: @user.email_addresses.exists?, + slack_available: @user.slack_uid.present? + } + } end - def goal_params = params.require(:goal).permit(:period, :target_seconds, languages: [], projects: []) + def goal_params + params.require(:goal).permit( + :period, + :target_seconds, + :notify_by_email, + :notify_by_slack, + languages: [], + projects: [] + ) + end def goal_form_props(goal, mode) { open: true, mode: mode, goal_id: goal.id&.to_s, period: goal.period, target_seconds: goal.target_seconds, languages: goal.languages, projects: goal.projects, + notify_by_email: goal.notify_by_email, notify_by_slack: goal.notify_by_slack, errors: goal.errors.full_messages } end end diff --git a/app/javascript/pages/Users/Settings/Goals.svelte b/app/javascript/pages/Users/Settings/Goals.svelte index 694345a44..803722831 100644 --- a/app/javascript/pages/Users/Settings/Goals.svelte +++ b/app/javascript/pages/Users/Settings/Goals.svelte @@ -2,6 +2,7 @@ import { router } from "@inertiajs/svelte"; import { secondsToDisplay } from "../../../utils"; import Button from "../../../components/Button.svelte"; + import CheckboxField from "../../../components/CheckboxField.svelte"; import Modal from "../../../components/Modal.svelte"; import MultiSelectCombobox from "../../../components/MultiSelectCombobox.svelte"; import Select from "../../../components/Select.svelte"; @@ -35,6 +36,7 @@ subheading, programming_goals, options, + notification_options, errors, goal_form, }: GoalsPageProps = $props(); @@ -52,6 +54,8 @@ let selectedPeriod = $state(defaultPeriod()); let selectedLanguages = $state([]); let selectedProjects = $state([]); + let notifyByEmail = $state(false); + let notifyBySlack = $state(false); let submitting = $state(false); $effect(() => { @@ -62,6 +66,8 @@ setFromSeconds(goal_form.target_seconds || 1800); selectedLanguages = goal_form.languages || []; selectedProjects = goal_form.projects || []; + notifyByEmail = goal_form.notify_by_email ?? false; + notifyBySlack = goal_form.notify_by_slack ?? false; editingGoal = goal_form.mode === "edit" && goal_form.goal_id ? ((programming_goals || []).find((g) => g.id === goal_form.goal_id) ?? @@ -83,6 +89,15 @@ return parts.join(" AND ") || "All programming activity"; } + function notificationSubtitle(goal: ProgrammingGoal) { + const channels = []; + if (goal.notify_by_email) channels.push("email"); + if (goal.notify_by_slack) channels.push("Slack"); + return channels.length > 0 + ? `Completion notification: ${channels.join(" and ")}` + : "Completion notifications off"; + } + function setFromSeconds(seconds: number) { targetUnit = seconds % 3600 === 0 ? "hours" : "minutes"; targetAmount = targetUnit === "hours" ? seconds / 3600 : seconds / 60; @@ -94,6 +109,8 @@ setFromSeconds(options.goals.preset_target_seconds[0] || 1800); selectedLanguages = []; selectedProjects = []; + notifyByEmail = false; + notifyBySlack = false; goalModalOpen = true; } @@ -103,6 +120,8 @@ setFromSeconds(goal.target_seconds); selectedLanguages = [...goal.languages]; selectedProjects = [...goal.projects]; + notifyByEmail = goal.notify_by_email; + notifyBySlack = goal.notify_by_slack; goalModalOpen = true; } @@ -137,6 +156,8 @@ target_seconds: currentTargetSeconds, languages: selectedLanguages, projects: selectedProjects, + notify_by_email: notifyByEmail, + notify_by_slack: notifyBySlack, }, }; if (editingGoal) { @@ -195,6 +216,9 @@

{scopeSubtitle(goal)}

+

+ {notificationSubtitle(goal)} +

+
+ + Notify me when I reach this goal + + + +
+ {#if modalErrors.length > 0}

{ "goal_completion_check_job_#{arguments.first}" } + ) + + def perform(user_id) + user = User.find_by(id: user_id) + return if user.nil? || user.pending_deletion? + + goals = user.goals.where(notify_by_email: true).or(user.goals.where(notify_by_slack: true)) + return if goals.empty? + + ProgrammingGoalsProgressService.new(user: user, goals: goals).call.each do |progress| + next unless progress[:complete] + + notification = GoalCompletionNotification.create_or_find_by!( + goal_id: progress[:id], + period: progress[:period], + period_started_at: Time.zone.parse(progress[:period_start]) + ) do |record| + record.target_seconds = progress[:target_seconds] + record.tracked_seconds = progress[:tracked_seconds] + record.languages = progress[:languages] + record.projects = progress[:projects] + end + + goal = notification.goal + GoalCompletionEmailJob.perform_later(notification.id) if goal.notify_by_email? && notification.email_delivered_at.nil? + GoalCompletionSlackJob.perform_later(notification.id) if goal.notify_by_slack? && notification.slack_delivered_at.nil? + end + end +end diff --git a/app/jobs/goal_completion_email_job.rb b/app/jobs/goal_completion_email_job.rb new file mode 100644 index 000000000..a52cb6c96 --- /dev/null +++ b/app/jobs/goal_completion_email_job.rb @@ -0,0 +1,26 @@ +class GoalCompletionEmailJob < ApplicationJob + queue_as :latency_10s + + include GoodJob::ActiveJobExtensions::Concurrency + + good_job_control_concurrency_with( + total_limit: 1, key: -> { "goal_completion_email_job_#{arguments.first}" } + ) + + def perform(notification_id) + notification = GoalCompletionNotification.find(notification_id) + return if notification.email_delivered_at.present? + + goal = notification.goal + return unless goal.notify_by_email? + + user = goal.user + return if user.pending_deletion? + + recipient_email = user.email_addresses.order(:id).pick(:email) + return if recipient_email.blank? + + GoalCompletionMailer.reached(notification, recipient_email: recipient_email).deliver_now + notification.update!(email_delivered_at: Time.current) + end +end diff --git a/app/jobs/goal_completion_slack_job.rb b/app/jobs/goal_completion_slack_job.rb new file mode 100644 index 000000000..09fd4cbad --- /dev/null +++ b/app/jobs/goal_completion_slack_job.rb @@ -0,0 +1,39 @@ +class GoalCompletionSlackJob < ApplicationJob + queue_as :latency_10s + + include GoodJob::ActiveJobExtensions::Concurrency + + good_job_control_concurrency_with( + total_limit: 1, key: -> { "goal_completion_slack_job_#{arguments.first}" } + ) + + def perform(notification_id) + notification = GoalCompletionNotification.find(notification_id) + return if notification.slack_delivered_at.present? + + goal = notification.goal + return unless goal.notify_by_slack? + + user = goal.user + return if user.pending_deletion? || user.slack_uid.blank? + + duration = ApplicationController.helpers.short_time_simple(notification.target_seconds) + scope = notification_scope(notification) + message = ":tada: You reached your #{notification.period} coding goal of *#{duration}*#{scope}!" + response = HTTP.auth("Bearer #{ENV['SAILORS_LOG_SLACK_BOT_OAUTH_TOKEN']}") + .post("https://slack.com/api/chat.postMessage", json: { channel: user.slack_uid, text: message }) + data = JSON.parse(response.body) + raise "Failed to send goal completion Slack notification: #{data["error"]}" unless data["ok"] + + notification.update!(slack_delivered_at: Time.current) + end + + private + + def notification_scope(notification) + filters = [] + filters << "#{notification.languages.to_sentence} coding" if notification.languages.any? + filters << notification.projects.to_sentence if notification.projects.any? + filters.any? ? " for #{filters.join(" in ")}" : "" + end +end diff --git a/app/mailers/goal_completion_mailer.rb b/app/mailers/goal_completion_mailer.rb new file mode 100644 index 000000000..7880faff6 --- /dev/null +++ b/app/mailers/goal_completion_mailer.rb @@ -0,0 +1,16 @@ +class GoalCompletionMailer < ApplicationMailer + helper :application + + def reached(notification, recipient_email:) + @notification = notification + @user = notification.goal.user + @target_duration = ApplicationController.helpers.short_time_simple(notification.target_seconds) + @tracked_duration = ApplicationController.helpers.short_time_simple(notification.tracked_seconds) + @goals_url = my_settings_goals_url + + mail( + to: recipient_email, + subject: "You reached your #{notification.period} Hackatime goal!" + ) + end +end diff --git a/app/models/goal.rb b/app/models/goal.rb index 12aa95582..4b941b1bc 100644 --- a/app/models/goal.rb +++ b/app/models/goal.rb @@ -14,6 +14,7 @@ class Goal < ApplicationRecord MAX_GOALS = 5 belongs_to :user + has_many :completion_notifications, class_name: "GoalCompletionNotification", dependent: :destroy before_validation :normalize_fields @@ -30,7 +31,9 @@ def as_programming_goal_payload period: period, target_seconds: target_seconds, languages: languages, - projects: projects + projects: projects, + notify_by_email: notify_by_email, + notify_by_slack: notify_by_slack } end diff --git a/app/models/goal_completion_notification.rb b/app/models/goal_completion_notification.rb new file mode 100644 index 000000000..176e2a18f --- /dev/null +++ b/app/models/goal_completion_notification.rb @@ -0,0 +1,8 @@ +class GoalCompletionNotification < ApplicationRecord + belongs_to :goal + + validates :period, inclusion: { in: Goal::PERIODS } + validates :period_started_at, presence: true + validates :target_seconds, :tracked_seconds, + numericality: { only_integer: true, greater_than_or_equal_to: 0 } +end diff --git a/app/models/heartbeat.rb b/app/models/heartbeat.rb index 9247ccdad..8c7649479 100644 --- a/app/models/heartbeat.rb +++ b/app/models/heartbeat.rb @@ -2,6 +2,7 @@ class Heartbeat < ApplicationRecord before_save :set_fields_hash! before_save :set_time_epoch! after_commit :schedule_dashboard_rollup_refresh, on: %i[create update destroy] + after_commit :schedule_goal_completion_check, on: %i[create update] include Heartbeatable include TimeRangeFilterable @@ -79,4 +80,5 @@ def set_time_epoch! end def schedule_dashboard_rollup_refresh = DashboardRollupRefreshJob.schedule_for(user_id) + def schedule_goal_completion_check = GoalCompletionCheckJob.schedule_for(user_id) end diff --git a/app/services/heartbeat_ingest.rb b/app/services/heartbeat_ingest.rb index 3d50324a4..4d5da1513 100644 --- a/app/services/heartbeat_ingest.rb +++ b/app/services/heartbeat_ingest.rb @@ -20,6 +20,7 @@ class InvalidHeartbeatTime < ArgumentError; end def self.call(...) = new(...).call def self.schedule_rollup_refresh(user:) = DashboardRollupRefreshJob.schedule_for(user.id) + def self.schedule_goal_completion_check(user:) = GoalCompletionCheckJob.schedule_for(user.id) def initialize(user:, mode:, heartbeats:, request_context: {}, user_agents_by_id: {}, schedule_rollup_refresh: true) @user = user @@ -155,6 +156,7 @@ def persist_direct_heartbeats(entries) hashes.each { |fields_hash| persisted_by_hash.fetch(fields_hash) } if inserted_by_hash.any? && @schedule_rollup_refresh self.class.schedule_rollup_refresh(user: @user) + self.class.schedule_goal_completion_check(user: @user) end [ persisted_by_hash, inserted_by_hash.keys ] @@ -199,7 +201,10 @@ def ingest_import end persisted_count = flush_import_batch(seen_hashes) - self.class.schedule_rollup_refresh(user: @user) if persisted_count.positive? && @schedule_rollup_refresh + if persisted_count.positive? && @schedule_rollup_refresh + self.class.schedule_rollup_refresh(user: @user) + self.class.schedule_goal_completion_check(user: @user) + end Result.new( total_count:, diff --git a/app/services/programming_goals_progress_service.rb b/app/services/programming_goals_progress_service.rb index b3936d63c..892bf09c5 100644 --- a/app/services/programming_goals_progress_service.rb +++ b/app/services/programming_goals_progress_service.rb @@ -31,6 +31,7 @@ def build_progress(goal, now:) complete: tracked_seconds >= goal.target_seconds, languages: goal.languages, projects: goal.projects, + period_start: time_window.begin.iso8601, period_end: time_window.end.iso8601 } end diff --git a/app/views/goal_completion_mailer/reached.html.erb b/app/views/goal_completion_mailer/reached.html.erb new file mode 100644 index 000000000..a02851333 --- /dev/null +++ b/app/views/goal_completion_mailer/reached.html.erb @@ -0,0 +1,16 @@ +

You reached your <%= @notification.period %> coding goal!

+ +

Nice work, <%= @user.display_name %>! You coded for <%= @tracked_duration %>, reaching your target of <%= @target_duration %>.

+ +<% if @notification.languages.any? || @notification.projects.any? %> +
+ <% if @notification.languages.any? %> +

Languages: <%= @notification.languages.to_sentence %>

+ <% end %> + <% if @notification.projects.any? %> +

Projects: <%= @notification.projects.to_sentence %>

+ <% end %> +
+<% end %> + +

<%= link_to "Manage your goals", @goals_url %>

diff --git a/app/views/goal_completion_mailer/reached.text.erb b/app/views/goal_completion_mailer/reached.text.erb new file mode 100644 index 000000000..c0b22d9cc --- /dev/null +++ b/app/views/goal_completion_mailer/reached.text.erb @@ -0,0 +1,12 @@ +You reached your <%= @notification.period %> coding goal! +=================================================== + +Nice work, <%= @user.display_name %>! You coded for <%= @tracked_duration %>, reaching your target of <%= @target_duration %>. +<% if @notification.languages.any? %> +Languages: <%= @notification.languages.to_sentence %> +<% end %> +<% if @notification.projects.any? %> +Projects: <%= @notification.projects.to_sentence %> +<% end %> + +Manage your goals: <%= @goals_url %> diff --git a/db/migrate/20260820115208_add_notification_channels_to_goals.rb b/db/migrate/20260820115208_add_notification_channels_to_goals.rb new file mode 100644 index 000000000..61a4e96e5 --- /dev/null +++ b/db/migrate/20260820115208_add_notification_channels_to_goals.rb @@ -0,0 +1,6 @@ +class AddNotificationChannelsToGoals < ActiveRecord::Migration[8.1] + def change + add_column :goals, :notify_by_email, :boolean, default: false, null: false + add_column :goals, :notify_by_slack, :boolean, default: false, null: false + end +end diff --git a/db/migrate/20260820115209_create_goal_completion_notifications.rb b/db/migrate/20260820115209_create_goal_completion_notifications.rb new file mode 100644 index 000000000..5bb7ed5d9 --- /dev/null +++ b/db/migrate/20260820115209_create_goal_completion_notifications.rb @@ -0,0 +1,22 @@ +class CreateGoalCompletionNotifications < ActiveRecord::Migration[8.1] + def change + create_table :goal_completion_notifications do |t| + t.references :goal, null: false, foreign_key: true + t.string :period, null: false + t.datetime :period_started_at, null: false + t.integer :target_seconds, null: false + t.integer :tracked_seconds, null: false + t.string :languages, array: true, default: [], null: false + t.string :projects, array: true, default: [], null: false + t.datetime :email_delivered_at + t.datetime :slack_delivered_at + + t.timestamps + end + + add_index :goal_completion_notifications, + [ :goal_id, :period, :period_started_at ], + unique: true, + name: "index_goal_completion_notifications_on_goal_period" + end +end diff --git a/db/schema.rb b/db/schema.rb index b00e5b5bc..f9fa2207e 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.1].define(version: 2026_08_04_184518) do +ActiveRecord::Schema[8.1].define(version: 2026_08_20_115209) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" enable_extension "pg_stat_statements" @@ -168,9 +168,27 @@ t.index ["feature_key", "key", "value"], name: "index_flipper_gates_on_feature_key_and_key_and_value", unique: true end + create_table "goal_completion_notifications", force: :cascade do |t| + t.datetime "created_at", null: false + t.datetime "email_delivered_at" + t.bigint "goal_id", null: false + t.string "languages", default: [], null: false, array: true + t.string "period", null: false + t.datetime "period_started_at", null: false + t.string "projects", default: [], null: false, array: true + t.datetime "slack_delivered_at" + t.integer "target_seconds", null: false + t.integer "tracked_seconds", null: false + t.datetime "updated_at", null: false + t.index ["goal_id", "period", "period_started_at"], name: "index_goal_completion_notifications_on_goal_period", unique: true + t.index ["goal_id"], name: "index_goal_completion_notifications_on_goal_id" + end + create_table "goals", force: :cascade do |t| t.datetime "created_at", null: false t.string "languages", default: [], null: false, array: true + t.boolean "notify_by_email", default: false, null: false + t.boolean "notify_by_slack", default: false, null: false t.string "period", null: false t.string "projects", default: [], null: false, array: true t.integer "target_seconds", null: false @@ -765,6 +783,7 @@ add_foreign_key "documentation_feedbacks", "users", on_delete: :cascade add_foreign_key "email_addresses", "users" add_foreign_key "email_verification_requests", "users" + add_foreign_key "goal_completion_notifications", "goals" add_foreign_key "goals", "users" add_foreign_key "heartbeat_import_runs", "users" add_foreign_key "heartbeat_import_sources", "users" diff --git a/test/controllers/settings_goals_controller_test.rb b/test/controllers/settings_goals_controller_test.rb index 073d7dd41..d803ceb3f 100644 --- a/test/controllers/settings_goals_controller_test.rb +++ b/test/controllers/settings_goals_controller_test.rb @@ -5,6 +5,7 @@ class SettingsGoalsControllerTest < ActionDispatch::IntegrationTest test "show renders goals settings page" do user = users(:one) + user.email_addresses.create!(email: "goals-controller@example.com", source: :signing_in) sign_in_as(user) get my_settings_goals_path @@ -15,6 +16,8 @@ class SettingsGoalsControllerTest < ActionDispatch::IntegrationTest page = inertia_page assert_equal [], page.dig("props", "programming_goals") assert_nil page.dig("props", "user", "programming_goals") + assert_equal true, page.dig("props", "notification_options", "email_available") + assert_equal true, page.dig("props", "notification_options", "slack_available") end test "create saves valid goal" do @@ -26,7 +29,9 @@ class SettingsGoalsControllerTest < ActionDispatch::IntegrationTest period: "day", target_seconds: 3600, languages: [ "Ruby" ], - projects: [ "hackatime" ] + projects: [ "hackatime" ], + notify_by_email: true, + notify_by_slack: true } } @@ -37,6 +42,8 @@ class SettingsGoalsControllerTest < ActionDispatch::IntegrationTest assert_equal "day", saved_goal.period assert_equal [ "Ruby" ], saved_goal.languages assert_equal [ "hackatime" ], saved_goal.projects + assert saved_goal.notify_by_email? + assert saved_goal.notify_by_slack? end test "rejects sixth goal when limit reached" do @@ -131,7 +138,9 @@ class SettingsGoalsControllerTest < ActionDispatch::IntegrationTest period: "week", target_seconds: 7200, languages: [ "Python" ], - projects: [ "beta" ] + projects: [ "beta" ], + notify_by_email: true, + notify_by_slack: false } } @@ -143,6 +152,8 @@ class SettingsGoalsControllerTest < ActionDispatch::IntegrationTest assert_equal 7200, goal.target_seconds assert_equal [ "Python" ], goal.languages assert_equal [ "beta" ], goal.projects + assert goal.notify_by_email? + assert_not goal.notify_by_slack? end test "update rejects invalid goal and re-renders settings page" do diff --git a/test/jobs/goal_completion_jobs_test.rb b/test/jobs/goal_completion_jobs_test.rb new file mode 100644 index 000000000..1d4f8161c --- /dev/null +++ b/test/jobs/goal_completion_jobs_test.rb @@ -0,0 +1,157 @@ +require "test_helper" +require "webmock/minitest" + +class GoalCompletionJobsTest < ActiveJob::TestCase + setup do + @original_timeout = Heartbeat.heartbeat_timeout_duration + Heartbeat.heartbeat_timeout_duration(1.second) + @user = User.create!(timezone: "America/New_York", slack_uid: "U_GOAL_COMPLETION") + @user.email_addresses.create!(email: "goals@example.com", source: :signing_in) + ActionMailer::Base.deliveries.clear + GoodJob::Job.delete_all + end + + teardown do + Heartbeat.heartbeat_timeout_duration(@original_timeout) + ActionMailer::Base.deliveries.clear + GoodJob::Job.delete_all + end + + test "completion check creates one notification per goal period and enqueues selected channels" do + goal = @user.goals.create!( + period: "day", + target_seconds: 1, + languages: [ "Ruby" ], + projects: [ "hackatime" ], + notify_by_email: true, + notify_by_slack: true + ) + + travel_to Time.utc(2026, 8, 20, 16, 0, 0) do + create_heartbeat_pair + GoodJob::Job.delete_all + + assert_difference -> { GoalCompletionNotification.count }, 1 do + assert_difference -> { GoodJob::Job.where(job_class: "GoalCompletionEmailJob").count }, 1 do + assert_difference -> { GoodJob::Job.where(job_class: "GoalCompletionSlackJob").count }, 1 do + GoalCompletionCheckJob.perform_now(@user.id) + end + end + end + + notification = goal.completion_notifications.sole + assert_equal "day", notification.period + assert_equal Time.zone.parse("2026-08-20 04:00:00 UTC"), notification.period_started_at + assert_equal 1, notification.target_seconds + assert_equal [ "Ruby" ], notification.languages + assert_equal [ "hackatime" ], notification.projects + + assert_no_difference -> { GoalCompletionNotification.count } do + GoalCompletionCheckJob.perform_now(@user.id) + end + end + end + + test "completion check does nothing before the target is reached" do + @user.goals.create!(period: "day", target_seconds: 2, notify_by_email: true) + + travel_to Time.utc(2026, 8, 20, 16, 0, 0) do + create_heartbeat_pair + + assert_no_difference -> { GoalCompletionNotification.count } do + GoalCompletionCheckJob.perform_now(@user.id) + end + end + end + + test "completion check creates another notification in the next period" do + goal = @user.goals.create!(period: "day", target_seconds: 1, notify_by_email: true) + + travel_to Time.utc(2026, 8, 20, 16, 0, 0) do + create_heartbeat_pair(Time.zone.parse("2026-08-20 09:00:00 -0400")) + GoalCompletionCheckJob.perform_now(@user.id) + end + + travel_to Time.utc(2026, 8, 21, 16, 0, 0) do + create_heartbeat_pair(Time.zone.parse("2026-08-21 09:00:00 -0400")) + + assert_difference -> { goal.completion_notifications.count }, 1 do + GoalCompletionCheckJob.perform_now(@user.id) + end + end + + assert_equal 2, goal.completion_notifications.count + end + + test "email delivery records success and is idempotent" do + notification = create_notification + + assert_difference -> { ActionMailer::Base.deliveries.count }, 1 do + GoalCompletionEmailJob.perform_now(notification.id) + end + + mail = ActionMailer::Base.deliveries.last + assert_equal [ "goals@example.com" ], mail.to + assert_equal "You reached your day Hackatime goal!", mail.subject + assert_includes mail.text_part.body.decoded, "Languages: Ruby" + assert_includes mail.text_part.body.decoded, "Projects: hackatime" + assert notification.reload.email_delivered_at.present? + + assert_no_difference -> { ActionMailer::Base.deliveries.count } do + GoalCompletionEmailJob.perform_now(notification.id) + end + end + + test "Slack delivery sends a direct message and records success" do + notification = create_notification + slack_request = stub_request(:post, "https://slack.com/api/chat.postMessage") + .with do |request| + body = JSON.parse(request.body) + body.fetch("channel") == @user.slack_uid && + body.fetch("text").include?("reached your day coding goal") && + body.fetch("text").include?("Ruby coding in hackatime") + end + .to_return(status: 200, body: { ok: true }.to_json) + + GoalCompletionSlackJob.perform_now(notification.id) + + assert_requested slack_request, times: 1 + assert notification.reload.slack_delivered_at.present? + + GoalCompletionSlackJob.perform_now(notification.id) + assert_requested slack_request, times: 1 + end + + private + + def create_heartbeat_pair(start_at = Time.zone.parse("2026-08-20 09:00:00 -0400")) + [ start_at, start_at + 1.second ].each do |time| + @user.heartbeats.create!( + time: time.to_i, + language: "Ruby", + project: "hackatime", + category: "coding", + source_type: :test_entry + ) + end + end + + def create_notification + goal = @user.goals.create!( + period: "day", + target_seconds: 1.hour.to_i, + languages: [ "Ruby" ], + projects: [ "hackatime" ], + notify_by_email: true, + notify_by_slack: true + ) + goal.completion_notifications.create!( + period: "day", + period_started_at: Time.utc(2026, 8, 20), + target_seconds: 1.hour.to_i, + tracked_seconds: 75.minutes.to_i, + languages: [ "Ruby" ], + projects: [ "hackatime" ] + ) + end +end diff --git a/test/models/heartbeat_test.rb b/test/models/heartbeat_test.rb index 84e428842..e8ba00a78 100644 --- a/test/models/heartbeat_test.rb +++ b/test/models/heartbeat_test.rb @@ -127,6 +127,24 @@ class HeartbeatTest < ActiveSupport::TestCase end end + test "creating a heartbeat schedules a goal completion check for notification goals" do + user = User.create!(timezone: "UTC") + user.goals.create!(period: "day", target_seconds: 1.hour.to_i, notify_by_slack: true) + clear_enqueued_jobs + + assert_enqueued_with(job: GoalCompletionCheckJob, args: [ user.id ]) do + user.heartbeats.create!( + entity: "src/main.rb", + type: "file", + category: "coding", + editor: "vscode", + time: Time.current.to_f, + project: "heartbeat-test", + source_type: :test_entry + ) + end + end + private def create_heartbeat_sequence(user:, started_at:, editor:, count: 9) diff --git a/test/services/heartbeat_ingest_test.rb b/test/services/heartbeat_ingest_test.rb index cd5313744..e2653c5af 100644 --- a/test/services/heartbeat_ingest_test.rb +++ b/test/services/heartbeat_ingest_test.rb @@ -256,6 +256,25 @@ class HeartbeatIngestTest < ActiveSupport::TestCase end end + test "direct heartbeat ingest schedules a goal completion check for notification goals" do + user = User.create!(timezone: "UTC") + user.goals.create!(period: "day", target_seconds: 1.hour.to_i, notify_by_email: true) + clear_enqueued_jobs + + assert_enqueued_with(job: GoalCompletionCheckJob, args: [ user.id ]) do + HeartbeatIngest.call( + user: user, + mode: :direct, + heartbeats: [ { + entity: "src/main.rb", + project: "hackatime", + time: Time.current.to_f, + type: "file" + } ] + ) + end + end + test "import heartbeat ingest runs model validations before bulk insertion" do user = User.create!(timezone: "UTC") validation = lambda do |heartbeat| diff --git a/test/services/programming_goals_progress_service_test.rb b/test/services/programming_goals_progress_service_test.rb index d1bb8bcfa..6caebc603 100644 --- a/test/services/programming_goals_progress_service_test.rb +++ b/test/services/programming_goals_progress_service_test.rb @@ -21,6 +21,7 @@ class ProgrammingGoalsProgressServiceTest < ActiveSupport::TestCase progress = ProgrammingGoalsProgressService.new(user: user).call assert_equal 1, progress.first[:tracked_seconds] + assert_equal "2026-01-14T00:00:00-05:00", progress.first[:period_start] end end diff --git a/test/system/settings/goals_settings_test.rb b/test/system/settings/goals_settings_test.rb index 9f39951cd..d7c76e89f 100644 --- a/test/system/settings/goals_settings_test.rb +++ b/test/system/settings/goals_settings_test.rb @@ -6,6 +6,7 @@ class GoalsSettingsTest < ApplicationSystemTestCase setup do @user = User.create!(timezone: "UTC") + @user.email_addresses.create!(email: "goals-system@example.com", source: :signing_in) sign_in_as(@user) end @@ -25,13 +26,16 @@ class GoalsSettingsTest < ApplicationSystemTestCase within_modal do click_on "2h" + click_on "Email" click_on "Create Goal" end assert_text "Goal created." assert_text(/1 Active Goal/i) assert_text "Daily: 2h" + assert_text "Completion notification: email" assert_equal 2.hours.to_i, @user.reload.goals.first.target_seconds + assert @user.goals.first.notify_by_email? click_on "Edit" within_modal do