From ae76f2094688fbdeed897b280d1759ae43a99eba Mon Sep 17 00:00:00 2001 From: Max Wofford Date: Fri, 3 Oct 2025 20:52:36 -0400 Subject: [PATCH] Initial new activity log (#562) --- Gemfile | 2 + Gemfile.lock | 6 ++ .../api/hackatime/v1/hackatime_controller.rb | 11 +++ app/controllers/application_controller.rb | 6 ++ app/jobs/create_heartbeat_activity_job.rb | 73 +++++++++++++++++++ app/models/heartbeat.rb | 1 + app/models/physical_mail.rb | 29 ++++++++ app/models/user.rb | 7 ++ .../heartbeat/_coding_session.html.erb | 3 + .../heartbeat/_first_heartbeat.html.erb | 2 + .../heartbeat/_started_working.html.erb | 2 + .../_first_streak_achieved.html.erb | 2 + .../physical_mail/_mail_sent.html.erb | 2 + .../user/_first_signup.html.erb | 2 + app/views/shared/_nav.html.erb | 2 + config/initializers/monkey_patches.rb | 5 ++ .../20251003215127_create_activities.rb | 25 +++++++ db/schema.rb | 21 +++++- 18 files changed, 200 insertions(+), 1 deletion(-) create mode 100644 app/jobs/create_heartbeat_activity_job.rb create mode 100644 app/views/public_activity/heartbeat/_coding_session.html.erb create mode 100644 app/views/public_activity/heartbeat/_first_heartbeat.html.erb create mode 100644 app/views/public_activity/heartbeat/_started_working.html.erb create mode 100644 app/views/public_activity/physical_mail/_first_streak_achieved.html.erb create mode 100644 app/views/public_activity/physical_mail/_mail_sent.html.erb create mode 100644 app/views/public_activity/user/_first_signup.html.erb create mode 100644 db/migrate/20251003215127_create_activities.rb diff --git a/Gemfile b/Gemfile index 67f0ba1..a335b1f 100644 --- a/Gemfile +++ b/Gemfile @@ -72,6 +72,8 @@ gem "dotenv-rails" # Added from the code block gem "http" +gem "public_activity" + # Bulk import gem "activerecord-import" diff --git a/Gemfile.lock b/Gemfile.lock index 67049b5..61faaaa 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -345,6 +345,11 @@ GEM psych (5.2.6) date stringio + public_activity (3.0.1) + actionpack (>= 6.1.0) + activerecord (>= 6.1) + i18n (>= 0.5.0) + railties (>= 6.1.0) public_suffix (6.0.2) puma (7.0.4) nio4r (~> 2.0) @@ -592,6 +597,7 @@ DEPENDENCIES paper_trail pg propshaft + public_activity puma (>= 5.0) query_count rack-attack diff --git a/app/controllers/api/hackatime/v1/hackatime_controller.rb b/app/controllers/api/hackatime/v1/hackatime_controller.rb index 91e20a1..8b3d056 100644 --- a/app/controllers/api/hackatime/v1/hackatime_controller.rb +++ b/app/controllers/api/hackatime/v1/hackatime_controller.rb @@ -258,6 +258,7 @@ class Api::Hackatime::V1::HackatimeController < ApplicationController new_heartbeat.save! if new_heartbeat.changed? end queue_project_mapping(heartbeat[:project]) + queue_heartbeat_public_activity(@user.id, heartbeat[:project]) if new_heartbeat.persisted? results << [ new_heartbeat.attributes, 201 ] rescue => e Rails.logger.error("Error creating heartbeat: #{e.class.name} #{e.message}") @@ -276,6 +277,16 @@ class Api::Hackatime::V1::HackatimeController < ApplicationController Rails.logger.error("Error queuing project mapping: #{e.class.name} #{e.message}") end + def queue_heartbeat_public_activity(user_id, project_name) + # only queue the job once per minute + Rails.cache.fetch("heartbeat_public_activity_#{user_id}_#{project_name}", expires_in: 30.seconds) do + CreateHeartbeatActivityJob.perform_later(user_id, project_name) + end + rescue => e + # never raise an error here because it will break the heartbeat flow + Rails.logger.error("Error queuing heartbeat public activity: #{e.class.name} #{e.message}") + end + def set_user api_header = request.headers["Authorization"] raw_token = api_header&.split(" ")&.last diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 7b0036e..b8e0a95 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -4,6 +4,7 @@ class ApplicationController < ActionController::Base before_action :initialize_cache_counters before_action :try_rack_mini_profiler_enable before_action :track_request + before_action :set_public_activity after_action :track_action around_action :switch_time_zone, if: :current_user @@ -19,6 +20,11 @@ class ApplicationController < ActionController::Base private + def set_public_activity + return unless Flipper.enabled?(:public_activity_log, current_user) + @activities = PublicActivity::Activity.all + end + def honeybadger_context Honeybadger.context( user_id: current_user.id, diff --git a/app/jobs/create_heartbeat_activity_job.rb b/app/jobs/create_heartbeat_activity_job.rb new file mode 100644 index 0000000..5f8ca99 --- /dev/null +++ b/app/jobs/create_heartbeat_activity_job.rb @@ -0,0 +1,73 @@ +class CreateHeartbeatActivityJob < ApplicationJob + queue_as :default + + def perform(user_id, project_name) + @user_id = user_id + + # Look for future coding activity only (not past events that are already showing) + recent_activity = PublicActivity::Activity.with_future + .where(owner_id: user_id, trackable_type: "Heartbeat", key: "coding_session") + .where("created_at > ?", Time.current) + .first + + if recent_activity + # Keep pushing 5 minutes into future and update project/timing + last_updated = Time.current.to_i + started_at = recent_activity.parameters["started_at"] + duration_seconds = last_updated - started_at + + recent_activity.update!( + created_at: Time.current + 5.minutes, + parameters: recent_activity.parameters.merge( + project: project_name, + last_updated: last_updated, + duration_seconds: duration_seconds + ) + ) + else + return unless user + + # Create immediate "started working" activity - person just resumed coding + PublicActivity::Activity.create!( + trackable_type: "Heartbeat", + trackable_id: nil, + owner: user, + key: "started_working", + parameters: { project: project_name } + ) + + # Create new session 5 minutes in future + started_at = Time.current.to_i + activity = PublicActivity::Activity.create!( + trackable_type: "Heartbeat", + trackable_id: nil, # Not tied to specific heartbeat + owner: user, + key: "coding_session", + parameters: { + project: project_name, + started_at: started_at, + last_updated: started_at, + duration_seconds: 0 + }, + created_at: Time.current + 5.minutes + ) + + # Check if this is the user's first heartbeat ever + if user.heartbeats.count == 1 + PublicActivity::Activity.create!( + trackable_type: "Heartbeat", + trackable_id: nil, + owner: user, + key: "first_heartbeat", + parameters: { project: project_name } + ) + end + end + end + + private + + def user + @user ||= User.find_by(id: @user_id) + end +end diff --git a/app/models/heartbeat.rb b/app/models/heartbeat.rb index 30a6e75..351f8ea 100644 --- a/app/models/heartbeat.rb +++ b/app/models/heartbeat.rb @@ -4,6 +4,7 @@ class Heartbeat < ApplicationRecord include Heartbeatable include TimeRangeFilterable + include PublicActivity::Common time_range_filterable_field :time diff --git a/app/models/physical_mail.rb b/app/models/physical_mail.rb index dfb0ef8..49d6511 100644 --- a/app/models/physical_mail.rb +++ b/app/models/physical_mail.rb @@ -1,6 +1,17 @@ class PhysicalMail < ApplicationRecord belongs_to :user + include PublicActivity::Model + tracked only: [ :create, :update ], owner: :user, params: proc { |controller, model| + { + mission_type: model.mission_type, + humanized_mission_type: model.humanized_mission_type + } + } + + after_create :create_streak_activity, if: :first_time_7_streak? + after_update :create_sent_activity, if: :became_sent? + scope :going_out, -> { where(status: :pending).or(where(status: :sent)) } enum :status, { @@ -92,4 +103,22 @@ class PhysicalMail < ApplicationRecord def user_address user.mailing_address end + + def became_sent? + saved_change_to_status? && status == "sent" && status_before_last_save == "pending" + end + + def create_streak_activity + create_activity :first_streak_achieved, owner: user, params: { + mission_type: mission_type, + humanized_mission_type: humanized_mission_type + } + end + + def create_sent_activity + create_activity :mail_sent, owner: user, params: { + mission_type: mission_type, + humanized_mission_type: humanized_mission_type + } + end end diff --git a/app/models/user.rb b/app/models/user.rb index c934c53..407e6a4 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1,7 +1,10 @@ class User < ApplicationRecord include TimezoneRegions + include PublicActivity::Model has_paper_trail + + after_create :create_signup_activity encrypts :slack_access_token, :github_access_token validates :slack_uid, uniqueness: true, allow_nil: true @@ -502,4 +505,8 @@ class User < ApplicationRecord def invalidate_activity_graph_cache Rails.cache.delete("user_#{id}_daily_durations") end + + def create_signup_activity + create_activity :first_signup, owner: self + end end diff --git a/app/views/public_activity/heartbeat/_coding_session.html.erb b/app/views/public_activity/heartbeat/_coding_session.html.erb new file mode 100644 index 0000000..afdc585 --- /dev/null +++ b/app/views/public_activity/heartbeat/_coding_session.html.erb @@ -0,0 +1,3 @@ +<%= render "shared/user_mention", user: activity.owner %> +just finished coding on <%= activity.parameters['project'] %> +for <%= short_time_simple(activity.parameters['duration_seconds']) %> \ No newline at end of file diff --git a/app/views/public_activity/heartbeat/_first_heartbeat.html.erb b/app/views/public_activity/heartbeat/_first_heartbeat.html.erb new file mode 100644 index 0000000..cc2436f --- /dev/null +++ b/app/views/public_activity/heartbeat/_first_heartbeat.html.erb @@ -0,0 +1,2 @@ +<%= render "shared/user_mention", user: activity.owner %> +just started tracking their coding time on <%= activity.parameters['project'] %>! diff --git a/app/views/public_activity/heartbeat/_started_working.html.erb b/app/views/public_activity/heartbeat/_started_working.html.erb new file mode 100644 index 0000000..ecb3dfd --- /dev/null +++ b/app/views/public_activity/heartbeat/_started_working.html.erb @@ -0,0 +1,2 @@ +<%= render "shared/user_mention", user: activity.owner %> +just started working on <%= activity.parameters['project'] %> diff --git a/app/views/public_activity/physical_mail/_first_streak_achieved.html.erb b/app/views/public_activity/physical_mail/_first_streak_achieved.html.erb new file mode 100644 index 0000000..159d8eb --- /dev/null +++ b/app/views/public_activity/physical_mail/_first_streak_achieved.html.erb @@ -0,0 +1,2 @@ +<%= render "shared/user_mention", user: activity.owner %> +just hit their first 7 day coding streak! \ No newline at end of file diff --git a/app/views/public_activity/physical_mail/_mail_sent.html.erb b/app/views/public_activity/physical_mail/_mail_sent.html.erb new file mode 100644 index 0000000..f32c975 --- /dev/null +++ b/app/views/public_activity/physical_mail/_mail_sent.html.erb @@ -0,0 +1,2 @@ +<%= render "shared/user_mention", user: activity.owner %> +was just sent a letter for '<%= activity.parameters[:humanized_mission_type] %>' \ No newline at end of file diff --git a/app/views/public_activity/user/_first_signup.html.erb b/app/views/public_activity/user/_first_signup.html.erb new file mode 100644 index 0000000..6d3a89d --- /dev/null +++ b/app/views/public_activity/user/_first_signup.html.erb @@ -0,0 +1,2 @@ +<%= render "shared/user_mention", user: activity.owner %> +just signed in for the first time \ No newline at end of file diff --git a/app/views/shared/_nav.html.erb b/app/views/shared/_nav.html.erb index 597411f..cec504a 100644 --- a/app/views/shared/_nav.html.erb +++ b/app/views/shared/_nav.html.erb @@ -151,6 +151,8 @@ Feature Flags <% end %> <% end %> + + <%= render_activities(@activities) if defined?(@activities) %> diff --git a/config/initializers/monkey_patches.rb b/config/initializers/monkey_patches.rb index 2cc80e0..015a15a 100644 --- a/config/initializers/monkey_patches.rb +++ b/config/initializers/monkey_patches.rb @@ -12,4 +12,9 @@ Rails.configuration.to_prepare do end Doorkeeper::ApplicationsController.layout "application" # show oauth2 admin in normal hackatime ui + + PublicActivity::Activity.class_eval do + default_scope { where("created_at <= ?", Time.current) } + scope :with_future, -> { unscope(where: :created_at) } + end end diff --git a/db/migrate/20251003215127_create_activities.rb b/db/migrate/20251003215127_create_activities.rb new file mode 100644 index 0000000..eb12906 --- /dev/null +++ b/db/migrate/20251003215127_create_activities.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +# Migration responsible for creating a table with activities +class CreateActivities < ActiveRecord::Migration[6.1] + def self.up + create_table :activities do |t| + t.belongs_to :trackable, polymorphic: true + t.belongs_to :owner, polymorphic: true + t.string :key + t.text :parameters + t.belongs_to :recipient, polymorphic: true + + t.timestamps + end + + add_index :activities, %i[trackable_id trackable_type] + add_index :activities, %i[owner_id owner_type] + add_index :activities, %i[recipient_id recipient_type] + end + + # Drop table + def self.down + drop_table :activities + end +end diff --git a/db/schema.rb b/db/schema.rb index 299b499..ef38686 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,13 +10,32 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2025_10_03_161836) do +ActiveRecord::Schema[8.0].define(version: 2025_10_03_215127) do create_schema "pganalyze" # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" enable_extension "pg_stat_statements" + create_table "activities", force: :cascade do |t| + t.string "trackable_type" + t.bigint "trackable_id" + t.string "owner_type" + t.bigint "owner_id" + t.string "key" + t.text "parameters" + t.string "recipient_type" + t.bigint "recipient_id" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["owner_id", "owner_type"], name: "index_activities_on_owner_id_and_owner_type" + t.index ["owner_type", "owner_id"], name: "index_activities_on_owner" + t.index ["recipient_id", "recipient_type"], name: "index_activities_on_recipient_id_and_recipient_type" + t.index ["recipient_type", "recipient_id"], name: "index_activities_on_recipient" + t.index ["trackable_id", "trackable_type"], name: "index_activities_on_trackable_id_and_trackable_type" + t.index ["trackable_type", "trackable_id"], name: "index_activities_on_trackable" + end + create_table "admin_api_keys", force: :cascade do |t| t.bigint "user_id", null: false t.text "name", null: false