mirror of
https://github.com/Freika/dawarich.git
synced 2026-01-11 09:41:40 -05:00
Compare commits
49 commits
dd8f15d310
...
6d467ccf51
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d467ccf51 | ||
|
|
abe0129d03 | ||
|
|
9631696231 | ||
|
|
9fb251fa4a | ||
|
|
7920209187 | ||
|
|
94f6dbe18e | ||
|
|
51dd2e0a4b | ||
|
|
bf199de2a0 | ||
|
|
2e46069fcc | ||
|
|
7ea149bd4e | ||
|
|
88e3f53cc5 | ||
|
|
278a4d28b5 | ||
|
|
4239f5b31a | ||
|
|
5a4a5e9625 | ||
|
|
d8033a1e27 | ||
|
|
2ac8fba058 | ||
|
|
4044e77fcd | ||
|
|
25a185b206 | ||
|
|
dfec1afd7e | ||
|
|
04a16029a4 | ||
|
|
bdcfb5eb62 | ||
|
|
9803ccc6a8 | ||
|
|
0c904a6b84 | ||
|
|
abfd3be1c5 | ||
|
|
bd2558ed29 | ||
|
|
685f7eebd2 | ||
|
|
0bfddd932f | ||
|
|
27857ba078 | ||
|
|
7c8a7e7f38 | ||
|
|
962983aa82 | ||
|
|
c22b260e28 | ||
|
|
1158444c0a | ||
|
|
bf9b0d037a | ||
|
|
c14054fdc3 | ||
|
|
cbdef5fa43 | ||
|
|
6e5dd4bed6 | ||
|
|
58ffca74f6 | ||
|
|
18aed4a10c | ||
|
|
da38c12819 | ||
|
|
88909b3e9f | ||
|
|
97d6037448 | ||
|
|
ed350971ee | ||
|
|
c18b09181e | ||
|
|
7c1c42dfc1 | ||
|
|
7afc399724 | ||
|
|
405a455d6d | ||
|
|
4e35cdd305 | ||
|
|
d0aaa3c674 | ||
|
|
90efb5b0bb |
41 changed files with 706 additions and 159 deletions
|
|
@ -1 +1 @@
|
|||
0.30.0
|
||||
0.30.3
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
33
CHANGELOG.md
33
CHANGELOG.md
|
|
@ -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 👇
|
||||
|
|
|
|||
34
Gemfile.lock
34
Gemfile.lock
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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?
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'])
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
71
app/queries/stats/daily_distance_query.rb
Normal file
71
app/queries/stats/daily_distance_query.rb
Normal 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
|
||||
33
app/queries/stats_query.rb
Normal file
33
app/queries/stats_query.rb
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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}",
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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}",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
- imports
|
||||
- exports
|
||||
- stats
|
||||
- trips
|
||||
- tracks
|
||||
- reverse_geocoding
|
||||
- visit_suggesting
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
7
db/schema.rb
generated
|
|
@ -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
|
||||
|
|
|
|||
17
docker/.env.dawarich-common
Normal file
17
docker/.env.dawarich-common
Normal 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
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
130
spec/queries/stats_query_spec.rb
Normal file
130
spec/queries/stats_query_spec.rb
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue