diff --git a/AGENTS.md b/AGENTS.md
index 6fdfa9ae1..5657f935d 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -55,6 +55,10 @@ Skip running checks which aren't relevant to your changes. However, at the very
- **Auth**: `ensure_authenticated!` for APIs, token via `Authorization` header or `?api_key=`
- **CSS**: Using Tailwind CSS, no inline styles, use utility classes. We define some custom classes in `config/tailwind.config.js` and `app/assets/tailwind/application.css`.
+## Heartbeat Counters
+
+`users.active_heartbeats_count` is a cached count of non-deleted heartbeats. Use it only through `User#active_heartbeats_count_or_count` so users that have not been backfilled still fall back to an exact count. Heartbeat bulk writes (`insert_all`, `update_all`, soft-delete/anonymization/merge paths) bypass callbacks, so update the counter explicitly when changing active heartbeat rows outside normal ActiveRecord create/update flows.
+
## Inertia Components
On Inertia pages, use the ` ` component for buttons, not `` tags.
diff --git a/Gemfile b/Gemfile
index 4931ab7f7..38057dd0c 100644
--- a/Gemfile
+++ b/Gemfile
@@ -56,6 +56,9 @@ gem "thruster", require: false
# For query count tracking
gem "query_count"
+# `counter_cache` on steroids
+gem "counter_culture"
+
# Compact request logging
gem "lograge"
diff --git a/Gemfile.lock b/Gemfile.lock
index 816b0be70..60dc2f43a 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -138,6 +138,9 @@ GEM
zeitwerk (>= 2.5.0)
concurrent-ruby (1.3.6)
connection_pool (3.0.2)
+ counter_culture (3.13.1)
+ activerecord (>= 4.2)
+ activesupport (>= 4.2)
countries (8.1.0)
unaccent (~> 0.3)
crack (1.0.1)
@@ -667,6 +670,7 @@ DEPENDENCIES
bullet
capybara
cloudflare-rails
+ counter_culture
countries
debug
doorkeeper (~> 5.8)
diff --git a/app/controllers/admin/account_merger_controller.rb b/app/controllers/admin/account_merger_controller.rb
index 25e7ee9d4..718eddf16 100644
--- a/app/controllers/admin/account_merger_controller.rb
+++ b/app/controllers/admin/account_merger_controller.rb
@@ -94,6 +94,8 @@ def perform_merge(older_user, newer_user)
ActiveRecord::Base.transaction do
# 1. Move heartbeats from newer to older
heartbeat_count = Heartbeat.where(user_id: newer_user.id).update_all(user_id: older_user.id)
+ Heartbeat.adjust_active_count_for(older_user.id, heartbeat_count) if heartbeat_count.positive?
+ Heartbeat.adjust_active_count_for(newer_user.id, -heartbeat_count) if heartbeat_count.positive?
results << "#{heartbeat_count} heartbeats moved"
# 2. Transfer API keys from newer to older
diff --git a/app/controllers/concerns/api/admin/v1/user_utilities.rb b/app/controllers/concerns/api/admin/v1/user_utilities.rb
index 3bd079413..1074eca42 100644
--- a/app/controllers/concerns/api/admin/v1/user_utilities.rb
+++ b/app/controllers/concerns/api/admin/v1/user_utilities.rb
@@ -454,7 +454,7 @@ def user_heartbeats
query = query.where(editor: editor) if editor.present?
query = query.where(machine: machine) if machine.present?
- total_count = query.count
+ total_count = unfiltered_heartbeats_request? ? user.active_heartbeats_count_or_count : query.count
source_types = Heartbeat.source_types.invert
heartbeats = query.order(time: :asc).limit(limit).offset(offset).pluck(*HEARTBEAT_RESPONSE_COLUMNS).map do |id, time, lineno, cursorpos, is_write, project, language, entity, branch, category, editor, machine, user_agent, ip_address, lines, source_type|
{
@@ -558,6 +558,10 @@ def find_user_by_id
nil
end
+ def unfiltered_heartbeats_request?
+ params.values_at(:start_date, :end_date, :project, :language, :entity, :editor, :machine).all?(&:blank?)
+ end
+
def parse_date_param
date_param = params[:date]
return Date.current if date_param.blank?
diff --git a/app/controllers/settings/imports_exports_controller.rb b/app/controllers/settings/imports_exports_controller.rb
index cfcd5120b..aa8a2273e 100644
--- a/app/controllers/settings/imports_exports_controller.rb
+++ b/app/controllers/settings/imports_exports_controller.rb
@@ -22,7 +22,7 @@ def section_props
{
data_export: InertiaRails.defer {
{
- total_heartbeats: number_with_delimiter(@user.heartbeats.count),
+ total_heartbeats: number_with_delimiter(@user.active_heartbeats_count_or_count),
total_coding_time: @user.heartbeats.duration_simple,
heartbeats_last_7_days: number_with_delimiter(@user.heartbeats.where("time >= ?", 7.days.ago.to_f).count),
is_restricted: (@user.trust_level == "red")
diff --git a/app/jobs/one_time/transfer_user_data_job.rb b/app/jobs/one_time/transfer_user_data_job.rb
index edd768e2a..193352150 100644
--- a/app/jobs/one_time/transfer_user_data_job.rb
+++ b/app/jobs/one_time/transfer_user_data_job.rb
@@ -41,7 +41,9 @@ def transfer_api_keys
end
def transfer_heartbeats
- Heartbeat.where(user_id: @source_user_id).update_all(user_id: @target_user_id)
+ heartbeat_count = Heartbeat.where(user_id: @source_user_id).update_all(user_id: @target_user_id)
+ Heartbeat.adjust_active_count_for(@target_user_id, heartbeat_count) if heartbeat_count.positive?
+ Heartbeat.adjust_active_count_for(@source_user_id, -heartbeat_count) if heartbeat_count.positive?
end
def source_user
diff --git a/app/models/heartbeat.rb b/app/models/heartbeat.rb
index 4e5b1130a..b94ffefe2 100644
--- a/app/models/heartbeat.rb
+++ b/app/models/heartbeat.rb
@@ -87,6 +87,7 @@ class Heartbeat < ApplicationRecord
self.inheritance_column = nil
belongs_to :user
+ counter_culture :user, column_name: proc { |heartbeat| heartbeat.deleted_at.nil? ? "active_heartbeats_count" : nil }
validates :time, presence: true
@@ -111,15 +112,23 @@ def self.indexed_attributes
end
def soft_delete
+ was_active = deleted_at.nil?
update_column(:deleted_at, Time.current)
+ self.class.adjust_active_count_for(user_id, -1) if was_active
DashboardRollupRefreshJob.schedule_for(user_id)
end
def restore
+ was_deleted = deleted_at.present?
update_column(:deleted_at, nil)
+ self.class.adjust_active_count_for(user_id, 1) if was_deleted
DashboardRollupRefreshJob.schedule_for(user_id)
end
+ def self.adjust_active_count_for(user_id, amount)
+ User.where(id: user_id).update_all([ "active_heartbeats_count = COALESCE(active_heartbeats_count, 0) + ?", amount ])
+ end
+
private
def set_fields_hash!
diff --git a/app/models/user.rb b/app/models/user.rb
index 472605f11..b46728899 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -15,6 +15,7 @@ class User < ApplicationRecord
after_create :subscribe_to_default_lists
after_create_commit :schedule_onboarding_check_in_email
+ before_create :initialize_active_heartbeats_count
before_validation :normalize_username
encrypts :slack_access_token, :github_access_token, :hca_access_token
@@ -390,6 +391,12 @@ def most_recent_direct_entry_heartbeat
heartbeats.where(source_type: :direct_entry).order(time: :desc).first
end
+ def active_heartbeats_count_or_count
+ return active_heartbeats_count if active_heartbeats_count_backfilled?
+
+ heartbeats.count
+ end
+
def create_email_signin_token(continue_param: nil)
sign_in_tokens.create!(auth_type: :email, continue_param: continue_param)
end
@@ -438,6 +445,11 @@ def subscribe_to_default_lists
subscribe("weekly_summary")
end
+ def initialize_active_heartbeats_count
+ self.active_heartbeats_count = 0 if active_heartbeats_count.nil?
+ self.active_heartbeats_count_backfilled = true
+ end
+
def schedule_onboarding_check_in_email
OnboardingCheckInEmailJob.set(wait: 1.week).perform_later(id)
end
diff --git a/app/services/anonymize_user_service.rb b/app/services/anonymize_user_service.rb
index 3d064821a..ebcc2e37e 100644
--- a/app/services/anonymize_user_service.rb
+++ b/app/services/anonymize_user_service.rb
@@ -76,7 +76,8 @@ def destroy_associated_records
user.goals.destroy_all
# tombstone
- Heartbeat.unscoped.where(user_id: user.id, deleted_at: nil).update_all(deleted_at: Time.current)
+ tombstoned_heartbeats_count = Heartbeat.unscoped.where(user_id: user.id, deleted_at: nil).update_all(deleted_at: Time.current)
+ Heartbeat.adjust_active_count_for(user.id, -tombstoned_heartbeats_count) if tombstoned_heartbeats_count.positive?
user.access_grants.destroy_all
user.access_tokens.destroy_all
diff --git a/app/services/heartbeat_ingest.rb b/app/services/heartbeat_ingest.rb
index 2ab672464..1df4f58e1 100644
--- a/app/services/heartbeat_ingest.rb
+++ b/app/services/heartbeat_ingest.rb
@@ -121,6 +121,7 @@ def persist_direct_heartbeat(attrs)
end
self.class.schedule_rollup_refresh(user: @user) if result.any? && @schedule_rollup_refresh
+ Heartbeat.adjust_active_count_for(@user.id, 1) if result.any?
[ persisted, !result.any? ]
end
@@ -196,7 +197,7 @@ def flush_import_batch(seen_hashes)
ActiveRecord::Base.logger.silence do
Heartbeat.insert_all(records, unique_by: [ :fields_hash ]).length
- end
+ end.tap { |persisted_count| Heartbeat.adjust_active_count_for(@user.id, persisted_count) if persisted_count.positive? }
end
def parse_user_agent(user_agent)
diff --git a/db/migrate/20260521192204_add_active_heartbeats_count_to_users.rb b/db/migrate/20260521192204_add_active_heartbeats_count_to_users.rb
new file mode 100644
index 000000000..3d33362b3
--- /dev/null
+++ b/db/migrate/20260521192204_add_active_heartbeats_count_to_users.rb
@@ -0,0 +1,6 @@
+class AddActiveHeartbeatsCountToUsers < ActiveRecord::Migration[8.1]
+ def change
+ add_column :users, :active_heartbeats_count, :bigint
+ add_column :users, :active_heartbeats_count_backfilled, :boolean, null: false, default: false
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 134efc561..cccc93389 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_05_18_170142) do
+ActiveRecord::Schema[8.1].define(version: 2026_05_21_192204) do
# These are extensions that must be enabled in order to support this database
enable_extension "pg_catalog.plpgsql"
enable_extension "pg_stat_statements"
@@ -631,6 +631,8 @@
end
create_table "users", force: :cascade do |t|
+ t.bigint "active_heartbeats_count"
+ t.boolean "active_heartbeats_count_backfilled", default: false, null: false
t.integer "admin_level", default: 0, null: false
t.boolean "allow_public_stats_lookup", default: true, null: false
t.string "country_code"