diff --git a/.app_version b/.app_version index 1a44cad7..0f721773 100644 --- a/.app_version +++ b/.app_version @@ -1 +1 @@ -0.30.1 +0.30.2 diff --git a/CHANGELOG.md b/CHANGELOG.md index 02ab8cb0..e7c5a882 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +# [0.30.2] - 2025-07-22 + +## Fixed + +- Stats calculation is now significantly faster. + + # [0.30.1] - 2025-07-22 ## Fixed diff --git a/app/controllers/stats_controller.rb b/app/controllers/stats_controller.rb index 0300deae..ef5ff993 100644 --- a/app/controllers/stats_controller.rb +++ b/app/controllers/stats_controller.rb @@ -13,6 +13,7 @@ class StatsController < ApplicationController def show @year = params[:year].to_i @stats = current_user.stats.where(year: @year).order(:month) + @year_distances = { @year => Stat.year_distance(@year, current_user) } end def update diff --git a/app/models/stat.rb b/app/models/stat.rb index 0fa4e5e5..2cf26d04 100644 --- a/app/models/stat.rb +++ b/app/models/stat.rb @@ -37,18 +37,16 @@ class Stat < ApplicationRecord end def calculate_daily_distances(monthly_points) - timespan.to_a.map.with_index(1) do |day, index| - daily_points = filter_points_for_day(monthly_points, day) - # Calculate distance in meters for consistent storage - distance_meters = Point.total_distance(daily_points, :m) - [index, distance_meters.round] - end + Stats::DailyDistanceQuery.new(monthly_points, timespan, user_timezone).call end - def filter_points_for_day(points, day) - beginning_of_day = day.beginning_of_day.to_i - end_of_day = day.end_of_day.to_i + private - points.select { |p| p.timestamp.between?(beginning_of_day, end_of_day) } + def user_timezone + # Future: Once user.timezone column exists, uncomment the line below + # user.timezone.presence || Time.zone.name + + # For now, use application timezone + Time.zone.name end end diff --git a/app/queries/stats/daily_distance_query.rb b/app/queries/stats/daily_distance_query.rb new file mode 100644 index 00000000..39ae9f4e --- /dev/null +++ b/app/queries/stats/daily_distance_query.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +class Stats::DailyDistanceQuery + def initialize(monthly_points, timespan, timezone = nil) + @monthly_points = monthly_points + @timespan = timespan + @timezone = validate_timezone(timezone) + end + + def call + daily_distances = daily_distances(monthly_points) + distance_by_day_map = distance_by_day_map(daily_distances) + + convert_to_daily_distances(distance_by_day_map) + end + + private + + attr_reader :monthly_points, :timespan, :timezone + + def daily_distances(monthly_points) + Stat.connection.select_all(<<-SQL.squish) + WITH points_with_distances AS ( + SELECT + EXTRACT(day FROM (to_timestamp(timestamp) AT TIME ZONE 'UTC' AT TIME ZONE '#{timezone}')) as day_of_month, + CASE + WHEN LAG(lonlat) OVER ( + PARTITION BY EXTRACT(day FROM (to_timestamp(timestamp) AT TIME ZONE 'UTC' AT TIME ZONE '#{timezone}')) + ORDER BY timestamp + ) IS NOT NULL THEN + ST_Distance( + lonlat::geography, + LAG(lonlat) OVER ( + PARTITION BY EXTRACT(day FROM (to_timestamp(timestamp) AT TIME ZONE 'UTC' AT TIME ZONE '#{timezone}')) + ORDER BY timestamp + )::geography + ) + ELSE 0 + END as segment_distance + FROM (#{monthly_points.to_sql}) as points + ) + SELECT + day_of_month, + ROUND(COALESCE(SUM(segment_distance), 0)) as distance_meters + FROM points_with_distances + GROUP BY day_of_month + ORDER BY day_of_month + SQL + end + + def distance_by_day_map(daily_distances) + daily_distances.index_by do |row| + row['day_of_month'].to_i + end + end + + def convert_to_daily_distances(distance_by_day_map) + timespan.to_a.map.with_index(1) do |day, index| + distance_meters = + distance_by_day_map[day.day]&.fetch('distance_meters', 0) || 0 + + [index, distance_meters.to_i] + end + end + + def validate_timezone(timezone) + return timezone if ActiveSupport::TimeZone.all.any? { |tz| tz.name == timezone } + + 'UTC' + end +end diff --git a/app/services/stats/bulk_calculator.rb b/app/services/stats/bulk_calculator.rb index aa74d60c..b803d3b1 100644 --- a/app/services/stats/bulk_calculator.rb +++ b/app/services/stats/bulk_calculator.rb @@ -21,7 +21,7 @@ module Stats last_calculated_at ||= DateTime.new(1970, 1, 1) time_diff = last_calculated_at.to_i..Time.current.to_i - Point.where(user_id:, timestamp: time_diff).pluck(:timestamp) + Point.where(user_id:, timestamp: time_diff).pluck(:timestamp).uniq end def extract_months(timestamps) diff --git a/app/services/stats/calculate_month.rb b/app/services/stats/calculate_month.rb index e9d6d64d..839ea55a 100644 --- a/app/services/stats/calculate_month.rb +++ b/app/services/stats/calculate_month.rb @@ -50,7 +50,7 @@ class Stats::CalculateMonth .tracked_points .without_raw_data .where(timestamp: start_timestamp..end_timestamp) - .select(:lonlat, :timestamp, :city, :country) + .select(:lonlat, :timestamp) .order(timestamp: :asc) end @@ -59,7 +59,14 @@ class Stats::CalculateMonth end def toponyms - CountriesAndCities.new(points).call + toponym_points = user + .tracked_points + .without_raw_data + .where(timestamp: start_timestamp..end_timestamp) + .select(:city, :country) + .distinct + + CountriesAndCities.new(toponym_points).call end def create_stats_update_failed_notification(user, error) diff --git a/app/views/stats/_stat.html.erb b/app/views/stats/_stat.html.erb index 470d3438..14430331 100644 --- a/app/views/stats/_stat.html.erb +++ b/app/views/stats/_stat.html.erb @@ -20,7 +20,7 @@ <%= area_chart( stat.daily_distance.map { |day, distance_meters| - [day, Stat.convert_distance(distance_meters, current_user.safe_settings.distance_unit).round(2)] + [day, Stat.convert_distance(distance_meters, current_user.safe_settings.distance_unit).round] }, height: '200px', suffix: " #{current_user.safe_settings.distance_unit}", diff --git a/app/views/stats/index.html.erb b/app/views/stats/index.html.erb index 2f14fa36..a5ab367b 100644 --- a/app/views/stats/index.html.erb +++ b/app/views/stats/index.html.erb @@ -83,7 +83,7 @@ <% end %> <%= column_chart( @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] }, height: '200px', suffix: " #{current_user.safe_settings.distance_unit}", diff --git a/db/migrate/20250721204404_add_index_on_places_geodata_osm_id.rb b/db/migrate/20250721204404_add_index_on_places_geodata_osm_id.rb index 83359ec4..50f8ab97 100644 --- a/db/migrate/20250721204404_add_index_on_places_geodata_osm_id.rb +++ b/db/migrate/20250721204404_add_index_on_places_geodata_osm_id.rb @@ -2,8 +2,8 @@ class AddIndexOnPlacesGeodataOsmId < ActiveRecord::Migration[8.0] disable_ddl_transaction! def change - add_index :places, "(geodata->'properties'->>'osm_id')", - using: :btree, + add_index :places, "(geodata->'properties'->>'osm_id')", + using: :btree, name: 'index_places_on_geodata_osm_id', algorithm: :concurrently end