mirror of
https://github.com/Freika/dawarich.git
synced 2026-01-11 09:41:40 -05:00
Merge pull request #1667 from Freika/feature/points-counter-cache
Introduce points counter cache to optimize performance
This commit is contained in:
commit
38fd8ddc92
40 changed files with 131 additions and 77 deletions
|
|
@ -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/)
|
The format is based on [Keep a Changelog](http://keepachangelog.com/)
|
||||||
and this project adheres to [Semantic Versioning](http://semver.org/).
|
and this project adheres to [Semantic Versioning](http://semver.org/).
|
||||||
|
|
||||||
|
# [UNRELEASED] - 2025-08-26
|
||||||
|
|
||||||
|
## Fixed
|
||||||
|
|
||||||
|
- Number of user points is not being cached resulting in performance boost on certain pages and operations.
|
||||||
|
|
||||||
|
|
||||||
# [0.30.11] - 2025-08-23
|
# [0.30.11] - 2025-08-23
|
||||||
|
|
||||||
## Changed
|
## Changed
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ class Api::V1::Countries::VisitedCitiesController < ApiController
|
||||||
end_at = DateTime.parse(params[:end_at]).to_i
|
end_at = DateTime.parse(params[:end_at]).to_i
|
||||||
|
|
||||||
points = current_api_user
|
points = current_api_user
|
||||||
.tracked_points
|
.points
|
||||||
.where(timestamp: start_at..end_at)
|
.where(timestamp: start_at..end_at)
|
||||||
|
|
||||||
render json: { data: CountriesAndCities.new(points).call }
|
render json: { data: CountriesAndCities.new(points).call }
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ class Api::V1::PointsController < ApiController
|
||||||
order = params[:order] || 'desc'
|
order = params[:order] || 'desc'
|
||||||
|
|
||||||
points = current_api_user
|
points = current_api_user
|
||||||
.tracked_points
|
.points
|
||||||
.where(timestamp: start_at..end_at)
|
.where(timestamp: start_at..end_at)
|
||||||
.order(timestamp: order)
|
.order(timestamp: order)
|
||||||
.page(params[:page])
|
.page(params[:page])
|
||||||
|
|
@ -31,7 +31,7 @@ class Api::V1::PointsController < ApiController
|
||||||
end
|
end
|
||||||
|
|
||||||
def update
|
def update
|
||||||
point = current_api_user.tracked_points.find(params[:id])
|
point = current_api_user.points.find(params[:id])
|
||||||
|
|
||||||
point.update(lonlat: "POINT(#{point_params[:longitude]} #{point_params[:latitude]})")
|
point.update(lonlat: "POINT(#{point_params[:longitude]} #{point_params[:latitude]})")
|
||||||
|
|
||||||
|
|
@ -39,7 +39,7 @@ class Api::V1::PointsController < ApiController
|
||||||
end
|
end
|
||||||
|
|
||||||
def destroy
|
def destroy
|
||||||
point = current_api_user.tracked_points.find(params[:id])
|
point = current_api_user.points.find(params[:id])
|
||||||
point.destroy
|
point.destroy
|
||||||
|
|
||||||
render json: { message: 'Point deleted successfully' }
|
render json: { message: 'Point deleted successfully' }
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,6 @@ class HomeController < ApplicationController
|
||||||
|
|
||||||
redirect_to map_url if current_user
|
redirect_to map_url if current_user
|
||||||
|
|
||||||
@points = current_user.tracked_points.without_raw_data if current_user
|
@points = current_user.points.without_raw_data if current_user
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -88,6 +88,6 @@ class MapController < ApplicationController
|
||||||
end
|
end
|
||||||
|
|
||||||
def points_from_user
|
def points_from_user
|
||||||
current_user.tracked_points.without_raw_data.order(timestamp: :asc)
|
current_user.points.without_raw_data.order(timestamp: :asc)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ class PointsController < ApplicationController
|
||||||
alert: 'No points selected.',
|
alert: 'No points selected.',
|
||||||
status: :see_other and return if point_ids.blank?
|
status: :see_other and return if point_ids.blank?
|
||||||
|
|
||||||
current_user.tracked_points.where(id: point_ids).destroy_all
|
current_user.points.where(id: point_ids).destroy_all
|
||||||
|
|
||||||
redirect_to points_url(preserved_params),
|
redirect_to points_url(preserved_params),
|
||||||
notice: 'Points were successfully destroyed.',
|
notice: 'Points were successfully destroyed.',
|
||||||
|
|
@ -58,7 +58,7 @@ class PointsController < ApplicationController
|
||||||
end
|
end
|
||||||
|
|
||||||
def user_points
|
def user_points
|
||||||
current_user.tracked_points
|
current_user.points
|
||||||
end
|
end
|
||||||
|
|
||||||
def order_by
|
def order_by
|
||||||
|
|
|
||||||
|
|
@ -86,7 +86,7 @@ module ApplicationHelper
|
||||||
end
|
end
|
||||||
|
|
||||||
def points_exist?(year, month, user)
|
def points_exist?(year, month, user)
|
||||||
user.tracked_points.where(
|
user.points.where(
|
||||||
timestamp: DateTime.new(year, month).beginning_of_month..DateTime.new(year, month).end_of_month
|
timestamp: DateTime.new(year, month).beginning_of_month..DateTime.new(year, month).end_of_month
|
||||||
).exists?
|
).exists?
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ class BulkVisitsSuggestingJob < ApplicationJob
|
||||||
|
|
||||||
users.active.find_each do |user|
|
users.active.find_each do |user|
|
||||||
next unless user.safe_settings.visits_suggestions_enabled?
|
next unless user.safe_settings.visits_suggestions_enabled?
|
||||||
next if user.tracked_points.empty?
|
next unless user.points_count.positive?
|
||||||
|
|
||||||
schedule_chunked_jobs(user, time_chunks)
|
schedule_chunked_jobs(user, time_chunks)
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ class DataMigrations::MigratePointsLatlonJob < ApplicationJob
|
||||||
user = User.find(user_id)
|
user = User.find(user_id)
|
||||||
|
|
||||||
# rubocop:disable Rails/SkipsModelValidations
|
# rubocop:disable Rails/SkipsModelValidations
|
||||||
user.tracked_points.update_all('lonlat = ST_SetSRID(ST_MakePoint(longitude, latitude), 4326)')
|
user.points.update_all('lonlat = ST_SetSRID(ST_MakePoint(longitude, latitude), 4326)')
|
||||||
# rubocop:enable Rails/SkipsModelValidations
|
# rubocop:enable Rails/SkipsModelValidations
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
23
app/jobs/data_migrations/prefill_points_counter_cache_job.rb
Normal file
23
app/jobs/data_migrations/prefill_points_counter_cache_job.rb
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class DataMigrations::PrefillPointsCounterCacheJob < ApplicationJob
|
||||||
|
queue_as :data_migrations
|
||||||
|
|
||||||
|
def perform(user_id = nil)
|
||||||
|
if user_id
|
||||||
|
prefill_counter_for_user(user_id)
|
||||||
|
else
|
||||||
|
User.find_each(batch_size: 100) do |user|
|
||||||
|
prefill_counter_for_user(user.id)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def prefill_counter_for_user(user_id)
|
||||||
|
User.reset_counters(user_id, :points)
|
||||||
|
rescue ActiveRecord::RecordNotFound
|
||||||
|
Rails.logger.warn "User #{user_id} not found, skipping counter cache update"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -23,9 +23,9 @@ class Tracks::CleanupJob < ApplicationJob
|
||||||
private
|
private
|
||||||
|
|
||||||
def users_with_old_untracked_points(older_than)
|
def users_with_old_untracked_points(older_than)
|
||||||
User.active.joins(:tracked_points)
|
User.active.joins(:points)
|
||||||
.where(tracked_points: { track_id: nil, timestamp: ..older_than.to_i })
|
.where(points: { track_id: nil, timestamp: ..older_than.to_i })
|
||||||
.having('COUNT(tracked_points.id) >= 2') # Only users with enough points for tracks
|
.having('COUNT(points.id) >= 2') # Only users with enough points for tracks
|
||||||
.group(:id)
|
.group(:id)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ class Point < ApplicationRecord
|
||||||
|
|
||||||
belongs_to :import, optional: true, counter_cache: true
|
belongs_to :import, optional: true, counter_cache: true
|
||||||
belongs_to :visit, optional: true
|
belongs_to :visit, optional: true
|
||||||
belongs_to :user
|
belongs_to :user, counter_cache: true
|
||||||
belongs_to :country, optional: true
|
belongs_to :country, optional: true
|
||||||
belongs_to :track, optional: true
|
belongs_to :track, optional: true
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ class Stat < ApplicationRecord
|
||||||
end
|
end
|
||||||
|
|
||||||
def points
|
def points
|
||||||
user.tracked_points
|
user.points
|
||||||
.without_raw_data
|
.without_raw_data
|
||||||
.where(timestamp: timespan)
|
.where(timestamp: timespan)
|
||||||
.order(timestamp: :asc)
|
.order(timestamp: :asc)
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ class Trip < ApplicationRecord
|
||||||
end
|
end
|
||||||
|
|
||||||
def points
|
def points
|
||||||
user.tracked_points.where(timestamp: started_at.to_i..ended_at.to_i).order(:timestamp)
|
user.points.where(timestamp: started_at.to_i..ended_at.to_i).order(:timestamp)
|
||||||
end
|
end
|
||||||
|
|
||||||
def photo_previews
|
def photo_previews
|
||||||
|
|
|
||||||
|
|
@ -4,14 +4,13 @@ class User < ApplicationRecord # rubocop:disable Metrics/ClassLength
|
||||||
devise :database_authenticatable, :registerable,
|
devise :database_authenticatable, :registerable,
|
||||||
:recoverable, :rememberable, :validatable, :trackable
|
:recoverable, :rememberable, :validatable, :trackable
|
||||||
|
|
||||||
has_many :tracked_points, class_name: 'Point', dependent: :destroy
|
has_many :points, dependent: :destroy, counter_cache: true
|
||||||
has_many :imports, dependent: :destroy
|
has_many :imports, dependent: :destroy
|
||||||
has_many :stats, dependent: :destroy
|
has_many :stats, dependent: :destroy
|
||||||
has_many :exports, dependent: :destroy
|
has_many :exports, dependent: :destroy
|
||||||
has_many :notifications, dependent: :destroy
|
has_many :notifications, dependent: :destroy
|
||||||
has_many :areas, dependent: :destroy
|
has_many :areas, dependent: :destroy
|
||||||
has_many :visits, dependent: :destroy
|
has_many :visits, dependent: :destroy
|
||||||
has_many :points, through: :imports
|
|
||||||
has_many :places, through: :visits
|
has_many :places, through: :visits
|
||||||
has_many :trips, dependent: :destroy
|
has_many :trips, dependent: :destroy
|
||||||
has_many :tracks, dependent: :destroy
|
has_many :tracks, dependent: :destroy
|
||||||
|
|
@ -35,7 +34,7 @@ class User < ApplicationRecord # rubocop:disable Metrics/ClassLength
|
||||||
end
|
end
|
||||||
|
|
||||||
def countries_visited
|
def countries_visited
|
||||||
tracked_points
|
points
|
||||||
.where.not(country_name: [nil, ''])
|
.where.not(country_name: [nil, ''])
|
||||||
.distinct
|
.distinct
|
||||||
.pluck(:country_name)
|
.pluck(:country_name)
|
||||||
|
|
@ -43,7 +42,7 @@ class User < ApplicationRecord # rubocop:disable Metrics/ClassLength
|
||||||
end
|
end
|
||||||
|
|
||||||
def cities_visited
|
def cities_visited
|
||||||
tracked_points.where.not(city: [nil, '']).distinct.pluck(:city).compact
|
points.where.not(city: [nil, '']).distinct.pluck(:city).compact
|
||||||
end
|
end
|
||||||
|
|
||||||
def total_distance
|
def total_distance
|
||||||
|
|
@ -60,11 +59,11 @@ class User < ApplicationRecord # rubocop:disable Metrics/ClassLength
|
||||||
end
|
end
|
||||||
|
|
||||||
def total_reverse_geocoded_points
|
def total_reverse_geocoded_points
|
||||||
tracked_points.where.not(reverse_geocoded_at: nil).count
|
points.where.not(reverse_geocoded_at: nil).count
|
||||||
end
|
end
|
||||||
|
|
||||||
def total_reverse_geocoded_points_without_data
|
def total_reverse_geocoded_points_without_data
|
||||||
tracked_points.where(geodata: {}).count
|
points.where(geodata: {}).count
|
||||||
end
|
end
|
||||||
|
|
||||||
def immich_integration_configured?
|
def immich_integration_configured?
|
||||||
|
|
@ -118,7 +117,7 @@ class User < ApplicationRecord # rubocop:disable Metrics/ClassLength
|
||||||
end
|
end
|
||||||
|
|
||||||
def trial_state?
|
def trial_state?
|
||||||
tracked_points.none? && trial?
|
points_count.zero? && trial?
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ class StatsSerializer
|
||||||
def call
|
def call
|
||||||
{
|
{
|
||||||
totalDistanceKm: total_distance_km,
|
totalDistanceKm: total_distance_km,
|
||||||
totalPointsTracked: user.tracked_points.count,
|
totalPointsTracked: user.points_count,
|
||||||
totalReverseGeocodedPoints: reverse_geocoded_points,
|
totalReverseGeocodedPoints: reverse_geocoded_points,
|
||||||
totalCountriesVisited: user.countries_visited.count,
|
totalCountriesVisited: user.countries_visited.count,
|
||||||
totalCitiesVisited: user.cities_visited.count,
|
totalCitiesVisited: user.cities_visited.count,
|
||||||
|
|
@ -27,7 +27,7 @@ class StatsSerializer
|
||||||
end
|
end
|
||||||
|
|
||||||
def reverse_geocoded_points
|
def reverse_geocoded_points
|
||||||
user.tracked_points.reverse_geocoded.count
|
user.points.reverse_geocoded.count
|
||||||
end
|
end
|
||||||
|
|
||||||
def yearly_stats
|
def yearly_stats
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ class Exports::Create
|
||||||
|
|
||||||
def time_framed_points
|
def time_framed_points
|
||||||
user
|
user
|
||||||
.tracked_points
|
.points
|
||||||
.where(timestamp: start_at.to_i..end_at.to_i)
|
.where(timestamp: start_at.to_i..end_at.to_i)
|
||||||
.order(timestamp: :asc)
|
.order(timestamp: :asc)
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ class Imports::Create
|
||||||
schedule_stats_creating(user.id)
|
schedule_stats_creating(user.id)
|
||||||
schedule_visit_suggesting(user.id, import)
|
schedule_visit_suggesting(user.id, import)
|
||||||
update_import_points_count(import)
|
update_import_points_count(import)
|
||||||
|
User.reset_counters(user.id, :points)
|
||||||
rescue StandardError => e
|
rescue StandardError => e
|
||||||
import.update!(status: :failed)
|
import.update!(status: :failed)
|
||||||
broadcast_status_update
|
broadcast_status_update
|
||||||
|
|
|
||||||
|
|
@ -14,9 +14,9 @@ class Jobs::Create
|
||||||
points =
|
points =
|
||||||
case job_name
|
case job_name
|
||||||
when 'start_reverse_geocoding'
|
when 'start_reverse_geocoding'
|
||||||
user.tracked_points
|
user.points
|
||||||
when 'continue_reverse_geocoding'
|
when 'continue_reverse_geocoding'
|
||||||
user.tracked_points.not_reverse_geocoded
|
user.points.not_reverse_geocoded
|
||||||
else
|
else
|
||||||
raise InvalidJobName, 'Invalid job name'
|
raise InvalidJobName, 'Invalid job name'
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ class PointsLimitExceeded
|
||||||
return false if DawarichSettings.self_hosted?
|
return false if DawarichSettings.self_hosted?
|
||||||
|
|
||||||
Rails.cache.fetch(cache_key, expires_in: 1.day) do
|
Rails.cache.fetch(cache_key, expires_in: 1.day) do
|
||||||
@user.tracked_points.count >= points_limit
|
@user.points_count >= points_limit
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,7 @@ class Stats::CalculateMonth
|
||||||
return @points if defined?(@points)
|
return @points if defined?(@points)
|
||||||
|
|
||||||
@points = user
|
@points = user
|
||||||
.tracked_points
|
.points
|
||||||
.without_raw_data
|
.without_raw_data
|
||||||
.where(timestamp: start_timestamp..end_timestamp)
|
.where(timestamp: start_timestamp..end_timestamp)
|
||||||
.select(:lonlat, :timestamp)
|
.select(:lonlat, :timestamp)
|
||||||
|
|
@ -60,7 +60,7 @@ class Stats::CalculateMonth
|
||||||
|
|
||||||
def toponyms
|
def toponyms
|
||||||
toponym_points = user
|
toponym_points = user
|
||||||
.tracked_points
|
.points
|
||||||
.without_raw_data
|
.without_raw_data
|
||||||
.where(timestamp: start_timestamp..end_timestamp)
|
.where(timestamp: start_timestamp..end_timestamp)
|
||||||
.select(:city, :country_name)
|
.select(:city, :country_name)
|
||||||
|
|
|
||||||
|
|
@ -86,7 +86,7 @@ class Tracks::Generator
|
||||||
end
|
end
|
||||||
|
|
||||||
def load_bulk_points
|
def load_bulk_points
|
||||||
scope = user.tracked_points.order(:timestamp)
|
scope = user.points.order(:timestamp)
|
||||||
scope = scope.where(timestamp: timestamp_range) if time_range_defined?
|
scope = scope.where(timestamp: timestamp_range) if time_range_defined?
|
||||||
|
|
||||||
scope
|
scope
|
||||||
|
|
@ -95,7 +95,7 @@ class Tracks::Generator
|
||||||
def load_incremental_points
|
def load_incremental_points
|
||||||
# For incremental mode, we process untracked points
|
# For incremental mode, we process untracked points
|
||||||
# If end_at is specified, only process points up to that time
|
# If end_at is specified, only process points up to that time
|
||||||
scope = user.tracked_points.where(track_id: nil).order(:timestamp)
|
scope = user.points.where(track_id: nil).order(:timestamp)
|
||||||
scope = scope.where(timestamp: ..end_at.to_i) if end_at.present?
|
scope = scope.where(timestamp: ..end_at.to_i) if end_at.present?
|
||||||
|
|
||||||
scope
|
scope
|
||||||
|
|
@ -104,7 +104,7 @@ class Tracks::Generator
|
||||||
def load_daily_points
|
def load_daily_points
|
||||||
day_range = daily_time_range
|
day_range = daily_time_range
|
||||||
|
|
||||||
user.tracked_points.where(timestamp: day_range).order(:timestamp)
|
user.points.where(timestamp: day_range).order(:timestamp)
|
||||||
end
|
end
|
||||||
|
|
||||||
def create_track_from_segment(segment_data)
|
def create_track_from_segment(segment_data)
|
||||||
|
|
@ -195,8 +195,8 @@ class Tracks::Generator
|
||||||
def bulk_timestamp_range
|
def bulk_timestamp_range
|
||||||
return [start_at.to_i, end_at.to_i] if start_at && end_at
|
return [start_at.to_i, end_at.to_i] if start_at && end_at
|
||||||
|
|
||||||
first_point = user.tracked_points.order(:timestamp).first
|
first_point = user.points.order(:timestamp).first
|
||||||
last_point = user.tracked_points.order(:timestamp).last
|
last_point = user.points.order(:timestamp).last
|
||||||
|
|
||||||
[first_point&.timestamp || 0, last_point&.timestamp || Time.current.to_i]
|
[first_point&.timestamp || 0, last_point&.timestamp || Time.current.to_i]
|
||||||
end
|
end
|
||||||
|
|
@ -207,7 +207,7 @@ class Tracks::Generator
|
||||||
end
|
end
|
||||||
|
|
||||||
def incremental_timestamp_range
|
def incremental_timestamp_range
|
||||||
first_point = user.tracked_points.where(track_id: nil).order(:timestamp).first
|
first_point = user.points.where(track_id: nil).order(:timestamp).first
|
||||||
end_timestamp = end_at ? end_at.to_i : Time.current.to_i
|
end_timestamp = end_at ? end_at.to_i : Time.current.to_i
|
||||||
|
|
||||||
[first_point&.timestamp || 0, end_timestamp]
|
[first_point&.timestamp || 0, end_timestamp]
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,7 @@ class Tracks::IncrementalProcessor
|
||||||
|
|
||||||
def find_previous_point
|
def find_previous_point
|
||||||
@previous_point ||=
|
@previous_point ||=
|
||||||
user.tracked_points
|
user.points
|
||||||
.where('timestamp < ?', new_point.timestamp)
|
.where('timestamp < ?', new_point.timestamp)
|
||||||
.order(:timestamp)
|
.order(:timestamp)
|
||||||
.last
|
.last
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,8 @@ module Tracks::TrackBuilder
|
||||||
original_path: build_path(points)
|
original_path: build_path(points)
|
||||||
)
|
)
|
||||||
|
|
||||||
track.distance = pre_calculated_distance.round
|
# TODO: Move trips attrs to columns with more precision and range
|
||||||
|
track.distance = [[pre_calculated_distance.round, 999999.99].min, 0].max
|
||||||
track.duration = calculate_duration(points)
|
track.duration = calculate_duration(points)
|
||||||
track.avg_speed = calculate_average_speed(track.distance, track.duration)
|
track.avg_speed = calculate_average_speed(track.distance, track.duration)
|
||||||
|
|
||||||
|
|
@ -99,8 +100,10 @@ module Tracks::TrackBuilder
|
||||||
|
|
||||||
# Speed in meters per second, then convert to km/h for storage
|
# Speed in meters per second, then convert to km/h for storage
|
||||||
speed_mps = distance_in_meters.to_f / duration_seconds
|
speed_mps = distance_in_meters.to_f / duration_seconds
|
||||||
|
speed_kmh = (speed_mps * 3.6).round(2) # m/s to km/h
|
||||||
|
|
||||||
(speed_mps * 3.6).round(2) # m/s to km/h
|
# Cap the speed to prevent database precision overflow (max 999999.99)
|
||||||
|
[speed_kmh, 999999.99].min
|
||||||
end
|
end
|
||||||
|
|
||||||
def calculate_elevation_stats(points)
|
def calculate_elevation_stats(points)
|
||||||
|
|
|
||||||
|
|
@ -331,7 +331,7 @@ class Users::ExportData
|
||||||
trips: user.trips.count,
|
trips: user.trips.count,
|
||||||
stats: user.stats.count,
|
stats: user.stats.count,
|
||||||
notifications: user.notifications.count,
|
notifications: user.notifications.count,
|
||||||
points: user.tracked_points.count,
|
points: user.points_count,
|
||||||
visits: user.visits.count,
|
visits: user.visits.count,
|
||||||
places: user.places.count
|
places: user.places.count
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ module Visits
|
||||||
@user = user
|
@user = user
|
||||||
@start_at = start_at.to_i
|
@start_at = start_at.to_i
|
||||||
@end_at = end_at.to_i
|
@end_at = end_at.to_i
|
||||||
@points = user.tracked_points.not_visited
|
@points = user.points.not_visited
|
||||||
.order(timestamp: :asc)
|
.order(timestamp: :asc)
|
||||||
.where(timestamp: start_at..end_at)
|
.where(timestamp: start_at..end_at)
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ class Visits::Suggest
|
||||||
def initialize(user, start_at:, end_at:)
|
def initialize(user, start_at:, end_at:)
|
||||||
@start_at = start_at.to_i
|
@start_at = start_at.to_i
|
||||||
@end_at = end_at.to_i
|
@end_at = end_at.to_i
|
||||||
@points = user.tracked_points.not_visited.order(timestamp: :asc).where(timestamp: start_at..end_at)
|
@points = user.points.not_visited.order(timestamp: :asc).where(timestamp: start_at..end_at)
|
||||||
@user = user
|
@user = user
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<p class="py-6">
|
<p class="py-6">
|
||||||
<p class='py-2'>
|
<p class='py-2'>
|
||||||
You have used <%= number_with_delimiter(current_user.tracked_points.count) %> points of <%= number_with_delimiter(DawarichSettings::BASIC_PAID_PLAN_LIMIT) %> available.
|
You have used <%= number_with_delimiter(current_user.points_count) %> points of <%= number_with_delimiter(DawarichSettings::BASIC_PAID_PLAN_LIMIT) %> available.
|
||||||
</p>
|
</p>
|
||||||
<progress class="progress progress-primary w-1/2 h-5" value="<%= current_user.tracked_points.count %>" max="<%= DawarichSettings::BASIC_PAID_PLAN_LIMIT %>"></progress>
|
<progress class="progress progress-primary w-1/2 h-5" value="<%= current_user.points_count %>" max="<%= DawarichSettings::BASIC_PAID_PLAN_LIMIT %>"></progress>
|
||||||
</p>
|
</p>
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<%= number_with_delimiter user.tracked_points.count %>
|
<%= number_with_delimiter user.points_count %>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<%= human_datetime(user.created_at) %>
|
<%= human_datetime(user.created_at) %>
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ class CreateTracksFromPoints < ActiveRecord::Migration[8.0]
|
||||||
processed_users = 0
|
processed_users = 0
|
||||||
|
|
||||||
User.find_each do |user|
|
User.find_each do |user|
|
||||||
points_count = user.tracked_points.count
|
points_count = user.points.count
|
||||||
|
|
||||||
if points_count > 0
|
if points_count > 0
|
||||||
puts "Enqueuing track creation for user #{user.id} (#{points_count} points)"
|
puts "Enqueuing track creation for user #{user.id} (#{points_count} points)"
|
||||||
|
|
|
||||||
12
db/migrate/20250821192219_add_points_count_to_users.rb
Normal file
12
db/migrate/20250821192219_add_points_count_to_users.rb
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
class AddPointsCountToUsers < ActiveRecord::Migration[8.0]
|
||||||
|
def change
|
||||||
|
add_column :users, :points_count, :integer, default: 0, null: false
|
||||||
|
|
||||||
|
# Initialize counter cache for existing users using background job
|
||||||
|
reversible do |dir|
|
||||||
|
dir.up do
|
||||||
|
DataMigrations::PrefillPointsCounterCacheJob.perform_later
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
3
db/schema.rb
generated
3
db/schema.rb
generated
|
|
@ -230,7 +230,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_08_23_125940) do
|
||||||
t.datetime "end_at", null: false
|
t.datetime "end_at", null: false
|
||||||
t.bigint "user_id", null: false
|
t.bigint "user_id", null: false
|
||||||
t.geometry "original_path", limit: {srid: 0, type: "line_string"}, null: false
|
t.geometry "original_path", limit: {srid: 0, type: "line_string"}, null: false
|
||||||
t.integer "distance"
|
t.decimal "distance", precision: 8, scale: 2
|
||||||
t.float "avg_speed"
|
t.float "avg_speed"
|
||||||
t.integer "duration"
|
t.integer "duration"
|
||||||
t.integer "elevation_gain"
|
t.integer "elevation_gain"
|
||||||
|
|
@ -274,6 +274,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_08_23_125940) do
|
||||||
t.string "last_sign_in_ip"
|
t.string "last_sign_in_ip"
|
||||||
t.integer "status", default: 0
|
t.integer "status", default: 0
|
||||||
t.datetime "active_until"
|
t.datetime "active_until"
|
||||||
|
t.integer "points_count", default: 0, null: false
|
||||||
t.index ["email"], name: "index_users_on_email", unique: true
|
t.index ["email"], name: "index_users_on_email", unique: true
|
||||||
t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true
|
t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,8 @@ require 'rails_helper'
|
||||||
RSpec.describe User, type: :model do
|
RSpec.describe User, type: :model do
|
||||||
describe 'associations' do
|
describe 'associations' do
|
||||||
it { is_expected.to have_many(:imports).dependent(:destroy) }
|
it { is_expected.to have_many(:imports).dependent(:destroy) }
|
||||||
it { is_expected.to have_many(:points).through(:imports) }
|
|
||||||
it { is_expected.to have_many(:stats) }
|
it { is_expected.to have_many(:stats) }
|
||||||
it { is_expected.to have_many(:tracked_points).class_name('Point').dependent(:destroy) }
|
it { is_expected.to have_many(:points).class_name('Point').dependent(:destroy) }
|
||||||
it { is_expected.to have_many(:exports).dependent(:destroy) }
|
it { is_expected.to have_many(:exports).dependent(:destroy) }
|
||||||
it { is_expected.to have_many(:notifications).dependent(:destroy) }
|
it { is_expected.to have_many(:notifications).dependent(:destroy) }
|
||||||
it { is_expected.to have_many(:areas).dependent(:destroy) }
|
it { is_expected.to have_many(:areas).dependent(:destroy) }
|
||||||
|
|
@ -124,7 +123,7 @@ RSpec.describe User, type: :model do
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'returns true' do
|
it 'returns true' do
|
||||||
user.tracked_points.destroy_all
|
user.points.destroy_all
|
||||||
|
|
||||||
expect(user.trial_state?).to be_truthy
|
expect(user.trial_state?).to be_truthy
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -123,7 +123,7 @@ RSpec.describe TrackSerializer do
|
||||||
context 'with very large values' do
|
context 'with very large values' do
|
||||||
let(:track) do
|
let(:track) do
|
||||||
create(:track, user: user,
|
create(:track, user: user,
|
||||||
distance: 1_000_000.0,
|
distance: 999_999.99,
|
||||||
avg_speed: 999.99,
|
avg_speed: 999.99,
|
||||||
duration: 86_400, # 24 hours in seconds
|
duration: 86_400, # 24 hours in seconds
|
||||||
elevation_gain: 10_000,
|
elevation_gain: 10_000,
|
||||||
|
|
@ -133,7 +133,7 @@ RSpec.describe TrackSerializer do
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'handles large values correctly' do
|
it 'handles large values correctly' do
|
||||||
expect(serialized_track[:distance]).to eq(1_000_000)
|
expect(serialized_track[:distance]).to eq(999_999)
|
||||||
expect(serialized_track[:avg_speed]).to eq(999.99)
|
expect(serialized_track[:avg_speed]).to eq(999.99)
|
||||||
expect(serialized_track[:duration]).to eq(86_400)
|
expect(serialized_track[:duration]).to eq(86_400)
|
||||||
expect(serialized_track[:elevation_gain]).to eq(10_000)
|
expect(serialized_track[:elevation_gain]).to eq(10_000)
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,15 @@ RSpec.describe Imports::Create do
|
||||||
expect(import.reload.source).to eq('owntracks')
|
expect(import.reload.source).to eq('owntracks')
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'resets points counter cache' do
|
||||||
|
allow(User).to receive(:reset_counters)
|
||||||
|
|
||||||
|
service.call
|
||||||
|
|
||||||
|
expect(User).to have_received(:reset_counters).with(user.id, :points)
|
||||||
|
end
|
||||||
|
|
||||||
|
|
||||||
context 'when import succeeds' do
|
context 'when import succeeds' do
|
||||||
it 'sets status to completed' do
|
it 'sets status to completed' do
|
||||||
service.call
|
service.call
|
||||||
|
|
|
||||||
|
|
@ -24,20 +24,20 @@ RSpec.describe PointsLimitExceeded do
|
||||||
|
|
||||||
context 'when user points count is equal to the limit' do
|
context 'when user points count is equal to the limit' do
|
||||||
before do
|
before do
|
||||||
allow(user.tracked_points).to receive(:count).and_return(10)
|
allow(user).to receive(:points_count).and_return(10)
|
||||||
end
|
end
|
||||||
|
|
||||||
it { is_expected.to be true }
|
it { is_expected.to be true }
|
||||||
|
|
||||||
it 'caches the result' do
|
it 'caches the result' do
|
||||||
expect(user.tracked_points).to receive(:count).once
|
expect(user).to receive(:points_count).once
|
||||||
2.times { described_class.new(user).call }
|
2.times { described_class.new(user).call }
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'when user points count exceeds the limit' do
|
context 'when user points count exceeds the limit' do
|
||||||
before do
|
before do
|
||||||
allow(user.tracked_points).to receive(:count).and_return(11)
|
allow(user).to receive(:points_count).and_return(11)
|
||||||
end
|
end
|
||||||
|
|
||||||
it { is_expected.to be true }
|
it { is_expected.to be true }
|
||||||
|
|
@ -45,7 +45,7 @@ RSpec.describe PointsLimitExceeded do
|
||||||
|
|
||||||
context 'when user points count is below the limit' do
|
context 'when user points count is below the limit' do
|
||||||
before do
|
before do
|
||||||
allow(user.tracked_points).to receive(:count).and_return(9)
|
allow(user).to receive(:points_count).and_return(9)
|
||||||
end
|
end
|
||||||
|
|
||||||
it { is_expected.to be false }
|
it { is_expected.to be false }
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,7 @@ RSpec.describe Users::ExportData, type: :service do
|
||||||
allow(user).to receive(:trips).and_return(double(count: 8))
|
allow(user).to receive(:trips).and_return(double(count: 8))
|
||||||
allow(user).to receive(:stats).and_return(double(count: 24))
|
allow(user).to receive(:stats).and_return(double(count: 24))
|
||||||
allow(user).to receive(:notifications).and_return(double(count: 10))
|
allow(user).to receive(:notifications).and_return(double(count: 10))
|
||||||
allow(user).to receive(:tracked_points).and_return(double(count: 15000))
|
allow(user).to receive(:points_count).and_return(15000)
|
||||||
allow(user).to receive(:visits).and_return(double(count: 45))
|
allow(user).to receive(:visits).and_return(double(count: 45))
|
||||||
allow(user).to receive(:places).and_return(double(count: 20))
|
allow(user).to receive(:places).and_return(double(count: 20))
|
||||||
|
|
||||||
|
|
@ -187,7 +187,7 @@ RSpec.describe Users::ExportData, type: :service do
|
||||||
allow(user).to receive(:trips).and_return(double(count: 8))
|
allow(user).to receive(:trips).and_return(double(count: 8))
|
||||||
allow(user).to receive(:stats).and_return(double(count: 24))
|
allow(user).to receive(:stats).and_return(double(count: 24))
|
||||||
allow(user).to receive(:notifications).and_return(double(count: 10))
|
allow(user).to receive(:notifications).and_return(double(count: 10))
|
||||||
allow(user).to receive(:tracked_points).and_return(double(count: 15000))
|
allow(user).to receive(:points_count).and_return(15000)
|
||||||
allow(user).to receive(:visits).and_return(double(count: 45))
|
allow(user).to receive(:visits).and_return(double(count: 45))
|
||||||
allow(user).to receive(:places).and_return(double(count: 20))
|
allow(user).to receive(:places).and_return(double(count: 20))
|
||||||
|
|
||||||
|
|
@ -267,7 +267,7 @@ RSpec.describe Users::ExportData, type: :service do
|
||||||
allow(user).to receive(:trips).and_return(double(count: 8))
|
allow(user).to receive(:trips).and_return(double(count: 8))
|
||||||
allow(user).to receive(:stats).and_return(double(count: 24))
|
allow(user).to receive(:stats).and_return(double(count: 24))
|
||||||
allow(user).to receive(:notifications).and_return(double(count: 10))
|
allow(user).to receive(:notifications).and_return(double(count: 10))
|
||||||
allow(user).to receive(:tracked_points).and_return(double(count: 15000))
|
allow(user).to receive(:points_count).and_return(15000)
|
||||||
allow(user).to receive(:visits).and_return(double(count: 45))
|
allow(user).to receive(:visits).and_return(double(count: 45))
|
||||||
allow(user).to receive(:places).and_return(double(count: 20))
|
allow(user).to receive(:places).and_return(double(count: 20))
|
||||||
|
|
||||||
|
|
@ -374,7 +374,7 @@ RSpec.describe Users::ExportData, type: :service do
|
||||||
allow(user).to receive(:trips).and_return(double(count: 8))
|
allow(user).to receive(:trips).and_return(double(count: 8))
|
||||||
allow(user).to receive(:stats).and_return(double(count: 24))
|
allow(user).to receive(:stats).and_return(double(count: 24))
|
||||||
allow(user).to receive(:notifications).and_return(double(count: 10))
|
allow(user).to receive(:notifications).and_return(double(count: 10))
|
||||||
allow(user).to receive(:tracked_points).and_return(double(count: 15000))
|
allow(user).to receive(:points_count).and_return(15000)
|
||||||
allow(user).to receive(:visits).and_return(double(count: 45))
|
allow(user).to receive(:visits).and_return(double(count: 45))
|
||||||
allow(user).to receive(:places).and_return(double(count: 20))
|
allow(user).to receive(:places).and_return(double(count: 20))
|
||||||
allow(Rails.logger).to receive(:info)
|
allow(Rails.logger).to receive(:info)
|
||||||
|
|
|
||||||
|
|
@ -312,33 +312,33 @@ RSpec.describe 'Users Export-Import Integration', type: :service do
|
||||||
trips: user.trips.count,
|
trips: user.trips.count,
|
||||||
stats: user.stats.count,
|
stats: user.stats.count,
|
||||||
notifications: user.notifications.count,
|
notifications: user.notifications.count,
|
||||||
points: user.tracked_points.count,
|
points: user.points.count,
|
||||||
visits: user.visits.count,
|
visits: user.visits.count,
|
||||||
places: user.places.count
|
places: user.places.count
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
def verify_relationships_preserved(original_user, target_user)
|
def verify_relationships_preserved(original_user, target_user)
|
||||||
original_points_with_imports = original_user.tracked_points.where.not(import_id: nil).count
|
original_points_with_imports = original_user.points.where.not(import_id: nil).count
|
||||||
target_points_with_imports = target_user.tracked_points.where.not(import_id: nil).count
|
target_points_with_imports = target_user.points.where.not(import_id: nil).count
|
||||||
expect(target_points_with_imports).to eq(original_points_with_imports)
|
expect(target_points_with_imports).to eq(original_points_with_imports)
|
||||||
|
|
||||||
original_points_with_countries = original_user.tracked_points.where.not(country_id: nil).count
|
original_points_with_countries = original_user.points.where.not(country_id: nil).count
|
||||||
target_points_with_countries = target_user.tracked_points.where.not(country_id: nil).count
|
target_points_with_countries = target_user.points.where.not(country_id: nil).count
|
||||||
expect(target_points_with_countries).to eq(original_points_with_countries)
|
expect(target_points_with_countries).to eq(original_points_with_countries)
|
||||||
|
|
||||||
original_points_with_visits = original_user.tracked_points.where.not(visit_id: nil).count
|
original_points_with_visits = original_user.points.where.not(visit_id: nil).count
|
||||||
target_points_with_visits = target_user.tracked_points.where.not(visit_id: nil).count
|
target_points_with_visits = target_user.points.where.not(visit_id: nil).count
|
||||||
expect(target_points_with_visits).to eq(original_points_with_visits)
|
expect(target_points_with_visits).to eq(original_points_with_visits)
|
||||||
|
|
||||||
original_visits_with_places = original_user.visits.where.not(place_id: nil).count
|
original_visits_with_places = original_user.visits.where.not(place_id: nil).count
|
||||||
target_visits_with_places = target_user.visits.where.not(place_id: nil).count
|
target_visits_with_places = target_user.visits.where.not(place_id: nil).count
|
||||||
expect(target_visits_with_places).to eq(original_visits_with_places)
|
expect(target_visits_with_places).to eq(original_visits_with_places)
|
||||||
|
|
||||||
original_office_points = original_user.tracked_points.where(
|
original_office_points = original_user.points.where(
|
||||||
latitude: 40.7589, longitude: -73.9851
|
latitude: 40.7589, longitude: -73.9851
|
||||||
).first
|
).first
|
||||||
target_office_points = target_user.tracked_points.where(
|
target_office_points = target_user.points.where(
|
||||||
latitude: 40.7589, longitude: -73.9851
|
latitude: 40.7589, longitude: -73.9851
|
||||||
).first
|
).first
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -35,13 +35,13 @@ RSpec.describe Users::ImportData::Points, type: :service do
|
||||||
|
|
||||||
it 'assigns the correct country association' do
|
it 'assigns the correct country association' do
|
||||||
service.call
|
service.call
|
||||||
point = user.tracked_points.last
|
point = user.points.last
|
||||||
expect(point.country).to eq(country)
|
expect(point.country).to eq(country)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'excludes the string country field from attributes' do
|
it 'excludes the string country field from attributes' do
|
||||||
service.call
|
service.call
|
||||||
point = user.tracked_points.last
|
point = user.points.last
|
||||||
# The country association should be set, not the string attribute
|
# The country association should be set, not the string attribute
|
||||||
expect(point.read_attribute(:country)).to be_nil
|
expect(point.read_attribute(:country)).to be_nil
|
||||||
expect(point.country).to eq(country)
|
expect(point.country).to eq(country)
|
||||||
|
|
@ -68,7 +68,7 @@ RSpec.describe Users::ImportData::Points, type: :service do
|
||||||
it 'does not create country and leaves country_id nil' do
|
it 'does not create country and leaves country_id nil' do
|
||||||
expect { service.call }.not_to change(Country, :count)
|
expect { service.call }.not_to change(Country, :count)
|
||||||
|
|
||||||
point = user.tracked_points.last
|
point = user.points.last
|
||||||
expect(point.country_id).to be_nil
|
expect(point.country_id).to be_nil
|
||||||
expect(point.city).to eq('Berlin')
|
expect(point.city).to eq('Berlin')
|
||||||
end
|
end
|
||||||
|
|
@ -126,10 +126,10 @@ RSpec.describe Users::ImportData::Points, type: :service do
|
||||||
|
|
||||||
it 'imports valid points and reconstructs lonlat when needed' do
|
it 'imports valid points and reconstructs lonlat when needed' do
|
||||||
expect(service.call).to eq(2) # Two valid points (original + reconstructed)
|
expect(service.call).to eq(2) # Two valid points (original + reconstructed)
|
||||||
expect(user.tracked_points.count).to eq(2)
|
expect(user.points.count).to eq(2)
|
||||||
|
|
||||||
# Check that lonlat was reconstructed properly
|
# Check that lonlat was reconstructed properly
|
||||||
munich_point = user.tracked_points.find_by(city: 'Munich')
|
munich_point = user.points.find_by(city: 'Munich')
|
||||||
expect(munich_point).to be_present
|
expect(munich_point).to be_present
|
||||||
expect(munich_point.lonlat.to_s).to match(/POINT\s*\(11\.582\s+48\.1351\)/)
|
expect(munich_point.lonlat.to_s).to match(/POINT\s*\(11\.582\s+48\.1351\)/)
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ RSpec.describe Visits::SmartDetect do
|
||||||
let(:created_visits) { [instance_double(Visit)] }
|
let(:created_visits) { [instance_double(Visit)] }
|
||||||
|
|
||||||
before do
|
before do
|
||||||
allow(user).to receive_message_chain(:tracked_points, :not_visited, :order, :where).and_return(points)
|
allow(user).to receive_message_chain(:points, :not_visited, :order, :where).and_return(points)
|
||||||
allow(Visits::Detector).to receive(:new).with(points).and_return(visit_detector)
|
allow(Visits::Detector).to receive(:new).with(points).and_return(visit_detector)
|
||||||
allow(Visits::Merger).to receive(:new).with(points).and_return(visit_merger)
|
allow(Visits::Merger).to receive(:new).with(points).and_return(visit_merger)
|
||||||
allow(Visits::Creator).to receive(:new).with(user).and_return(visit_creator)
|
allow(Visits::Creator).to receive(:new).with(user).and_return(visit_creator)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue