Optimize stats page performance

This commit is contained in:
Eugene Burmakin 2025-07-22 19:17:28 +02:00
parent 7c1c42dfc1
commit 88909b3e9f
14 changed files with 56 additions and 12 deletions

View file

@ -77,4 +77,5 @@ group :development do
gem 'database_consistency', require: false gem 'database_consistency', require: false
gem 'foreman' gem 'foreman'
gem 'rubocop-rails', require: false gem 'rubocop-rails', require: false
gem 'bullet'
end end

View file

@ -113,6 +113,9 @@ GEM
brakeman (7.0.2) brakeman (7.0.2)
racc racc
builder (3.3.0) builder (3.3.0)
bullet (8.0.8)
activesupport (>= 3.0.0)
uniform_notifier (~> 1.11)
bundler-audit (0.9.2) bundler-audit (0.9.2)
bundler (>= 1.2.0, < 3) bundler (>= 1.2.0, < 3)
thor (~> 1.0) thor (~> 1.0)
@ -486,6 +489,7 @@ GEM
unicode-display_width (3.1.4) unicode-display_width (3.1.4)
unicode-emoji (~> 4.0, >= 4.0.4) unicode-emoji (~> 4.0, >= 4.0.4)
unicode-emoji (4.0.4) unicode-emoji (4.0.4)
uniform_notifier (1.17.0)
uri (1.0.3) uri (1.0.3)
useragent (0.16.11) useragent (0.16.11)
warden (1.2.9) warden (1.2.9)
@ -519,6 +523,7 @@ DEPENDENCIES
aws-sdk-s3 (~> 1.177.0) aws-sdk-s3 (~> 1.177.0)
bootsnap bootsnap
brakeman brakeman
bullet
bundler-audit bundler-audit
capybara capybara
chartkick chartkick

View file

@ -5,10 +5,30 @@ class StatsController < ApplicationController
before_action :authenticate_active_user!, only: %i[update update_all] before_action :authenticate_active_user!, only: %i[update update_all]
def index def index
@stats = current_user.stats.group_by(&:year).transform_values { |stats| stats.sort_by(&:updated_at).reverse }.sort.reverse @stats = current_user.stats.group_by(&:year).transform_values do |stats|
@points_total = current_user.tracked_points.count stats.sort_by(&:updated_at).reverse
@points_reverse_geocoded = current_user.total_reverse_geocoded_points end.sort.reverse
@points_reverse_geocoded_without_data = current_user.total_reverse_geocoded_points_without_data
# Single aggregated query to replace 3 separate COUNT queries
result = current_user.tracked_points.connection.execute(<<~SQL.squish)
SELECT#{' '}
COUNT(*) as total,
COUNT(reverse_geocoded_at) as geocoded,
COUNT(CASE WHEN geodata = '{}' THEN 1 END) as without_data
FROM points#{' '}
WHERE user_id = #{current_user.id}
SQL
row = result.first
@points_total = row['total'].to_i
@points_reverse_geocoded = row['geocoded'].to_i
@points_reverse_geocoded_without_data = row['without_data'].to_i
# Precompute year distance data to avoid N+1 queries in view
@year_distances = {}
@stats.each do |year, _stats|
@year_distances[year] = Stat.year_distance(year, current_user)
end
end end
def show def show

View file

@ -1,7 +1,7 @@
# frozen_string_literal: true # frozen_string_literal: true
class Trips::CalculateAllJob < ApplicationJob class Trips::CalculateAllJob < ApplicationJob
queue_as :default queue_as :trips
def perform(trip_id, distance_unit = 'km') def perform(trip_id, distance_unit = 'km')
Trips::CalculatePathJob.perform_later(trip_id) Trips::CalculatePathJob.perform_later(trip_id)

View file

@ -1,7 +1,7 @@
# frozen_string_literal: true # frozen_string_literal: true
class Trips::CalculateCountriesJob < ApplicationJob class Trips::CalculateCountriesJob < ApplicationJob
queue_as :default queue_as :trips
def perform(trip_id, distance_unit) def perform(trip_id, distance_unit)
trip = Trip.find(trip_id) trip = Trip.find(trip_id)

View file

@ -1,7 +1,7 @@
# frozen_string_literal: true # frozen_string_literal: true
class Trips::CalculateDistanceJob < ApplicationJob class Trips::CalculateDistanceJob < ApplicationJob
queue_as :default queue_as :trips
def perform(trip_id, distance_unit) def perform(trip_id, distance_unit)
trip = Trip.find(trip_id) trip = Trip.find(trip_id)

View file

@ -1,7 +1,7 @@
# frozen_string_literal: true # frozen_string_literal: true
class Trips::CalculatePathJob < ApplicationJob class Trips::CalculatePathJob < ApplicationJob
queue_as :default queue_as :trips
def perform(trip_id) def perform(trip_id)
trip = Trip.find(trip_id) trip = Trip.find(trip_id)

View file

@ -12,6 +12,8 @@ class Country < ApplicationRecord
end end
def self.names_to_iso_a2 def self.names_to_iso_a2
Rails.cache.fetch('countries_names_to_iso_a2', expires_in: 1.day) do
pluck(:name, :iso_a2).to_h pluck(:name, :iso_a2).to_h
end end
end end
end

View file

@ -4,7 +4,7 @@
</h2> </h2>
<div class='my-10'> <div class='my-10'>
<%= column_chart( <%= column_chart(
Stat.year_distance(year, current_user), @year_distances[year],
height: '200px', height: '200px',
suffix: " #{current_user.safe_settings.distance_unit}", suffix: " #{current_user.safe_settings.distance_unit}",
xtitle: 'Days', xtitle: 'Days',

View file

@ -82,7 +82,7 @@
</div> </div>
<% end %> <% end %>
<%= column_chart( <%= column_chart(
Stat.year_distance(year, current_user).map { |month_name, distance_meters| @year_distances[year].map { |month_name, distance_meters|
[month_name, Stat.convert_distance(distance_meters, current_user.safe_settings.distance_unit).round(2)] [month_name, Stat.convert_distance(distance_meters, current_user.safe_settings.distance_unit).round(2)]
}, },
height: '200px', height: '200px',

View file

@ -3,6 +3,15 @@
require 'active_support/core_ext/integer/time' require 'active_support/core_ext/integer/time'
Rails.application.configure do Rails.application.configure do
config.after_initialize do
Bullet.enable = true
Bullet.alert = true
Bullet.bullet_logger = true
Bullet.console = true
Bullet.rails_logger = true
Bullet.add_footer = true
end
# Settings specified here will take precedence over those in config/application.rb. # Settings specified here will take precedence over those in config/application.rb.
# In the development environment your application's code is reloaded any time # In the development environment your application's code is reloaded any time

View file

@ -29,7 +29,7 @@ Rails.application.configure do
# config.assets.css_compressor = :sass # config.assets.css_compressor = :sass
# Do not fallback to assets pipeline if a precompiled asset is missed. # Do not fallback to assets pipeline if a precompiled asset is missed.
config.assets.compile = true config.assets.compile = false
config.assets.content_type = { config.assets.content_type = {
geojson: 'application/geo+json' geojson: 'application/geo+json'

View file

@ -8,6 +8,12 @@ require 'active_support/core_ext/integer/time'
# and recreated between test runs. Don't rely on the data there! # and recreated between test runs. Don't rely on the data there!
Rails.application.configure do Rails.application.configure do
config.after_initialize do
Bullet.enable = true
Bullet.bullet_logger = true
Bullet.raise = true # raise an error if n+1 query occurs
end
# Settings specified here will take precedence over those in config/application.rb. # Settings specified here will take precedence over those in config/application.rb.
# While tests run files are not watched, reloading is not necessary. # While tests run files are not watched, reloading is not necessary.

View file

@ -6,6 +6,7 @@
- imports - imports
- exports - exports
- stats - stats
- trips
- tracks - tracks
- reverse_geocoding - reverse_geocoding
- visit_suggesting - visit_suggesting