Compare commits

...

50 commits

Author SHA1 Message Date
Patrick C.
234d5c2d54
Merge 0394b31630 into abe0129d03 2025-07-25 13:18:37 +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
Patrick Cernko
0394b31630 fixed created postgresql username
thanks to @sshaikh for reporting
2025-07-14 05:36:57 +02: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
Patrick Cernko
2f34f06591 support and instructions for manual install using systemd services
(tested with Debian 12 only)
2025-05-14 14:37:17 +02:00
45 changed files with 892 additions and 123 deletions

View file

@ -1 +1 @@
0.30.0
0.30.3

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

0
docker/sidekiq-entrypoint.sh Normal file → Executable file
View file

0
docker/web-entrypoint.sh Normal file → Executable file
View file

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

113
systemd/README.md Normal file
View file

@ -0,0 +1,113 @@
# Installing dawarich with systemd
This guide is based on my experience setting up dawarich on Debian 12.
## Prerequisites
### Postgresql
You need a recent version of [Postgresql](https://www.postgresql.org/)
with [PostGIS](https://postgis.net/) support.
In Debian you can install it with
```sh
apt install postgresql-postgis
```
If you do not want to run the database on the same host as the
dawarich service, you need to reconfigure Postgresql to allow
connections from that host.
Dawarich will populate it's database itself and only needs a user
account to do so. This account needs to have superuser capabilities as
the database population includes enabling the postgis extention:
```sh
sudo -u postgres psql <<EOF
CREATE USER dawarich PASSWORD 'UseAStrongPasswordAndKeepItSecret';
ALTER USER dawarich WITH SUPERUSER;
EOF
```
### Redis
Install a recent version of [Redis](https://redis.io/).
In Debian you can install it with
```sh
apt install redis-server
```
If you do not want to run the redis service on the same host as the
dawarich service, you need to reconfigure redis to accept connection
from that host and most likely configure authentication.
### System User account
Create an account that will run the ruby services of dawarich. Of
course, you can choose another directory for it's HOME.
```sh
adduser --system --home /service/dawarich dawarich
```
### Ruby
Dawarich currently uses [Ruby](https://www.ruby-lang.org/) version
3.4.1 (yes, exactly this one). At least on Debian, this version is not
available at all in the package repositories. So I installed Ruby by
compiling from source:
```sh
apt install build-essential pkg-config libpq-dev libffi-dev libyaml-dev zlib1g-dev
# compile & install as unprivileged user
sudo -u dawarich bash
# download
cd ~
mkdir src
cd src
wget https://cache.ruby-lang.org/pub/ruby/3.4/ruby-3.4.1.tar.gz
# unpack
tar -xzf ruby-3.4.1.tar.gz
cd ~/ruby-3.4.1
# build & install
./configure --prefix $HOME/ruby-3.4.1
make all test install
# allow easy replacement of used ruby installation
ln -s ruby-3.4.1 ~dawarich/ruby
exit # sudo -u dawarich bash
```
## Install dawarich
0. Clone the repo to `/service/dawarich/dawarich` and install dependencies.
```sh
# install as unprivileged user
sudo -u dawarich bash
cd ~
git clone https://github.com/Freika/dawarich.git
cd dawarich
# install dependencies
bash systemd/install.sh
exit # sudo -u dawarich bash
```
0. Install, enable and start systemd services
```sh
# install systemd services
install systemd/dawarich.service systemd/dawarich-sidekiq.service /etc/systemd/system
systemctl daemon-reload
systemctl enable --now dawarich.service dawarich-sidekiq.service
systemctl status dawarich.service dawarich-sidekiq.service
```

View file

@ -0,0 +1,14 @@
[Unit]
Description=DaWarIch SideKiq Service
Wants=network-online.target system-postgresql.slice redis-server.service
After=network-online.target system-postgresql.slice redis-server.service
[Service]
Type=simple
User=dawarich
WorkingDirectory=/service/dawarich/dawarich
EnvironmentFile=-/service/dawarich/dawarich/systemd/environment
ExecStart=/service/dawarich/dawarich/docker/sidekiq-entrypoint.sh sidekiq
[Install]
WantedBy=multi-user.target

14
systemd/dawarich.service Normal file
View file

@ -0,0 +1,14 @@
[Unit]
Description=DaWarIch Service
Wants=network-online.target system-postgresql.slice redis-server.service
After=network-online.target system-postgresql.slice redis-server.service
[Service]
Type=simple
User=dawarich
WorkingDirectory=/service/dawarich/dawarich
EnvironmentFile=-/service/dawarich/dawarich/systemd/environment
ExecStart=/service/dawarich/dawarich/docker/web-entrypoint.sh bin/rails server -p 3000 -b ::
[Install]
WantedBy=multi-user.target

34
systemd/environment Normal file
View file

@ -0,0 +1,34 @@
# this file is used by systemd, do not use $VAR as it won't be expanded!
# set PATH and GEM_HOME for locally installed ruby
PATH=/service/dawarich/ruby/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
GEM_HOME=/service/dawarich/ruby/lib/ruby/gems/3.4.0
# from docker/Dockerfile.dev
APP_PATH=/service/dawarich/dawarich
BUNDLE_VERSION=2.5.21
RAILS_LOG_TO_STDOUT=true
RAILS_PORT=3000
RAILS_ENV=development
SELF_HOSTED=true
# from docker/docker-compose.yml
RAILS_ENV=development
REDIS_URL=redis://localhost:6379/0
DATABASE_HOST=localhost
DATABASE_USERNAME=dawarich
DATABASE_PASSWORD=ayophaing8bafohnoeGa
DATABASE_NAME=dawarich
MIN_MINUTES_SPENT_IN_CITY=60
APPLICATION_HOSTS=localhost
TIME_ZONE=Europe/London
APPLICATION_PROTOCOL=http
DISTANCE_UNIT=km
PROMETHEUS_EXPORTER_ENABLED=false
PROMETHEUS_EXPORTER_HOST=0.0.0.0
PROMETHEUS_EXPORTER_PORT=9394
SELF_HOSTED=true
# Local variables:
# mode: sh
# End:

44
systemd/install.sh Normal file
View file

@ -0,0 +1,44 @@
#!/bin/bash
set -euo pipefail
dirname=${0%/*}
if [ "$dirname" != "systemd" ]; then
echo "This installed must be called in the repository root!" >&2
exit 1
fi
# make shellcheck happy (vars are defined in while loop below)
BUNDLE_VERSION=''
GEM_HOME=''
# "source" "$dirname"/environment and EXPORT all vars
# export all vars from env
envfile="$dirname"/environment
while IFS='#' read -r line; do
if [[ "$line" =~ ^([A-Z0-9_]+)=\"?(.*)\"?$ ]]; then
k=${BASH_REMATCH[1]}
v=${BASH_REMATCH[2]}
export "$k"="$v"
fi
done < "$envfile"
if [ "$APP_PATH" != "$PWD" ]; then
echo "Error: APP_PATH (defined in $envfile) != $PWD!" >&2
exit 1
fi
set -x
# from docker/Dockerfile.dev
# Update gem system and install bundler
gem update --system 3.6.2
gem install bundler --version "$BUNDLE_VERSION"
rm -rf "$GEM_HOME"/cache/*
# Install all gems into the image
bundle config set --local path 'vendor/bundle'
bundle install --jobs 4 --retry 3
rm -rf vendor/bundle/ruby/3.4.1/cache/*.gem
exit 0