diff --git a/app/controllers/concerns/dashboard_data.rb b/app/controllers/concerns/dashboard_data.rb index 15eacd5e8..a80723642 100644 --- a/app/controllers/concerns/dashboard_data.rb +++ b/app/controllers/concerns/dashboard_data.rb @@ -3,6 +3,11 @@ module DashboardData FILTER_OPTIONS_CACHE_VERSION = "v1".freeze WEEKLY_PROJECT_DIMENSION = "weekly_project".freeze + DAILY_DURATION_DIMENSION = "daily_duration".freeze + TODAY_CONTEXT_DIMENSION = "today_context".freeze + TODAY_TOTAL_DURATION_DIMENSION = "today_total_duration".freeze + TODAY_LANGUAGE_COUNT_DIMENSION = "today_language_count".freeze + TODAY_EDITOR_COUNT_DIMENSION = "today_editor_count".freeze private @@ -22,9 +27,12 @@ def filterable_dashboard_data def activity_graph_data tz = current_user.timezone - key = "user_#{current_user.id}_daily_durations_#{tz}" - durations = Rails.cache.fetch(key, expires_in: 1.minute) do - Time.use_zone(tz) { current_user.heartbeats.daily_durations(user_timezone: tz).to_h } + durations = dashboard_rollup_daily_durations + unless durations + key = "user_#{current_user.id}_daily_durations_#{tz}" + durations = Rails.cache.fetch(key, expires_in: 1.minute) do + Time.use_zone(tz) { current_user.heartbeats.daily_durations(user_timezone: tz).to_h } + end end { @@ -38,39 +46,44 @@ def activity_graph_data end def today_stats_data - h = ApplicationController.helpers - Time.use_zone(current_user.timezone) do - rows = current_user.heartbeats.today - .select(:language, :editor, - "COUNT(*) OVER (PARTITION BY language) as language_count", - "COUNT(*) OVER (PARTITION BY editor) as editor_count") - .distinct.to_a - - lang_counts = rows - .map { |r| [ r.language&.categorize_language, r.language_count ] } - .reject { |l, _| l.blank? } - .group_by(&:first).transform_values { |p| p.sum(&:last) } - .sort_by { |_, c| -c } - - ed_counts = rows - .map { |r| [ r.editor, r.editor_count ] } - .reject { |e, _| e.blank? }.uniq - .sort_by { |_, c| -c } - - todays_languages = lang_counts.map { |l, _| h.display_language_name(l) } - todays_editors = ed_counts.map { |e, _| h.display_editor_name(e) } - todays_duration = current_user.heartbeats.today.duration_seconds - show_logged_time_sentence = todays_duration > 1.minute && (todays_languages.any? || todays_editors.any?) - - { - show_logged_time_sentence: show_logged_time_sentence, - todays_duration_display: h.short_time_detailed(todays_duration.to_i), - todays_languages: todays_languages, - todays_editors: todays_editors - } + cache_key, timezone = today_stats_cache_key + Rails.cache.fetch(cache_key, expires_in: 1.minute) do + rollup_stats = dashboard_rollup_today_stats(timezone) + if rollup_stats + rollup_stats + else + today_data = DashboardRollupRefreshService.today_rollup_data_for(current_user) + DashboardRollupRefreshService.upsert_today_rollup!(user: current_user, data: today_data) + dashboard_clear_rollup_rows_memo + + build_today_stats_from_rollup_data(today_data) + end end end + def build_today_stats_from_rollup_data(data) + h = ApplicationController.helpers + todays_languages = data.fetch(:language_counts).sort_by { |_, count| -count } + .map { |language, _| h.display_language_name(language) } + todays_editors = data.fetch(:editor_counts).sort_by { |_, count| -count } + .map { |editor, _| h.display_editor_name(editor) } + todays_duration = data.fetch(:total_duration).to_i + + { + show_logged_time_sentence: todays_duration > 1.minute && (todays_languages.any? || todays_editors.any?), + todays_duration_display: h.short_time_detailed(todays_duration), + todays_languages: todays_languages, + todays_editors: todays_editors + } + end + + def today_stats_cache_key + timezone = current_user.timezone + local_date = Time.use_zone(timezone) { Time.zone.today.iso8601 } + key = [ "user", current_user.id, "today_stats", timezone, local_date ] + [ key, timezone ] + end + def dashboard_filters %i[project language operating_system editor category] end @@ -229,23 +242,13 @@ def dashboard_fill_aggregate_result(result:, grouped_durations:, total_time:, to end def dashboard_rollup_snapshot - return unless dashboard_rollups_available? return unless dashboard_rollup_eligible? - rows = DashboardRollup.where(user_id: current_user.id).to_a - total_row = rows.find(&:total_dimension?) - unless total_row - DashboardRollupRefreshJob.schedule_for(current_user.id, wait: 0.seconds) - return - end - - if DashboardRollup.dirty?(current_user.id) - DashboardRollupRefreshJob.schedule_for(current_user.id, wait: 0.seconds) - elsif dashboard_rollup_time_fingerprint(total_row.source_max_heartbeat_time) != dashboard_rollup_source_max_heartbeat_time - DashboardRollupRefreshJob.schedule_for(current_user.id, wait: 0.seconds) - end + rows = dashboard_rollup_rows + return unless rows grouped_rows = rows.reject(&:total_dimension?).group_by(&:dimension) + total_row = rows.find(&:total_dimension?) { total_time: total_row.total_seconds, @@ -270,6 +273,73 @@ def dashboard_rollups_available? false end + def dashboard_rollup_rows + return @dashboard_rollup_rows if defined?(@dashboard_rollup_rows) + + unless dashboard_rollups_available? + @dashboard_rollup_rows = nil + return + end + + rows = DashboardRollup.where(user_id: current_user.id).to_a + total_row = rows.find(&:total_dimension?) + unless total_row + DashboardRollupRefreshJob.schedule_for(current_user.id, wait: 0.seconds) + @dashboard_rollup_rows = nil + return + end + + if DashboardRollup.dirty?(current_user.id) + DashboardRollupRefreshJob.schedule_for(current_user.id, wait: 0.seconds) + elsif dashboard_rollup_time_fingerprint(total_row.source_max_heartbeat_time) != dashboard_rollup_source_max_heartbeat_time + DashboardRollupRefreshJob.schedule_for(current_user.id, wait: 0.seconds) + end + + @dashboard_rollup_rows = rows + end + + def dashboard_rollup_grouped_rows + return @dashboard_rollup_grouped_rows if defined?(@dashboard_rollup_grouped_rows) + + rows = dashboard_rollup_rows + @dashboard_rollup_grouped_rows = rows ? rows.reject(&:total_dimension?).group_by(&:dimension) : {} + end + + def dashboard_rollup_daily_durations + rows = dashboard_rollup_grouped_rows.fetch(DAILY_DURATION_DIMENSION, []) + return if rows.empty? + + rows.to_h { |row| [ row.bucket, row.total_seconds.to_i ] } + end + + def dashboard_rollup_today_stats(timezone) + context_row = dashboard_rollup_grouped_rows.fetch(TODAY_CONTEXT_DIMENSION, []).first + return unless context_row + + context_timezone, context_date = JSON.parse(context_row.bucket_value) + current_date = Time.use_zone(timezone) { Time.zone.today.iso8601 } + return unless context_timezone == timezone && context_date == current_date + + total_row = dashboard_rollup_grouped_rows.fetch(TODAY_TOTAL_DURATION_DIMENSION, []).first + return unless total_row + + data = { + timezone: timezone, + local_date: current_date, + total_duration: total_row.total_seconds.to_i, + language_counts: dashboard_rollup_grouped_rows.fetch(TODAY_LANGUAGE_COUNT_DIMENSION, []).to_h { |row| [ row.bucket.to_s, row.total_seconds.to_i ] }, + editor_counts: dashboard_rollup_grouped_rows.fetch(TODAY_EDITOR_COUNT_DIMENSION, []).to_h { |row| [ row.bucket.to_s, row.total_seconds.to_i ] } + } + build_today_stats_from_rollup_data(data) + rescue JSON::ParserError + nil + end + + def dashboard_clear_rollup_rows_memo + remove_instance_variable(:@dashboard_rollup_rows) if instance_variable_defined?(:@dashboard_rollup_rows) + remove_instance_variable(:@dashboard_rollup_grouped_rows) if instance_variable_defined?(:@dashboard_rollup_grouped_rows) + end + def dashboard_rollup_source_max_heartbeat_time dashboard_rollup_time_fingerprint(current_user.heartbeats.maximum(:time)) end diff --git a/app/controllers/static_pages_controller.rb b/app/controllers/static_pages_controller.rb index 925a16625..f97e9edfb 100644 --- a/app/controllers/static_pages_controller.rb +++ b/app/controllers/static_pages_controller.rb @@ -176,13 +176,10 @@ def programming_goals_progress_data goals = current_user.goals.order(:id) return [] if goals.blank? - goals_hash = ActiveSupport::Digest.hexdigest( - goals.pluck(:id, :period, :target_seconds, :languages, :projects).to_json - ) - cache_key = "user_#{current_user.id}_programming_goals_progress_#{current_user.timezone}_#{goals_hash}" - - Rails.cache.fetch(cache_key, expires_in: 1.minute) do - ProgrammingGoalsProgressService.new(user: current_user, goals: goals).call - end + ProgrammingGoalsProgressService.new( + user: current_user, + goals: goals, + rollup_rows: dashboard_rollup_rows + ).call end end diff --git a/app/models/dashboard_rollup.rb b/app/models/dashboard_rollup.rb index 623326ee8..718e22192 100644 --- a/app/models/dashboard_rollup.rb +++ b/app/models/dashboard_rollup.rb @@ -1,5 +1,21 @@ class DashboardRollup < ApplicationRecord - DIMENSIONS = %w[total project language editor operating_system category weekly_project].freeze + DIMENSIONS = %w[ + total + project + language + editor + operating_system + category + weekly_project + daily_duration + today_context + today_total_duration + today_language_count + today_editor_count + goals_period_total + goals_period_project + goals_period_language + ].freeze TOTAL_DIMENSION = "total".freeze DIRTY_CACHE_KEY_PREFIX = "dashboard_rollup_dirty".freeze diff --git a/app/models/user.rb b/app/models/user.rb index 06d44c1eb..d6c86c89b 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -236,7 +236,7 @@ def streak_days_formatted compliment_text: 2 } - after_save :invalidate_activity_graph_cache, if: :saved_change_to_timezone? + after_save :handle_timezone_change, if: :saved_change_to_timezone? def flipper_id "User;#{id}" @@ -339,8 +339,16 @@ def self.not_suspect private - def invalidate_activity_graph_cache - Rails.cache.delete("user_#{id}_daily_durations") + def handle_timezone_change + previous_timezone, current_timezone = saved_change_to_timezone + [ previous_timezone, current_timezone ].compact_blank.uniq.each do |timezone| + Rails.cache.delete("user_#{id}_daily_durations_#{timezone}") + + local_date = Time.use_zone(timezone) { Time.zone.today.iso8601 } + Rails.cache.delete([ "user", id, "today_stats", timezone, local_date ]) + end + + DashboardRollupRefreshJob.schedule_for(id, wait: 0.seconds) end def track_signup diff --git a/app/services/dashboard_rollup_refresh_service.rb b/app/services/dashboard_rollup_refresh_service.rb index 222e71e49..038f5982b 100644 --- a/app/services/dashboard_rollup_refresh_service.rb +++ b/app/services/dashboard_rollup_refresh_service.rb @@ -1,6 +1,24 @@ class DashboardRollupRefreshService < ApplicationService GROUPED_DIMENSIONS = %i[project language editor operating_system category].freeze WEEKLY_PROJECT_DIMENSION = "weekly_project".freeze + DAILY_DURATION_DIMENSION = "daily_duration".freeze + TODAY_CONTEXT_DIMENSION = "today_context".freeze + TODAY_TOTAL_DURATION_DIMENSION = "today_total_duration".freeze + TODAY_LANGUAGE_COUNT_DIMENSION = "today_language_count".freeze + TODAY_EDITOR_COUNT_DIMENSION = "today_editor_count".freeze + GOALS_PERIOD_TOTAL_DIMENSION = "goals_period_total".freeze + GOALS_PERIOD_PROJECT_DIMENSION = "goals_period_project".freeze + GOALS_PERIOD_LANGUAGE_DIMENSION = "goals_period_language".freeze + GOALS_PERIODS = %w[day week month].freeze + TODAY_DIMENSIONS = [ + TODAY_CONTEXT_DIMENSION, + TODAY_TOTAL_DURATION_DIMENSION, + TODAY_LANGUAGE_COUNT_DIMENSION, + TODAY_EDITOR_COUNT_DIMENSION + ].freeze + TODAY_ROLLUP_LOCK_NAMESPACE = 42_001 + MAX_SIGNED_INT64 = (2**63) - 1 + UINT64_RANGE = 2**64 def initialize(user:) @user = user @@ -31,6 +49,46 @@ def call end end + daily_durations.each do |date_key, total_seconds| + records << build_record( + dimension: DAILY_DURATION_DIMENSION, + bucket: date_key, + total_seconds: total_seconds, + now: now + ) + end + + today_rollup_records(now).each do |record| + records << record + end + + goals_rollup_data.each do |period, period_data| + records << build_record( + dimension: GOALS_PERIOD_TOTAL_DIMENSION, + bucket: period, + total_seconds: period_data.fetch(:total), + now: now + ) + + period_data.fetch(:project).each do |project, total_seconds| + records << build_record( + dimension: GOALS_PERIOD_PROJECT_DIMENSION, + bucket: [ period, project ].to_json, + total_seconds: total_seconds, + now: now + ) + end + + period_data.fetch(:language).each do |language, total_seconds| + records << build_record( + dimension: GOALS_PERIOD_LANGUAGE_DIMENSION, + bucket: [ period, language ].to_json, + total_seconds: total_seconds, + now: now + ) + end + end + DashboardRollup.transaction do DashboardRollup.where(user_id: @user.id).delete_all DashboardRollup.insert_all!(records) @@ -128,4 +186,197 @@ def dashboard_week_ranges [ week_start.to_date.iso8601, week_start.to_f, w.weeks.ago.end_of_week.to_f ] end end + + def daily_durations + @scope.daily_durations(user_timezone: @user.timezone).to_h.transform_keys { |date| date.iso8601 } + end + + def self.today_rollup_data_for(user) + timezone = user.timezone + Time.use_zone(timezone) do + today_scope = user.heartbeats.today + + language_counts = today_scope + .where.not(language: [ nil, "" ]) + .group(:language) + .count + .each_with_object({}) do |(language, count), grouped| + categorized = language&.categorize_language + next if categorized.blank? + + grouped[categorized] = (grouped[categorized] || 0) + count.to_i + end + + editor_counts = today_scope + .where.not(editor: [ nil, "" ]) + .group(:editor) + .count + .transform_values(&:to_i) + + { + timezone: timezone, + local_date: Time.zone.today.iso8601, + total_duration: today_scope.duration_seconds.to_i, + language_counts: language_counts, + editor_counts: editor_counts + } + end + end + + def self.upsert_today_rollup!(user:, data:, now: Time.current) + records = [] + records << { + user_id: user.id, + dimension: TODAY_CONTEXT_DIMENSION, + bucket_value: [ data.fetch(:timezone), data.fetch(:local_date) ].to_json, + bucket_value_present: true, + total_seconds: 0, + source_heartbeats_count: nil, + source_max_heartbeat_time: nil, + created_at: now, + updated_at: now + } + records << { + user_id: user.id, + dimension: TODAY_TOTAL_DURATION_DIMENSION, + bucket_value: "", + bucket_value_present: false, + total_seconds: data.fetch(:total_duration).to_i, + source_heartbeats_count: nil, + source_max_heartbeat_time: nil, + created_at: now, + updated_at: now + } + + data.fetch(:language_counts, {}).each do |language, count| + records << { + user_id: user.id, + dimension: TODAY_LANGUAGE_COUNT_DIMENSION, + bucket_value: language.to_s, + bucket_value_present: true, + total_seconds: count.to_i, + source_heartbeats_count: nil, + source_max_heartbeat_time: nil, + created_at: now, + updated_at: now + } + end + + data.fetch(:editor_counts, {}).each do |editor, count| + records << { + user_id: user.id, + dimension: TODAY_EDITOR_COUNT_DIMENSION, + bucket_value: editor.to_s, + bucket_value_present: true, + total_seconds: count.to_i, + source_heartbeats_count: nil, + source_max_heartbeat_time: nil, + created_at: now, + updated_at: now + } + end + + DashboardRollup.transaction do + lock_key = today_rollup_lock_key(user.id) + DashboardRollup.connection.execute( + "SELECT pg_advisory_xact_lock(#{lock_key})" + ) + DashboardRollup.where(user_id: user.id, dimension: TODAY_DIMENSIONS).delete_all + DashboardRollup.insert_all!(records) + end + end + + def self.today_rollup_lock_key(user_id) + namespace = TODAY_ROLLUP_LOCK_NAMESPACE.to_i & 0xffff_ffff + id_bits = user_id.to_i & 0xffff_ffff + raw_key = (namespace << 32) | id_bits + + raw_key > MAX_SIGNED_INT64 ? raw_key - UINT64_RANGE : raw_key + end + + def goals_rollup_data + GOALS_PERIODS.each_with_object({}) do |period, result| + scope = goals_period_scope(period) + grouped_languages = scope.group(:language).duration_seconds.each_with_object({}) do |(language, seconds), grouped| + next if language.blank? + + categorized = language.categorize_language + next if categorized.blank? + + grouped[categorized] = (grouped[categorized] || 0) + seconds + end + + result[period] = { + total: scope.duration_seconds, + project: project_grouped_durations_for(scope), + language: grouped_languages + } + end + end + + def goals_period_scope(period) + range = Time.use_zone(@user.timezone) do + now = Time.zone.now + case period + when "day" + now.beginning_of_day..now.end_of_day + when "week" + now.beginning_of_week(:monday)..now.end_of_week(:monday) + when "month" + now.beginning_of_month..now.end_of_month + else + now.beginning_of_day..now.end_of_day + end + end + + @scope.where(time: range.begin.to_i..range.end.to_i) + end + + def project_grouped_durations_for(scope) + non_null = scope.where.not(project: nil).group(:project).duration_seconds + return non_null if scope.where(project: nil).none? + + null_duration = scope.where(project: nil).duration_seconds + return non_null if null_duration.zero? + + non_null.merge(nil => null_duration) + end + + def today_rollup_records(now) + data = self.class.today_rollup_data_for(@user) + records = [] + + records << build_record( + dimension: TODAY_CONTEXT_DIMENSION, + bucket: [ data.fetch(:timezone), data.fetch(:local_date) ].to_json, + total_seconds: 0, + now: now + ) + records << build_record( + dimension: TODAY_TOTAL_DURATION_DIMENSION, + bucket: nil, + total_seconds: data.fetch(:total_duration), + now: now + ) + + data.fetch(:language_counts).each do |language, count| + records << build_record( + dimension: TODAY_LANGUAGE_COUNT_DIMENSION, + bucket: language, + total_seconds: count, + now: now + ) + end + + data.fetch(:editor_counts).each do |editor, count| + records << build_record( + dimension: TODAY_EDITOR_COUNT_DIMENSION, + bucket: editor, + total_seconds: count, + now: now + ) + end + + records + end end diff --git a/app/services/programming_goals_progress_service.rb b/app/services/programming_goals_progress_service.rb index 087560948..5beb101db 100644 --- a/app/services/programming_goals_progress_service.rb +++ b/app/services/programming_goals_progress_service.rb @@ -1,7 +1,8 @@ class ProgrammingGoalsProgressService - def initialize(user:, goals: nil) + def initialize(user:, goals: nil, rollup_rows: nil) @user = user @goals = goals || user.goals.order(:created_at) + @rollup_rows = rollup_rows end def call @@ -15,7 +16,7 @@ def call private - attr_reader :user, :goals + attr_reader :user, :goals, :rollup_rows def build_progress(goal, now:) tracked_seconds = tracked_seconds_for_goal(goal, now: now) @@ -36,6 +37,9 @@ def build_progress(goal, now:) end def tracked_seconds_for_goal(goal, now:) + rollup_tracked_seconds = tracked_seconds_from_rollup(goal) + return rollup_tracked_seconds unless rollup_tracked_seconds.nil? + time_window = time_window_for(goal.period, now: now) scope = user.heartbeats.where(time: time_window.begin.to_i..time_window.end.to_i) scope = scope.where(project: goal.projects) if goal.projects.any? @@ -52,6 +56,67 @@ def tracked_seconds_for_goal(goal, now:) scope.duration_seconds.to_i end + def tracked_seconds_from_rollup(goal) + return if rollup_rows.blank? + return unless rollup_rows.any? { |row| row.dimension == DashboardRollupRefreshService::GOALS_PERIOD_TOTAL_DIMENSION } + + period_data = rollup_goals_data.fetch(goal.period, nil) + return if period_data.nil? + + projects = goal.projects.compact_blank + languages = goal.languages.compact_blank + + if projects.empty? && languages.empty? + period_data.fetch(:total, 0) + elsif projects.any? && languages.empty? + projects.sum { |project| period_data.fetch(:project, {}).fetch(project, 0) } + elsif languages.any? && projects.empty? + languages.sum { |language| period_data.fetch(:language, {}).fetch(language, 0) } + end + end + + def rollup_goals_data + return @rollup_goals_data if defined?(@rollup_goals_data) + + grouped = rollup_rows.group_by(&:dimension) + totals = grouped.fetch(DashboardRollupRefreshService::GOALS_PERIOD_TOTAL_DIMENSION, []) + .index_by(&:bucket) + .transform_values { |row| row.total_seconds.to_i } + + project = grouped.fetch(DashboardRollupRefreshService::GOALS_PERIOD_PROJECT_DIMENSION, []) + .each_with_object(Hash.new { |hash, key| hash[key] = {} }) do |row, acc| + period, project_name = parse_rollup_bucket_pair(row.bucket_value) + next if period.blank? + + acc[period][project_name] = row.total_seconds.to_i + end + + language = grouped.fetch(DashboardRollupRefreshService::GOALS_PERIOD_LANGUAGE_DIMENSION, []) + .each_with_object(Hash.new { |hash, key| hash[key] = {} }) do |row, acc| + period, language_name = parse_rollup_bucket_pair(row.bucket_value) + next if period.blank? + + acc[period][language_name] = row.total_seconds.to_i + end + + @rollup_goals_data = DashboardRollupRefreshService::GOALS_PERIODS.each_with_object({}) do |period, data| + data[period] = { + total: totals.fetch(period, 0), + project: project.fetch(period, {}), + language: language.fetch(period, {}) + } + end + end + + def parse_rollup_bucket_pair(bucket) + parsed = JSON.parse(bucket) + return [ nil, nil ] unless parsed.is_a?(Array) && parsed.size == 2 + + parsed + rescue JSON::ParserError + [ nil, nil ] + end + def languages_grouped_by_category(languages) languages.each_with_object(Hash.new { |hash, key| hash[key] = [] }) do |language, grouped| next if language.blank? diff --git a/test/controllers/concerns/dashboard_data_test.rb b/test/controllers/concerns/dashboard_data_test.rb index 846852332..53df8fcb4 100644 --- a/test/controllers/concerns/dashboard_data_test.rb +++ b/test/controllers/concerns/dashboard_data_test.rb @@ -218,6 +218,38 @@ def harness.dashboard_grouped_durations_snapshot(_scope) end end + test "today stats refreshes rollup-backed today data when context is stale" do + with_memory_cache_store do + Rails.cache.clear + + user = User.create!(timezone: "UTC") + harness = Harness.new + harness.current_user = user + harness.params = ActionController::Parameters.new + + travel_to Time.utc(2026, 4, 14, 10, 0, 0) do + create_heartbeat(user, project: "alpha", language: "ruby", editor: "vscode", operating_system: "macos", category: "coding") + travel 1.minute + create_heartbeat(user, project: "alpha", language: "ruby", editor: "vscode", operating_system: "macos", category: "coding") + + DashboardRollupRefreshService.new(user: user).call + end + + stale_context = DashboardRollup.find_by!(user: user, dimension: DashboardRollupRefreshService::TODAY_CONTEXT_DIMENSION) + stale_context.update!(bucket_value: [ "UTC", "2026-04-13" ].to_json) + + travel_to Time.utc(2026, 4, 14, 12, 0, 0) do + stats = harness.send(:today_stats_data) + + assert_equal false, stats[:show_logged_time_sentence] + assert_equal [ "Ruby" ], stats[:todays_languages] + + refreshed_context = DashboardRollup.find_by!(user: user, dimension: DashboardRollupRefreshService::TODAY_CONTEXT_DIMENSION) + assert_equal [ "UTC", "2026-04-14" ], JSON.parse(refreshed_context.bucket_value) + end + end + end + private def with_memory_cache_store diff --git a/test/models/user_test.rb b/test/models/user_test.rb index 289ff22f5..1d0e267b7 100644 --- a/test/models/user_test.rb +++ b/test/models/user_test.rb @@ -1,6 +1,8 @@ require "test_helper" class UserTest < ActiveSupport::TestCase + include ActiveJob::TestHelper + test "theme defaults to gruvbox dark" do user = User.new @@ -72,4 +74,36 @@ class UserTest < ActiveSupport::TestCase assert user.active_remote_heartbeat_import_run? end + + test "changing timezone clears timezone-specific caches and schedules immediate rollup refresh" do + original_adapter = ActiveJob::Base.queue_adapter + ActiveJob::Base.queue_adapter = :test + + user = User.create!(timezone: "UTC") + + old_day_key = Time.use_zone("UTC") { Time.zone.today.iso8601 } + old_today_key = [ "user", user.id, "today_stats", "UTC", old_day_key ] + new_day_key = Time.use_zone("America/New_York") { Time.zone.today.iso8601 } + new_today_key = [ "user", user.id, "today_stats", "America/New_York", new_day_key ] + old_daily_key = "user_#{user.id}_daily_durations_UTC" + new_daily_key = "user_#{user.id}_daily_durations_America/New_York" + + Rails.cache.write(old_today_key, { stale: true }) + Rails.cache.write(new_today_key, { stale: true }) + Rails.cache.write(old_daily_key, { stale: true }) + Rails.cache.write(new_daily_key, { stale: true }) + + clear_enqueued_jobs + + assert_enqueued_with(job: DashboardRollupRefreshJob, args: [ user.id ]) do + user.update!(timezone: "America/New_York") + end + + assert_nil Rails.cache.read(old_today_key) + assert_nil Rails.cache.read(new_today_key) + assert_nil Rails.cache.read(old_daily_key) + assert_nil Rails.cache.read(new_daily_key) + ensure + ActiveJob::Base.queue_adapter = original_adapter + end end diff --git a/test/services/dashboard_rollup_refresh_service_test.rb b/test/services/dashboard_rollup_refresh_service_test.rb index a0a8b2309..524c4415a 100644 --- a/test/services/dashboard_rollup_refresh_service_test.rb +++ b/test/services/dashboard_rollup_refresh_service_test.rb @@ -27,6 +27,61 @@ class DashboardRollupRefreshServiceTest < ActiveSupport::TestCase user.heartbeats.group(:language).duration_seconds, DashboardRollup.where(user: user, dimension: "language").to_h { |row| [ row.bucket, row.total_seconds ] } ) + + assert_equal( + user.heartbeats.daily_durations(user_timezone: user.timezone).to_h.transform_keys(&:iso8601), + DashboardRollup.where(user: user, dimension: DashboardRollupRefreshService::DAILY_DURATION_DIMENSION).to_h { |row| [ row.bucket, row.total_seconds ] } + ) + + today_scope = Time.use_zone(user.timezone) do + now = Time.zone.now + user.heartbeats.where(time: now.beginning_of_day.to_i..now.end_of_day.to_i) + end + + context_row = DashboardRollup.find_by(user: user, dimension: DashboardRollupRefreshService::TODAY_CONTEXT_DIMENSION) + assert_equal [ user.timezone, Time.use_zone(user.timezone) { Time.zone.today.iso8601 } ], JSON.parse(context_row.bucket_value) + + today_total = DashboardRollup.find_by(user: user, dimension: DashboardRollupRefreshService::TODAY_TOTAL_DURATION_DIMENSION) + assert_equal today_scope.duration_seconds, today_total.total_seconds + + expected_language_counts = today_scope + .where.not(language: [ nil, "" ]) + .group(:language) + .count + .each_with_object({}) do |(language, count), grouped| + categorized = language&.categorize_language + next if categorized.blank? + + grouped[categorized] = (grouped[categorized] || 0) + count.to_i + end + assert_equal( + expected_language_counts, + DashboardRollup.where(user: user, dimension: DashboardRollupRefreshService::TODAY_LANGUAGE_COUNT_DIMENSION).to_h { |row| [ row.bucket, row.total_seconds ] } + ) + + expected_editor_counts = today_scope + .where.not(editor: [ nil, "" ]) + .group(:editor) + .count + .transform_values(&:to_i) + assert_equal( + expected_editor_counts, + DashboardRollup.where(user: user, dimension: DashboardRollupRefreshService::TODAY_EDITOR_COUNT_DIMENSION).to_h { |row| [ row.bucket, row.total_seconds ] } + ) + + %w[day week month].each do |period| + period_scope = period_scope(user, period) + + total_row = DashboardRollup.find_by(user: user, dimension: DashboardRollupRefreshService::GOALS_PERIOD_TOTAL_DIMENSION, bucket_value: period) + assert_equal period_scope.duration_seconds, total_row.total_seconds + end + end + + test "today rollup advisory lock key stays in signed bigint range" do + lock_key = DashboardRollupRefreshService.today_rollup_lock_key((2**63) - 1) + + assert_operator lock_key, :>=, -(2**63) + assert_operator lock_key, :<=, (2**63) - 1 end private @@ -43,4 +98,22 @@ def create_heartbeat(user, timestamp, project:, language:, editor:, operating_sy source_type: :test_entry ) end + + def period_scope(user, period) + range = Time.use_zone(user.timezone) do + now = Time.zone.now + case period + when "day" + now.beginning_of_day..now.end_of_day + when "week" + now.beginning_of_week(:monday)..now.end_of_week(:monday) + when "month" + now.beginning_of_month..now.end_of_month + else + now.beginning_of_day..now.end_of_day + end + end + + user.heartbeats.where(time: range.begin.to_i..range.end.to_i) + end end diff --git a/test/services/programming_goals_progress_service_test.rb b/test/services/programming_goals_progress_service_test.rb index 8f315e001..9b7a45f0b 100644 --- a/test/services/programming_goals_progress_service_test.rb +++ b/test/services/programming_goals_progress_service_test.rb @@ -105,6 +105,83 @@ class ProgrammingGoalsProgressServiceTest < ActiveSupport::TestCase end end + test "uses rollup aggregates for unfiltered project-only and language-only goals" do + user = User.create!(timezone: "America/New_York") + + unfiltered_goal = user.goals.create!(period: "day", target_seconds: 10) + project_goal = user.goals.create!(period: "day", target_seconds: 10, projects: [ "alpha" ]) + language_goal = user.goals.create!(period: "day", target_seconds: 10, languages: [ "Ruby" ]) + + travel_to Time.utc(2026, 1, 14, 16, 0, 0) do + create_heartbeat_pair(user, "2026-01-14 09:00:00", language: "rb", project: "alpha") + create_heartbeat_pair(user, "2026-01-14 09:10:00", language: "python", project: "alpha") + create_heartbeat_pair(user, "2026-01-14 09:20:00", language: "rb", project: "beta") + + DashboardRollupRefreshService.new(user: user).call + rows = DashboardRollup.where(user: user).to_a + + progress = ProgrammingGoalsProgressService.new(user: user, rollup_rows: rows).call.index_by { |goal| goal[:id] } + + assert_equal 5, progress[unfiltered_goal.id.to_s][:tracked_seconds] + assert_equal 3, progress[project_goal.id.to_s][:tracked_seconds] + assert_equal 3, progress[language_goal.id.to_s][:tracked_seconds] + end + end + + test "falls back to heartbeat queries for combined language and project filters" do + user = User.create!(timezone: "America/New_York") + and_goal = user.goals.create!( + period: "day", + target_seconds: 10, + languages: [ "Ruby" ], + projects: [ "alpha" ] + ) + + travel_to Time.utc(2026, 1, 14, 16, 0, 0) do + create_heartbeat_pair(user, "2026-01-14 09:00:00", language: "rb", project: "alpha") + create_heartbeat_pair(user, "2026-01-14 09:10:00", language: "python", project: "alpha") + create_heartbeat_pair(user, "2026-01-14 09:20:00", language: "rb", project: "beta") + + DashboardRollupRefreshService.new(user: user).call + rows = DashboardRollup.where(user: user).to_a + + progress = ProgrammingGoalsProgressService.new(user: user, rollup_rows: rows).call.index_by { |goal| goal[:id] } + + assert_equal 1, progress[and_goal.id.to_s][:tracked_seconds] + end + end + + test "falls back to heartbeat queries when goals rollup dimensions are missing" do + user = User.create!(timezone: "America/New_York") + goal = user.goals.create!(period: "day", target_seconds: 10) + + travel_to Time.utc(2026, 1, 14, 16, 0, 0) do + create_heartbeat_pair(user, "2026-01-14 09:00:00", language: "rb", project: "alpha") + create_heartbeat_pair(user, "2026-01-14 09:10:00", language: "python", project: "alpha") + + DashboardRollupRefreshService.new(user: user).call + stale_rows = DashboardRollup.where(user: user) + .where.not( + dimension: [ + DashboardRollupRefreshService::GOALS_PERIOD_TOTAL_DIMENSION, + DashboardRollupRefreshService::GOALS_PERIOD_PROJECT_DIMENSION, + DashboardRollupRefreshService::GOALS_PERIOD_LANGUAGE_DIMENSION + ] + ) + .to_a + + progress = ProgrammingGoalsProgressService.new(user: user, rollup_rows: stale_rows).call + + expected_tracked_seconds = Time.use_zone(user.timezone) do + now = Time.zone.now + user.heartbeats.where(time: now.beginning_of_day.to_i..now.end_of_day.to_i).duration_seconds + end + + assert_equal expected_tracked_seconds, progress.first[:tracked_seconds] + assert_equal goal.id.to_s, progress.first[:id] + end + end + private def create_heartbeat_pair(user, start_time, language: "Ruby", project: "alpha")