Compare commits

...

49 commits

Author SHA1 Message Date
Jivan Pal
6d467ccf51
Merge 405a455d6d into abe0129d03 2025-07-24 08:49:30 +02:00
Evgenii Burmakin
abe0129d03
Merge pull request #1560 from Freika/dev
0.30.3
2025-07-23 21:07:43 +02:00
Evgenii Burmakin
9631696231
Merge pull request #1559 from Freika/fix/tracks-generator-speedup
Fix/tracks generator speedup
2025-07-23 21:01:58 +02:00
Eugene Burmakin
9fb251fa4a Sanitize input in distanceable 2025-07-23 20:52:02 +02:00
Eugene Burmakin
7920209187 Return tailwind.css 2025-07-23 20:35:42 +02:00
Eugene Burmakin
94f6dbe18e Extract timestamp range calculation to separate methods 2025-07-23 20:27:55 +02:00
Eugene Burmakin
51dd2e0a4b Fix auth rules for export and import 2025-07-23 20:21:33 +02:00
Eugene Burmakin
bf199de2a0 Fix non-selfhosted users export and import 2025-07-23 20:17:07 +02:00
Eugene Burmakin
2e46069fcc Clean up code a bit 2025-07-23 20:08:24 +02:00
Eugene Burmakin
7ea149bd4e Fix specs 2025-07-23 20:02:38 +02:00
Eugene Burmakin
88e3f53cc5 Remove old code 2025-07-23 19:48:39 +02:00
Eugene Burmakin
278a4d28b5 Remove tailwind.css 2025-07-23 19:33:38 +02:00
Eugene Burmakin
4239f5b31a Remove bullet gem 2025-07-23 19:32:51 +02:00
Eugene Burmakin
5a4a5e9625 Add composite index for track generation 2025-07-23 19:30:56 +02:00
Eugene Burmakin
d8033a1e27 Update track generation 2025-07-23 18:21:21 +02:00
Evgenii Burmakin
2ac8fba058
Merge pull request #1554 from Freika/dev
0.30.2
2025-07-23 00:24:16 +02:00
Evgenii Burmakin
4044e77fcd
Merge pull request #1553 from Freika/fix/stats-calculation-performance
Fix stats calculation performance
2025-07-23 00:23:19 +02:00
Eugene Burmakin
25a185b206 Add timezone validation to Stats::DailyDistanceQuery 2025-07-23 00:10:48 +02:00
Eugene Burmakin
dfec1afd7e Remove example migration file 2025-07-23 00:01:41 +02:00
Eugene Burmakin
04a16029a4 Remove benchmark_stats.rb 2025-07-22 23:57:54 +02:00
Eugene Burmakin
bdcfb5eb62 Stats calculation is now timezone-aware. 2025-07-22 23:57:25 +02:00
Eugene Burmakin
9803ccc6a8 Remove unused method 2025-07-22 22:44:41 +02:00
Eugene Burmakin
0c904a6b84 Fix stats calculation performance 2025-07-22 22:41:12 +02:00
Evgenii Burmakin
abfd3be1c5
Merge pull request #1552 from Freika/dev
0.30.1
2025-07-22 20:46:11 +02:00
Eugene Burmakin
bd2558ed29 Enable assets compilation in production 2025-07-22 20:35:45 +02:00
Evgenii Burmakin
685f7eebd2
Merge pull request #1551 from Freika/chore/disable-tracks-generation
Temporary disable track creation
2025-07-22 20:29:03 +02:00
Eugene Burmakin
0bfddd932f Disable specs for track generation 2025-07-22 20:28:46 +02:00
Eugene Burmakin
27857ba078 Disable tracks panel on the map 2025-07-22 20:26:58 +02:00
Eugene Burmakin
7c8a7e7f38 Temporary disable track creation 2025-07-22 20:25:44 +02:00
Evgenii Burmakin
962983aa82
Merge pull request #1498 from Freika/dependabot/bundler/chartkick-5.2.0
Bump chartkick from 5.1.5 to 5.2.0
2025-07-22 20:24:54 +02:00
Evgenii Burmakin
c22b260e28
Merge pull request #1497 from Freika/dependabot/bundler/debug-1.11.0
Bump debug from 1.10.0 to 1.11.0
2025-07-22 20:24:27 +02:00
Evgenii Burmakin
1158444c0a
Merge pull request #1496 from Freika/dependabot/bundler/super_diff-0.16.0
Bump super_diff from 0.15.0 to 0.16.0
2025-07-22 20:23:37 +02:00
Evgenii Burmakin
bf9b0d037a
Merge pull request #1550 from Freika/fix/stats-page-performance
Fix/stats page performance
2025-07-22 20:17:13 +02:00
Eugene Burmakin
c14054fdc3 Disable track generation failure notification for self-hosted users 2025-07-22 20:15:52 +02:00
Eugene Burmakin
cbdef5fa43 Parameterize stats query 2025-07-22 19:56:12 +02:00
Eugene Burmakin
6e5dd4bed6 Update stats query 2025-07-22 19:52:24 +02:00
Eugene Burmakin
58ffca74f6 Remove bullet 2025-07-22 19:44:50 +02:00
Evgenii Burmakin
18aed4a10c
Merge pull request #1542 from Freika/dependabot/bundler/bundler-f02c9c4a61
Bump the bundler group with 2 updates
2025-07-22 19:44:27 +02:00
Eugene Burmakin
da38c12819 Extract stats query 2025-07-22 19:43:27 +02:00
Eugene Burmakin
88909b3e9f Optimize stats page performance 2025-07-22 19:17:28 +02:00
dependabot[bot]
97d6037448
Bump the bundler group with 2 updates
Bumps the bundler group with 2 updates: [nokogiri](https://github.com/sparklemotion/nokogiri) and [thor](https://github.com/rails/thor).


Updates `nokogiri` from 1.18.8 to 1.18.9
- [Release notes](https://github.com/sparklemotion/nokogiri/releases)
- [Changelog](https://github.com/sparklemotion/nokogiri/blob/main/CHANGELOG.md)
- [Commits](https://github.com/sparklemotion/nokogiri/compare/v1.18.8...v1.18.9)

Updates `thor` from 1.3.2 to 1.4.0
- [Release notes](https://github.com/rails/thor/releases)
- [Commits](https://github.com/rails/thor/compare/v1.3.2...v1.4.0)

---
updated-dependencies:
- dependency-name: nokogiri
  dependency-version: 1.18.9
  dependency-type: indirect
  dependency-group: bundler
- dependency-name: thor
  dependency-version: 1.4.0
  dependency-type: indirect
  dependency-group: bundler
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-22 05:39:51 +00:00
Evgenii Burmakin
ed350971ee
Merge pull request #1540 from Freika/fix/place-osm-id-index
Add index on places geodata osm id
2025-07-21 23:30:44 +02:00
Eugene Burmakin
c18b09181e Add index on places geodata osm id 2025-07-21 22:45:57 +02:00
Evgenii Burmakin
7c1c42dfc1
Merge pull request #1539 from Freika/fix/cache-point-limit-result
Add cache to points limit exceeded check
2025-07-21 22:35:22 +02:00
Eugene Burmakin
7afc399724 Add cache to points limit exceeded check 2025-07-21 22:27:20 +02:00
Jivan Pal
405a455d6d Refactor Docker files: docker-compose.yml, environment variables, add interpolation vars for Dawarich version and other values commonly modified by Dawarich instance admins. 2025-07-08 16:52:07 +01:00
dependabot[bot]
4e35cdd305
Bump chartkick from 5.1.5 to 5.2.0
Bumps [chartkick](https://github.com/ankane/chartkick) from 5.1.5 to 5.2.0.
- [Changelog](https://github.com/ankane/chartkick/blob/master/CHANGELOG.md)
- [Commits](https://github.com/ankane/chartkick/compare/v5.1.5...v5.2.0)

---
updated-dependencies:
- dependency-name: chartkick
  dependency-version: 5.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-07 16:47:02 +00:00
dependabot[bot]
d0aaa3c674
Bump debug from 1.10.0 to 1.11.0
Bumps [debug](https://github.com/ruby/debug) from 1.10.0 to 1.11.0.
- [Release notes](https://github.com/ruby/debug/releases)
- [Commits](https://github.com/ruby/debug/compare/v1.10.0...v1.11.0)

---
updated-dependencies:
- dependency-name: debug
  dependency-version: 1.11.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-07 16:31:00 +00:00
dependabot[bot]
90efb5b0bb
Bump super_diff from 0.15.0 to 0.16.0
Bumps [super_diff](https://github.com/splitwise/super_diff) from 0.15.0 to 0.16.0.
- [Release notes](https://github.com/splitwise/super_diff/releases)
- [Changelog](https://github.com/splitwise/super_diff/blob/main/CHANGELOG.md)
- [Commits](https://github.com/splitwise/super_diff/compare/v0.15.0...v0.16.0)

---
updated-dependencies:
- dependency-name: super_diff
  dependency-version: 0.16.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-07 16:30:16 +00:00
41 changed files with 706 additions and 159 deletions

View file

@ -1 +1 @@
0.30.0
0.30.3

View file

@ -1,6 +1,8 @@
DATABASE_HOST=localhost
DATABASE_USERNAME=postgres
DATABASE_PASSWORD=password
DATABASE_NAME=dawarich_development
DATABASE_PORT=5432
REDIS_URL=redis://localhost:6379
DAWARICH_VERSION=latest
DAWARICH_DATABASE_USER=postgres
DAWARICH_DATABASE_PASSWORD=password
DAWARICH_DATABASE_NAME=dawarich_development
DAWARICH_HOSTS=your-domain.example.com,localhost
DAWARICH_PROTOCOL=http

View file

@ -4,10 +4,41 @@ 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.3] - 2025-07-23
## Changed
- Track generation is now significantly faster and less resource intensive.
## Fixed
- Distance on the stats page is now rounded. #1548
- Non-selfhosted users can now export and import their account data.
# [0.30.2] - 2025-07-22
## Fixed
- Stats calculation is now significantly faster.
# [0.30.1] - 2025-07-22
## Fixed
- Points limit exceeded check is now cached.
- Reverse geocoding for places is now significantly faster.
## Changed
- Stats page should load faster now.
- Track creation is temporarily disabled.
# [0.30.0] - 2025-07-21
⚠️ If you were using RC, please run the following commands in the console, otherwise read on. ⚠️
⚠️ If you were using 0.29.2 RC, please run the following commands in the console, otherwise read on. ⚠️
```ruby
# This will delete all tracks 👇

View file

@ -126,7 +126,7 @@ GEM
rack-test (>= 0.6.3)
regexp_parser (>= 1.5, < 3.0)
xpath (~> 3.2)
chartkick (5.1.5)
chartkick (5.2.0)
coderay (1.1.3)
concurrent-ruby (1.3.5)
connection_pool (2.5.3)
@ -144,7 +144,7 @@ GEM
database_consistency (2.0.4)
activerecord (>= 3.2)
date (3.4.1)
debug (1.10.0)
debug (1.11.0)
irb (~> 1.10)
reline (>= 0.3.8)
devise (4.9.4)
@ -160,7 +160,7 @@ GEM
dotenv (= 3.1.8)
railties (>= 6.1)
drb (2.2.3)
erb (5.0.1)
erb (5.0.2)
erubi (1.13.1)
et-orbi (1.2.11)
tzinfo
@ -194,7 +194,7 @@ GEM
actionpack (>= 6.0.0)
activesupport (>= 6.0.0)
railties (>= 6.0.0)
io-console (0.8.0)
io-console (0.8.1)
irb (1.15.2)
pp (>= 0.6.0)
rdoc (>= 4.0.0)
@ -243,7 +243,7 @@ GEM
multi_json (1.15.0)
multi_xml (0.7.1)
bigdecimal (~> 3.1)
net-imap (0.5.8)
net-imap (0.5.9)
date
net-protocol
net-pop (0.1.2)
@ -253,23 +253,23 @@ GEM
net-smtp (0.5.1)
net-protocol
nio4r (2.7.4)
nokogiri (1.18.8)
nokogiri (1.18.9)
mini_portile2 (~> 2.8.2)
racc (~> 1.4)
nokogiri (1.18.8-aarch64-linux-gnu)
nokogiri (1.18.9-aarch64-linux-gnu)
racc (~> 1.4)
nokogiri (1.18.8-arm-linux-gnu)
nokogiri (1.18.9-arm-linux-gnu)
racc (~> 1.4)
nokogiri (1.18.8-arm64-darwin)
nokogiri (1.18.9-arm64-darwin)
racc (~> 1.4)
nokogiri (1.18.8-x86_64-darwin)
nokogiri (1.18.9-x86_64-darwin)
racc (~> 1.4)
nokogiri (1.18.8-x86_64-linux-gnu)
nokogiri (1.18.9-x86_64-linux-gnu)
racc (~> 1.4)
oj (3.16.11)
bigdecimal (>= 3.0)
ostruct (>= 0.2)
optimist (3.2.0)
optimist (3.2.1)
orm_adapter (0.5.0)
ostruct (0.6.1)
parallel (1.27.0)
@ -342,7 +342,7 @@ GEM
zeitwerk (~> 2.6)
rainbow (3.1.1)
rake (13.3.0)
rdoc (6.14.1)
rdoc (6.14.2)
erb
psych (>= 4.0.0)
redis (5.4.0)
@ -350,7 +350,7 @@ GEM
redis-client (0.24.0)
connection_pool
regexp_parser (2.10.0)
reline (0.6.1)
reline (0.6.2)
io-console (~> 0.5)
request_store (1.7.0)
rack (>= 1.4)
@ -462,7 +462,7 @@ GEM
stringio (3.1.7)
strong_migrations (2.3.0)
activerecord (>= 7)
super_diff (0.15.0)
super_diff (0.16.0)
attr_extras (>= 6.2.4)
diff-lcs
patience_diff
@ -475,7 +475,7 @@ GEM
tailwindcss-ruby (3.4.17-arm64-darwin)
tailwindcss-ruby (3.4.17-x86_64-darwin)
tailwindcss-ruby (3.4.17-x86_64-linux)
thor (1.3.2)
thor (1.4.0)
timeout (0.4.3)
turbo-rails (2.0.16)
actionpack (>= 7.1.0)
@ -496,7 +496,7 @@ GEM
hashdiff (>= 0.4.0, < 2.0.0)
webrick (1.9.1)
websocket (1.2.11)
websocket-driver (0.7.7)
websocket-driver (0.8.0)
base64
websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5)

View file

@ -1,9 +1,9 @@
# frozen_string_literal: true
class Settings::UsersController < ApplicationController
before_action :authenticate_self_hosted!
before_action :authenticate_self_hosted!, except: [:export, :import]
before_action :authenticate_admin!, except: [:export, :import]
before_action :authenticate_user!, only: [:export, :import]
before_action :authenticate_user!
def index
@users = User.order(created_at: :desc)

View file

@ -5,15 +5,15 @@ class StatsController < ApplicationController
before_action :authenticate_active_user!, only: %i[update update_all]
def index
@stats = current_user.stats.group_by(&:year).transform_values { |stats| stats.sort_by(&:updated_at).reverse }.sort.reverse
@points_total = current_user.tracked_points.count
@points_reverse_geocoded = current_user.total_reverse_geocoded_points
@points_reverse_geocoded_without_data = current_user.total_reverse_geocoded_points_without_data
@stats = build_stats
assign_points_statistics
@year_distances = precompute_year_distances
end
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
@ -43,4 +43,30 @@ class StatsController < ApplicationController
redirect_to stats_path, notice: 'Stats are being updated', status: :see_other
end
private
def assign_points_statistics
points_stats = ::StatsQuery.new(current_user).points_stats
@points_total = points_stats[:total]
@points_reverse_geocoded = points_stats[:geocoded]
@points_reverse_geocoded_without_data = points_stats[:without_data]
end
def precompute_year_distances
year_distances = {}
@stats.each do |year, _stats|
year_distances[year] = Stat.year_distance(year, current_user)
end
year_distances
end
def build_stats
current_user.stats.group_by(&:year).transform_values do |stats|
stats.sort_by(&:updated_at).reverse
end.sort.reverse
end
end

View file

@ -219,7 +219,7 @@ export default class extends BaseController {
this.setupTracksSubscription();
// Handle routes/tracks mode selection
this.addRoutesTracksSelector();
// this.addRoutesTracksSelector(); # Temporarily disabled
this.switchRouteMode('routes', true);
// Initialize layers based on settings

View file

@ -27,6 +27,8 @@ class Tracks::CreateJob < ApplicationJob
end
def create_error_notification(user, error)
return unless DawarichSettings.self_hosted?
Notifications::Create.new(
user: user,
kind: :error,

View file

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

View file

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

View file

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

View file

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

View file

@ -5,7 +5,6 @@ module Distanceable
module ClassMethods
def total_distance(points = nil, unit = :km)
# Handle method being called directly on relation vs with array
if points.nil?
calculate_distance_for_relation(unit)
else
@ -50,16 +49,48 @@ module Distanceable
return 0 if points.length < 2
total_meters = points.each_cons(2).sum do |point1, point2|
connection.select_value(
'SELECT ST_Distance(ST_GeomFromEWKT($1)::geography, ST_GeomFromEWKT($2)::geography)',
nil,
[point1.lonlat, point2.lonlat]
)
end
total_meters = calculate_batch_distances(points).sum
total_meters.to_f / ::DISTANCE_UNITS[unit.to_sym]
end
def calculate_batch_distances(points)
return [] if points.length < 2
point_pairs = points.each_cons(2).to_a
return [] if point_pairs.empty?
# Create parameterized placeholders for VALUES clause using ? placeholders
values_placeholders = point_pairs.map do |_|
"(?, ST_GeomFromEWKT(?)::geography, ST_GeomFromEWKT(?)::geography)"
end.join(', ')
# Flatten parameters: [pair_id, lonlat1, lonlat2, pair_id, lonlat1, lonlat2, ...]
params = point_pairs.flat_map.with_index do |(p1, p2), index|
[index, p1.lonlat, p2.lonlat]
end
# Single query to calculate all distances using parameterized query
sql_with_params = ActiveRecord::Base.sanitize_sql_array([<<-SQL.squish] + params)
WITH point_pairs AS (
SELECT
pair_id,
point1,
point2
FROM (VALUES #{values_placeholders}) AS t(pair_id, point1, point2)
)
SELECT
pair_id,
ST_Distance(point1, point2) as distance_meters
FROM point_pairs
ORDER BY pair_id
SQL
results = connection.select_all(sql_with_params)
# Return array of distances in meters
results.map { |row| row['distance_meters'].to_f }
end
end
def distance_to(other_point, unit = :km)
@ -67,7 +98,6 @@ module Distanceable
raise ArgumentError, "Invalid unit. Supported units are: #{::DISTANCE_UNITS.keys.join(', ')}"
end
# Extract coordinates based on what type other_point is
other_lonlat = extract_point(other_point)
return nil if other_lonlat.nil?

View file

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

View file

@ -33,8 +33,8 @@ class Point < ApplicationRecord
after_create :async_reverse_geocode, if: -> { DawarichSettings.store_geodata? && !reverse_geocoded? }
after_create :set_country
after_create_commit :broadcast_coordinates
after_create_commit :trigger_incremental_track_generation, if: -> { import_id.nil? }
after_commit :recalculate_track, on: :update, if: -> { track.present? }
# after_create_commit :trigger_incremental_track_generation, if: -> { import_id.nil? }
# after_commit :recalculate_track, on: :update, if: -> { track.present? }
def self.without_raw_data
select(column_names - ['raw_data'])

View file

@ -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

View file

@ -25,6 +25,114 @@ class Track < ApplicationRecord
.first
end
def self.segment_points_in_sql(user_id, start_timestamp, end_timestamp, time_threshold_minutes, distance_threshold_meters, untracked_only: false)
time_threshold_seconds = time_threshold_minutes * 60
where_clause = if untracked_only
"WHERE user_id = $1 AND timestamp BETWEEN $2 AND $3 AND track_id IS NULL"
else
"WHERE user_id = $1 AND timestamp BETWEEN $2 AND $3"
end
sql = <<~SQL
WITH points_with_gaps AS (
SELECT
id,
timestamp,
lonlat,
LAG(lonlat) OVER (ORDER BY timestamp) as prev_lonlat,
LAG(timestamp) OVER (ORDER BY timestamp) as prev_timestamp,
ST_Distance(
lonlat::geography,
LAG(lonlat) OVER (ORDER BY timestamp)::geography
) as distance_meters,
(timestamp - LAG(timestamp) OVER (ORDER BY timestamp)) as time_diff_seconds
FROM points
#{where_clause}
ORDER BY timestamp
),
segment_breaks AS (
SELECT *,
CASE
WHEN prev_lonlat IS NULL THEN 1
WHEN time_diff_seconds > $4 THEN 1
WHEN distance_meters > $5 THEN 1
ELSE 0
END as is_break
FROM points_with_gaps
),
segments AS (
SELECT *,
SUM(is_break) OVER (ORDER BY timestamp ROWS UNBOUNDED PRECEDING) as segment_id
FROM segment_breaks
)
SELECT
segment_id,
array_agg(id ORDER BY timestamp) as point_ids,
count(*) as point_count,
min(timestamp) as start_timestamp,
max(timestamp) as end_timestamp,
sum(COALESCE(distance_meters, 0)) as total_distance_meters
FROM segments
GROUP BY segment_id
HAVING count(*) >= 2
ORDER BY segment_id
SQL
results = Point.connection.exec_query(
sql,
'segment_points_in_sql',
[user_id, start_timestamp, end_timestamp, time_threshold_seconds, distance_threshold_meters]
)
# Convert results to segment data
segments_data = []
results.each do |row|
segments_data << {
segment_id: row['segment_id'].to_i,
point_ids: parse_postgres_array(row['point_ids']),
point_count: row['point_count'].to_i,
start_timestamp: row['start_timestamp'].to_i,
end_timestamp: row['end_timestamp'].to_i,
total_distance_meters: row['total_distance_meters'].to_f
}
end
segments_data
end
# Get actual Point objects for each segment with pre-calculated distances
def self.get_segments_with_points(user_id, start_timestamp, end_timestamp, time_threshold_minutes, distance_threshold_meters, untracked_only: false)
segments_data = segment_points_in_sql(
user_id,
start_timestamp,
end_timestamp,
time_threshold_minutes,
distance_threshold_meters,
untracked_only: untracked_only
)
point_ids = segments_data.flat_map { |seg| seg[:point_ids] }
points_by_id = Point.where(id: point_ids).index_by(&:id)
segments_data.map do |seg_data|
{
points: seg_data[:point_ids].map { |id| points_by_id[id] }.compact,
pre_calculated_distance: seg_data[:total_distance_meters],
start_timestamp: seg_data[:start_timestamp],
end_timestamp: seg_data[:end_timestamp]
}
end
end
# Parse PostgreSQL array format like "{1,2,3}" into Ruby array
def self.parse_postgres_array(pg_array_string)
return [] if pg_array_string.nil? || pg_array_string.empty?
# Remove curly braces and split by comma
pg_array_string.gsub(/[{}]/, '').split(',').map(&:to_i)
end
private
def broadcast_track_created
@ -45,23 +153,7 @@ class Track < ApplicationRecord
def broadcast_track_update(action)
TracksChannel.broadcast_to(user, {
action: action,
track: serialize_track_data
track: TrackSerializer.new(self).call
})
end
def serialize_track_data
{
id: id,
start_at: start_at.iso8601,
end_at: end_at.iso8601,
distance: distance.to_i,
avg_speed: avg_speed.to_f,
duration: duration,
elevation_gain: elevation_gain,
elevation_loss: elevation_loss,
elevation_max: elevation_max,
elevation_min: elevation_min,
original_path: original_path.to_s
}
end
end

View file

@ -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

View file

@ -0,0 +1,33 @@
# frozen_string_literal: true
class StatsQuery
def initialize(user)
@user = user
end
def points_stats
sql = ActiveRecord::Base.sanitize_sql_array([
<<~SQL.squish,
SELECT
COUNT(id) as total,
COUNT(reverse_geocoded_at) as geocoded,
COUNT(CASE WHEN geodata = '{}'::jsonb THEN 1 END) as without_data
FROM points
WHERE user_id = ?
SQL
user.id
])
result = Point.connection.select_one(sql)
{
total: result['total'].to_i,
geocoded: result['geocoded'].to_i,
without_data: result['without_data'].to_i
}
end
private
attr_reader :user
end

View file

@ -7,13 +7,18 @@ class PointsLimitExceeded
def call
return false if DawarichSettings.self_hosted?
return true if @user.tracked_points.count >= points_limit
false
Rails.cache.fetch(cache_key, expires_in: 1.day) do
@user.tracked_points.count >= points_limit
end
end
private
def cache_key
"points_limit_exceeded/#{@user.id}"
end
def points_limit
DawarichSettings::BASIC_PAID_PLAN_LIMIT
end

View file

@ -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)

View file

@ -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)

View file

@ -40,12 +40,20 @@ class Tracks::Generator
def call
clean_existing_tracks if should_clean_tracks?
points = load_points
Rails.logger.debug "Generator: loaded #{points.size} points for user #{user.id} in #{mode} mode"
return 0 if points.empty?
start_timestamp, end_timestamp = get_timestamp_range
segments = split_points_into_segments(points)
Rails.logger.debug "Generator: created #{segments.size} segments"
Rails.logger.debug "Generator: querying points for user #{user.id} in #{mode} mode"
segments = Track.get_segments_with_points(
user.id,
start_timestamp,
end_timestamp,
time_threshold_minutes,
distance_threshold_meters,
untracked_only: mode == :incremental
)
Rails.logger.debug "Generator: created #{segments.size} segments via SQL"
tracks_created = 0
@ -99,11 +107,14 @@ class Tracks::Generator
user.tracked_points.where(timestamp: day_range).order(:timestamp)
end
def create_track_from_segment(segment)
Rails.logger.debug "Generator: processing segment with #{segment.size} points"
return unless segment.size >= 2
def create_track_from_segment(segment_data)
points = segment_data[:points]
pre_calculated_distance = segment_data[:pre_calculated_distance]
track = create_track_from_points(segment)
Rails.logger.debug "Generator: processing segment with #{points.size} points"
return unless points.size >= 2
track = create_track_from_points(points, pre_calculated_distance)
Rails.logger.debug "Generator: created track #{track&.id}"
track
end
@ -171,7 +182,37 @@ class Tracks::Generator
scope.destroy_all
end
# Threshold methods from safe_settings
def get_timestamp_range
case mode
when :bulk then bulk_timestamp_range
when :daily then daily_timestamp_range
when :incremental then incremental_timestamp_range
else
raise ArgumentError, "Unknown mode: #{mode}"
end
end
def bulk_timestamp_range
return [start_at.to_i, end_at.to_i] if start_at && end_at
first_point = user.tracked_points.order(:timestamp).first
last_point = user.tracked_points.order(:timestamp).last
[first_point&.timestamp || 0, last_point&.timestamp || Time.current.to_i]
end
def daily_timestamp_range
day = start_at&.to_date || Date.current
[day.beginning_of_day.to_i, day.end_of_day.to_i]
end
def incremental_timestamp_range
first_point = user.tracked_points.where(track_id: nil).order(:timestamp).first
end_timestamp = end_at ? end_at.to_i : Time.current.to_i
[first_point&.timestamp || 0, end_timestamp]
end
def distance_threshold_meters
@distance_threshold_meters ||= user.safe_settings.meters_between_routes.to_i
end

View file

@ -86,11 +86,13 @@ module Tracks::Segmentation
end
def calculate_km_distance_between_points(point1, point2)
lat1, lon1 = point_coordinates(point1)
lat2, lon2 = point_coordinates(point2)
distance_meters = Point.connection.select_value(
'SELECT ST_Distance(ST_GeomFromEWKT($1)::geography, ST_GeomFromEWKT($2)::geography)',
nil,
[point1.lonlat, point2.lonlat]
)
# Use Geocoder to match behavior with frontend (same library used elsewhere in app)
Geocoder::Calculations.distance_between([lat1, lon1], [lat2, lon2], units: :km)
distance_meters.to_f / 1000.0 # Convert meters to kilometers
end
def should_finalize_segment?(segment_points, grace_period_minutes = 5)

View file

@ -49,7 +49,7 @@
module Tracks::TrackBuilder
extend ActiveSupport::Concern
def create_track_from_points(points)
def create_track_from_points(points, pre_calculated_distance)
return nil if points.size < 2
track = Track.new(
@ -59,17 +59,16 @@ module Tracks::TrackBuilder
original_path: build_path(points)
)
# Calculate track statistics
track.distance = calculate_track_distance(points)
track.duration = calculate_duration(points)
track.distance = pre_calculated_distance.round
track.duration = calculate_duration(points)
track.avg_speed = calculate_average_speed(track.distance, track.duration)
# Calculate elevation statistics
# Calculate elevation statistics (no DB queries needed)
elevation_stats = calculate_elevation_stats(points)
track.elevation_gain = elevation_stats[:gain]
track.elevation_loss = elevation_stats[:loss]
track.elevation_max = elevation_stats[:max]
track.elevation_min = elevation_stats[:min]
track.elevation_max = elevation_stats[:max]
track.elevation_min = elevation_stats[:min]
if track.save
Point.where(id: points.map(&:id)).update_all(track_id: track.id)
@ -77,7 +76,6 @@ module Tracks::TrackBuilder
track
else
Rails.logger.error "Failed to create track for user #{user.id}: #{track.errors.full_messages.join(', ')}"
nil
end
end
@ -101,6 +99,7 @@ module Tracks::TrackBuilder
# Speed in meters per second, then convert to km/h for storage
speed_mps = distance_in_meters.to_f / duration_seconds
(speed_mps * 3.6).round(2) # m/s to km/h
end

View file

@ -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}",

View file

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

View file

@ -21,7 +21,9 @@
<% end %>
</div>
<%= link_to 'Update stats', update_all_stats_path, data: { turbo_method: :put }, class: 'btn btn-primary mt-5' %>
<% if current_user.active? %>
<%= link_to 'Update stats', update_all_stats_path, data: { turbo_method: :put }, class: 'btn btn-primary mt-5' %>
<% end %>
<div class="mt-6 grid grid-cols-1 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-2 gap-6">
<% @stats.each do |year, stats| %>
@ -33,13 +35,13 @@
<%= link_to '[Map]', map_url(year_timespan(year)), class: 'underline hover:no-underline' %>
</div>
<div class="gap-2">
<span class='text-xs text-gray-500'>Last updated: <%= human_date(stats.first.updated_at) %></span>
<span class='text-xs text-gray-500'>Last update: <%= human_date(stats.first.updated_at) %></span>
<%= link_to '🔄', update_year_month_stats_path(year, :all), data: { turbo_method: :put }, class: 'text-sm text-gray-500 hover:underline' %>
</div>
</h2>
<p>
<% cache [current_user, 'year_distance_stat', year], skip_digest: true do %>
<%= number_with_delimiter year_distance_stat(year, current_user) %><%= current_user.safe_settings.distance_unit %>
<%= number_with_delimiter year_distance_stat(year, current_user).round %> <%= current_user.safe_settings.distance_unit %>
<% end %>
</p>
<% if DawarichSettings.reverse_geocoding_enabled? %>
@ -82,8 +84,8 @@
</div>
<% end %>
<%= column_chart(
Stat.year_distance(year, current_user).map { |month_name, distance_meters|
[month_name, Stat.convert_distance(distance_meters, current_user.safe_settings.distance_unit).round(2)]
@year_distances[year].map { |month_name, distance_meters|
[month_name, Stat.convert_distance(distance_meters, current_user.safe_settings.distance_unit).round]
},
height: '200px',
suffix: " #{current_user.safe_settings.distance_unit}",

View file

@ -30,10 +30,10 @@ cache_preheating_job:
class: "Cache::PreheatingJob"
queue: default
tracks_cleanup_job:
cron: "0 2 * * 0" # every Sunday at 02:00
class: "Tracks::CleanupJob"
queue: tracks
# tracks_cleanup_job:
# cron: "0 2 * * 0" # every Sunday at 02:00
# class: "Tracks::CleanupJob"
# queue: tracks
place_name_fetching_job:
cron: "30 0 * * *" # every day at 00:30

View file

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

View file

@ -0,0 +1,10 @@
class AddIndexOnPlacesGeodataOsmId < ActiveRecord::Migration[8.0]
disable_ddl_transaction!
def change
add_index :places, "(geodata->'properties'->>'osm_id')",
using: :btree,
name: 'index_places_on_geodata_osm_id',
algorithm: :concurrently
end
end

View file

@ -0,0 +1,9 @@
class AddTrackGenerationCompositeIndex < ActiveRecord::Migration[8.0]
disable_ddl_transaction!
def change
add_index :points, [:user_id, :timestamp, :track_id],
algorithm: :concurrently,
name: 'idx_points_track_generation', if_not_exists: true
end
end

7
db/schema.rb generated
View file

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.0].define(version: 2025_07_03_193657) do
ActiveRecord::Schema[8.0].define(version: 2025_07_23_164055) do
# These are extensions that must be enabled in order to support this database
enable_extension "pg_catalog.plpgsql"
enable_extension "postgis"
@ -77,6 +77,9 @@ ActiveRecord::Schema[8.0].define(version: 2025_07_03_193657) do
t.index ["name"], name: "index_countries_on_name"
end
create_table "data_migrations", primary_key: "version", id: :string, force: :cascade do |t|
end
create_table "exports", force: :cascade do |t|
t.string "name", null: false
t.string "url"
@ -143,6 +146,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_07_03_193657) do
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.geography "lonlat", limit: {srid: 4326, type: "st_point", geographic: true}
t.index "(((geodata -> 'properties'::text) ->> 'osm_id'::text))", name: "index_places_on_geodata_osm_id"
t.index ["lonlat"], name: "index_places_on_lonlat", using: :gist
end
@ -199,6 +203,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_07_03_193657) do
t.index ["timestamp"], name: "index_points_on_timestamp"
t.index ["track_id"], name: "index_points_on_track_id"
t.index ["trigger"], name: "index_points_on_trigger"
t.index ["user_id", "timestamp", "track_id"], name: "idx_points_track_generation"
t.index ["user_id"], name: "index_points_on_user_id"
t.index ["visit_id"], name: "index_points_on_visit_id"
end

View file

@ -0,0 +1,17 @@
RAILS_ENV=development
REDIS_URL=redis://dawarich_redis:6379
DATABASE_HOST=dawarich_db
DATABASE_USERNAME=${DAWARICH_DATABASE_USER}
DATABASE_PASSWORD=${DAWARICH_DATABASE_PASSWORD}
DATABASE_NAME=${DAWARICH_DATABASE_SCHEMA}
APPLICATION_HOSTS=${DAWARICH_HOSTS}
APPLICATION_PROTOCOL=${DAWARICH_PROTOCOL}
PROMETHEUS_EXPORTER_ENABLED=false
PROMETHEUS_EXPORTER_PORT=9394
SELF_HOSTED=true
STORE_GEODATA=true

View file

@ -27,19 +27,19 @@ services:
networks:
- dawarich
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
POSTGRES_DB: dawarich_development
POSTGRES_USER: ${DAWARICH_DATABASE_USER}
POSTGRES_PASSWORD: ${DAWARICH_DATABASE_PASSWORD}
POSTGRES_DB: ${DAWARICH_DATABASE_NAME}
restart: always
healthcheck:
test: [ "CMD-SHELL", "pg_isready -U postgres -d dawarich_development" ]
test: [ "CMD-SHELL", "pg_isready -U ${DAWARICH_DATABASE_USER} -d ${DAWARICH_DATABASE_NAME}" ]
interval: 10s
retries: 5
start_period: 30s
timeout: 10s
# command: postgres -c config_file=/etc/postgresql/postgresql.conf # Use custom config, uncomment if you want to use a custom config
dawarich_app:
image: freikin/dawarich:latest
image: freikin/dawarich:${DAWARICH_VERSION}
container_name: dawarich_app
volumes:
- dawarich_public:/var/app/public
@ -56,22 +56,11 @@ services:
entrypoint: web-entrypoint.sh
command: ['bin/rails', 'server', '-p', '3000', '-b', '::']
restart: on-failure
env_file: '.env.dawarich-common'
environment:
RAILS_ENV: development
REDIS_URL: redis://dawarich_redis:6379
DATABASE_HOST: dawarich_db
DATABASE_USERNAME: postgres
DATABASE_PASSWORD: password
DATABASE_NAME: dawarich_development
MIN_MINUTES_SPENT_IN_CITY: 60
APPLICATION_HOSTS: localhost
TIME_ZONE: Europe/London
APPLICATION_PROTOCOL: http
PROMETHEUS_EXPORTER_ENABLED: "false"
PROMETHEUS_EXPORTER_HOST: 0.0.0.0
PROMETHEUS_EXPORTER_PORT: 9394
SELF_HOSTED: "true"
STORE_GEODATA: "true"
logging:
driver: "json-file"
options:
@ -96,7 +85,7 @@ services:
cpus: '0.50' # Limit CPU usage to 50% of one core
memory: '4G' # Limit memory usage to 4GB
dawarich_sidekiq:
image: freikin/dawarich:latest
image: freikin/dawarich:${DAWARICH_VERSION}
container_name: dawarich_sidekiq
volumes:
- dawarich_public:/var/app/public
@ -109,21 +98,10 @@ services:
entrypoint: sidekiq-entrypoint.sh
command: ['sidekiq']
restart: on-failure
env_file: '.env.dawarich-common'
environment:
RAILS_ENV: development
REDIS_URL: redis://dawarich_redis:6379
DATABASE_HOST: dawarich_db
DATABASE_USERNAME: postgres
DATABASE_PASSWORD: password
DATABASE_NAME: dawarich_development
APPLICATION_HOSTS: localhost
BACKGROUND_PROCESSING_CONCURRENCY: 10
APPLICATION_PROTOCOL: http
PROMETHEUS_EXPORTER_ENABLED: "false"
PROMETHEUS_EXPORTER_HOST: dawarich_app
PROMETHEUS_EXPORTER_PORT: 9394
SELF_HOSTED: "true"
STORE_GEODATA: "true"
logging:
driver: "json-file"
options:

View file

@ -1,7 +1,6 @@
# frozen_string_literal: true
module Timestamps
def self.parse_timestamp(timestamp)
begin
# if the timestamp is in ISO 8601 format, try to parse it

View file

@ -151,4 +151,50 @@ RSpec.describe Tracks::CreateJob, type: :job do
expect(described_class.new.queue_name).to eq('tracks')
end
end
context 'when self-hosted' do
let(:generator_instance) { instance_double(Tracks::Generator) }
let(:notification_service) { instance_double(Notifications::Create) }
let(:error_message) { 'Something went wrong' }
before do
allow(DawarichSettings).to receive(:self_hosted?).and_return(true)
allow(Tracks::Generator).to receive(:new).and_return(generator_instance)
allow(generator_instance).to receive(:call).and_raise(StandardError, error_message)
allow(Notifications::Create).to receive(:new).and_return(notification_service)
allow(notification_service).to receive(:call)
end
it 'creates a failure notification when self-hosted' do
described_class.new.perform(user.id)
expect(Notifications::Create).to have_received(:new).with(
user: user,
kind: :error,
title: 'Track Generation Failed',
content: "Failed to generate tracks from your location data: #{error_message}"
)
expect(notification_service).to have_received(:call)
end
end
context 'when not self-hosted' do
let(:generator_instance) { instance_double(Tracks::Generator) }
let(:notification_service) { instance_double(Notifications::Create) }
let(:error_message) { 'Something went wrong' }
before do
allow(DawarichSettings).to receive(:self_hosted?).and_return(false)
allow(Tracks::Generator).to receive(:new).and_return(generator_instance)
allow(generator_instance).to receive(:call).and_raise(StandardError, error_message)
allow(Notifications::Create).to receive(:new).and_return(notification_service)
allow(notification_service).to receive(:call)
end
it 'does not create a failure notification' do
described_class.new.perform(user.id)
expect(notification_service).not_to have_received(:call)
end
end
end

View file

@ -30,7 +30,7 @@ RSpec.describe Point, type: :model do
end
end
describe '#recalculate_track' do
xdescribe '#recalculate_track' do
let(:point) { create(:point, track: track) }
let(:track) { create(:track) }
@ -121,7 +121,7 @@ RSpec.describe Point, type: :model do
end
end
describe '#trigger_incremental_track_generation' do
xdescribe '#trigger_incremental_track_generation' do
let(:point) do
create(:point, track: track, import_id: nil, timestamp: 1.hour.ago.to_i, reverse_geocoded_at: 1.hour.ago)
end

View file

@ -0,0 +1,130 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe StatsQuery do
describe '#points_stats' do
subject(:points_stats) { described_class.new(user).points_stats }
let(:user) { create(:user) }
let!(:import) { create(:import, user: user) }
context 'when user has no points' do
it 'returns zero counts for all statistics' do
expect(points_stats).to eq({
total: 0,
geocoded: 0,
without_data: 0
})
end
end
context 'when user has points' do
let!(:geocoded_point_with_data) do
create(:point,
user: user,
import: import,
reverse_geocoded_at: Time.current,
geodata: { 'address' => '123 Main St' })
end
let!(:geocoded_point_without_data) do
create(:point,
user: user,
import: import,
reverse_geocoded_at: Time.current,
geodata: {})
end
let!(:non_geocoded_point) do
create(:point,
user: user,
import: import,
reverse_geocoded_at: nil,
geodata: { 'some' => 'data' })
end
it 'returns correct counts for all statistics' do
expect(points_stats).to eq({
total: 3,
geocoded: 2,
without_data: 1
})
end
context 'when another user has points' do
let(:other_user) { create(:user) }
let!(:other_import) { create(:import, user: other_user) }
let!(:other_point) do
create(:point,
user: other_user,
import: other_import,
reverse_geocoded_at: Time.current,
geodata: { 'address' => 'Other Address' })
end
it 'only counts points for the specified user' do
expect(points_stats).to eq({
total: 3,
geocoded: 2,
without_data: 1
})
end
end
end
context 'when all points are geocoded with data' do
before do
create_list(:point, 5,
user: user,
import: import,
reverse_geocoded_at: Time.current,
geodata: { 'address' => 'Some Address' })
end
it 'returns correct statistics' do
expect(points_stats).to eq({
total: 5,
geocoded: 5,
without_data: 0
})
end
end
context 'when all points are without geodata' do
before do
create_list(:point, 3,
user: user,
import: import,
reverse_geocoded_at: Time.current,
geodata: {})
end
it 'returns correct statistics' do
expect(points_stats).to eq({
total: 3,
geocoded: 3,
without_data: 3
})
end
end
context 'when all points are not geocoded' do
before do
create_list(:point, 4,
user: user,
import: import,
reverse_geocoded_at: nil,
geodata: { 'some' => 'data' })
end
it 'returns correct statistics' do
expect(points_stats).to eq({
total: 4,
geocoded: 0,
without_data: 0
})
end
end
end
end

View file

@ -28,6 +28,11 @@ RSpec.describe PointsLimitExceeded do
end
it { is_expected.to be true }
it 'caches the result' do
expect(user.tracked_points).to receive(:count).once
2.times { described_class.new(user).call }
end
end
context 'when user points count exceeds the limit' do

View file

@ -39,21 +39,23 @@ RSpec.describe Tracks::TrackBuilder do
]
end
let(:pre_calculated_distance) { 1500 } # 1500 meters
it 'creates a track with correct attributes' do
track = builder.create_track_from_points(points)
track = builder.create_track_from_points(points, pre_calculated_distance)
expect(track).to be_persisted
expect(track.user).to eq(user)
expect(track.start_at).to be_within(1.second).of(Time.zone.at(points.first.timestamp))
expect(track.end_at).to be_within(1.second).of(Time.zone.at(points.last.timestamp))
expect(track.distance).to be > 0
expect(track.distance).to eq(1500)
expect(track.duration).to eq(90.minutes.to_i)
expect(track.avg_speed).to be > 0
expect(track.original_path).to be_present
end
it 'calculates elevation statistics correctly' do
track = builder.create_track_from_points(points)
track = builder.create_track_from_points(points, pre_calculated_distance)
expect(track.elevation_gain).to eq(10) # 110 - 100
expect(track.elevation_loss).to eq(5) # 110 - 105
@ -62,7 +64,7 @@ RSpec.describe Tracks::TrackBuilder do
end
it 'associates points with the track' do
track = builder.create_track_from_points(points)
track = builder.create_track_from_points(points, pre_calculated_distance)
points.each(&:reload)
expect(points.map(&:track)).to all(eq(track))
@ -73,12 +75,12 @@ RSpec.describe Tracks::TrackBuilder do
let(:single_point) { [create(:point, user: user)] }
it 'returns nil for single point' do
result = builder.create_track_from_points(single_point)
result = builder.create_track_from_points(single_point, 1000)
expect(result).to be_nil
end
it 'returns nil for empty array' do
result = builder.create_track_from_points([])
result = builder.create_track_from_points([], 1000)
expect(result).to be_nil
end
end
@ -100,7 +102,7 @@ RSpec.describe Tracks::TrackBuilder do
/Failed to create track for user #{user.id}/
)
result = builder.create_track_from_points(points)
result = builder.create_track_from_points(points, 1000)
expect(result).to be_nil
end
end
@ -120,7 +122,7 @@ RSpec.describe Tracks::TrackBuilder do
).and_call_original
result = builder.build_path(points)
expect(result).to respond_to(:as_text)
expect(result).to be_a(RGeo::Geographic::SphericalLineStringImpl)
end
end
@ -134,7 +136,7 @@ RSpec.describe Tracks::TrackBuilder do
before do
# Mock Point.total_distance to return distance in meters
allow(Point).to receive(:total_distance).and_return(1500) # 1500 meters
allow(Point).to receive(:total_distance).with(points, :m).and_return(1500) # 1500 meters
end
it 'stores distance in meters regardless of user unit preference' do
@ -143,7 +145,7 @@ RSpec.describe Tracks::TrackBuilder do
end
it 'rounds distance to nearest meter' do
allow(Point).to receive(:total_distance).and_return(1500.7)
allow(Point).to receive(:total_distance).with(points, :m).and_return(1500.7)
result = builder.calculate_track_distance(points)
expect(result).to eq(1501) # Rounded to nearest meter
end
@ -312,13 +314,15 @@ RSpec.describe Tracks::TrackBuilder do
]
end
let(:pre_calculated_distance) { 2000 }
it 'creates a complete track end-to-end' do
expect { builder.create_track_from_points(points) }.to change(Track, :count).by(1)
expect { builder.create_track_from_points(points, pre_calculated_distance) }.to change(Track, :count).by(1)
track = Track.last
expect(track.user).to eq(user)
expect(track.points).to match_array(points)
expect(track.distance).to be > 0
expect(track.distance).to eq(2000)
expect(track.duration).to eq(1.hour.to_i)
expect(track.elevation_gain).to eq(20)
end