Initial new activity log (#562)

This commit is contained in:
Max Wofford
2025-10-03 20:52:36 -04:00
committed by GitHub
parent cb353b4414
commit ae76f20946
18 changed files with 200 additions and 1 deletions

View File

@@ -72,6 +72,8 @@ gem "dotenv-rails"
# Added from the code block
gem "http"
gem "public_activity"
# Bulk import
gem "activerecord-import"

View File

@@ -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

View File

@@ -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

View File

@@ -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,

View File

@@ -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

View File

@@ -4,6 +4,7 @@ class Heartbeat < ApplicationRecord
include Heartbeatable
include TimeRangeFilterable
include PublicActivity::Common
time_range_filterable_field :time

View File

@@ -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

View File

@@ -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

View File

@@ -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']) %>

View File

@@ -0,0 +1,2 @@
<%= render "shared/user_mention", user: activity.owner %>
just started tracking their coding time on <%= activity.parameters['project'] %>!

View File

@@ -0,0 +1,2 @@
<%= render "shared/user_mention", user: activity.owner %>
just started working on <%= activity.parameters['project'] %>

View File

@@ -0,0 +1,2 @@
<%= render "shared/user_mention", user: activity.owner %>
just hit their first 7 day coding streak!

View File

@@ -0,0 +1,2 @@
<%= render "shared/user_mention", user: activity.owner %>
was just sent a letter for '<%= activity.parameters[:humanized_mission_type] %>'

View File

@@ -0,0 +1,2 @@
<%= render "shared/user_mention", user: activity.owner %>
just signed in for the first time

View File

@@ -151,6 +151,8 @@
Feature Flags
<% end %>
<% end %>
<%= render_activities(@activities) if defined?(@activities) %>
</div>
</div>
</div>

View File

@@ -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

View File

@@ -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

21
db/schema.rb generated
View File

@@ -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