mirror of
https://github.com/Freika/dawarich.git
synced 2026-01-11 09:41:40 -05:00
Compare commits
97 commits
709238a0ca
...
4c0365d2c0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c0365d2c0 | ||
|
|
abfd3be1c5 | ||
|
|
bd2558ed29 | ||
|
|
685f7eebd2 | ||
|
|
0bfddd932f | ||
|
|
27857ba078 | ||
|
|
7c8a7e7f38 | ||
|
|
962983aa82 | ||
|
|
c22b260e28 | ||
|
|
1158444c0a | ||
|
|
bf9b0d037a | ||
|
|
c14054fdc3 | ||
|
|
cbdef5fa43 | ||
|
|
6e5dd4bed6 | ||
|
|
58ffca74f6 | ||
|
|
18aed4a10c | ||
|
|
da38c12819 | ||
|
|
88909b3e9f | ||
|
|
97d6037448 | ||
|
|
ed350971ee | ||
|
|
c18b09181e | ||
|
|
7c1c42dfc1 | ||
|
|
7afc399724 | ||
|
|
3f22162cf0 | ||
|
|
2206622726 | ||
|
|
9bcd522e25 | ||
|
|
6a6c3c938f | ||
|
|
59a4d760bf | ||
|
|
fbdf630502 | ||
|
|
c74ba7d1fe | ||
|
|
6ec24ffc3d | ||
|
|
b7aa05f4ea | ||
|
|
8b03b0c7f5 | ||
|
|
f969d5d3e6 | ||
|
|
708bca26eb | ||
|
|
45713f46dc | ||
|
|
3149767675 | ||
|
|
f5c399a8cc | ||
|
|
002b3bd635 | ||
|
|
5ad660ccd4 | ||
|
|
9d616c7957 | ||
|
|
7cdb7d2f21 | ||
|
|
dc8460a948 | ||
|
|
91f4cf7c7a | ||
|
|
f5ef2ab9ef | ||
|
|
1f5325d9bb | ||
|
|
10777714b1 | ||
|
|
eca09ce3eb | ||
|
|
c31d09e5c3 | ||
|
|
54aaf03453 | ||
|
|
699504f4e9 | ||
|
|
878d863569 | ||
|
|
24378b150d | ||
|
|
d2e2e50298 | ||
|
|
7885374993 | ||
|
|
244fb2b192 | ||
|
|
418df71c53 | ||
|
|
2425b2423a | ||
|
|
43bc8c444c | ||
|
|
6b96e1f0be | ||
|
|
0dff80e12b | ||
|
|
58a7972976 | ||
|
|
cf50541be1 | ||
|
|
bc36882e73 | ||
|
|
e9eeb6aae2 | ||
|
|
bfeb936638 | ||
|
|
ee6666e7bf | ||
|
|
ceef7702fa | ||
|
|
13fd9da1f9 | ||
|
|
9a326733c7 | ||
|
|
0295d3f2a0 | ||
|
|
b7e5296235 | ||
|
|
f4687a101c | ||
|
|
042696caeb | ||
|
|
b3e8155e43 | ||
|
|
f4605989b6 | ||
|
|
6dd048cee3 | ||
|
|
f1720b859b | ||
|
|
81eb759fb8 | ||
|
|
e64e706b0f | ||
|
|
a66f41d9fb | ||
|
|
f33dcdfe21 | ||
|
|
0d657b9d6e | ||
|
|
92a15c8ad3 | ||
|
|
4e35cdd305 | ||
|
|
d0aaa3c674 | ||
|
|
90efb5b0bb | ||
|
|
7619feff69 | ||
|
|
15be46b604 | ||
|
|
1468f1f9dc | ||
|
|
565f92c463 | ||
|
|
7bd098b54f | ||
|
|
862f601e1d | ||
|
|
fd4b785a19 | ||
|
|
3b474704ea | ||
|
|
12a53aac20 | ||
|
|
03df481032 |
151 changed files with 6068 additions and 757 deletions
|
|
@ -1 +1 @@
|
|||
0.29.1
|
||||
0.30.1
|
||||
|
|
|
|||
7
.gitignore
vendored
7
.gitignore
vendored
|
|
@ -76,3 +76,10 @@ Makefile
|
|||
/db/*.sqlite3
|
||||
/db/*.sqlite3-shm
|
||||
/db/*.sqlite3-wal
|
||||
|
||||
# Playwright
|
||||
node_modules/
|
||||
/test-results/
|
||||
/playwright-report/
|
||||
/blob-report/
|
||||
/playwright/.cache/
|
||||
|
|
|
|||
84
CHANGELOG.md
84
CHANGELOG.md
|
|
@ -4,6 +4,90 @@ 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.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 0.29.2 RC, please run the following commands in the console, otherwise read on. ⚠️
|
||||
|
||||
```ruby
|
||||
# This will delete all tracks 👇
|
||||
Track.delete_all
|
||||
|
||||
# This will remove all tracks relations from points 👇
|
||||
Point.update_all(track_id: nil)
|
||||
|
||||
# This will create tracks for all users 👇
|
||||
User.find_each do |user|
|
||||
Tracks::CreateJob.perform_later(user.id, start_at: nil, end_at: nil, mode: :bulk)
|
||||
end
|
||||
```
|
||||
|
||||
## Added
|
||||
|
||||
- In the User Settings -> Background Jobs, you can now disable visits suggestions, which is enabled by default. It's a background task that runs every day around midnight. Disabling it might be useful if you don't want to receive visits suggestions or if you're using the Dawarich iOS app, which has its own visits suggestions.
|
||||
- Tracks are now being calculated and stored in the database instead of being calculated on the fly in the browser. This will make the map page load faster.
|
||||
|
||||
## Changed
|
||||
|
||||
- Don't check for new version in production.
|
||||
- Area popup styles are now more consistent.
|
||||
- Notification about Photon API load is now disabled.
|
||||
- All distance values are now stored in the database in meters. Conversion to user's preferred unit is done on the fly.
|
||||
- Every night, Dawarich will try to fetch names for places and visits that don't have them. #1281 #902 #583 #212
|
||||
- ⚠️ User settings are now being serialized in a more consistent way ⚠. `GET /api/v1/users/me` now returns the following data structure:
|
||||
```json
|
||||
{
|
||||
"user": {
|
||||
"email": "test@example.com",
|
||||
"theme": "light",
|
||||
"created_at": "2025-01-01T00:00:00Z",
|
||||
"updated_at": "2025-01-01T00:00:00Z",
|
||||
"settings": {
|
||||
"maps": {
|
||||
"url": "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
|
||||
"name": "Custom OpenStreetMap",
|
||||
"distance_unit": "km"
|
||||
},
|
||||
"fog_of_war_meters": 51,
|
||||
"meters_between_routes": 500,
|
||||
"preferred_map_layer": "Light",
|
||||
"speed_colored_routes": false,
|
||||
"points_rendering_mode": "raw",
|
||||
"minutes_between_routes": 30,
|
||||
"time_threshold_minutes": 30,
|
||||
"merge_threshold_minutes": 15,
|
||||
"live_map_enabled": false,
|
||||
"route_opacity": 0.3,
|
||||
"immich_url": "https://persistence-test-1752264458724.com",
|
||||
"photoprism_url": "",
|
||||
"visits_suggestions_enabled": true,
|
||||
"speed_color_scale": "0:#00ff00|15:#00ffff|30:#ff00ff|50:#ffff00|100:#ff3300",
|
||||
"fog_of_war_threshold": 5
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
- Links in emails will be based on the `DOMAIN` environment variable instead of `SMTP_DOMAIN`.
|
||||
|
||||
## Fixed
|
||||
|
||||
- Swagger documentation is now valid again.
|
||||
- Invalid owntracks points are now ignored.
|
||||
- An older Owntrack's .rec format is now also supported.
|
||||
- Course and course accuracy are now rounded to 8 decimal places to fix the issue with points creation.
|
||||
|
||||
# [0.29.1] - 2025-07-02
|
||||
|
||||
## Fixed
|
||||
|
|
|
|||
36
Gemfile.lock
36
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)
|
||||
|
|
@ -203,7 +203,7 @@ GEM
|
|||
json (2.12.0)
|
||||
json-schema (5.0.1)
|
||||
addressable (~> 2.8)
|
||||
jwt (2.10.1)
|
||||
jwt (3.1.2)
|
||||
base64
|
||||
kaminari (1.2.2)
|
||||
activesupport (>= 4.1.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)
|
||||
|
|
|
|||
3
Procfile.production
Normal file
3
Procfile.production
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
web: bundle exec puma -C config/puma.rb
|
||||
worker: bundle exec sidekiq -C config/sidekiq.yml
|
||||
prometheus_exporter: bundle exec prometheus_exporter -b ANY
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -1,3 +1,4 @@
|
|||
//= link rails-ujs.js
|
||||
//= link_tree ../images
|
||||
//= link_directory ../stylesheets .css
|
||||
//= link_tree ../builds
|
||||
|
|
|
|||
7
app/channels/tracks_channel.rb
Normal file
7
app/channels/tracks_channel.rb
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class TracksChannel < ApplicationCable::Channel
|
||||
def subscribed
|
||||
stream_for current_user
|
||||
end
|
||||
end
|
||||
|
|
@ -5,7 +5,7 @@ class Api::V1::SettingsController < ApiController
|
|||
|
||||
def index
|
||||
render json: {
|
||||
settings: current_api_user.settings,
|
||||
settings: current_api_user.safe_settings,
|
||||
status: 'success'
|
||||
}, status: :ok
|
||||
end
|
||||
|
|
|
|||
|
|
@ -2,6 +2,6 @@
|
|||
|
||||
class Api::V1::UsersController < ApiController
|
||||
def me
|
||||
render json: { user: current_api_user }
|
||||
render json: Api::UserSerializer.new(current_api_user).call
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -4,20 +4,67 @@ class MapController < ApplicationController
|
|||
before_action :authenticate_user!
|
||||
|
||||
def index
|
||||
@points = points.where('timestamp >= ? AND timestamp <= ?', start_at, end_at)
|
||||
|
||||
@coordinates =
|
||||
@points.pluck(:lonlat, :battery, :altitude, :timestamp, :velocity, :id, :country)
|
||||
.map { |lonlat, *rest| [lonlat.y, lonlat.x, *rest.map(&:to_s)] }
|
||||
@distance = distance
|
||||
@start_at = Time.zone.at(start_at)
|
||||
@end_at = Time.zone.at(end_at)
|
||||
@years = (@start_at.year..@end_at.year).to_a
|
||||
@points_number = @coordinates.count
|
||||
@points = filtered_points
|
||||
@coordinates = build_coordinates
|
||||
@tracks = build_tracks
|
||||
@distance = calculate_distance
|
||||
@start_at = parsed_start_at
|
||||
@end_at = parsed_end_at
|
||||
@years = years_range
|
||||
@points_number = points_count
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def filtered_points
|
||||
points.where('timestamp >= ? AND timestamp <= ?', start_at, end_at)
|
||||
end
|
||||
|
||||
def build_coordinates
|
||||
@points.pluck(:lonlat, :battery, :altitude, :timestamp, :velocity, :id, :country, :track_id)
|
||||
.map { |lonlat, *rest| [lonlat.y, lonlat.x, *rest.map(&:to_s)] }
|
||||
end
|
||||
|
||||
def extract_track_ids
|
||||
@coordinates.map { |coord| coord[8]&.to_i }.compact.uniq.reject(&:zero?)
|
||||
end
|
||||
|
||||
def build_tracks
|
||||
track_ids = extract_track_ids
|
||||
|
||||
TracksSerializer.new(current_user, track_ids).call
|
||||
end
|
||||
|
||||
def calculate_distance
|
||||
total_distance = 0
|
||||
|
||||
@coordinates.each_cons(2) do
|
||||
distance_km = Geocoder::Calculations.distance_between(
|
||||
[_1[0], _1[1]], [_2[0], _2[1]], units: :km
|
||||
)
|
||||
|
||||
total_distance += distance_km
|
||||
end
|
||||
|
||||
total_distance.round
|
||||
end
|
||||
|
||||
def parsed_start_at
|
||||
Time.zone.at(start_at)
|
||||
end
|
||||
|
||||
def parsed_end_at
|
||||
Time.zone.at(end_at)
|
||||
end
|
||||
|
||||
def years_range
|
||||
(parsed_start_at.year..parsed_end_at.year).to_a
|
||||
end
|
||||
|
||||
def points_count
|
||||
@coordinates.count
|
||||
end
|
||||
|
||||
def start_at
|
||||
return Time.zone.parse(params[:start_at]).to_i if params[:start_at].present?
|
||||
return Time.zone.at(points.last.timestamp).beginning_of_day.to_i if points.any?
|
||||
|
|
@ -32,18 +79,6 @@ class MapController < ApplicationController
|
|||
Time.zone.today.end_of_day.to_i
|
||||
end
|
||||
|
||||
def distance
|
||||
@distance ||= 0
|
||||
|
||||
@coordinates.each_cons(2) do
|
||||
@distance += Geocoder::Calculations.distance_between(
|
||||
[_1[0], _1[1]], [_2[0], _2[1]], units: current_user.safe_settings.distance_unit.to_sym
|
||||
)
|
||||
end
|
||||
|
||||
@distance.round(1)
|
||||
end
|
||||
|
||||
def points
|
||||
params[:import_id] ? points_from_import : points_from_user
|
||||
end
|
||||
|
|
|
|||
|
|
@ -3,10 +3,13 @@
|
|||
class SettingsController < ApplicationController
|
||||
before_action :authenticate_user!
|
||||
before_action :authenticate_active_user!, only: %i[update]
|
||||
|
||||
def index; end
|
||||
|
||||
def update
|
||||
current_user.update(settings: settings_params)
|
||||
existing_settings = current_user.safe_settings.settings
|
||||
|
||||
current_user.update(settings: existing_settings.merge(settings_params))
|
||||
|
||||
flash.now[:notice] = 'Settings updated'
|
||||
|
||||
|
|
@ -31,7 +34,8 @@ class SettingsController < ApplicationController
|
|||
params.require(:settings).permit(
|
||||
:meters_between_routes, :minutes_between_routes, :fog_of_war_meters,
|
||||
:time_threshold_minutes, :merge_threshold_minutes, :route_opacity,
|
||||
:immich_url, :immich_api_key, :photoprism_url, :photoprism_api_key
|
||||
:immich_url, :immich_api_key, :photoprism_url, :photoprism_api_key,
|
||||
:visits_suggestions_enabled
|
||||
)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -5,10 +5,9 @@ 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
|
||||
|
|
@ -43,4 +42,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
|
||||
|
|
|
|||
|
|
@ -76,8 +76,9 @@ module ApplicationHelper
|
|||
end
|
||||
|
||||
def year_distance_stat(year, user)
|
||||
# In km or miles, depending on the user.safe_settings.distance_unit
|
||||
Stat.year_distance(year, user).sum { _1[1] }
|
||||
# Distance is now stored in meters, convert to user's preferred unit for display
|
||||
total_distance_meters = Stat.year_distance(year, user).sum { _1[1] }
|
||||
Stat.convert_distance(total_distance_meters, user.safe_settings.distance_unit)
|
||||
end
|
||||
|
||||
def past?(year, month)
|
||||
|
|
@ -98,21 +99,6 @@ module ApplicationHelper
|
|||
current_user&.theme == 'light' ? 'light' : 'dark'
|
||||
end
|
||||
|
||||
def sidebar_distance(distance)
|
||||
return unless distance
|
||||
|
||||
"#{distance} #{current_user.safe_settings.distance_unit}"
|
||||
end
|
||||
|
||||
def sidebar_points(points)
|
||||
return unless points
|
||||
|
||||
points_number = points.size
|
||||
points_pluralized = pluralize(points_number, 'point')
|
||||
|
||||
"(#{points_pluralized})"
|
||||
end
|
||||
|
||||
def active_class?(link_path)
|
||||
'btn-active' if current_page?(link_path)
|
||||
end
|
||||
|
|
|
|||
|
|
@ -12,3 +12,6 @@ import "./channels"
|
|||
|
||||
import "trix"
|
||||
import "@rails/actiontext"
|
||||
|
||||
import "@rails/ujs"
|
||||
Rails.start()
|
||||
|
|
|
|||
|
|
@ -11,9 +11,23 @@ import {
|
|||
updatePolylinesColors,
|
||||
colorFormatEncode,
|
||||
colorFormatDecode,
|
||||
colorStopsFallback
|
||||
colorStopsFallback,
|
||||
reestablishPolylineEventHandlers,
|
||||
managePaneVisibility
|
||||
} from "../maps/polylines";
|
||||
|
||||
import {
|
||||
createTracksLayer,
|
||||
updateTracksOpacity,
|
||||
toggleTracksVisibility,
|
||||
filterTracks,
|
||||
trackColorPalette,
|
||||
handleIncrementalTrackUpdate,
|
||||
addOrUpdateTrack,
|
||||
removeTrackById,
|
||||
isTrackInTimeRange
|
||||
} from "../maps/tracks";
|
||||
|
||||
import { fetchAndDrawAreas, handleAreaCreated } from "../maps/areas";
|
||||
|
||||
import { showFlashMessage, fetchAndDisplayPhotos } from "../maps/helpers";
|
||||
|
|
@ -34,6 +48,9 @@ export default class extends BaseController {
|
|||
visitedCitiesCache = new Map();
|
||||
trackedMonthsCache = null;
|
||||
currentPopup = null;
|
||||
tracksLayer = null;
|
||||
tracksVisible = false;
|
||||
tracksSubscription = null;
|
||||
|
||||
connect() {
|
||||
super.connect();
|
||||
|
|
@ -41,9 +58,33 @@ export default class extends BaseController {
|
|||
|
||||
this.apiKey = this.element.dataset.api_key;
|
||||
this.selfHosted = this.element.dataset.self_hosted;
|
||||
this.markers = JSON.parse(this.element.dataset.coordinates);
|
||||
|
||||
// Defensive JSON parsing with error handling
|
||||
try {
|
||||
this.markers = this.element.dataset.coordinates ? JSON.parse(this.element.dataset.coordinates) : [];
|
||||
} catch (error) {
|
||||
console.error('Error parsing coordinates data:', error);
|
||||
console.error('Raw coordinates data:', this.element.dataset.coordinates);
|
||||
this.markers = [];
|
||||
}
|
||||
|
||||
try {
|
||||
this.tracksData = this.element.dataset.tracks ? JSON.parse(this.element.dataset.tracks) : null;
|
||||
} catch (error) {
|
||||
console.error('Error parsing tracks data:', error);
|
||||
console.error('Raw tracks data:', this.element.dataset.tracks);
|
||||
this.tracksData = null;
|
||||
}
|
||||
|
||||
this.timezone = this.element.dataset.timezone;
|
||||
this.userSettings = JSON.parse(this.element.dataset.user_settings);
|
||||
|
||||
try {
|
||||
this.userSettings = this.element.dataset.user_settings ? JSON.parse(this.element.dataset.user_settings) : {};
|
||||
} catch (error) {
|
||||
console.error('Error parsing user_settings data:', error);
|
||||
console.error('Raw user_settings data:', this.element.dataset.user_settings);
|
||||
this.userSettings = {};
|
||||
}
|
||||
this.clearFogRadius = parseInt(this.userSettings.fog_of_war_meters) || 50;
|
||||
this.fogLinethreshold = parseInt(this.userSettings.fog_of_war_threshold) || 90;
|
||||
// Store route opacity as decimal (0-1) internally
|
||||
|
|
@ -55,7 +96,14 @@ export default class extends BaseController {
|
|||
this.speedColoredPolylines = this.userSettings.speed_colored_routes || false;
|
||||
this.speedColorScale = this.userSettings.speed_color_scale || colorFormatEncode(colorStopsFallback);
|
||||
|
||||
this.center = this.markers[this.markers.length - 1] || [52.514568, 13.350111];
|
||||
// Ensure we have valid markers array
|
||||
if (!Array.isArray(this.markers)) {
|
||||
console.warn('Markers is not an array, setting to empty array');
|
||||
this.markers = [];
|
||||
}
|
||||
|
||||
// Set default center (Berlin) if no markers available
|
||||
this.center = this.markers.length > 0 ? this.markers[this.markers.length - 1] : [52.514568, 13.350111];
|
||||
|
||||
this.map = L.map(this.containerTarget).setView([this.center[0], this.center[1]], 14);
|
||||
|
||||
|
|
@ -74,9 +122,15 @@ export default class extends BaseController {
|
|||
},
|
||||
onAdd: (map) => {
|
||||
const div = L.DomUtil.create('div', 'leaflet-control-stats');
|
||||
const distance = this.element.dataset.distance || '0';
|
||||
let distance = parseInt(this.element.dataset.distance) || 0;
|
||||
const pointsNumber = this.element.dataset.points_number || '0';
|
||||
const unit = this.distanceUnit === 'mi' ? 'mi' : 'km';
|
||||
|
||||
// Convert distance to miles if user prefers miles (assuming backend sends km)
|
||||
if (this.distanceUnit === 'mi') {
|
||||
distance = distance * 0.621371; // km to miles conversion
|
||||
}
|
||||
|
||||
const unit = this.distanceUnit === 'km' ? 'km' : 'mi';
|
||||
div.innerHTML = `${distance} ${unit} | ${pointsNumber} points`;
|
||||
div.style.backgroundColor = 'white';
|
||||
div.style.padding = '0 5px';
|
||||
|
|
@ -102,6 +156,9 @@ export default class extends BaseController {
|
|||
this.polylinesLayer = createPolylinesLayer(this.markers, this.map, this.timezone, this.routeOpacity, this.userSettings, this.distanceUnit);
|
||||
this.heatmapLayer = L.heatLayer(this.heatmapMarkers, { radius: 20 }).addTo(this.map);
|
||||
|
||||
// Initialize empty tracks layer for layer control (will be populated later)
|
||||
this.tracksLayer = L.layerGroup();
|
||||
|
||||
// Create a proper Leaflet layer for fog
|
||||
this.fogOverlay = createFogOverlay();
|
||||
|
||||
|
|
@ -142,6 +199,7 @@ export default class extends BaseController {
|
|||
const controlsLayer = {
|
||||
Points: this.markersLayer,
|
||||
Routes: this.polylinesLayer,
|
||||
Tracks: this.tracksLayer,
|
||||
Heatmap: this.heatmapLayer,
|
||||
"Fog of War": new this.fogOverlay(),
|
||||
"Scratch map": this.scratchLayer,
|
||||
|
|
@ -151,158 +209,57 @@ export default class extends BaseController {
|
|||
"Confirmed Visits": this.visitsManager.getConfirmedVisitCirclesLayer()
|
||||
};
|
||||
|
||||
// Initialize layer control first
|
||||
this.layerControl = L.control.layers(this.baseMaps(), controlsLayer).addTo(this.map);
|
||||
|
||||
// Add the toggle panel button
|
||||
this.addTogglePanelButton();
|
||||
// Initialize tile monitor
|
||||
this.tileMonitor = new TileMonitor(this.map, this.apiKey);
|
||||
|
||||
// Check if we should open the panel based on localStorage or URL params
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const isPanelOpen = localStorage.getItem('mapPanelOpen') === 'true';
|
||||
const hasDateParams = urlParams.has('start_at') && urlParams.has('end_at');
|
||||
|
||||
// Always create the panel first
|
||||
this.toggleRightPanel();
|
||||
|
||||
// Then hide it if it shouldn't be open
|
||||
if (!isPanelOpen && !hasDateParams) {
|
||||
const panel = document.querySelector('.leaflet-right-panel');
|
||||
if (panel) {
|
||||
panel.style.display = 'none';
|
||||
localStorage.setItem('mapPanelOpen', 'false');
|
||||
}
|
||||
}
|
||||
|
||||
// Update event handlers
|
||||
this.map.on('moveend', () => {
|
||||
if (document.getElementById('fog')) {
|
||||
this.updateFog(this.markers, this.clearFogRadius, this.fogLinethreshold);
|
||||
}
|
||||
});
|
||||
|
||||
this.map.on('zoomend', () => {
|
||||
if (document.getElementById('fog')) {
|
||||
this.updateFog(this.markers, this.clearFogRadius, this.fogLinethreshold);
|
||||
}
|
||||
});
|
||||
|
||||
// Fetch and draw areas when the map is loaded
|
||||
fetchAndDrawAreas(this.areasLayer, this.apiKey);
|
||||
|
||||
let fogEnabled = false;
|
||||
|
||||
// Hide fog by default
|
||||
document.getElementById('fog').style.display = 'none';
|
||||
|
||||
// Toggle fog layer visibility
|
||||
this.map.on('overlayadd', (e) => {
|
||||
if (e.name === 'Fog of War') {
|
||||
fogEnabled = true;
|
||||
document.getElementById('fog').style.display = 'block';
|
||||
this.updateFog(this.markers, this.clearFogRadius, this.fogLinethreshold);
|
||||
}
|
||||
});
|
||||
|
||||
this.map.on('overlayremove', (e) => {
|
||||
if (e.name === 'Fog of War') {
|
||||
fogEnabled = false;
|
||||
document.getElementById('fog').style.display = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
// Update fog circles on zoom and move
|
||||
this.map.on('zoomend moveend', () => {
|
||||
if (fogEnabled) {
|
||||
this.updateFog(this.markers, this.clearFogRadius, this.fogLinethreshold);
|
||||
}
|
||||
});
|
||||
|
||||
this.addLastMarker(this.map, this.markers);
|
||||
this.addEventListeners();
|
||||
this.setupSubscription();
|
||||
this.setupTracksSubscription();
|
||||
|
||||
// Initialize Leaflet.draw
|
||||
// Handle routes/tracks mode selection
|
||||
// this.addRoutesTracksSelector(); # Temporarily disabled
|
||||
this.switchRouteMode('routes', true);
|
||||
|
||||
// Initialize layers based on settings
|
||||
this.initializeLayersFromSettings();
|
||||
|
||||
// Initialize tracks layer
|
||||
this.initializeTracksLayer();
|
||||
|
||||
// Setup draw control
|
||||
this.initializeDrawControl();
|
||||
|
||||
// Add event listeners to toggle draw controls
|
||||
this.map.on('overlayadd', async (e) => {
|
||||
if (e.name === 'Areas') {
|
||||
this.map.addControl(this.drawControl);
|
||||
}
|
||||
if (e.name === 'Photos') {
|
||||
if (
|
||||
(!this.userSettings.immich_url || !this.userSettings.immich_api_key) &&
|
||||
(!this.userSettings.photoprism_url || !this.userSettings.photoprism_api_key)
|
||||
) {
|
||||
showFlashMessage(
|
||||
'error',
|
||||
'Photos integration is not configured. Please check your integrations settings.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Preload areas
|
||||
fetchAndDrawAreas(this.areasLayer, this.apiKey);
|
||||
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const startDate = urlParams.get('start_at') || new Date().toISOString();
|
||||
const endDate = urlParams.get('end_at')|| new Date().toISOString();
|
||||
await fetchAndDisplayPhotos({
|
||||
map: this.map,
|
||||
photoMarkers: this.photoMarkers,
|
||||
apiKey: this.apiKey,
|
||||
startDate: startDate,
|
||||
endDate: endDate,
|
||||
userSettings: this.userSettings
|
||||
});
|
||||
}
|
||||
});
|
||||
// Add right panel toggle
|
||||
this.addTogglePanelButton();
|
||||
|
||||
this.map.on('overlayremove', (e) => {
|
||||
if (e.name === 'Areas') {
|
||||
this.map.removeControl(this.drawControl);
|
||||
}
|
||||
});
|
||||
|
||||
if (this.liveMapEnabled) {
|
||||
this.setupSubscription();
|
||||
}
|
||||
|
||||
// Initialize tile monitor
|
||||
this.tileMonitor = new TileMonitor(this.apiKey);
|
||||
|
||||
// Add tile load event handlers to each base layer
|
||||
Object.entries(this.baseMaps()).forEach(([name, layer]) => {
|
||||
layer.on('tileload', () => {
|
||||
this.tileMonitor.recordTileLoad(name);
|
||||
});
|
||||
});
|
||||
|
||||
// Start monitoring
|
||||
this.tileMonitor.startMonitoring();
|
||||
|
||||
// Add the drawer button for visits
|
||||
// Add visits buttons after calendar button to position them below
|
||||
this.visitsManager.addDrawerButton();
|
||||
|
||||
// Fetch and display visits when map loads
|
||||
this.visitsManager.fetchAndDisplayVisits();
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
if (this.handleDeleteClick) {
|
||||
document.removeEventListener('click', this.handleDeleteClick);
|
||||
super.disconnect();
|
||||
this.removeEventListeners();
|
||||
if (this.tracksSubscription) {
|
||||
this.tracksSubscription.unsubscribe();
|
||||
}
|
||||
// Store panel state before disconnecting
|
||||
if (this.rightPanel) {
|
||||
const panel = document.querySelector('.leaflet-right-panel');
|
||||
const finalState = panel ? (panel.style.display !== 'none' ? 'true' : 'false') : 'false';
|
||||
localStorage.setItem('mapPanelOpen', finalState);
|
||||
if (this.tileMonitor) {
|
||||
this.tileMonitor.destroy();
|
||||
}
|
||||
if (this.visitsManager) {
|
||||
this.visitsManager.destroy();
|
||||
}
|
||||
if (this.layerControl) {
|
||||
this.map.removeControl(this.layerControl);
|
||||
}
|
||||
if (this.map) {
|
||||
this.map.remove();
|
||||
}
|
||||
|
||||
// Stop tile monitoring
|
||||
if (this.tileMonitor) {
|
||||
this.tileMonitor.stopMonitoring();
|
||||
}
|
||||
console.log("Map controller disconnected");
|
||||
}
|
||||
|
||||
setupSubscription() {
|
||||
|
|
@ -318,6 +275,42 @@ export default class extends BaseController {
|
|||
});
|
||||
}
|
||||
|
||||
setupTracksSubscription() {
|
||||
this.tracksSubscription = consumer.subscriptions.create("TracksChannel", {
|
||||
received: (data) => {
|
||||
console.log("Received track update:", data);
|
||||
if (this.map && this.map._loaded && this.tracksLayer) {
|
||||
this.handleTrackUpdate(data);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
handleTrackUpdate(data) {
|
||||
// Get current time range for filtering
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const currentStartAt = urlParams.get('start_at') || new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString();
|
||||
const currentEndAt = urlParams.get('end_at') || new Date().toISOString();
|
||||
|
||||
// Handle the track update
|
||||
handleIncrementalTrackUpdate(
|
||||
this.tracksLayer,
|
||||
data,
|
||||
this.map,
|
||||
this.userSettings,
|
||||
this.distanceUnit,
|
||||
currentStartAt,
|
||||
currentEndAt
|
||||
);
|
||||
|
||||
// If tracks are visible, make sure the layer is properly displayed
|
||||
if (this.tracksVisible && this.tracksLayer) {
|
||||
if (!this.map.hasLayer(this.tracksLayer)) {
|
||||
this.map.addLayer(this.tracksLayer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
appendPoint(data) {
|
||||
// Parse the received point data
|
||||
const newPoint = data;
|
||||
|
|
@ -505,6 +498,33 @@ export default class extends BaseController {
|
|||
const selectedLayerName = event.name;
|
||||
this.updatePreferredBaseLayer(selectedLayerName);
|
||||
});
|
||||
|
||||
// Add event listeners for overlay layer changes to keep routes/tracks selector in sync
|
||||
this.map.on('overlayadd', (event) => {
|
||||
if (event.name === 'Routes') {
|
||||
this.handleRouteLayerToggle('routes');
|
||||
// Re-establish event handlers when routes are manually added
|
||||
if (event.layer === this.polylinesLayer) {
|
||||
reestablishPolylineEventHandlers(this.polylinesLayer, this.map, this.userSettings, this.distanceUnit);
|
||||
}
|
||||
} else if (event.name === 'Tracks') {
|
||||
this.handleRouteLayerToggle('tracks');
|
||||
}
|
||||
|
||||
// Manage pane visibility when layers are manually toggled
|
||||
this.updatePaneVisibilityAfterLayerChange();
|
||||
});
|
||||
|
||||
this.map.on('overlayremove', (event) => {
|
||||
if (event.name === 'Routes' || event.name === 'Tracks') {
|
||||
// Don't auto-switch when layers are manually turned off
|
||||
// Just update the radio button state to reflect current visibility
|
||||
this.updateRadioButtonState();
|
||||
|
||||
// Manage pane visibility when layers are manually toggled
|
||||
this.updatePaneVisibilityAfterLayerChange();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
updatePreferredBaseLayer(selectedLayerName) {
|
||||
|
|
@ -726,7 +746,7 @@ export default class extends BaseController {
|
|||
|
||||
// Form HTML
|
||||
div.innerHTML = `
|
||||
<form id="settings-form" style="overflow-y: auto; height: 36rem; width: 12rem;">
|
||||
<form id="settings-form" style="overflow-y: auto; max-height: 70vh; width: 12rem; padding-right: 5px;">
|
||||
<label for="route-opacity">Route Opacity, %</label>
|
||||
<div class="join">
|
||||
<input type="number" class="input input-ghost join-item focus:input-ghost input-xs input-bordered w-full max-w-xs" id="route-opacity" name="route_opacity" min="10" max="100" step="10" value="${Math.round(this.routeOpacity * 100)}">
|
||||
|
|
@ -953,6 +973,7 @@ export default class extends BaseController {
|
|||
const layerStates = {
|
||||
Points: this.map.hasLayer(this.markersLayer),
|
||||
Routes: this.map.hasLayer(this.polylinesLayer),
|
||||
Tracks: this.tracksLayer ? this.map.hasLayer(this.tracksLayer) : false,
|
||||
Heatmap: this.map.hasLayer(this.heatmapLayer),
|
||||
"Fog of War": this.map.hasLayer(this.fogOverlay),
|
||||
"Scratch map": this.map.hasLayer(this.scratchLayer),
|
||||
|
|
@ -969,6 +990,7 @@ export default class extends BaseController {
|
|||
const controlsLayer = {
|
||||
Points: this.markersLayer || L.layerGroup(),
|
||||
Routes: this.polylinesLayer || L.layerGroup(),
|
||||
Tracks: this.tracksLayer || L.layerGroup(),
|
||||
Heatmap: this.heatmapLayer || L.heatLayer([]),
|
||||
"Fog of War": new this.fogOverlay(),
|
||||
"Scratch map": this.scratchLayer || L.layerGroup(),
|
||||
|
|
@ -984,11 +1006,27 @@ export default class extends BaseController {
|
|||
const layer = controlsLayer[name];
|
||||
if (wasVisible && layer) {
|
||||
layer.addTo(this.map);
|
||||
// Re-establish event handlers for polylines layer when it's re-added
|
||||
if (name === 'Routes' && layer === this.polylinesLayer) {
|
||||
reestablishPolylineEventHandlers(this.polylinesLayer, this.map, this.userSettings, this.distanceUnit);
|
||||
}
|
||||
} else if (layer && this.map.hasLayer(layer)) {
|
||||
this.map.removeLayer(layer);
|
||||
}
|
||||
});
|
||||
|
||||
// Manage pane visibility based on which layers are visible
|
||||
const routesVisible = this.map.hasLayer(this.polylinesLayer);
|
||||
const tracksVisible = this.tracksLayer && this.map.hasLayer(this.tracksLayer);
|
||||
|
||||
if (routesVisible && !tracksVisible) {
|
||||
managePaneVisibility(this.map, 'routes');
|
||||
} else if (tracksVisible && !routesVisible) {
|
||||
managePaneVisibility(this.map, 'tracks');
|
||||
} else {
|
||||
managePaneVisibility(this.map, 'both');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error updating map settings:', error);
|
||||
console.error(error.stack);
|
||||
|
|
@ -1082,6 +1120,189 @@ export default class extends BaseController {
|
|||
this.map.addControl(new TogglePanelControl({ position: 'topright' }));
|
||||
}
|
||||
|
||||
addRoutesTracksSelector() {
|
||||
// Store reference to the controller instance for use in the control
|
||||
const controller = this;
|
||||
|
||||
const RouteTracksControl = L.Control.extend({
|
||||
onAdd: function(map) {
|
||||
const container = L.DomUtil.create('div', 'routes-tracks-selector leaflet-bar');
|
||||
container.style.backgroundColor = 'white';
|
||||
container.style.padding = '8px';
|
||||
container.style.borderRadius = '4px';
|
||||
container.style.boxShadow = '0 1px 4px rgba(0,0,0,0.3)';
|
||||
container.style.fontSize = '12px';
|
||||
container.style.lineHeight = '1.2';
|
||||
|
||||
// Get saved preference or default to 'routes'
|
||||
const savedPreference = localStorage.getItem('mapRouteMode') || 'routes';
|
||||
|
||||
container.innerHTML = `
|
||||
<div style="margin-bottom: 4px; font-weight: bold; text-align: center;">Display</div>
|
||||
<div>
|
||||
<label style="display: block; margin-bottom: 4px; cursor: pointer;">
|
||||
<input type="radio" name="route-mode" value="routes" ${savedPreference === 'routes' ? 'checked' : ''} style="margin-right: 4px;">
|
||||
Routes
|
||||
</label>
|
||||
<label style="display: block; cursor: pointer;">
|
||||
<input type="radio" name="route-mode" value="tracks" ${savedPreference === 'tracks' ? 'checked' : ''} style="margin-right: 4px;">
|
||||
Tracks
|
||||
</label>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Disable map interactions when clicking the control
|
||||
L.DomEvent.disableClickPropagation(container);
|
||||
|
||||
// Add change event listeners
|
||||
const radioButtons = container.querySelectorAll('input[name="route-mode"]');
|
||||
radioButtons.forEach(radio => {
|
||||
L.DomEvent.on(radio, 'change', () => {
|
||||
if (radio.checked) {
|
||||
controller.switchRouteMode(radio.value);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return container;
|
||||
}
|
||||
});
|
||||
|
||||
// Add the control to the map
|
||||
this.map.addControl(new RouteTracksControl({ position: 'topleft' }));
|
||||
|
||||
// Apply initial state based on saved preference
|
||||
const savedPreference = localStorage.getItem('mapRouteMode') || 'routes';
|
||||
this.switchRouteMode(savedPreference, true);
|
||||
|
||||
// Set initial pane visibility
|
||||
this.updatePaneVisibilityAfterLayerChange();
|
||||
}
|
||||
|
||||
switchRouteMode(mode, isInitial = false) {
|
||||
// Save preference to localStorage
|
||||
localStorage.setItem('mapRouteMode', mode);
|
||||
|
||||
if (mode === 'routes') {
|
||||
// Hide tracks layer if it exists and is visible
|
||||
if (this.tracksLayer && this.map.hasLayer(this.tracksLayer)) {
|
||||
this.map.removeLayer(this.tracksLayer);
|
||||
}
|
||||
|
||||
// Show routes layer if it exists and is not visible
|
||||
if (this.polylinesLayer && !this.map.hasLayer(this.polylinesLayer)) {
|
||||
this.map.addLayer(this.polylinesLayer);
|
||||
// Re-establish event handlers after adding the layer back
|
||||
reestablishPolylineEventHandlers(this.polylinesLayer, this.map, this.userSettings, this.distanceUnit);
|
||||
} else if (this.polylinesLayer) {
|
||||
reestablishPolylineEventHandlers(this.polylinesLayer, this.map, this.userSettings, this.distanceUnit);
|
||||
}
|
||||
|
||||
// Manage pane visibility to fix z-index blocking
|
||||
managePaneVisibility(this.map, 'routes');
|
||||
|
||||
// Update layer control checkboxes
|
||||
this.updateLayerControlCheckboxes('Routes', true);
|
||||
this.updateLayerControlCheckboxes('Tracks', false);
|
||||
} else if (mode === 'tracks') {
|
||||
// Hide routes layer if it exists and is visible
|
||||
if (this.polylinesLayer && this.map.hasLayer(this.polylinesLayer)) {
|
||||
this.map.removeLayer(this.polylinesLayer);
|
||||
}
|
||||
|
||||
// Show tracks layer if it exists and is not visible
|
||||
if (this.tracksLayer && !this.map.hasLayer(this.tracksLayer)) {
|
||||
this.map.addLayer(this.tracksLayer);
|
||||
}
|
||||
|
||||
// Manage pane visibility to fix z-index blocking
|
||||
managePaneVisibility(this.map, 'tracks');
|
||||
|
||||
// Update layer control checkboxes
|
||||
this.updateLayerControlCheckboxes('Routes', false);
|
||||
this.updateLayerControlCheckboxes('Tracks', true);
|
||||
}
|
||||
}
|
||||
|
||||
updateLayerControlCheckboxes(layerName, isVisible) {
|
||||
// Find the layer control input for the specified layer
|
||||
const layerControlContainer = document.querySelector('.leaflet-control-layers');
|
||||
if (!layerControlContainer) return;
|
||||
|
||||
const inputs = layerControlContainer.querySelectorAll('input[type="checkbox"]');
|
||||
inputs.forEach(input => {
|
||||
const label = input.nextElementSibling;
|
||||
if (label && label.textContent.trim() === layerName) {
|
||||
input.checked = isVisible;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
handleRouteLayerToggle(mode) {
|
||||
// Update the radio button selection
|
||||
const radioButtons = document.querySelectorAll('input[name="route-mode"]');
|
||||
radioButtons.forEach(radio => {
|
||||
if (radio.value === mode) {
|
||||
radio.checked = true;
|
||||
}
|
||||
});
|
||||
|
||||
// Switch to the selected mode and enforce mutual exclusivity
|
||||
this.switchRouteMode(mode);
|
||||
}
|
||||
|
||||
updateRadioButtonState() {
|
||||
// Update radio buttons to reflect current layer visibility
|
||||
const routesVisible = this.polylinesLayer && this.map.hasLayer(this.polylinesLayer);
|
||||
const tracksVisible = this.tracksLayer && this.map.hasLayer(this.tracksLayer);
|
||||
|
||||
const radioButtons = document.querySelectorAll('input[name="route-mode"]');
|
||||
radioButtons.forEach(radio => {
|
||||
if (radio.value === 'routes' && routesVisible && !tracksVisible) {
|
||||
radio.checked = true;
|
||||
} else if (radio.value === 'tracks' && tracksVisible && !routesVisible) {
|
||||
radio.checked = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
updatePaneVisibilityAfterLayerChange() {
|
||||
// Update pane visibility based on current layer visibility
|
||||
const routesVisible = this.polylinesLayer && this.map.hasLayer(this.polylinesLayer);
|
||||
const tracksVisible = this.tracksLayer && this.map.hasLayer(this.tracksLayer);
|
||||
|
||||
if (routesVisible && !tracksVisible) {
|
||||
managePaneVisibility(this.map, 'routes');
|
||||
} else if (tracksVisible && !routesVisible) {
|
||||
managePaneVisibility(this.map, 'tracks');
|
||||
} else {
|
||||
managePaneVisibility(this.map, 'both');
|
||||
}
|
||||
}
|
||||
|
||||
initializeLayersFromSettings() {
|
||||
// Initialize layer visibility based on user settings or defaults
|
||||
// This method sets up the initial state of overlay layers
|
||||
|
||||
// Note: Don't automatically add layers to map here - let the layer control and user preferences handle it
|
||||
// The layer control will manage which layers are visible based on user interaction
|
||||
|
||||
// Initialize photos layer if user wants it visible
|
||||
if (this.userSettings.photos_enabled) {
|
||||
fetchAndDisplayPhotos(this.photoMarkers, this.apiKey, this.userSettings);
|
||||
}
|
||||
|
||||
// Initialize fog of war if enabled in settings
|
||||
if (this.userSettings.fog_of_war_enabled) {
|
||||
this.updateFog(this.markers, this.clearFogRadius, this.fogLinethreshold);
|
||||
}
|
||||
|
||||
// Initialize visits manager functionality
|
||||
if (this.visitsManager && typeof this.visitsManager.fetchAndDisplayVisits === 'function') {
|
||||
this.visitsManager.fetchAndDisplayVisits();
|
||||
}
|
||||
}
|
||||
|
||||
toggleRightPanel() {
|
||||
if (this.rightPanel) {
|
||||
const panel = document.querySelector('.leaflet-right-panel');
|
||||
|
|
@ -1557,4 +1778,73 @@ export default class extends BaseController {
|
|||
modal.appendChild(content);
|
||||
document.body.appendChild(modal);
|
||||
}
|
||||
|
||||
// Track-related methods
|
||||
async initializeTracksLayer() {
|
||||
// Use pre-loaded tracks data if available
|
||||
if (this.tracksData && this.tracksData.length > 0) {
|
||||
this.createTracksFromData(this.tracksData);
|
||||
} else {
|
||||
// Create empty layer for layer control
|
||||
this.tracksLayer = L.layerGroup();
|
||||
}
|
||||
}
|
||||
|
||||
createTracksFromData(tracksData) {
|
||||
// Clear existing tracks
|
||||
this.tracksLayer.clearLayers();
|
||||
|
||||
if (!tracksData || tracksData.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Create tracks layer with data and add to existing tracks layer
|
||||
const newTracksLayer = createTracksLayer(
|
||||
tracksData,
|
||||
this.map,
|
||||
this.userSettings,
|
||||
this.distanceUnit
|
||||
);
|
||||
|
||||
// Add all tracks to the existing tracks layer
|
||||
newTracksLayer.eachLayer((layer) => {
|
||||
this.tracksLayer.addLayer(layer);
|
||||
});
|
||||
}
|
||||
|
||||
updateLayerControl() {
|
||||
if (!this.layerControl) return;
|
||||
|
||||
// Remove existing layer control
|
||||
this.map.removeControl(this.layerControl);
|
||||
|
||||
// Create new controls layer object
|
||||
const controlsLayer = {
|
||||
Points: this.markersLayer || L.layerGroup(),
|
||||
Routes: this.polylinesLayer || L.layerGroup(),
|
||||
Tracks: this.tracksLayer || L.layerGroup(),
|
||||
Heatmap: this.heatmapLayer || L.heatLayer([]),
|
||||
"Fog of War": new this.fogOverlay(),
|
||||
"Scratch map": this.scratchLayer || L.layerGroup(),
|
||||
Areas: this.areasLayer || L.layerGroup(),
|
||||
Photos: this.photoMarkers || L.layerGroup(),
|
||||
"Suggested Visits": this.visitsManager?.getVisitCirclesLayer() || L.layerGroup(),
|
||||
"Confirmed Visits": this.visitsManager?.getConfirmedVisitCirclesLayer() || L.layerGroup()
|
||||
};
|
||||
|
||||
// Re-add the layer control
|
||||
this.layerControl = L.control.layers(this.baseMaps(), controlsLayer).addTo(this.map);
|
||||
}
|
||||
|
||||
toggleTracksVisibility(event) {
|
||||
this.tracksVisible = event.target.checked;
|
||||
|
||||
if (this.tracksLayer) {
|
||||
toggleTracksVisibility(this.tracksLayer, this.map, this.tracksVisible);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,19 +1,96 @@
|
|||
import { showFlashMessage } from "./helpers";
|
||||
|
||||
// Add custom CSS for popup styling
|
||||
const addPopupStyles = () => {
|
||||
if (!document.querySelector('#area-popup-styles')) {
|
||||
const style = document.createElement('style');
|
||||
style.id = 'area-popup-styles';
|
||||
style.textContent = `
|
||||
.area-form-popup,
|
||||
.area-info-popup {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.area-form-popup .leaflet-popup-content-wrapper,
|
||||
.area-info-popup .leaflet-popup-content-wrapper {
|
||||
background: transparent !important;
|
||||
padding: 0 !important;
|
||||
margin: 0 !important;
|
||||
border-radius: 0 !important;
|
||||
box-shadow: none !important;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.area-form-popup .leaflet-popup-content,
|
||||
.area-info-popup .leaflet-popup-content {
|
||||
margin: 0 !important;
|
||||
padding: 0 1rem 0 0 !important;
|
||||
background: transparent !important;
|
||||
border-radius: 1rem !important;
|
||||
overflow: hidden !important;
|
||||
width: 100% !important;
|
||||
max-width: none !important;
|
||||
}
|
||||
|
||||
.area-form-popup .leaflet-popup-tip,
|
||||
.area-info-popup .leaflet-popup-tip {
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.area-form-popup .leaflet-popup,
|
||||
.area-info-popup .leaflet-popup {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
.area-form-popup .leaflet-popup-close-button,
|
||||
.area-info-popup .leaflet-popup-close-button {
|
||||
right: 1.25rem !important;
|
||||
top: 1.25rem !important;
|
||||
width: 1.5rem !important;
|
||||
height: 1.5rem !important;
|
||||
padding: 0 !important;
|
||||
color: oklch(var(--bc) / 0.6) !important;
|
||||
background: oklch(var(--b2)) !important;
|
||||
border-radius: 0.5rem !important;
|
||||
border: 1px solid oklch(var(--bc) / 0.2) !important;
|
||||
font-size: 1rem !important;
|
||||
font-weight: bold !important;
|
||||
line-height: 1 !important;
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
transition: all 0.2s ease !important;
|
||||
}
|
||||
|
||||
.area-form-popup .leaflet-popup-close-button:hover,
|
||||
.area-info-popup .leaflet-popup-close-button:hover {
|
||||
background: oklch(var(--b3)) !important;
|
||||
color: oklch(var(--bc)) !important;
|
||||
border-color: oklch(var(--bc) / 0.3) !important;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
};
|
||||
|
||||
export function handleAreaCreated(areasLayer, layer, apiKey) {
|
||||
// Add popup styles
|
||||
addPopupStyles();
|
||||
const radius = layer.getRadius();
|
||||
const center = layer.getLatLng();
|
||||
|
||||
const formHtml = `
|
||||
<div class="card w-96">
|
||||
<div class="card w-96 bg-base-100 border border-base-300 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">New Area</h2>
|
||||
<h2 class="card-title text-gray-500">New Area</h2>
|
||||
<form id="circle-form" class="space-y-4">
|
||||
<div class="form-control">
|
||||
<input type="text"
|
||||
id="circle-name"
|
||||
name="area[name]"
|
||||
class="input input-bordered w-full"
|
||||
class="input input-bordered input-primary w-full bg-base-200 text-base-content placeholder-base-content/70 border-base-300 focus:border-primary focus:bg-base-100"
|
||||
placeholder="Enter area name"
|
||||
autofocus
|
||||
required>
|
||||
|
|
@ -23,7 +100,7 @@ export function handleAreaCreated(areasLayer, layer, apiKey) {
|
|||
<input type="hidden" name="area[radius]" value="${radius}">
|
||||
<div class="flex justify-between mt-4">
|
||||
<button type="button"
|
||||
class="btn btn-outline"
|
||||
class="btn btn-outline btn-neutral text-base-content border-base-300 hover:bg-base-200"
|
||||
onclick="this.closest('.leaflet-popup').querySelector('.leaflet-popup-close-button').click()">
|
||||
Cancel
|
||||
</button>
|
||||
|
|
@ -35,11 +112,14 @@ export function handleAreaCreated(areasLayer, layer, apiKey) {
|
|||
`;
|
||||
|
||||
layer.bindPopup(formHtml, {
|
||||
maxWidth: "auto",
|
||||
minWidth: 300,
|
||||
maxWidth: 400,
|
||||
minWidth: 384,
|
||||
maxHeight: 600,
|
||||
closeButton: true,
|
||||
closeOnClick: false,
|
||||
className: 'area-form-popup'
|
||||
className: 'area-form-popup',
|
||||
autoPan: true,
|
||||
keepInView: true
|
||||
}).openPopup();
|
||||
|
||||
areasLayer.addLayer(layer);
|
||||
|
|
@ -69,7 +149,7 @@ export function handleAreaCreated(areasLayer, layer, apiKey) {
|
|||
e.stopPropagation();
|
||||
|
||||
if (!nameInput.value.trim()) {
|
||||
nameInput.classList.add('input-error');
|
||||
nameInput.classList.add('input-error', 'border-error');
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -106,10 +186,29 @@ export function saveArea(formData, areasLayer, layer, apiKey) {
|
|||
.then(data => {
|
||||
layer.closePopup();
|
||||
layer.bindPopup(`
|
||||
Name: ${data.name}<br>
|
||||
Radius: ${Math.round(data.radius)} meters<br>
|
||||
<a href="#" data-id="${data.id}" class="delete-area">[Delete]</a>
|
||||
`).openPopup();
|
||||
<div class="card w-80 bg-base-100 border border-base-300 shadow-lg">
|
||||
<div class="card-body">
|
||||
<h3 class="card-title text-base-content text-lg">${data.name}</h3>
|
||||
<div class="space-y-2 text-base-content/80">
|
||||
<p><span class="font-medium text-base-content">Radius:</span> ${Math.round(data.radius)} meters</p>
|
||||
</div>
|
||||
<div class="card-actions justify-end mt-4">
|
||||
<button class="btn btn-sm btn-error delete-area" data-id="${data.id}">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`, {
|
||||
maxWidth: 340,
|
||||
minWidth: 320,
|
||||
className: 'area-info-popup',
|
||||
closeButton: true,
|
||||
closeOnClick: false
|
||||
}).openPopup();
|
||||
|
||||
// Add event listener for the delete button
|
||||
layer.on('popupopen', () => {
|
||||
|
|
@ -151,6 +250,9 @@ export function deleteArea(id, areasLayer, layer, apiKey) {
|
|||
}
|
||||
|
||||
export function fetchAndDrawAreas(areasLayer, apiKey) {
|
||||
// Add popup styles
|
||||
addPopupStyles();
|
||||
|
||||
fetch(`/api/v1/areas?api_key=${apiKey}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
|
|
@ -186,20 +288,42 @@ export function fetchAndDrawAreas(areasLayer, apiKey) {
|
|||
pane: 'areasPane'
|
||||
});
|
||||
|
||||
// Bind popup content
|
||||
// Bind popup content with proper theme-aware styling
|
||||
const popupContent = `
|
||||
<div class="card w-full">
|
||||
<div class="card w-96 bg-base-100 border border-base-300 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">${area.name}</h2>
|
||||
<p>Radius: ${Math.round(radius)} meters</p>
|
||||
<p>Center: [${lat.toFixed(4)}, ${lng.toFixed(4)}]</p>
|
||||
<div class="flex justify-end mt-4">
|
||||
<button class="btn btn-sm btn-error delete-area" data-id="${area.id}">Delete</button>
|
||||
<h2 class="card-title text-base-content text-xl">${area.name}</h2>
|
||||
<div class="space-y-3">
|
||||
<div class="stats stats-vertical shadow bg-base-200">
|
||||
<div class="stat py-2">
|
||||
<div class="stat-title text-base-content/70 text-sm">Radius</div>
|
||||
<div class="stat-value text-base-content text-lg">${Math.round(radius)} meters</div>
|
||||
</div>
|
||||
<div class="stat py-2">
|
||||
<div class="stat-title text-base-content/70 text-sm">Center</div>
|
||||
<div class="stat-value text-base-content text-sm">[${lat.toFixed(4)}, ${lng.toFixed(4)}]</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-actions justify-between items-center mt-6">
|
||||
<div class="badge badge-primary badge-outline">Area ${area.id}</div>
|
||||
<button class="btn btn-error btn-sm delete-area" data-id="${area.id}">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
circle.bindPopup(popupContent);
|
||||
circle.bindPopup(popupContent, {
|
||||
maxWidth: 400,
|
||||
minWidth: 384,
|
||||
className: 'area-info-popup',
|
||||
closeButton: true,
|
||||
closeOnClick: false
|
||||
});
|
||||
|
||||
// Add delete button handler when popup opens
|
||||
circle.on('popupopen', () => {
|
||||
|
|
|
|||
|
|
@ -54,7 +54,31 @@ export function minutesToDaysHoursMinutes(minutes) {
|
|||
}
|
||||
|
||||
export function formatDate(timestamp, timezone) {
|
||||
const date = new Date(timestamp * 1000);
|
||||
let date;
|
||||
|
||||
// Handle different timestamp formats
|
||||
if (typeof timestamp === 'number') {
|
||||
// Unix timestamp in seconds, convert to milliseconds
|
||||
date = new Date(timestamp * 1000);
|
||||
} else if (typeof timestamp === 'string') {
|
||||
// Check if string is a numeric timestamp
|
||||
if (/^\d+$/.test(timestamp)) {
|
||||
// String representation of Unix timestamp in seconds
|
||||
date = new Date(parseInt(timestamp) * 1000);
|
||||
} else {
|
||||
// Assume it's an ISO8601 string, parse directly
|
||||
date = new Date(timestamp);
|
||||
}
|
||||
} else {
|
||||
// Invalid input
|
||||
return 'Invalid Date';
|
||||
}
|
||||
|
||||
// Check if date is valid
|
||||
if (isNaN(date.getTime())) {
|
||||
return 'Invalid Date';
|
||||
}
|
||||
|
||||
let locale;
|
||||
if (navigator.languages !== undefined) {
|
||||
locale = navigator.languages[0];
|
||||
|
|
|
|||
|
|
@ -464,6 +464,9 @@ export function createPolylinesLayer(markers, map, timezone, routeOpacity, userS
|
|||
segmentGroup.options.interactive = true;
|
||||
segmentGroup.options.bubblingMouseEvents = false;
|
||||
|
||||
// Store the original coordinates for later use
|
||||
segmentGroup._polylineCoordinates = polylineCoordinates;
|
||||
|
||||
// Add the hover functionality to the group
|
||||
addHighlightOnHover(segmentGroup, map, polylineCoordinates, userSettings, distanceUnit);
|
||||
|
||||
|
|
@ -550,3 +553,120 @@ export function updatePolylinesOpacity(polylinesLayer, opacity) {
|
|||
segment.setStyle({ opacity: opacity });
|
||||
});
|
||||
}
|
||||
|
||||
export function reestablishPolylineEventHandlers(polylinesLayer, map, userSettings, distanceUnit) {
|
||||
let groupsProcessed = 0;
|
||||
let segmentsProcessed = 0;
|
||||
|
||||
// Re-establish event handlers for all polyline groups
|
||||
polylinesLayer.eachLayer((groupLayer) => {
|
||||
if (groupLayer instanceof L.LayerGroup || groupLayer instanceof L.FeatureGroup) {
|
||||
groupsProcessed++;
|
||||
|
||||
let segments = [];
|
||||
|
||||
groupLayer.eachLayer((segment) => {
|
||||
if (segment instanceof L.Polyline) {
|
||||
segments.push(segment);
|
||||
segmentsProcessed++;
|
||||
}
|
||||
});
|
||||
|
||||
// If we have stored polyline coordinates, use them; otherwise create a basic representation
|
||||
let polylineCoordinates = groupLayer._polylineCoordinates || [];
|
||||
|
||||
if (polylineCoordinates.length === 0) {
|
||||
// Fallback: reconstruct coordinates from segments
|
||||
const coordsMap = new Map();
|
||||
segments.forEach(segment => {
|
||||
const coords = segment.getLatLngs();
|
||||
coords.forEach(coord => {
|
||||
const key = `${coord.lat.toFixed(6)},${coord.lng.toFixed(6)}`;
|
||||
if (!coordsMap.has(key)) {
|
||||
const timestamp = segment.options.timestamp || Date.now() / 1000;
|
||||
const speed = segment.options.speed || 0;
|
||||
coordsMap.set(key, [coord.lat, coord.lng, 0, 0, timestamp, speed]);
|
||||
}
|
||||
});
|
||||
});
|
||||
polylineCoordinates = Array.from(coordsMap.values());
|
||||
}
|
||||
|
||||
// Re-establish the highlight hover functionality
|
||||
if (polylineCoordinates.length > 0) {
|
||||
addHighlightOnHover(groupLayer, map, polylineCoordinates, userSettings, distanceUnit);
|
||||
}
|
||||
|
||||
// Re-establish basic group event handlers
|
||||
groupLayer.on('mouseover', function(e) {
|
||||
L.DomEvent.stopPropagation(e);
|
||||
segments.forEach(segment => {
|
||||
segment.setStyle({
|
||||
weight: 8,
|
||||
opacity: 1
|
||||
});
|
||||
if (map.hasLayer(segment)) {
|
||||
segment.bringToFront();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
groupLayer.on('mouseout', function(e) {
|
||||
L.DomEvent.stopPropagation(e);
|
||||
segments.forEach(segment => {
|
||||
segment.setStyle({
|
||||
weight: 3,
|
||||
opacity: userSettings.route_opacity,
|
||||
color: segment.options.originalColor
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
groupLayer.on('click', function(e) {
|
||||
// Click handler placeholder
|
||||
});
|
||||
|
||||
// Ensure the group is interactive
|
||||
groupLayer.options.interactive = true;
|
||||
groupLayer.options.bubblingMouseEvents = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
export function managePaneVisibility(map, activeLayerType) {
|
||||
const polylinesPane = map.getPane('polylinesPane');
|
||||
const tracksPane = map.getPane('tracksPane');
|
||||
|
||||
if (activeLayerType === 'routes') {
|
||||
// Enable polylines pane events and disable tracks pane events
|
||||
if (polylinesPane) {
|
||||
polylinesPane.style.pointerEvents = 'auto';
|
||||
polylinesPane.style.zIndex = 470; // Temporarily boost above tracks
|
||||
}
|
||||
if (tracksPane) {
|
||||
tracksPane.style.pointerEvents = 'none';
|
||||
}
|
||||
} else if (activeLayerType === 'tracks') {
|
||||
// Enable tracks pane events and disable polylines pane events
|
||||
if (tracksPane) {
|
||||
tracksPane.style.pointerEvents = 'auto';
|
||||
tracksPane.style.zIndex = 470; // Boost above polylines
|
||||
}
|
||||
if (polylinesPane) {
|
||||
polylinesPane.style.pointerEvents = 'none';
|
||||
polylinesPane.style.zIndex = 450; // Reset to original
|
||||
}
|
||||
} else {
|
||||
// Both layers might be active or neither - enable both
|
||||
if (polylinesPane) {
|
||||
polylinesPane.style.pointerEvents = 'auto';
|
||||
polylinesPane.style.zIndex = 450; // Reset to original
|
||||
}
|
||||
if (tracksPane) {
|
||||
tracksPane.style.pointerEvents = 'auto';
|
||||
tracksPane.style.zIndex = 460; // Reset to original
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
527
app/javascript/maps/tracks.js
Normal file
527
app/javascript/maps/tracks.js
Normal file
|
|
@ -0,0 +1,527 @@
|
|||
import { formatDate } from "../maps/helpers";
|
||||
import { formatDistance } from "../maps/helpers";
|
||||
import { formatSpeed } from "../maps/helpers";
|
||||
import { minutesToDaysHoursMinutes } from "../maps/helpers";
|
||||
|
||||
// Track-specific color palette - different from regular polylines
|
||||
export const trackColorPalette = {
|
||||
default: 'red', // Green - distinct from blue polylines
|
||||
hover: '#FF6B35', // Orange-red for hover
|
||||
active: '#E74C3C', // Red for active/clicked
|
||||
start: '#2ECC71', // Green for start marker
|
||||
end: '#E67E22' // Orange for end marker
|
||||
};
|
||||
|
||||
export function getTrackColor() {
|
||||
// All tracks use the same default color
|
||||
return trackColorPalette.default;
|
||||
}
|
||||
|
||||
export function createTrackPopupContent(track, distanceUnit) {
|
||||
const startTime = formatDate(track.start_at, 'UTC');
|
||||
const endTime = formatDate(track.end_at, 'UTC');
|
||||
const duration = track.duration || 0;
|
||||
const durationFormatted = minutesToDaysHoursMinutes(Math.round(duration / 60));
|
||||
|
||||
return `
|
||||
<div class="track-popup">
|
||||
<h4 class="track-popup-title">📍 Track #${track.id}</h4>
|
||||
<div class="track-info">
|
||||
<strong>🕐 Start:</strong> ${startTime}<br>
|
||||
<strong>🏁 End:</strong> ${endTime}<br>
|
||||
<strong>⏱️ Duration:</strong> ${durationFormatted}<br>
|
||||
<strong>📏 Distance:</strong> ${formatDistance(track.distance / 1000, distanceUnit)}<br>
|
||||
<strong>⚡ Avg Speed:</strong> ${formatSpeed(track.avg_speed, distanceUnit)}<br>
|
||||
<strong>⛰️ Elevation:</strong> +${track.elevation_gain || 0}m / -${track.elevation_loss || 0}m<br>
|
||||
<strong>📊 Max Alt:</strong> ${track.elevation_max || 0}m<br>
|
||||
<strong>📉 Min Alt:</strong> ${track.elevation_min || 0}m
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
export function addTrackInteractions(trackGroup, map, track, userSettings, distanceUnit) {
|
||||
let hoverPopup = null;
|
||||
let isClicked = false;
|
||||
|
||||
// Create start and end markers
|
||||
const startIcon = L.divIcon({
|
||||
html: "🚀",
|
||||
className: "track-start-icon emoji-icon",
|
||||
iconSize: [20, 20]
|
||||
});
|
||||
|
||||
const endIcon = L.divIcon({
|
||||
html: "🎯",
|
||||
className: "track-end-icon emoji-icon",
|
||||
iconSize: [20, 20]
|
||||
});
|
||||
|
||||
// Get first and last coordinates from the track path
|
||||
const coordinates = getTrackCoordinates(track);
|
||||
if (!coordinates || coordinates.length < 2) return;
|
||||
|
||||
const startCoord = coordinates[0];
|
||||
const endCoord = coordinates[coordinates.length - 1];
|
||||
|
||||
const startMarker = L.marker([startCoord[0], startCoord[1]], { icon: startIcon });
|
||||
const endMarker = L.marker([endCoord[0], endCoord[1]], { icon: endIcon });
|
||||
|
||||
function handleTrackHover(e) {
|
||||
if (isClicked) {
|
||||
return; // Don't change hover state if clicked
|
||||
}
|
||||
|
||||
// Apply hover style to all segments in the track
|
||||
trackGroup.eachLayer((layer) => {
|
||||
if (layer instanceof L.Polyline) {
|
||||
layer.setStyle({
|
||||
color: trackColorPalette.hover,
|
||||
weight: 6,
|
||||
opacity: 0.9
|
||||
});
|
||||
layer.bringToFront();
|
||||
}
|
||||
});
|
||||
|
||||
// Show markers and popup
|
||||
startMarker.addTo(map);
|
||||
endMarker.addTo(map);
|
||||
|
||||
const popupContent = createTrackPopupContent(track, distanceUnit);
|
||||
|
||||
if (hoverPopup) {
|
||||
map.closePopup(hoverPopup);
|
||||
}
|
||||
|
||||
hoverPopup = L.popup()
|
||||
.setLatLng(e.latlng)
|
||||
.setContent(popupContent)
|
||||
.addTo(map);
|
||||
}
|
||||
|
||||
function handleTrackMouseOut(e) {
|
||||
if (isClicked) return; // Don't reset if clicked
|
||||
|
||||
// Reset to original style
|
||||
trackGroup.eachLayer((layer) => {
|
||||
if (layer instanceof L.Polyline) {
|
||||
layer.setStyle({
|
||||
color: layer.options.originalColor,
|
||||
weight: 4,
|
||||
opacity: userSettings.route_opacity || 0.7
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Remove markers and popup
|
||||
if (hoverPopup) {
|
||||
map.closePopup(hoverPopup);
|
||||
map.removeLayer(startMarker);
|
||||
map.removeLayer(endMarker);
|
||||
}
|
||||
}
|
||||
|
||||
function handleTrackClick(e) {
|
||||
e.originalEvent.stopPropagation();
|
||||
|
||||
// Toggle clicked state
|
||||
isClicked = !isClicked;
|
||||
|
||||
if (isClicked) {
|
||||
// Apply clicked style
|
||||
trackGroup.eachLayer((layer) => {
|
||||
if (layer instanceof L.Polyline) {
|
||||
layer.setStyle({
|
||||
color: trackColorPalette.active,
|
||||
weight: 8,
|
||||
opacity: 1
|
||||
});
|
||||
layer.bringToFront();
|
||||
}
|
||||
});
|
||||
|
||||
startMarker.addTo(map);
|
||||
endMarker.addTo(map);
|
||||
|
||||
// Show persistent popup
|
||||
const popupContent = createTrackPopupContent(track, distanceUnit);
|
||||
|
||||
L.popup()
|
||||
.setLatLng(e.latlng)
|
||||
.setContent(popupContent)
|
||||
.addTo(map);
|
||||
|
||||
// Store reference for cleanup
|
||||
trackGroup._isTrackClicked = true;
|
||||
trackGroup._trackStartMarker = startMarker;
|
||||
trackGroup._trackEndMarker = endMarker;
|
||||
} else {
|
||||
// Reset to hover state or original state
|
||||
handleTrackMouseOut(e);
|
||||
trackGroup._isTrackClicked = false;
|
||||
if (trackGroup._trackStartMarker) map.removeLayer(trackGroup._trackStartMarker);
|
||||
if (trackGroup._trackEndMarker) map.removeLayer(trackGroup._trackEndMarker);
|
||||
}
|
||||
}
|
||||
|
||||
// Add event listeners to all layers in the track group
|
||||
trackGroup.eachLayer((layer) => {
|
||||
if (layer instanceof L.Polyline) {
|
||||
layer.on('mouseover', handleTrackHover);
|
||||
layer.on('mouseout', handleTrackMouseOut);
|
||||
layer.on('click', handleTrackClick);
|
||||
}
|
||||
});
|
||||
|
||||
// Reset when clicking elsewhere on map
|
||||
map.on('click', function() {
|
||||
if (trackGroup._isTrackClicked) {
|
||||
isClicked = false;
|
||||
trackGroup._isTrackClicked = false;
|
||||
handleTrackMouseOut({ latlng: [0, 0] });
|
||||
if (trackGroup._trackStartMarker) map.removeLayer(trackGroup._trackStartMarker);
|
||||
if (trackGroup._trackEndMarker) map.removeLayer(trackGroup._trackEndMarker);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function getTrackCoordinates(track) {
|
||||
// First check if coordinates are already provided as an array
|
||||
if (track.coordinates && Array.isArray(track.coordinates)) {
|
||||
return track.coordinates; // If already provided as array of [lat, lng]
|
||||
}
|
||||
|
||||
// If coordinates are provided as a path property
|
||||
if (track.path && Array.isArray(track.path)) {
|
||||
return track.path;
|
||||
}
|
||||
|
||||
// Try to parse from original_path (PostGIS LineString format)
|
||||
if (track.original_path && typeof track.original_path === 'string') {
|
||||
try {
|
||||
// Parse PostGIS LineString format: "LINESTRING (lng lat, lng lat, ...)" or "LINESTRING(lng lat, lng lat, ...)"
|
||||
const match = track.original_path.match(/LINESTRING\s*\(([^)]+)\)/i);
|
||||
if (match) {
|
||||
const coordString = match[1];
|
||||
const coordinates = coordString.split(',').map(pair => {
|
||||
const [lng, lat] = pair.trim().split(/\s+/).map(parseFloat);
|
||||
if (isNaN(lng) || isNaN(lat)) {
|
||||
console.warn(`Invalid coordinates in track ${track.id}: "${pair.trim()}"`);
|
||||
return null;
|
||||
}
|
||||
return [lat, lng]; // Return as [lat, lng] for Leaflet
|
||||
}).filter(Boolean); // Remove null entries
|
||||
|
||||
if (coordinates.length >= 2) {
|
||||
return coordinates;
|
||||
} else {
|
||||
console.warn(`Track ${track.id} has only ${coordinates.length} valid coordinates`);
|
||||
}
|
||||
} else {
|
||||
console.warn(`No LINESTRING match found for track ${track.id}. Raw: "${track.original_path}"`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to parse track original_path for track ${track.id}:`, error);
|
||||
console.error(`Raw original_path: "${track.original_path}"`);
|
||||
}
|
||||
}
|
||||
|
||||
// For development/testing, create a simple line if we have start/end coordinates
|
||||
if (track.start_point && track.end_point) {
|
||||
return [
|
||||
[track.start_point.lat, track.start_point.lng],
|
||||
[track.end_point.lat, track.end_point.lng]
|
||||
];
|
||||
}
|
||||
|
||||
console.warn('Track coordinates not available for track', track.id);
|
||||
return [];
|
||||
}
|
||||
|
||||
export function createTracksLayer(tracks, map, userSettings, distanceUnit) {
|
||||
// Create a custom pane for tracks with higher z-index than regular polylines
|
||||
if (!map.getPane('tracksPane')) {
|
||||
map.createPane('tracksPane');
|
||||
map.getPane('tracksPane').style.zIndex = 460; // Above polylines pane (450)
|
||||
}
|
||||
|
||||
const renderer = L.canvas({
|
||||
padding: 0.5,
|
||||
pane: 'tracksPane'
|
||||
});
|
||||
|
||||
const trackLayers = tracks.map((track) => {
|
||||
const coordinates = getTrackCoordinates(track);
|
||||
|
||||
if (!coordinates || coordinates.length < 2) {
|
||||
console.warn(`Track ${track.id} has insufficient coordinates`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const trackColor = getTrackColor();
|
||||
const trackGroup = L.featureGroup();
|
||||
|
||||
// Create polyline segments for the track
|
||||
// For now, create a single polyline, but this could be segmented for elevation/speed coloring
|
||||
const trackPolyline = L.polyline(coordinates, {
|
||||
renderer: renderer,
|
||||
color: trackColor,
|
||||
originalColor: trackColor,
|
||||
opacity: userSettings.route_opacity || 0.7,
|
||||
weight: 4,
|
||||
interactive: true,
|
||||
pane: 'tracksPane',
|
||||
bubblingMouseEvents: false,
|
||||
trackId: track.id
|
||||
});
|
||||
|
||||
trackGroup.addLayer(trackPolyline);
|
||||
|
||||
// Add interactions
|
||||
addTrackInteractions(trackGroup, map, track, userSettings, distanceUnit);
|
||||
|
||||
// Store track data for reference
|
||||
trackGroup._trackData = track;
|
||||
|
||||
return trackGroup;
|
||||
}).filter(Boolean); // Remove null entries
|
||||
|
||||
// Create the main layer group
|
||||
const tracksLayerGroup = L.layerGroup(trackLayers);
|
||||
|
||||
// Add CSS for track styling
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
.leaflet-tracksPane-pane {
|
||||
pointer-events: auto !important;
|
||||
}
|
||||
.leaflet-tracksPane-pane canvas {
|
||||
pointer-events: auto !important;
|
||||
}
|
||||
.track-popup {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
.track-popup-title {
|
||||
margin: 0 0 8px 0;
|
||||
color: #2c3e50;
|
||||
font-size: 16px;
|
||||
}
|
||||
.track-info {
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.track-start-icon, .track-end-icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
|
||||
return tracksLayerGroup;
|
||||
}
|
||||
|
||||
export function updateTracksColors(tracksLayer) {
|
||||
const defaultColor = getTrackColor();
|
||||
|
||||
tracksLayer.eachLayer((trackGroup) => {
|
||||
trackGroup.eachLayer((layer) => {
|
||||
if (layer instanceof L.Polyline) {
|
||||
layer.setStyle({
|
||||
color: defaultColor,
|
||||
originalColor: defaultColor
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function updateTracksOpacity(tracksLayer, opacity) {
|
||||
tracksLayer.eachLayer((trackGroup) => {
|
||||
trackGroup.eachLayer((layer) => {
|
||||
if (layer instanceof L.Polyline) {
|
||||
layer.setStyle({ opacity: opacity });
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function toggleTracksVisibility(tracksLayer, map, isVisible) {
|
||||
if (isVisible && !map.hasLayer(tracksLayer)) {
|
||||
tracksLayer.addTo(map);
|
||||
} else if (!isVisible && map.hasLayer(tracksLayer)) {
|
||||
map.removeLayer(tracksLayer);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to filter tracks by criteria
|
||||
export function filterTracks(tracks, criteria) {
|
||||
return tracks.filter(track => {
|
||||
if (criteria.minDistance && track.distance < criteria.minDistance) return false;
|
||||
if (criteria.maxDistance && track.distance > criteria.maxDistance) return false;
|
||||
if (criteria.minDuration && track.duration < criteria.minDuration * 60) return false;
|
||||
if (criteria.maxDuration && track.duration > criteria.maxDuration * 60) return false;
|
||||
if (criteria.startDate && new Date(track.start_at) < new Date(criteria.startDate)) return false;
|
||||
if (criteria.endDate && new Date(track.end_at) > new Date(criteria.endDate)) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
// === INCREMENTAL TRACK HANDLING ===
|
||||
|
||||
/**
|
||||
* Create a single track layer from track data
|
||||
* @param {Object} track - Track data
|
||||
* @param {Object} map - Leaflet map instance
|
||||
* @param {Object} userSettings - User settings
|
||||
* @param {string} distanceUnit - Distance unit preference
|
||||
* @returns {L.FeatureGroup} Track layer group
|
||||
*/
|
||||
export function createSingleTrackLayer(track, map, userSettings, distanceUnit) {
|
||||
const coordinates = getTrackCoordinates(track);
|
||||
|
||||
if (!coordinates || coordinates.length < 2) {
|
||||
console.warn(`Track ${track.id} has insufficient coordinates`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Create a custom pane for tracks if it doesn't exist
|
||||
if (!map.getPane('tracksPane')) {
|
||||
map.createPane('tracksPane');
|
||||
map.getPane('tracksPane').style.zIndex = 460;
|
||||
}
|
||||
|
||||
const renderer = L.canvas({
|
||||
padding: 0.5,
|
||||
pane: 'tracksPane'
|
||||
});
|
||||
|
||||
const trackColor = getTrackColor();
|
||||
const trackGroup = L.featureGroup();
|
||||
|
||||
const trackPolyline = L.polyline(coordinates, {
|
||||
renderer: renderer,
|
||||
color: trackColor,
|
||||
originalColor: trackColor,
|
||||
opacity: userSettings.route_opacity || 0.7,
|
||||
weight: 4,
|
||||
interactive: true,
|
||||
pane: 'tracksPane',
|
||||
bubblingMouseEvents: false,
|
||||
trackId: track.id
|
||||
});
|
||||
|
||||
trackGroup.addLayer(trackPolyline);
|
||||
addTrackInteractions(trackGroup, map, track, userSettings, distanceUnit);
|
||||
trackGroup._trackData = track;
|
||||
|
||||
return trackGroup;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add or update a track in the tracks layer
|
||||
* @param {L.LayerGroup} tracksLayer - Main tracks layer group
|
||||
* @param {Object} track - Track data
|
||||
* @param {Object} map - Leaflet map instance
|
||||
* @param {Object} userSettings - User settings
|
||||
* @param {string} distanceUnit - Distance unit preference
|
||||
*/
|
||||
export function addOrUpdateTrack(tracksLayer, track, map, userSettings, distanceUnit) {
|
||||
// Remove existing track if it exists
|
||||
removeTrackById(tracksLayer, track.id);
|
||||
|
||||
// Create new track layer
|
||||
const trackLayer = createSingleTrackLayer(track, map, userSettings, distanceUnit);
|
||||
|
||||
if (trackLayer) {
|
||||
tracksLayer.addLayer(trackLayer);
|
||||
console.log(`Track ${track.id} added/updated on map`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a track from the tracks layer by ID
|
||||
* @param {L.LayerGroup} tracksLayer - Main tracks layer group
|
||||
* @param {number} trackId - Track ID to remove
|
||||
*/
|
||||
export function removeTrackById(tracksLayer, trackId) {
|
||||
let layerToRemove = null;
|
||||
|
||||
tracksLayer.eachLayer((layer) => {
|
||||
if (layer._trackData && layer._trackData.id === trackId) {
|
||||
layerToRemove = layer;
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
if (layerToRemove) {
|
||||
// Clean up any markers that might be showing
|
||||
if (layerToRemove._trackStartMarker) {
|
||||
tracksLayer.removeLayer(layerToRemove._trackStartMarker);
|
||||
}
|
||||
if (layerToRemove._trackEndMarker) {
|
||||
tracksLayer.removeLayer(layerToRemove._trackEndMarker);
|
||||
}
|
||||
|
||||
tracksLayer.removeLayer(layerToRemove);
|
||||
console.log(`Track ${trackId} removed from map`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a track is within the current map time range
|
||||
* @param {Object} track - Track data
|
||||
* @param {string} startAt - Start time filter
|
||||
* @param {string} endAt - End time filter
|
||||
* @returns {boolean} Whether track is in range
|
||||
*/
|
||||
export function isTrackInTimeRange(track, startAt, endAt) {
|
||||
if (!startAt || !endAt) return true;
|
||||
|
||||
const trackStart = new Date(track.start_at);
|
||||
const trackEnd = new Date(track.end_at);
|
||||
const rangeStart = new Date(startAt);
|
||||
const rangeEnd = new Date(endAt);
|
||||
|
||||
// Track is in range if it overlaps with the time range
|
||||
return trackStart <= rangeEnd && trackEnd >= rangeStart;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incremental track updates from WebSocket
|
||||
* @param {L.LayerGroup} tracksLayer - Main tracks layer group
|
||||
* @param {Object} data - WebSocket data
|
||||
* @param {Object} map - Leaflet map instance
|
||||
* @param {Object} userSettings - User settings
|
||||
* @param {string} distanceUnit - Distance unit preference
|
||||
* @param {string} currentStartAt - Current time range start
|
||||
* @param {string} currentEndAt - Current time range end
|
||||
*/
|
||||
export function handleIncrementalTrackUpdate(tracksLayer, data, map, userSettings, distanceUnit, currentStartAt, currentEndAt) {
|
||||
const { action, track, track_id } = data;
|
||||
|
||||
switch (action) {
|
||||
case 'created':
|
||||
// Only add if track is within current time range
|
||||
if (isTrackInTimeRange(track, currentStartAt, currentEndAt)) {
|
||||
addOrUpdateTrack(tracksLayer, track, map, userSettings, distanceUnit);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'updated':
|
||||
// Update track if it exists or add if it's now in range
|
||||
if (isTrackInTimeRange(track, currentStartAt, currentEndAt)) {
|
||||
addOrUpdateTrack(tracksLayer, track, map, userSettings, distanceUnit);
|
||||
} else {
|
||||
// Remove track if it's no longer in range
|
||||
removeTrackById(tracksLayer, track.id);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'destroyed':
|
||||
removeTrackById(tracksLayer, track_id);
|
||||
break;
|
||||
|
||||
default:
|
||||
console.warn('Unknown track update action:', action);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class AreaVisitsCalculatingJob < ApplicationJob
|
||||
queue_as :default
|
||||
queue_as :visit_suggesting
|
||||
sidekiq_options retry: false
|
||||
|
||||
def perform(user_id)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class AreaVisitsCalculationSchedulingJob < ApplicationJob
|
||||
queue_as :default
|
||||
queue_as :visit_suggesting
|
||||
sidekiq_options retry: false
|
||||
|
||||
def perform
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ class BulkStatsCalculatingJob < ApplicationJob
|
|||
queue_as :stats
|
||||
|
||||
def perform
|
||||
user_ids = User.pluck(:id)
|
||||
user_ids = User.active.pluck(:id)
|
||||
|
||||
user_ids.each do |user_id|
|
||||
Stats::BulkCalculator.new(user_id).call
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ class BulkVisitsSuggestingJob < ApplicationJob
|
|||
time_chunks = Visits::TimeChunks.new(start_at:, end_at:).call
|
||||
|
||||
users.active.find_each do |user|
|
||||
next unless user.safe_settings.visits_suggestions_enabled?
|
||||
next if user.tracked_points.empty?
|
||||
|
||||
schedule_chunked_jobs(user, time_chunks)
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ class Owntracks::PointCreatingJob < ApplicationJob
|
|||
def perform(point_params, user_id)
|
||||
parsed_params = OwnTracks::Params.new(point_params).call
|
||||
|
||||
return if parsed_params[:timestamp].nil? || parsed_params[:lonlat].nil?
|
||||
return if parsed_params.try(:[], :timestamp).nil? || parsed_params.try(:[], :lonlat).nil?
|
||||
return if point_exists?(parsed_params, user_id)
|
||||
|
||||
Point.create!(parsed_params.merge(user_id:))
|
||||
|
|
|
|||
11
app/jobs/places/bulk_name_fetching_job.rb
Normal file
11
app/jobs/places/bulk_name_fetching_job.rb
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Places::BulkNameFetchingJob < ApplicationJob
|
||||
queue_as :places
|
||||
|
||||
def perform
|
||||
Place.where(name: Place::DEFAULT_NAME).find_each do |place|
|
||||
Places::NameFetchingJob.perform_later(place.id)
|
||||
end
|
||||
end
|
||||
end
|
||||
11
app/jobs/places/name_fetching_job.rb
Normal file
11
app/jobs/places/name_fetching_job.rb
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Places::NameFetchingJob < ApplicationJob
|
||||
queue_as :places
|
||||
|
||||
def perform(place_id)
|
||||
place = Place.find(place_id)
|
||||
|
||||
Places::NameFetcher.new(place).call
|
||||
end
|
||||
end
|
||||
31
app/jobs/tracks/cleanup_job.rb
Normal file
31
app/jobs/tracks/cleanup_job.rb
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# Lightweight cleanup job that runs weekly to catch any missed track generation.
|
||||
#
|
||||
# This provides a safety net while avoiding the overhead of daily bulk processing.
|
||||
class Tracks::CleanupJob < ApplicationJob
|
||||
queue_as :tracks
|
||||
sidekiq_options retry: false
|
||||
|
||||
def perform(older_than: 1.day.ago)
|
||||
users_with_old_untracked_points(older_than).find_each do |user|
|
||||
Rails.logger.info "Processing missed tracks for user #{user.id}"
|
||||
|
||||
# Process only the old untracked points
|
||||
Tracks::Generator.new(
|
||||
user,
|
||||
end_at: older_than,
|
||||
mode: :incremental
|
||||
).call
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def users_with_old_untracked_points(older_than)
|
||||
User.active.joins(:tracked_points)
|
||||
.where(tracked_points: { track_id: nil, timestamp: ..older_than.to_i })
|
||||
.having('COUNT(tracked_points.id) >= 2') # Only users with enough points for tracks
|
||||
.group(:id)
|
||||
end
|
||||
end
|
||||
39
app/jobs/tracks/create_job.rb
Normal file
39
app/jobs/tracks/create_job.rb
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Tracks::CreateJob < ApplicationJob
|
||||
queue_as :tracks
|
||||
|
||||
def perform(user_id, start_at: nil, end_at: nil, mode: :daily)
|
||||
user = User.find(user_id)
|
||||
|
||||
tracks_created = Tracks::Generator.new(user, start_at:, end_at:, mode:).call
|
||||
|
||||
create_success_notification(user, tracks_created)
|
||||
rescue StandardError => e
|
||||
ExceptionReporter.call(e, 'Failed to create tracks for user')
|
||||
|
||||
create_error_notification(user, e)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def create_success_notification(user, tracks_created)
|
||||
Notifications::Create.new(
|
||||
user: user,
|
||||
kind: :info,
|
||||
title: 'Tracks Generated',
|
||||
content: "Created #{tracks_created} tracks from your location data. Check your tracks section to view them."
|
||||
).call
|
||||
end
|
||||
|
||||
def create_error_notification(user, error)
|
||||
return unless DawarichSettings.self_hosted?
|
||||
|
||||
Notifications::Create.new(
|
||||
user: user,
|
||||
kind: :error,
|
||||
title: 'Track Generation Failed',
|
||||
content: "Failed to generate tracks from your location data: #{error.message}"
|
||||
).call
|
||||
end
|
||||
end
|
||||
12
app/jobs/tracks/incremental_check_job.rb
Normal file
12
app/jobs/tracks/incremental_check_job.rb
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Tracks::IncrementalCheckJob < ApplicationJob
|
||||
queue_as :tracks
|
||||
|
||||
def perform(user_id, point_id)
|
||||
user = User.find(user_id)
|
||||
point = Point.find(point_id)
|
||||
|
||||
Tracks::IncrementalProcessor.new(user, point).call
|
||||
end
|
||||
end
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
64
app/models/concerns/calculateable.rb
Normal file
64
app/models/concerns/calculateable.rb
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Calculateable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def calculate_path
|
||||
updated_path = build_path_from_coordinates
|
||||
set_path_attributes(updated_path)
|
||||
end
|
||||
|
||||
def calculate_distance
|
||||
calculated_distance_meters = calculate_distance_from_coordinates
|
||||
self.distance = convert_distance_for_storage(calculated_distance_meters)
|
||||
end
|
||||
|
||||
def recalculate_path!
|
||||
calculate_path
|
||||
save_if_changed!
|
||||
end
|
||||
|
||||
def recalculate_distance!
|
||||
calculate_distance
|
||||
save_if_changed!
|
||||
end
|
||||
|
||||
def recalculate_path_and_distance!
|
||||
calculate_path
|
||||
calculate_distance
|
||||
save_if_changed!
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def path_coordinates
|
||||
points.pluck(:lonlat)
|
||||
end
|
||||
|
||||
def build_path_from_coordinates
|
||||
Tracks::BuildPath.new(path_coordinates).call
|
||||
end
|
||||
|
||||
def set_path_attributes(updated_path)
|
||||
self.path = updated_path if respond_to?(:path=)
|
||||
self.original_path = updated_path if respond_to?(:original_path=)
|
||||
end
|
||||
|
||||
def calculate_distance_from_coordinates
|
||||
# Always calculate in meters for consistent storage
|
||||
Point.total_distance(points, :m)
|
||||
end
|
||||
|
||||
def convert_distance_for_storage(calculated_distance_meters)
|
||||
# Store as integer meters for consistency
|
||||
calculated_distance_meters.round
|
||||
end
|
||||
|
||||
def track_model?
|
||||
self.class.name == 'Track'
|
||||
end
|
||||
|
||||
def save_if_changed!
|
||||
save! if changed?
|
||||
end
|
||||
end
|
||||
59
app/models/concerns/distance_convertible.rb
Normal file
59
app/models/concerns/distance_convertible.rb
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# Module for converting distances from stored meters to user's preferred unit at runtime.
|
||||
#
|
||||
# All distances are stored in meters in the database for consistency. This module provides
|
||||
# methods to convert those stored meter values to the user's preferred unit (km, mi, etc.)
|
||||
# for display purposes.
|
||||
#
|
||||
# This approach ensures:
|
||||
# - Consistent data storage regardless of user preferences
|
||||
# - No data corruption when users change distance units
|
||||
# - Easy conversion for display without affecting stored data
|
||||
#
|
||||
# Usage:
|
||||
# class Track < ApplicationRecord
|
||||
# include DistanceConvertible
|
||||
# end
|
||||
#
|
||||
# track.distance # => 5000 (meters stored in DB)
|
||||
# track.distance_in_unit('km') # => 5.0 (converted to km)
|
||||
# track.distance_in_unit('mi') # => 3.11 (converted to miles)
|
||||
#
|
||||
module DistanceConvertible
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def distance_in_unit(unit)
|
||||
return 0.0 unless distance.present?
|
||||
|
||||
unit_sym = unit.to_sym
|
||||
conversion_factor = ::DISTANCE_UNITS[unit_sym]
|
||||
|
||||
unless conversion_factor
|
||||
raise ArgumentError, "Invalid unit '#{unit}'. Supported units: #{::DISTANCE_UNITS.keys.join(', ')}"
|
||||
end
|
||||
|
||||
# Distance is stored in meters, convert to target unit
|
||||
distance.to_f / conversion_factor
|
||||
end
|
||||
|
||||
def distance_for_user(user)
|
||||
user_unit = user.safe_settings.distance_unit
|
||||
distance_in_unit(user_unit)
|
||||
end
|
||||
|
||||
module ClassMethods
|
||||
def convert_distance(distance_meters, unit)
|
||||
return 0.0 unless distance_meters.present?
|
||||
|
||||
unit_sym = unit.to_sym
|
||||
conversion_factor = ::DISTANCE_UNITS[unit_sym]
|
||||
|
||||
unless conversion_factor
|
||||
raise ArgumentError, "Invalid unit '#{unit}'. Supported units: #{::DISTANCE_UNITS.keys.join(', ')}"
|
||||
end
|
||||
|
||||
distance_meters.to_f / conversion_factor
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ class Point < ApplicationRecord
|
|||
belongs_to :visit, optional: true
|
||||
belongs_to :user
|
||||
belongs_to :country, optional: true
|
||||
belongs_to :track, optional: true
|
||||
|
||||
validates :timestamp, :lonlat, presence: true
|
||||
validates :lonlat, uniqueness: {
|
||||
|
|
@ -32,6 +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? }
|
||||
|
||||
def self.without_raw_data
|
||||
select(column_names - ['raw_data'])
|
||||
|
|
@ -89,7 +92,17 @@ class Point < ApplicationRecord
|
|||
end
|
||||
|
||||
def country_name
|
||||
# Safely get country name from association or attribute
|
||||
# We have a country column in the database,
|
||||
# but we also have a country_id column.
|
||||
# TODO: rename country column to country_name
|
||||
self.country&.name || read_attribute(:country) || ''
|
||||
end
|
||||
|
||||
def recalculate_track
|
||||
track.recalculate_path_and_distance!
|
||||
end
|
||||
|
||||
def trigger_incremental_track_generation
|
||||
Tracks::IncrementalCheckJob.perform_later(user.id, id)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Stat < ApplicationRecord
|
||||
include DistanceConvertible
|
||||
|
||||
validates :year, :month, presence: true
|
||||
|
||||
belongs_to :user
|
||||
|
|
@ -37,8 +39,9 @@ class Stat < ApplicationRecord
|
|||
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)
|
||||
distance = Point.total_distance(daily_points, user.safe_settings.distance_unit)
|
||||
[index, distance.round(2)]
|
||||
# Calculate distance in meters for consistent storage
|
||||
distance_meters = Point.total_distance(daily_points, :m)
|
||||
[index, distance_meters.round]
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
67
app/models/track.rb
Normal file
67
app/models/track.rb
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Track < ApplicationRecord
|
||||
include Calculateable
|
||||
include DistanceConvertible
|
||||
|
||||
belongs_to :user
|
||||
has_many :points, dependent: :nullify
|
||||
|
||||
validates :start_at, :end_at, :original_path, presence: true
|
||||
validates :distance, :avg_speed, :duration, numericality: { greater_than_or_equal_to: 0 }
|
||||
|
||||
after_update :recalculate_path_and_distance!, if: -> { points.exists? && (saved_change_to_start_at? || saved_change_to_end_at?) }
|
||||
after_create :broadcast_track_created
|
||||
after_update :broadcast_track_updated
|
||||
after_destroy :broadcast_track_destroyed
|
||||
|
||||
def self.last_for_day(user, day)
|
||||
day_start = day.beginning_of_day
|
||||
day_end = day.end_of_day
|
||||
|
||||
where(user: user)
|
||||
.where(end_at: day_start..day_end)
|
||||
.order(end_at: :desc)
|
||||
.first
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def broadcast_track_created
|
||||
broadcast_track_update('created')
|
||||
end
|
||||
|
||||
def broadcast_track_updated
|
||||
broadcast_track_update('updated')
|
||||
end
|
||||
|
||||
def broadcast_track_destroyed
|
||||
TracksChannel.broadcast_to(user, {
|
||||
action: 'destroyed',
|
||||
track_id: id
|
||||
})
|
||||
end
|
||||
|
||||
def broadcast_track_update(action)
|
||||
TracksChannel.broadcast_to(user, {
|
||||
action: action,
|
||||
track: serialize_track_data
|
||||
})
|
||||
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
|
||||
|
|
@ -1,6 +1,9 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Trip < ApplicationRecord
|
||||
include Calculateable
|
||||
include DistanceConvertible
|
||||
|
||||
has_rich_text :notes
|
||||
|
||||
belongs_to :user
|
||||
|
|
@ -32,17 +35,7 @@ class Trip < ApplicationRecord
|
|||
@photo_sources ||= photos.map { _1[:source] }.uniq
|
||||
end
|
||||
|
||||
def calculate_path
|
||||
trip_path = Tracks::BuildPath.new(points.pluck(:lonlat)).call
|
||||
|
||||
self.path = trip_path
|
||||
end
|
||||
|
||||
def calculate_distance
|
||||
distance = Point.total_distance(points, user.safe_settings.distance_unit)
|
||||
|
||||
self.distance = distance.round
|
||||
end
|
||||
|
||||
def calculate_countries
|
||||
countries =
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ class User < ApplicationRecord
|
|||
has_many :points, through: :imports
|
||||
has_many :places, through: :visits
|
||||
has_many :trips, dependent: :destroy
|
||||
has_many :tracks, dependent: :destroy
|
||||
|
||||
after_create :create_api_key
|
||||
after_commit :activate, on: :create, if: -> { DawarichSettings.self_hosted? }
|
||||
|
|
@ -49,8 +50,9 @@ class User < ApplicationRecord
|
|||
end
|
||||
|
||||
def total_distance
|
||||
# In km or miles, depending on user.safe_settings.distance_unit
|
||||
stats.sum(:distance)
|
||||
# Distance is stored in meters, convert to user's preferred unit for display
|
||||
total_distance_meters = stats.sum(:distance)
|
||||
Stat.convert_distance(total_distance_meters, safe_settings.distance_unit)
|
||||
end
|
||||
|
||||
def total_countries
|
||||
|
|
|
|||
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
|
||||
44
app/serializers/api/user_serializer.rb
Normal file
44
app/serializers/api/user_serializer.rb
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Api::UserSerializer
|
||||
def initialize(user)
|
||||
@user = user
|
||||
end
|
||||
|
||||
def call
|
||||
{
|
||||
user: {
|
||||
email: user.email,
|
||||
theme: user.theme,
|
||||
created_at: user.created_at,
|
||||
updated_at: user.updated_at,
|
||||
settings: settings,
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :user
|
||||
|
||||
def settings
|
||||
{
|
||||
maps: user.safe_settings.maps,
|
||||
fog_of_war_meters: user.safe_settings.fog_of_war_meters.to_i,
|
||||
meters_between_routes: user.safe_settings.meters_between_routes.to_i,
|
||||
preferred_map_layer: user.safe_settings.preferred_map_layer,
|
||||
speed_colored_routes: user.safe_settings.speed_colored_routes,
|
||||
points_rendering_mode: user.safe_settings.points_rendering_mode,
|
||||
minutes_between_routes: user.safe_settings.minutes_between_routes.to_i,
|
||||
time_threshold_minutes: user.safe_settings.time_threshold_minutes.to_i,
|
||||
merge_threshold_minutes: user.safe_settings.merge_threshold_minutes.to_i,
|
||||
live_map_enabled: user.safe_settings.live_map_enabled,
|
||||
route_opacity: user.safe_settings.route_opacity.to_f,
|
||||
immich_url: user.safe_settings.immich_url,
|
||||
photoprism_url: user.safe_settings.photoprism_url,
|
||||
visits_suggestions_enabled: user.safe_settings.visits_suggestions_enabled?,
|
||||
speed_color_scale: user.safe_settings.speed_color_scale,
|
||||
fog_of_war_threshold: user.safe_settings.fog_of_war_threshold
|
||||
}
|
||||
end
|
||||
end
|
||||
|
|
@ -9,7 +9,7 @@ class StatsSerializer
|
|||
|
||||
def call
|
||||
{
|
||||
totalDistanceKm: total_distance,
|
||||
totalDistanceKm: total_distance_km,
|
||||
totalPointsTracked: user.tracked_points.count,
|
||||
totalReverseGeocodedPoints: reverse_geocoded_points,
|
||||
totalCountriesVisited: user.countries_visited.count,
|
||||
|
|
@ -20,8 +20,10 @@ class StatsSerializer
|
|||
|
||||
private
|
||||
|
||||
def total_distance
|
||||
user.stats.sum(:distance)
|
||||
def total_distance_km
|
||||
total_distance_meters = user.stats.sum(:distance)
|
||||
|
||||
(total_distance_meters / 1000)
|
||||
end
|
||||
|
||||
def reverse_geocoded_points
|
||||
|
|
@ -32,7 +34,7 @@ class StatsSerializer
|
|||
user.stats.group_by(&:year).sort.reverse.map do |year, stats|
|
||||
{
|
||||
year:,
|
||||
totalDistanceKm: stats.sum(&:distance),
|
||||
totalDistanceKm: stats_distance_km(stats),
|
||||
totalCountriesVisited: user.countries_visited.count,
|
||||
totalCitiesVisited: user.cities_visited.count,
|
||||
monthlyDistanceKm: monthly_distance(year, stats)
|
||||
|
|
@ -40,15 +42,24 @@ class StatsSerializer
|
|||
end
|
||||
end
|
||||
|
||||
def stats_distance_km(stats)
|
||||
# Convert from stored meters to kilometers
|
||||
total_meters = stats.sum(&:distance)
|
||||
total_meters / 1000
|
||||
end
|
||||
|
||||
def monthly_distance(year, stats)
|
||||
months = {}
|
||||
|
||||
(1..12).each { |month| months[Date::MONTHNAMES[month]&.downcase] = distance(month, year, stats) }
|
||||
(1..12).each { |month| months[Date::MONTHNAMES[month]&.downcase] = distance_km(month, year, stats) }
|
||||
|
||||
months
|
||||
end
|
||||
|
||||
def distance(month, year, stats)
|
||||
stats.find { _1.month == month && _1.year == year }&.distance.to_i
|
||||
def distance_km(month, year, stats)
|
||||
# Convert from stored meters to kilometers
|
||||
distance_meters = stats.find { _1.month == month && _1.year == year }&.distance.to_i
|
||||
|
||||
distance_meters / 1000
|
||||
end
|
||||
end
|
||||
|
|
|
|||
23
app/serializers/track_serializer.rb
Normal file
23
app/serializers/track_serializer.rb
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class TrackSerializer
|
||||
def initialize(track)
|
||||
@track = track
|
||||
end
|
||||
|
||||
def call
|
||||
{
|
||||
id: @track.id,
|
||||
start_at: @track.start_at.iso8601,
|
||||
end_at: @track.end_at.iso8601,
|
||||
distance: @track.distance.to_i,
|
||||
avg_speed: @track.avg_speed.to_f,
|
||||
duration: @track.duration,
|
||||
elevation_gain: @track.elevation_gain,
|
||||
elevation_loss: @track.elevation_loss,
|
||||
elevation_max: @track.elevation_max,
|
||||
elevation_min: @track.elevation_min,
|
||||
original_path: @track.original_path.to_s
|
||||
}
|
||||
end
|
||||
end
|
||||
22
app/serializers/tracks_serializer.rb
Normal file
22
app/serializers/tracks_serializer.rb
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class TracksSerializer
|
||||
def initialize(user, track_ids)
|
||||
@user = user
|
||||
@track_ids = track_ids
|
||||
end
|
||||
|
||||
def call
|
||||
return [] if track_ids.empty?
|
||||
|
||||
tracks = user.tracks
|
||||
.where(id: track_ids)
|
||||
.order(start_at: :asc)
|
||||
|
||||
tracks.map { |track| TrackSerializer.new(track).call }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :user, :track_ids
|
||||
end
|
||||
|
|
@ -8,6 +8,8 @@ class CheckAppVersion
|
|||
end
|
||||
|
||||
def call
|
||||
return false if Rails.env.production?
|
||||
|
||||
latest_version != APP_VERSION
|
||||
rescue StandardError
|
||||
false
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ class OwnTracks::Params
|
|||
# rubocop:disable Metrics/MethodLength
|
||||
# rubocop:disable Metrics/AbcSize
|
||||
def call
|
||||
return unless valid_point?
|
||||
|
||||
{
|
||||
lonlat: "POINT(#{params[:lon]} #{params[:lat]})",
|
||||
battery: params[:batt],
|
||||
|
|
@ -84,4 +86,8 @@ class OwnTracks::Params
|
|||
def owntracks_point?
|
||||
params[:topic].present?
|
||||
end
|
||||
|
||||
def valid_point?
|
||||
params[:lon].present? && params[:lat].present? && params[:tst].present?
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -9,8 +9,12 @@ class OwnTracks::RecParser
|
|||
|
||||
def call
|
||||
file.split("\n").map do |line|
|
||||
# Try tab-separated first, then fall back to whitespace-separated
|
||||
parts = line.split("\t")
|
||||
|
||||
# If tab splitting didn't work (only 1 part), try whitespace splitting
|
||||
parts = line.split(/\s+/) if parts.size == 1
|
||||
|
||||
Oj.load(parts[2]) if parts.size > 2 && parts[1].strip == '*'
|
||||
end.compact
|
||||
end
|
||||
|
|
|
|||
43
app/services/places/name_fetcher.rb
Normal file
43
app/services/places/name_fetcher.rb
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Places
|
||||
class NameFetcher
|
||||
def initialize(place)
|
||||
@place = place
|
||||
end
|
||||
|
||||
def call
|
||||
geodata = Geocoder.search([place.lat, place.lon], units: :km, limit: 1, distance_sort: true).first
|
||||
|
||||
return if geodata.blank?
|
||||
|
||||
properties = geodata.data&.dig('properties')
|
||||
return if properties.blank?
|
||||
|
||||
ActiveRecord::Base.transaction do
|
||||
update_place_name(properties, geodata)
|
||||
|
||||
update_visits_name(properties) if properties['name'].present?
|
||||
|
||||
place
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :place
|
||||
|
||||
def update_place_name(properties, geodata)
|
||||
place.name = properties['name'] if properties['name'].present?
|
||||
place.city = properties['city'] if properties['city'].present?
|
||||
place.country = properties['country'] if properties['country'].present?
|
||||
place.geodata = geodata.data if DawarichSettings.store_geodata?
|
||||
|
||||
place.save!
|
||||
end
|
||||
|
||||
def update_visits_name(properties)
|
||||
place.visits.where(name: Place::DEFAULT_NAME).update_all(name: properties['name'])
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -7,13 +7,18 @@ class PointsLimitExceeded
|
|||
|
||||
def call
|
||||
return false if DawarichSettings.self_hosted?
|
||||
return true if @user.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
|
||||
|
|
|
|||
182
app/services/tracks/generator.rb
Normal file
182
app/services/tracks/generator.rb
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# This service handles both bulk and incremental track generation using a unified
|
||||
# approach with different modes:
|
||||
#
|
||||
# - :bulk - Regenerates all tracks from scratch (replaces existing)
|
||||
# - :incremental - Processes untracked points up to a specified end time
|
||||
# - :daily - Processes tracks on a daily basis
|
||||
#
|
||||
# Key features:
|
||||
# - Deterministic results (same algorithm for all modes)
|
||||
# - Simple incremental processing without buffering complexity
|
||||
# - Configurable time and distance thresholds from user settings
|
||||
# - Automatic track statistics calculation
|
||||
# - Proper handling of edge cases (empty points, incomplete segments)
|
||||
#
|
||||
# Usage:
|
||||
# # Bulk regeneration
|
||||
# Tracks::Generator.new(user, mode: :bulk).call
|
||||
#
|
||||
# # Incremental processing
|
||||
# Tracks::Generator.new(user, mode: :incremental).call
|
||||
#
|
||||
# # Daily processing
|
||||
# Tracks::Generator.new(user, start_at: Date.current, mode: :daily).call
|
||||
#
|
||||
class Tracks::Generator
|
||||
include Tracks::Segmentation
|
||||
include Tracks::TrackBuilder
|
||||
|
||||
attr_reader :user, :start_at, :end_at, :mode
|
||||
|
||||
def initialize(user, start_at: nil, end_at: nil, mode: :bulk)
|
||||
@user = user
|
||||
@start_at = start_at
|
||||
@end_at = end_at
|
||||
@mode = mode.to_sym
|
||||
end
|
||||
|
||||
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?
|
||||
|
||||
segments = split_points_into_segments(points)
|
||||
Rails.logger.debug "Generator: created #{segments.size} segments"
|
||||
|
||||
tracks_created = 0
|
||||
|
||||
segments.each do |segment|
|
||||
track = create_track_from_segment(segment)
|
||||
tracks_created += 1 if track
|
||||
end
|
||||
|
||||
Rails.logger.info "Generated #{tracks_created} tracks for user #{user.id} in #{mode} mode"
|
||||
tracks_created
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def should_clean_tracks?
|
||||
case mode
|
||||
when :bulk, :daily then true
|
||||
else false
|
||||
end
|
||||
end
|
||||
|
||||
def load_points
|
||||
case mode
|
||||
when :bulk then load_bulk_points
|
||||
when :incremental then load_incremental_points
|
||||
when :daily then load_daily_points
|
||||
else
|
||||
raise ArgumentError, "Unknown mode: #{mode}"
|
||||
end
|
||||
end
|
||||
|
||||
def load_bulk_points
|
||||
scope = user.tracked_points.order(:timestamp)
|
||||
scope = scope.where(timestamp: timestamp_range) if time_range_defined?
|
||||
|
||||
scope
|
||||
end
|
||||
|
||||
def load_incremental_points
|
||||
# For incremental mode, we process untracked points
|
||||
# If end_at is specified, only process points up to that time
|
||||
scope = user.tracked_points.where(track_id: nil).order(:timestamp)
|
||||
scope = scope.where(timestamp: ..end_at.to_i) if end_at.present?
|
||||
|
||||
scope
|
||||
end
|
||||
|
||||
def load_daily_points
|
||||
day_range = daily_time_range
|
||||
|
||||
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
|
||||
|
||||
track = create_track_from_points(segment)
|
||||
Rails.logger.debug "Generator: created track #{track&.id}"
|
||||
track
|
||||
end
|
||||
|
||||
def time_range_defined?
|
||||
start_at.present? || end_at.present?
|
||||
end
|
||||
|
||||
def time_range
|
||||
return nil unless time_range_defined?
|
||||
|
||||
start_time = start_at&.to_i
|
||||
end_time = end_at&.to_i
|
||||
|
||||
if start_time && end_time
|
||||
Time.zone.at(start_time)..Time.zone.at(end_time)
|
||||
elsif start_time
|
||||
Time.zone.at(start_time)..
|
||||
elsif end_time
|
||||
..Time.zone.at(end_time)
|
||||
end
|
||||
end
|
||||
|
||||
def timestamp_range
|
||||
return nil unless time_range_defined?
|
||||
|
||||
start_time = start_at&.to_i
|
||||
end_time = end_at&.to_i
|
||||
|
||||
if start_time && end_time
|
||||
start_time..end_time
|
||||
elsif start_time
|
||||
start_time..
|
||||
elsif end_time
|
||||
..end_time
|
||||
end
|
||||
end
|
||||
|
||||
def daily_time_range
|
||||
day = start_at&.to_date || Date.current
|
||||
day.beginning_of_day.to_i..day.end_of_day.to_i
|
||||
end
|
||||
|
||||
def clean_existing_tracks
|
||||
case mode
|
||||
when :bulk then clean_bulk_tracks
|
||||
when :daily then clean_daily_tracks
|
||||
else
|
||||
raise ArgumentError, "Unknown mode: #{mode}"
|
||||
end
|
||||
end
|
||||
|
||||
def clean_bulk_tracks
|
||||
scope = user.tracks
|
||||
scope = scope.where(start_at: time_range) if time_range_defined?
|
||||
|
||||
scope.destroy_all
|
||||
end
|
||||
|
||||
def clean_daily_tracks
|
||||
day_range = daily_time_range
|
||||
range = Time.zone.at(day_range.begin)..Time.zone.at(day_range.end)
|
||||
|
||||
scope = user.tracks.where(start_at: range)
|
||||
scope.destroy_all
|
||||
end
|
||||
|
||||
# Threshold methods from safe_settings
|
||||
def distance_threshold_meters
|
||||
@distance_threshold_meters ||= user.safe_settings.meters_between_routes.to_i
|
||||
end
|
||||
|
||||
def time_threshold_minutes
|
||||
@time_threshold_minutes ||= user.safe_settings.minutes_between_routes.to_i
|
||||
end
|
||||
end
|
||||
92
app/services/tracks/incremental_processor.rb
Normal file
92
app/services/tracks/incremental_processor.rb
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# This service analyzes new points as they're created and determines whether
|
||||
# they should trigger incremental track generation based on time and distance
|
||||
# thresholds defined in user settings.
|
||||
#
|
||||
# The key insight is that we should trigger track generation when there's a
|
||||
# significant gap between the new point and the previous point, indicating
|
||||
# the end of a journey and the start of a new one.
|
||||
#
|
||||
# Process:
|
||||
# 1. Check if the new point should trigger processing (skip imported points)
|
||||
# 2. Find the last point before the new point
|
||||
# 3. Calculate time and distance differences
|
||||
# 4. If thresholds are exceeded, trigger incremental generation
|
||||
# 5. Set the end_at time to the previous point's timestamp for track finalization
|
||||
#
|
||||
# This ensures tracks are properly finalized when journeys end, not when they start.
|
||||
#
|
||||
# Usage:
|
||||
# # In Point model after_create_commit callback
|
||||
# Tracks::IncrementalProcessor.new(user, new_point).call
|
||||
#
|
||||
class Tracks::IncrementalProcessor
|
||||
attr_reader :user, :new_point, :previous_point
|
||||
|
||||
def initialize(user, new_point)
|
||||
@user = user
|
||||
@new_point = new_point
|
||||
@previous_point = find_previous_point
|
||||
end
|
||||
|
||||
def call
|
||||
return unless should_process?
|
||||
|
||||
start_at = find_start_time
|
||||
end_at = find_end_time
|
||||
|
||||
Tracks::CreateJob.perform_later(user.id, start_at:, end_at:, mode: :incremental)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def should_process?
|
||||
return false if new_point.import_id.present?
|
||||
return true unless previous_point
|
||||
|
||||
exceeds_thresholds?(previous_point, new_point)
|
||||
end
|
||||
|
||||
def find_previous_point
|
||||
@previous_point ||=
|
||||
user.tracked_points
|
||||
.where('timestamp < ?', new_point.timestamp)
|
||||
.order(:timestamp)
|
||||
.last
|
||||
end
|
||||
|
||||
def find_start_time
|
||||
user.tracks.order(:end_at).last&.end_at
|
||||
end
|
||||
|
||||
def find_end_time
|
||||
previous_point ? Time.zone.at(previous_point.timestamp) : nil
|
||||
end
|
||||
|
||||
def exceeds_thresholds?(previous_point, current_point)
|
||||
time_gap = time_difference_minutes(previous_point, current_point)
|
||||
distance_gap = distance_difference_meters(previous_point, current_point)
|
||||
|
||||
time_exceeded = time_gap >= time_threshold_minutes
|
||||
distance_exceeded = distance_gap >= distance_threshold_meters
|
||||
|
||||
time_exceeded || distance_exceeded
|
||||
end
|
||||
|
||||
def time_difference_minutes(point1, point2)
|
||||
(point2.timestamp - point1.timestamp) / 60.0
|
||||
end
|
||||
|
||||
def distance_difference_meters(point1, point2)
|
||||
point1.distance_to(point2) * 1000
|
||||
end
|
||||
|
||||
def time_threshold_minutes
|
||||
@time_threshold_minutes ||= user.safe_settings.minutes_between_routes.to_i
|
||||
end
|
||||
|
||||
def distance_threshold_meters
|
||||
@distance_threshold_meters ||= user.safe_settings.meters_between_routes.to_i
|
||||
end
|
||||
end
|
||||
121
app/services/tracks/segmentation.rb
Normal file
121
app/services/tracks/segmentation.rb
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# Track segmentation logic for splitting GPS points into meaningful track segments.
|
||||
#
|
||||
# This module provides the core algorithm for determining where one track ends
|
||||
# and another begins, based on time gaps and distance jumps between consecutive points.
|
||||
#
|
||||
# How it works:
|
||||
# 1. Analyzes consecutive GPS points to detect gaps that indicate separate journeys
|
||||
# 2. Uses configurable time and distance thresholds to identify segment boundaries
|
||||
# 3. Splits large arrays of points into smaller arrays representing individual tracks
|
||||
# 4. Provides utilities for handling both Point objects and hash representations
|
||||
#
|
||||
# Segmentation criteria:
|
||||
# - Time threshold: Gap longer than X minutes indicates a new track
|
||||
# - Distance threshold: Jump larger than X meters indicates a new track
|
||||
# - Minimum segment size: Segments must have at least 2 points to form a track
|
||||
#
|
||||
# The module is designed to be included in classes that need segmentation logic
|
||||
# and requires the including class to implement distance_threshold_meters and
|
||||
# time_threshold_minutes methods.
|
||||
#
|
||||
# Used by:
|
||||
# - Tracks::Generator for splitting points during track generation
|
||||
# - Tracks::CreateFromPoints for legacy compatibility
|
||||
#
|
||||
# Example usage:
|
||||
# class MyTrackProcessor
|
||||
# include Tracks::Segmentation
|
||||
#
|
||||
# def distance_threshold_meters; 500; end
|
||||
# def time_threshold_minutes; 60; end
|
||||
#
|
||||
# def process_points(points)
|
||||
# segments = split_points_into_segments(points)
|
||||
# # Process each segment...
|
||||
# end
|
||||
# end
|
||||
#
|
||||
module Tracks::Segmentation
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
private
|
||||
|
||||
def split_points_into_segments(points)
|
||||
return [] if points.empty?
|
||||
|
||||
segments = []
|
||||
current_segment = []
|
||||
|
||||
points.each do |point|
|
||||
if should_start_new_segment?(point, current_segment.last)
|
||||
# Finalize current segment if it has enough points
|
||||
segments << current_segment if current_segment.size >= 2
|
||||
current_segment = [point]
|
||||
else
|
||||
current_segment << point
|
||||
end
|
||||
end
|
||||
|
||||
# Don't forget the last segment
|
||||
segments << current_segment if current_segment.size >= 2
|
||||
|
||||
segments
|
||||
end
|
||||
|
||||
def should_start_new_segment?(current_point, previous_point)
|
||||
return false if previous_point.nil?
|
||||
|
||||
# Check time threshold (convert minutes to seconds)
|
||||
current_timestamp = current_point.timestamp
|
||||
previous_timestamp = previous_point.timestamp
|
||||
|
||||
time_diff_seconds = current_timestamp - previous_timestamp
|
||||
time_threshold_seconds = time_threshold_minutes.to_i * 60
|
||||
|
||||
return true if time_diff_seconds > time_threshold_seconds
|
||||
|
||||
# Check distance threshold - convert km to meters to match frontend logic
|
||||
distance_km = calculate_km_distance_between_points(previous_point, current_point)
|
||||
distance_meters = distance_km * 1000 # Convert km to meters
|
||||
|
||||
return true if distance_meters > distance_threshold_meters
|
||||
|
||||
false
|
||||
end
|
||||
|
||||
def calculate_km_distance_between_points(point1, point2)
|
||||
lat1, lon1 = point_coordinates(point1)
|
||||
lat2, lon2 = point_coordinates(point2)
|
||||
|
||||
# Use Geocoder to match behavior with frontend (same library used elsewhere in app)
|
||||
Geocoder::Calculations.distance_between([lat1, lon1], [lat2, lon2], units: :km)
|
||||
end
|
||||
|
||||
def should_finalize_segment?(segment_points, grace_period_minutes = 5)
|
||||
return false if segment_points.size < 2
|
||||
|
||||
last_point = segment_points.last
|
||||
last_timestamp = last_point.timestamp
|
||||
current_time = Time.current.to_i
|
||||
|
||||
# Don't finalize if the last point is too recent (within grace period)
|
||||
time_since_last_point = current_time - last_timestamp
|
||||
grace_period_seconds = grace_period_minutes * 60
|
||||
|
||||
time_since_last_point > grace_period_seconds
|
||||
end
|
||||
|
||||
def point_coordinates(point)
|
||||
[point.lat, point.lon]
|
||||
end
|
||||
|
||||
def distance_threshold_meters
|
||||
raise NotImplementedError, "Including class must implement distance_threshold_meters"
|
||||
end
|
||||
|
||||
def time_threshold_minutes
|
||||
raise NotImplementedError, "Including class must implement time_threshold_minutes"
|
||||
end
|
||||
end
|
||||
148
app/services/tracks/track_builder.rb
Normal file
148
app/services/tracks/track_builder.rb
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# Track creation and statistics calculation module for building Track records from GPS points.
|
||||
#
|
||||
# This module provides the core functionality for converting arrays of GPS points into
|
||||
# Track database records with calculated statistics including distance, duration, speed,
|
||||
# and elevation metrics.
|
||||
#
|
||||
# How it works:
|
||||
# 1. Takes an array of Point objects representing a track segment
|
||||
# 2. Creates a Track record with basic temporal and spatial boundaries
|
||||
# 3. Calculates comprehensive statistics: distance, duration, average speed
|
||||
# 4. Computes elevation metrics: gain, loss, maximum, minimum
|
||||
# 5. Builds a LineString path representation for mapping
|
||||
# 6. Associates all points with the created track
|
||||
#
|
||||
# Statistics calculated:
|
||||
# - Distance: Always stored in meters as integers for consistency
|
||||
# - Duration: Total time in seconds between first and last point
|
||||
# - Average speed: In km/h regardless of user's distance unit preference
|
||||
# - Elevation gain/loss: Cumulative ascent and descent in meters
|
||||
# - Elevation max/min: Highest and lowest altitudes in the track
|
||||
#
|
||||
# Distance is converted to user's preferred unit only at display time, not storage time.
|
||||
# This ensures consistency when users change their distance unit preferences.
|
||||
#
|
||||
# Used by:
|
||||
# - Tracks::Generator for creating tracks during generation
|
||||
# - Any class that needs to convert point arrays to Track records
|
||||
#
|
||||
# Example usage:
|
||||
# class MyTrackProcessor
|
||||
# include Tracks::TrackBuilder
|
||||
#
|
||||
# def initialize(user)
|
||||
# @user = user
|
||||
# end
|
||||
#
|
||||
# def process_segment(points)
|
||||
# track = create_track_from_points(points)
|
||||
# # Track now exists with calculated statistics
|
||||
# end
|
||||
#
|
||||
# private
|
||||
#
|
||||
# attr_reader :user
|
||||
# end
|
||||
#
|
||||
module Tracks::TrackBuilder
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def create_track_from_points(points)
|
||||
return nil if points.size < 2
|
||||
|
||||
track = Track.new(
|
||||
user_id: user.id,
|
||||
start_at: Time.zone.at(points.first.timestamp),
|
||||
end_at: Time.zone.at(points.last.timestamp),
|
||||
original_path: build_path(points)
|
||||
)
|
||||
|
||||
# Calculate track statistics
|
||||
track.distance = calculate_track_distance(points)
|
||||
track.duration = calculate_duration(points)
|
||||
track.avg_speed = calculate_average_speed(track.distance, track.duration)
|
||||
|
||||
# Calculate elevation statistics
|
||||
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]
|
||||
|
||||
if track.save
|
||||
Point.where(id: points.map(&:id)).update_all(track_id: track.id)
|
||||
|
||||
track
|
||||
else
|
||||
Rails.logger.error "Failed to create track for user #{user.id}: #{track.errors.full_messages.join(', ')}"
|
||||
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
def build_path(points)
|
||||
Tracks::BuildPath.new(points).call
|
||||
end
|
||||
|
||||
def calculate_track_distance(points)
|
||||
# Always calculate and store distance in meters for consistency
|
||||
distance_in_meters = Point.total_distance(points, :m)
|
||||
distance_in_meters.round
|
||||
end
|
||||
|
||||
def calculate_duration(points)
|
||||
points.last.timestamp - points.first.timestamp
|
||||
end
|
||||
|
||||
def calculate_average_speed(distance_in_meters, duration_seconds)
|
||||
return 0.0 if duration_seconds <= 0 || distance_in_meters <= 0
|
||||
|
||||
# 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
|
||||
|
||||
def calculate_elevation_stats(points)
|
||||
altitudes = points.map(&:altitude).compact
|
||||
|
||||
return default_elevation_stats if altitudes.empty?
|
||||
|
||||
elevation_gain = 0
|
||||
elevation_loss = 0
|
||||
previous_altitude = altitudes.first
|
||||
|
||||
altitudes[1..].each do |altitude|
|
||||
diff = altitude - previous_altitude
|
||||
if diff > 0
|
||||
elevation_gain += diff
|
||||
else
|
||||
elevation_loss += diff.abs
|
||||
end
|
||||
previous_altitude = altitude
|
||||
end
|
||||
|
||||
{
|
||||
gain: elevation_gain.round,
|
||||
loss: elevation_loss.round,
|
||||
max: altitudes.max,
|
||||
min: altitudes.min
|
||||
}
|
||||
end
|
||||
|
||||
def default_elevation_stats
|
||||
{
|
||||
gain: 0,
|
||||
loss: 0,
|
||||
max: 0,
|
||||
min: 0
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def user
|
||||
raise NotImplementedError, "Including class must implement user method"
|
||||
end
|
||||
end
|
||||
|
|
@ -18,7 +18,8 @@ class Users::SafeSettings
|
|||
'immich_api_key' => nil,
|
||||
'photoprism_url' => nil,
|
||||
'photoprism_api_key' => nil,
|
||||
'maps' => { 'distance_unit' => 'km' }
|
||||
'maps' => { 'distance_unit' => 'km' },
|
||||
'visits_suggestions_enabled' => 'true'
|
||||
}.freeze
|
||||
|
||||
def initialize(settings = {})
|
||||
|
|
@ -43,7 +44,10 @@ class Users::SafeSettings
|
|||
photoprism_url: photoprism_url,
|
||||
photoprism_api_key: photoprism_api_key,
|
||||
maps: maps,
|
||||
distance_unit: distance_unit
|
||||
distance_unit: distance_unit,
|
||||
visits_suggestions_enabled: visits_suggestions_enabled?,
|
||||
speed_color_scale: speed_color_scale,
|
||||
fog_of_war_threshold: fog_of_war_threshold
|
||||
}
|
||||
end
|
||||
# rubocop:enable Metrics/MethodLength
|
||||
|
|
@ -111,4 +115,16 @@ class Users::SafeSettings
|
|||
def distance_unit
|
||||
settings.dig('maps', 'distance_unit')
|
||||
end
|
||||
|
||||
def visits_suggestions_enabled?
|
||||
settings['visits_suggestions_enabled'] == 'true'
|
||||
end
|
||||
|
||||
def speed_color_scale
|
||||
settings['speed_color_scale']
|
||||
end
|
||||
|
||||
def fog_of_war_threshold
|
||||
settings['fog_of_war_threshold']
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Visits::Suggest
|
||||
include Rails.application.routes.url_helpers
|
||||
|
||||
attr_reader :points, :user, :start_at, :end_at
|
||||
|
||||
def initialize(user, start_at:, end_at:)
|
||||
|
|
@ -14,6 +12,7 @@ class Visits::Suggest
|
|||
|
||||
def call
|
||||
visits = Visits::SmartDetect.new(user, start_at:, end_at:).call
|
||||
|
||||
create_visits_notification(user) if visits.any?
|
||||
|
||||
return nil unless DawarichSettings.reverse_geocoding_enabled?
|
||||
|
|
@ -35,7 +34,7 @@ class Visits::Suggest
|
|||
|
||||
def create_visits_notification(user)
|
||||
content = <<~CONTENT
|
||||
New visits have been suggested based on your location data from #{Time.zone.at(start_at)} to #{Time.zone.at(end_at)}. You can review them on the <a href="#{visits_path}" class="link">Visits</a> page.
|
||||
New visits have been suggested based on your location data from #{Time.zone.at(start_at)} to #{Time.zone.at(end_at)}. You can review them on the <a href="/visits" class="link">Visits</a> page.
|
||||
CONTENT
|
||||
|
||||
user.notifications.create!(
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<p class="py-6">
|
||||
<p class='py-2'>
|
||||
You have used <%= number_with_delimiter(current_user.points.count) %> points of <%= number_with_delimiter(DawarichSettings::BASIC_PAID_PLAN_LIMIT) %> available.
|
||||
You have used <%= number_with_delimiter(current_user.tracked_points.count) %> points of <%= number_with_delimiter(DawarichSettings::BASIC_PAID_PLAN_LIMIT) %> available.
|
||||
</p>
|
||||
<progress class="progress progress-primary w-1/2 h-5" value="<%= current_user.points.count %>" max="<%= DawarichSettings::BASIC_PAID_PLAN_LIMIT %>"></progress>
|
||||
<progress class="progress progress-primary w-1/2 h-5" value="<%= current_user.tracked_points.count %>" max="<%= DawarichSettings::BASIC_PAID_PLAN_LIMIT %>"></progress>
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
<div class="w-full sm:w-1/12 md:w-1/12 lg:w-1/12">
|
||||
<div class="flex flex-col space-y-2">
|
||||
<span class="tooltip" data-tip="<%= human_date(@start_at - 1.day) %>">
|
||||
<%= link_to map_path(start_at: @start_at - 1.day, end_at: @end_at - 1.day, import_id: params[:import_id]), class: "btn btn-neutral hover:btn-ghost w-full" do %>
|
||||
<%= link_to map_path(start_at: @start_at - 1.day, end_at: @end_at - 1.day, import_id: params[:import_id]), class: "btn border border-base-300 hover:btn-ghost w-full" do %>
|
||||
◀️
|
||||
<% end %>
|
||||
</span>
|
||||
|
|
@ -29,7 +29,7 @@
|
|||
<div class="w-full sm:w-1/12 md:w-1/12 lg:w-1/12">
|
||||
<div class="flex flex-col space-y-2">
|
||||
<span class="tooltip" data-tip="<%= human_date(@start_at + 1.day) %>">
|
||||
<%= link_to map_path(start_at: @start_at + 1.day, end_at: @end_at + 1.day, import_id: params[:import_id]), class: "btn btn-neutral hover:btn-ghost w-full" do %>
|
||||
<%= link_to map_path(start_at: @start_at + 1.day, end_at: @end_at + 1.day, import_id: params[:import_id]), class: "btn border border-base-300 hover:btn-ghost w-full" do %>
|
||||
▶️
|
||||
<% end %>
|
||||
</span>
|
||||
|
|
@ -44,17 +44,17 @@
|
|||
<div class="flex flex-col space-y-2 text-center">
|
||||
<%= link_to "Today",
|
||||
map_path(start_at: Time.current.beginning_of_day, end_at: Time.current.end_of_day, import_id: params[:import_id]),
|
||||
class: "btn btn-neutral hover:btn-ghost" %>
|
||||
class: "btn border border-base-300 hover:btn-ghost" %>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full sm:w-6/12 md:w-3/12 lg:w-2/12">
|
||||
<div class="flex flex-col space-y-2 text-center">
|
||||
<%= link_to "Last 7 days", map_path(start_at: 1.week.ago.beginning_of_day, end_at: Time.current.end_of_day, import_id: params[:import_id]), class: "btn btn-neutral hover:btn-ghost" %>
|
||||
<%= link_to "Last 7 days", map_path(start_at: 1.week.ago.beginning_of_day, end_at: Time.current.end_of_day, import_id: params[:import_id]), class: "btn border border-base-300 hover:btn-ghost" %>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full sm:w-6/12 md:w-3/12 lg:w-2/12">
|
||||
<div class="flex flex-col space-y-2 text-center">
|
||||
<%= link_to "Last month", map_path(start_at: 1.month.ago.beginning_of_day, end_at: Time.current.end_of_day, import_id: params[:import_id]), class: "btn btn-neutral hover:btn-ghost" %>
|
||||
<%= link_to "Last month", map_path(start_at: 1.month.ago.beginning_of_day, end_at: Time.current.end_of_day, import_id: params[:import_id]), class: "btn border border-base-300 hover:btn-ghost" %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -67,8 +67,9 @@
|
|||
data-points-target="map"
|
||||
data-api_key="<%= current_user.api_key %>"
|
||||
data-self_hosted="<%= @self_hosted %>"
|
||||
data-user_settings='<%= current_user.settings.to_json.html_safe %>'
|
||||
data-coordinates="<%= @coordinates %>"
|
||||
data-user_settings='<%= (current_user.settings || {}).to_json.html_safe %>'
|
||||
data-coordinates='<%= @coordinates.to_json.html_safe %>'
|
||||
data-tracks='<%= @tracks.to_json.html_safe %>'
|
||||
data-distance="<%= @distance %>"
|
||||
data-points_number="<%= @points_number %>"
|
||||
data-timezone="<%= Rails.configuration.time_zone %>">
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@
|
|||
<span>Spamming many new jobs at once is a bad idea. Let them work or clear the queue beforehand.</span>
|
||||
</div>
|
||||
|
||||
<div class='flex'>
|
||||
<div class='flex flex-wrap'>
|
||||
<div class="card bg-base-300 w-96 shadow-xl m-5">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">Start Reverse Geocoding</h2>
|
||||
|
|
@ -49,5 +49,19 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-300 w-96 shadow-xl m-5">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">Visits suggestions</h2>
|
||||
<p>Enable or disable visits suggestions. It's a background task that runs every day at midnight. Disabling it might be useful if you don't want to receive visits suggestions or if you're using the Dawarich iOS app, which has its own visits suggestions.</p>
|
||||
<div class="card-actions justify-end">
|
||||
<% if current_user.safe_settings.visits_suggestions_enabled? %>
|
||||
<%= link_to 'Disable', settings_path(settings: { 'visits_suggestions_enabled' => 'false' }), method: :patch, data: { confirm: 'Are you sure?', turbo_confirm: 'Are you sure?', turbo_method: :patch }, class: 'btn btn-error' %>
|
||||
<% else %>
|
||||
<%= link_to 'Enable', settings_path(settings: { 'visits_suggestions_enabled' => 'true' }), method: :patch, data: { confirm: 'Are you sure?', turbo_confirm: 'Are you sure?', turbo_method: :patch }, class: 'btn btn-success' %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -124,7 +124,7 @@
|
|||
<li><%= link_to 'Subscription', "#{MANAGER_URL}/auth/dawarich?token=#{current_user.generate_subscription_token}" %></li>
|
||||
<% end %>
|
||||
|
||||
<li><%= link_to 'Logout', destroy_user_session_path, method: :delete, data: { turbo_method: :delete } %></li>
|
||||
<li><%= link_to 'Logout', destroy_user_session_path, method: :delete, data: { turbo: false } %></li>
|
||||
</ul>
|
||||
</details>
|
||||
</li>
|
||||
|
|
|
|||
|
|
@ -1,31 +1,30 @@
|
|||
<div id="<%= dom_id stat %>" class="card w-full bg-base-200 shadow-xl">
|
||||
<div class="card-body">
|
||||
<div class="flex justify-between items-center">
|
||||
<h2 class="card-title">
|
||||
<%= link_to map_url(timespan(stat.month, stat.year)), class: "underline hover:no-underline text-#{header_colors.sample}" do %>
|
||||
<%= Date::MONTHNAMES[stat.month] %>
|
||||
<% end %>
|
||||
</h2>
|
||||
<div class="border border-gray-500 rounded-md border-opacity-30 bg-gray-100 dark:bg-gray-800 p-3">
|
||||
<div class="flex justify-between">
|
||||
<h4 class="stat-title text-left"><%= Date::MONTHNAMES[stat.month] %> <%= stat.year %></h4>
|
||||
|
||||
<div class="gap-2">
|
||||
<span class='text-xs text-gray-500'>Last update <%= human_date(stat.updated_at) %></span>
|
||||
<%= link_to '🔄', update_year_month_stats_path(stat.year, stat.month), data: { turbo_method: :put }, class: 'text-sm text-gray-500 hover:underline' %>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<%= link_to "Details", points_path(year: stat.year, month: stat.month),
|
||||
class: "link link-primary" %>
|
||||
</div>
|
||||
<p><%= number_with_delimiter stat.distance %><%= current_user.safe_settings.distance_unit %></p>
|
||||
<% if DawarichSettings.reverse_geocoding_enabled? %>
|
||||
<div class="card-actions justify-end">
|
||||
<%= countries_and_cities_stat_for_month(stat) %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% if stat.daily_distance %>
|
||||
<%= column_chart(
|
||||
stat.daily_distance,
|
||||
height: '100px',
|
||||
suffix: " #{current_user.safe_settings.distance_unit}",
|
||||
xtitle: 'Days',
|
||||
ytitle: 'Distance'
|
||||
) %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="flex">
|
||||
<div class="stat-value">
|
||||
<p><%= number_with_delimiter stat.distance_in_unit(current_user.safe_settings.distance_unit).round %><%= current_user.safe_settings.distance_unit %></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-desc">
|
||||
<%= countries_and_cities_stat_for_month(stat) %>
|
||||
</div>
|
||||
|
||||
<%= area_chart(
|
||||
stat.daily_distance.map { |day, distance_meters|
|
||||
[day, Stat.convert_distance(distance_meters, current_user.safe_settings.distance_unit).round(2)]
|
||||
},
|
||||
height: '200px',
|
||||
suffix: " #{current_user.safe_settings.distance_unit}",
|
||||
xtitle: 'Day',
|
||||
ytitle: 'Distance'
|
||||
) %>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
<div class="stats stats-vertical lg:stats-horizontal shadow w-full bg-base-200">
|
||||
<div class="stat text-center">
|
||||
<div class="stat-value text-primary">
|
||||
<%= number_with_delimiter(current_user.total_distance) %> <%= current_user.safe_settings.distance_unit %>
|
||||
<%= number_with_delimiter(current_user.total_distance.round) %> <%= current_user.safe_settings.distance_unit %>
|
||||
</div>
|
||||
<div class="stat-title">Total distance</div>
|
||||
</div>
|
||||
|
|
@ -82,7 +82,9 @@
|
|||
</div>
|
||||
<% end %>
|
||||
<%= column_chart(
|
||||
Stat.year_distance(year, current_user),
|
||||
@year_distances[year].map { |month_name, distance_meters|
|
||||
[month_name, Stat.convert_distance(distance_meters, current_user.safe_settings.distance_unit).round(2)]
|
||||
},
|
||||
height: '200px',
|
||||
suffix: " #{current_user.safe_settings.distance_unit}",
|
||||
xtitle: 'Days',
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
<div class="card bg-base-200 shadow-lg">
|
||||
<div class="card-body p-4">
|
||||
<div class="stat-title text-xs">Distance</div>
|
||||
<div class="stat-value text-lg"><%= trip.distance %> <%= distance_unit %></div>
|
||||
<div class="stat-value text-lg"><%= trip.distance_for_user(current_user).round %> <%= distance_unit %></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card bg-base-200 shadow-lg">
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<% if trip.distance.present? %>
|
||||
<span class="text-md"><%= trip.distance %> <%= distance_unit %></span>
|
||||
<span class="text-md"><%= trip.distance_for_user(current_user).round %> <%= distance_unit %></span>
|
||||
<% else %>
|
||||
<span class="text-md">Calculating...</span>
|
||||
<span class="loading loading-dots loading-sm"></span>
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
<span class="hover:underline"><%= trip.name %></span>
|
||||
</h2>
|
||||
<p class="text-sm text-gray-600 text-center">
|
||||
<%= "#{human_date(trip.started_at)} – #{human_date(trip.ended_at)}, #{trip.distance} #{current_user.safe_settings.distance_unit}" %>
|
||||
<%= "#{human_date(trip.started_at)} – #{human_date(trip.ended_at)}, #{trip.distance_for_user(current_user).round} #{current_user.safe_settings.distance_unit}" %>
|
||||
</p>
|
||||
|
||||
<div style="width: 100%; aspect-ratio: 1/1;"
|
||||
|
|
|
|||
|
|
@ -88,7 +88,7 @@ Rails.application.configure do
|
|||
|
||||
hosts = ENV.fetch('APPLICATION_HOSTS', 'localhost').split(',')
|
||||
|
||||
config.action_mailer.default_url_options = { host: ENV['SMTP_DOMAIN'] || hosts.first, port: ENV.fetch('PORT', 3000) }
|
||||
config.action_mailer.default_url_options = { host: ENV['DOMAIN'] || hosts.first, port: ENV.fetch('PORT', 3000) }
|
||||
|
||||
config.hosts.concat(hosts) if hosts.present?
|
||||
|
||||
|
|
|
|||
|
|
@ -103,7 +103,7 @@ Rails.application.configure do
|
|||
config.host_authorization = { exclude: ->(request) { request.path == "/api/v1/health" } }
|
||||
hosts = ENV.fetch('APPLICATION_HOSTS', 'localhost').split(',')
|
||||
|
||||
config.action_mailer.default_url_options = { host: ENV['SMTP_DOMAIN'] }
|
||||
config.action_mailer.default_url_options = { host: ENV['DOMAIN'] }
|
||||
config.hosts.concat(hosts) if hosts.present?
|
||||
|
||||
config.action_mailer.delivery_method = :smtp
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ Rails.application.configure do
|
|||
# Show full error reports and disable caching.
|
||||
config.consider_all_requests_local = true
|
||||
config.action_controller.perform_caching = false
|
||||
config.cache_store = :null_store
|
||||
config.cache_store = :redis_cache_store, { url: "#{ENV.fetch('REDIS_URL', 'redis://localhost:6379')}/#{ENV.fetch('RAILS_CACHE_DB', 0)}" }
|
||||
|
||||
# Render exception templates for rescuable exceptions and raise for other exceptions.
|
||||
config.action_dispatch.show_exceptions = :rescuable
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ pin_all_from 'app/javascript/channels', under: 'channels'
|
|||
pin 'application', preload: true
|
||||
pin '@rails/actioncable', to: 'actioncable.esm.js'
|
||||
pin '@rails/activestorage', to: 'activestorage.esm.js'
|
||||
pin '@rails/ujs', to: 'rails-ujs.js'
|
||||
pin '@hotwired/turbo-rails', to: 'turbo.min.js', preload: true
|
||||
pin '@hotwired/stimulus', to: 'stimulus.min.js', preload: true
|
||||
pin '@hotwired/stimulus-loading', to: 'stimulus-loading.js', preload: true
|
||||
|
|
|
|||
|
|
@ -5,11 +5,11 @@ SELF_HOSTED = ENV.fetch('SELF_HOSTED', 'true') == 'true'
|
|||
MIN_MINUTES_SPENT_IN_CITY = ENV.fetch('MIN_MINUTES_SPENT_IN_CITY', 60).to_i
|
||||
|
||||
DISTANCE_UNITS = {
|
||||
km: 1000, # to meters
|
||||
km: 1000, # to meters
|
||||
mi: 1609.34, # to meters
|
||||
m: 1, # already in meters
|
||||
ft: 0.3048, # to meters
|
||||
yd: 0.9144 # to meters
|
||||
m: 1, # already in meters
|
||||
ft: 0.3048, # to meters
|
||||
yd: 0.9144 # to meters
|
||||
}.freeze
|
||||
|
||||
APP_VERSION = File.read('.app_version').strip
|
||||
|
|
|
|||
|
|
@ -29,3 +29,13 @@ cache_preheating_job:
|
|||
cron: "0 0 * * *" # every day at 0:00
|
||||
class: "Cache::PreheatingJob"
|
||||
queue: default
|
||||
|
||||
# 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
|
||||
class: "Places::BulkNameFetchingJob"
|
||||
queue: places
|
||||
|
|
|
|||
|
|
@ -6,5 +6,8 @@
|
|||
- imports
|
||||
- exports
|
||||
- stats
|
||||
- trips
|
||||
- tracks
|
||||
- reverse_geocoding
|
||||
- visit_suggesting
|
||||
- places
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@
|
|||
|
||||
class CreatePhotonLoadNotification < ActiveRecord::Migration[8.0]
|
||||
def up
|
||||
return
|
||||
|
||||
User.find_each do |user|
|
||||
Notifications::Create.new(
|
||||
user:, kind: :info, title: '⚠️ Photon API is under heavy load', content: notification_content
|
||||
|
|
|
|||
37
db/data/20250704185707_create_tracks_from_points.rb
Normal file
37
db/data/20250704185707_create_tracks_from_points.rb
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class CreateTracksFromPoints < ActiveRecord::Migration[8.0]
|
||||
def up
|
||||
puts "Starting bulk track creation for all users..."
|
||||
|
||||
total_users = User.count
|
||||
processed_users = 0
|
||||
|
||||
User.find_each do |user|
|
||||
points_count = user.tracked_points.count
|
||||
|
||||
if points_count > 0
|
||||
puts "Enqueuing track creation for user #{user.id} (#{points_count} points)"
|
||||
|
||||
# Use explicit parameters for bulk historical processing:
|
||||
# - No time limits (start_at: nil, end_at: nil) = process ALL historical data
|
||||
Tracks::CreateJob.perform_later(
|
||||
user.id,
|
||||
start_at: nil,
|
||||
end_at: nil,
|
||||
mode: :bulk
|
||||
)
|
||||
|
||||
processed_users += 1
|
||||
else
|
||||
puts "Skipping user #{user.id} (no tracked points)"
|
||||
end
|
||||
end
|
||||
|
||||
puts "Enqueued track creation jobs for #{processed_users}/#{total_users} users"
|
||||
end
|
||||
|
||||
def down
|
||||
raise ActiveRecord::IrreversibleMigration
|
||||
end
|
||||
end
|
||||
13
db/data/20250709195003_recalculate_trips_distance.rb
Normal file
13
db/data/20250709195003_recalculate_trips_distance.rb
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class RecalculateTripsDistance < ActiveRecord::Migration[8.0]
|
||||
def up
|
||||
Trip.find_each do |trip|
|
||||
trip.enqueue_calculation_jobs
|
||||
end
|
||||
end
|
||||
|
||||
def down
|
||||
raise ActiveRecord::IrreversibleMigration
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class RecalculateStatsAfterChangingDistanceUnits < ActiveRecord::Migration[8.0]
|
||||
def up
|
||||
BulkStatsCalculatingJob.perform_later
|
||||
end
|
||||
|
||||
def down
|
||||
raise ActiveRecord::IrreversibleMigration
|
||||
end
|
||||
end
|
||||
|
|
@ -1 +1 @@
|
|||
DataMigrate::Data.define(version: 20250518174305)
|
||||
DataMigrate::Data.define(version: 20250720171241)
|
||||
|
|
|
|||
19
db/migrate/20250703193656_create_tracks.rb
Normal file
19
db/migrate/20250703193656_create_tracks.rb
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
class CreateTracks < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
create_table :tracks do |t|
|
||||
t.datetime :start_at, null: false
|
||||
t.datetime :end_at, null: false
|
||||
t.references :user, null: false, foreign_key: true
|
||||
t.line_string :original_path, null: false
|
||||
t.decimal :distance, precision: 8, scale: 2
|
||||
t.float :avg_speed
|
||||
t.integer :duration
|
||||
t.integer :elevation_gain
|
||||
t.integer :elevation_loss
|
||||
t.integer :elevation_max
|
||||
t.integer :elevation_min
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
end
|
||||
end
|
||||
9
db/migrate/20250703193657_add_track_id_to_points.rb
Normal file
9
db/migrate/20250703193657_add_track_id_to_points.rb
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class AddTrackIdToPoints < ActiveRecord::Migration[8.0]
|
||||
disable_ddl_transaction!
|
||||
|
||||
def change
|
||||
add_reference :points, :track, index: { algorithm: :concurrently }
|
||||
end
|
||||
end
|
||||
|
|
@ -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
|
||||
26
db/schema.rb
generated
26
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_06_27_184017) do
|
||||
ActiveRecord::Schema[8.0].define(version: 2025_07_21_204404) 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_06_27_184017) 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_06_27_184017) 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
|
||||
|
||||
|
|
@ -181,6 +185,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_06_27_184017) do
|
|||
t.string "external_track_id"
|
||||
t.geography "lonlat", limit: {srid: 4326, type: "st_point", geographic: true}
|
||||
t.bigint "country_id"
|
||||
t.bigint "track_id"
|
||||
t.index ["altitude"], name: "index_points_on_altitude"
|
||||
t.index ["battery"], name: "index_points_on_battery"
|
||||
t.index ["battery_status"], name: "index_points_on_battery_status"
|
||||
|
|
@ -196,6 +201,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_06_27_184017) do
|
|||
t.index ["lonlat"], name: "index_points_on_lonlat", using: :gist
|
||||
t.index ["reverse_geocoded_at"], name: "index_points_on_reverse_geocoded_at"
|
||||
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"], name: "index_points_on_user_id"
|
||||
t.index ["visit_id"], name: "index_points_on_visit_id"
|
||||
|
|
@ -216,6 +222,23 @@ ActiveRecord::Schema[8.0].define(version: 2025_06_27_184017) do
|
|||
t.index ["year"], name: "index_stats_on_year"
|
||||
end
|
||||
|
||||
create_table "tracks", force: :cascade do |t|
|
||||
t.datetime "start_at", null: false
|
||||
t.datetime "end_at", null: false
|
||||
t.bigint "user_id", null: false
|
||||
t.geometry "original_path", limit: {srid: 0, type: "line_string"}, null: false
|
||||
t.integer "distance"
|
||||
t.float "avg_speed"
|
||||
t.integer "duration"
|
||||
t.integer "elevation_gain"
|
||||
t.integer "elevation_loss"
|
||||
t.integer "elevation_max"
|
||||
t.integer "elevation_min"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["user_id"], name: "index_tracks_on_user_id"
|
||||
end
|
||||
|
||||
create_table "trips", force: :cascade do |t|
|
||||
t.string "name", null: false
|
||||
t.datetime "started_at", null: false
|
||||
|
|
@ -280,6 +303,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_06_27_184017) do
|
|||
add_foreign_key "points", "users"
|
||||
add_foreign_key "points", "visits"
|
||||
add_foreign_key "stats", "users"
|
||||
add_foreign_key "tracks", "users"
|
||||
add_foreign_key "trips", "users"
|
||||
add_foreign_key "visits", "areas"
|
||||
add_foreign_key "visits", "places"
|
||||
|
|
|
|||
131
package-lock.json
generated
131
package-lock.json
generated
|
|
@ -12,6 +12,10 @@
|
|||
"postcss": "^8.4.49",
|
||||
"trix": "^2.1.15"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.54.1",
|
||||
"@types/node": "^24.0.13"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18.17.1",
|
||||
"npm": "9.6.7"
|
||||
|
|
@ -34,6 +38,22 @@
|
|||
"@rails/actioncable": "^7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.54.1",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.54.1.tgz",
|
||||
"integrity": "sha512-FS8hQ12acieG2dYSksmLOF7BNxnVf2afRJdCuM1eMSxj6QTSE6G4InGF7oApGgDb65MX7AwMVlIkpru0yZA4Xw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.54.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@rails/actioncable": {
|
||||
"version": "7.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@rails/actioncable/-/actioncable-7.1.3.tgz",
|
||||
|
|
@ -58,6 +78,16 @@
|
|||
"spark-md5": "^3.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "24.0.13",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.0.13.tgz",
|
||||
"integrity": "sha512-Qm9OYVOFHFYg3wJoTSrz80hoec5Lia/dPp84do3X7dZvLikQvM1YpmvTBEdIr/e+U8HTkFjLHLnl78K/qjf+jQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/trusted-types": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||
|
|
@ -133,6 +163,21 @@
|
|||
"resolved": "https://registry.npmjs.org/fastparse/-/fastparse-1.1.2.tgz",
|
||||
"integrity": "sha512-483XLLxTVIwWK3QTrMGRqUfUpoOs/0hbQrl2oz4J0pAcm3A3bu84wxTFqGqkJzewCLdME38xJLJAxBABfQT8sQ=="
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/leaflet": {
|
||||
"version": "1.9.4",
|
||||
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
|
||||
|
|
@ -160,6 +205,38 @@
|
|||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.54.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.54.1.tgz",
|
||||
"integrity": "sha512-peWpSwIBmSLi6aW2auvrUtf2DqY16YYcCMO8rTVx486jKmDTJg7UAhyrraP98GB8BoPURZP8+nxO7TSd4cPr5g==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.54.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.54.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.54.1.tgz",
|
||||
"integrity": "sha512-Nbjs2zjj0htNhzgiy5wu+3w09YetDx5pkrpI/kZotDlDUaYk0HVA5xrBVPdow4SAUIlhgKcJeJg4GRKW6xHusA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.3",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz",
|
||||
|
|
@ -226,6 +303,13 @@
|
|||
"dependencies": {
|
||||
"dompurify": "^3.2.5"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "7.8.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.8.0.tgz",
|
||||
"integrity": "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
|
|
@ -243,6 +327,15 @@
|
|||
"@rails/actioncable": "^7.0"
|
||||
}
|
||||
},
|
||||
"@playwright/test": {
|
||||
"version": "1.54.1",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.54.1.tgz",
|
||||
"integrity": "sha512-FS8hQ12acieG2dYSksmLOF7BNxnVf2afRJdCuM1eMSxj6QTSE6G4InGF7oApGgDb65MX7AwMVlIkpru0yZA4Xw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"playwright": "1.54.1"
|
||||
}
|
||||
},
|
||||
"@rails/actioncable": {
|
||||
"version": "7.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@rails/actioncable/-/actioncable-7.1.3.tgz",
|
||||
|
|
@ -264,6 +357,15 @@
|
|||
"spark-md5": "^3.0.1"
|
||||
}
|
||||
},
|
||||
"@types/node": {
|
||||
"version": "24.0.13",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.0.13.tgz",
|
||||
"integrity": "sha512-Qm9OYVOFHFYg3wJoTSrz80hoec5Lia/dPp84do3X7dZvLikQvM1YpmvTBEdIr/e+U8HTkFjLHLnl78K/qjf+jQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"undici-types": "~7.8.0"
|
||||
}
|
||||
},
|
||||
"@types/trusted-types": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||
|
|
@ -318,6 +420,13 @@
|
|||
"resolved": "https://registry.npmjs.org/fastparse/-/fastparse-1.1.2.tgz",
|
||||
"integrity": "sha512-483XLLxTVIwWK3QTrMGRqUfUpoOs/0hbQrl2oz4J0pAcm3A3bu84wxTFqGqkJzewCLdME38xJLJAxBABfQT8sQ=="
|
||||
},
|
||||
"fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"leaflet": {
|
||||
"version": "1.9.4",
|
||||
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
|
||||
|
|
@ -333,6 +442,22 @@
|
|||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="
|
||||
},
|
||||
"playwright": {
|
||||
"version": "1.54.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.54.1.tgz",
|
||||
"integrity": "sha512-peWpSwIBmSLi6aW2auvrUtf2DqY16YYcCMO8rTVx486jKmDTJg7UAhyrraP98GB8BoPURZP8+nxO7TSd4cPr5g==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"fsevents": "2.3.2",
|
||||
"playwright-core": "1.54.1"
|
||||
}
|
||||
},
|
||||
"playwright-core": {
|
||||
"version": "1.54.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.54.1.tgz",
|
||||
"integrity": "sha512-Nbjs2zjj0htNhzgiy5wu+3w09YetDx5pkrpI/kZotDlDUaYk0HVA5xrBVPdow4SAUIlhgKcJeJg4GRKW6xHusA==",
|
||||
"dev": true
|
||||
},
|
||||
"postcss": {
|
||||
"version": "8.5.3",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz",
|
||||
|
|
@ -368,6 +493,12 @@
|
|||
"requires": {
|
||||
"dompurify": "^3.2.5"
|
||||
}
|
||||
},
|
||||
"undici-types": {
|
||||
"version": "7.8.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.8.0.tgz",
|
||||
"integrity": "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==",
|
||||
"dev": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,5 +10,10 @@
|
|||
"engines": {
|
||||
"node": "18.17.1",
|
||||
"npm": "9.6.7"
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.54.1",
|
||||
"@types/node": "^24.0.13"
|
||||
},
|
||||
"scripts": {}
|
||||
}
|
||||
|
|
|
|||
78
spec/channels/tracks_channel_spec.rb
Normal file
78
spec/channels/tracks_channel_spec.rb
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe TracksChannel, type: :channel do
|
||||
let(:user) { create(:user) }
|
||||
|
||||
describe '#subscribed' do
|
||||
it 'successfully subscribes to the channel' do
|
||||
stub_connection current_user: user
|
||||
|
||||
subscribe
|
||||
|
||||
expect(subscription).to be_confirmed
|
||||
expect(subscription).to have_stream_for(user)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'track broadcasting' do
|
||||
let!(:track) { create(:track, user: user) }
|
||||
|
||||
before do
|
||||
stub_connection current_user: user
|
||||
subscribe
|
||||
end
|
||||
|
||||
it 'broadcasts track creation' do
|
||||
expect {
|
||||
TracksChannel.broadcast_to(user, {
|
||||
action: 'created',
|
||||
track: {
|
||||
id: track.id,
|
||||
start_at: track.start_at.iso8601,
|
||||
end_at: track.end_at.iso8601,
|
||||
distance: track.distance,
|
||||
avg_speed: track.avg_speed,
|
||||
duration: track.duration,
|
||||
elevation_gain: track.elevation_gain,
|
||||
elevation_loss: track.elevation_loss,
|
||||
elevation_max: track.elevation_max,
|
||||
elevation_min: track.elevation_min,
|
||||
original_path: track.original_path.to_s
|
||||
}
|
||||
})
|
||||
}.to have_broadcasted_to(user)
|
||||
end
|
||||
|
||||
it 'broadcasts track updates' do
|
||||
expect {
|
||||
TracksChannel.broadcast_to(user, {
|
||||
action: 'updated',
|
||||
track: {
|
||||
id: track.id,
|
||||
start_at: track.start_at.iso8601,
|
||||
end_at: track.end_at.iso8601,
|
||||
distance: track.distance,
|
||||
avg_speed: track.avg_speed,
|
||||
duration: track.duration,
|
||||
elevation_gain: track.elevation_gain,
|
||||
elevation_loss: track.elevation_loss,
|
||||
elevation_max: track.elevation_max,
|
||||
elevation_min: track.elevation_min,
|
||||
original_path: track.original_path.to_s
|
||||
}
|
||||
})
|
||||
}.to have_broadcasted_to(user)
|
||||
end
|
||||
|
||||
it 'broadcasts track destruction' do
|
||||
expect {
|
||||
TracksChannel.broadcast_to(user, {
|
||||
action: 'destroyed',
|
||||
track_id: track.id
|
||||
})
|
||||
}.to have_broadcasted_to(user)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -4,7 +4,7 @@ FactoryBot.define do
|
|||
factory :stat do
|
||||
year { 1 }
|
||||
month { 1 }
|
||||
distance { 1 }
|
||||
distance { 1000 } # 1 km
|
||||
user
|
||||
toponyms do
|
||||
[
|
||||
|
|
|
|||
15
spec/factories/tracks.rb
Normal file
15
spec/factories/tracks.rb
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
FactoryBot.define do
|
||||
factory :track do
|
||||
association :user
|
||||
start_at { 1.hour.ago }
|
||||
end_at { 30.minutes.ago }
|
||||
original_path { 'LINESTRING(-74.0060 40.7128, -74.0070 40.7130)' }
|
||||
distance { 1500.0 } # in meters
|
||||
avg_speed { 25.0 } # in km/h
|
||||
duration { 1800 } # 30 minutes in seconds
|
||||
elevation_gain { 50 }
|
||||
elevation_loss { 20 }
|
||||
elevation_max { 100 }
|
||||
elevation_min { 50 }
|
||||
end
|
||||
end
|
||||
|
|
@ -13,12 +13,12 @@ FactoryBot.define do
|
|||
|
||||
settings do
|
||||
{
|
||||
'route_opacity' => '0.5',
|
||||
'meters_between_routes' => '100',
|
||||
'minutes_between_routes' => '100',
|
||||
'route_opacity' => 60,
|
||||
'meters_between_routes' => '500',
|
||||
'minutes_between_routes' => '30',
|
||||
'fog_of_war_meters' => '100',
|
||||
'time_threshold_minutes' => '100',
|
||||
'merge_threshold_minutes' => '100',
|
||||
'time_threshold_minutes' => '30',
|
||||
'merge_threshold_minutes' => '15',
|
||||
'maps' => {
|
||||
'distance_unit' => 'km'
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
10
spec/fixtures/files/owntracks/2023-02_old.rec
vendored
Normal file
10
spec/fixtures/files/owntracks/2023-02_old.rec
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
2023-02-20T18:46:22Z * {"_type":"location","BSSID":"6c:c4:9f:e0:bb:b1","SSID":"WiFi","acc":14,"alt":136,"batt":38,"bs":1,"conn":"w","created_at":1676918783,"lat":22.0687934,"lon":24.7941786,"m":2,"tid":"l6","topic":"owntracks/pixel6/pixel99","tst":1676918782,"vac":0,"vel":0,"_http":true}
|
||||
2023-02-20T18:46:25Z * {"_type":"location","BSSID":"6c:c4:9f:e0:bb:b1","SSID":"WiFi","acc":13,"alt":136,"batt":38,"bs":1,"conn":"w","created_at":1676918785,"lat":22.0687967,"lon":24.7941813,"m":2,"tid":"l6","topic":"owntracks/pixel6/pixel99","tst":1676918785,"vac":0,"vel":0,"_http":true}
|
||||
2023-02-20T18:46:25Z * {"_type":"location","BSSID":"6c:c4:9f:e0:bb:b1","SSID":"WiFi","acc":13,"alt":136,"batt":38,"bs":1,"conn":"w","created_at":1676918790,"lat":22.0687967,"lon":24.7941813,"m":2,"tid":"l6","topic":"owntracks/pixel6/pixel99","tst":1676918785,"vac":0,"vel":0,"_http":true}
|
||||
2023-02-20T18:46:35Z * {"_type":"location","BSSID":"6c:c4:9f:e0:bb:b1","SSID":"WiFi","acc":14,"alt":136,"batt":38,"bs":1,"conn":"w","created_at":1676918795,"lat":22.0687906,"lon":24.794195,"m":2,"tid":"l6","topic":"owntracks/pixel6/pixel99","tst":1676918795,"vac":0,"vel":0,"_http":true}
|
||||
2023-02-20T18:46:40Z * {"_type":"location","BSSID":"6c:c4:9f:e0:bb:b1","SSID":"WiFi","acc":14,"alt":136,"batt":38,"bs":1,"conn":"w","created_at":1676918800,"lat":22.0687967,"lon":24.7941859,"m":2,"tid":"l6","topic":"owntracks/pixel6/pixel99","tst":1676918800,"vac":0,"vel":0,"_http":true}
|
||||
2023-02-20T18:46:45Z * {"_type":"location","BSSID":"6c:c4:9f:e0:bb:b1","SSID":"WiFi","acc":14,"alt":136,"batt":38,"bs":1,"conn":"w","created_at":1676918805,"lat":22.0687946,"lon":24.7941883,"m":2,"tid":"l6","topic":"owntracks/pixel6/pixel99","tst":1676918805,"vac":0,"vel":0,"_http":true}
|
||||
2023-02-20T18:46:50Z * {"_type":"location","BSSID":"6c:c4:9f:e0:bb:b1","SSID":"WiFi","acc":14,"alt":136,"batt":38,"bs":1,"conn":"w","created_at":1676918810,"lat":22.0687912,"lon":24.7941837,"m":2,"tid":"l6","topic":"owntracks/pixel6/pixel99","tst":1676918810,"vac":0,"vel":0,"_http":true}
|
||||
2023-02-20T18:46:55Z * {"_type":"location","BSSID":"6c:c4:9f:e0:bb:b1","SSID":"WiFi","acc":14,"alt":136,"batt":38,"bs":1,"conn":"w","created_at":1676918815,"lat":22.0687927,"lon":24.794186,"m":2,"tid":"l6","topic":"owntracks/pixel6/pixel99","tst":1676918815,"vac":0,"vel":0,"_http":true}
|
||||
2023-02-20T18:46:55Z * {"_type":"location","BSSID":"6c:c4:9f:e0:bb:b1","SSID":"WiFi","acc":14,"alt":136,"batt":38,"bs":1,"conn":"w","created_at":1676918815,"lat":22.0687937,"lon":24.794186,"m":2,"tid":"l6","topic":"owntracks/pixel6/pixel99","tst":1676918815,"vac":0,"vel":0,"_http":true}
|
||||
2023-02-20T18:47:00Z * {"_type":"location","BSSID":"6c:c4:9f:e0:bb:b1","SSID":"WiFi","acc":14,"alt":136,"batt":38,"bs":1,"conn":"w","created_at":1676918820,"lat":22.0687937,"lon":24.794186,"m":2,"tid":"l6","topic":"owntracks/pixel6/pixel99","tst":1676918820,"vac":0,"vel":0,"_http":true}
|
||||
|
|
@ -4,8 +4,8 @@ require 'rails_helper'
|
|||
|
||||
RSpec.describe AreaVisitsCalculationSchedulingJob, type: :job do
|
||||
describe '#perform' do
|
||||
let(:area) { create(:area) }
|
||||
let(:user) { create(:user) }
|
||||
let(:area) { create(:area, user: user) }
|
||||
|
||||
it 'calls the AreaVisitsCalculationService' do
|
||||
expect(AreaVisitsCalculatingJob).to receive(:perform_later).with(user.id).and_call_original
|
||||
|
|
|
|||
|
|
@ -102,5 +102,17 @@ RSpec.describe BulkVisitsSuggestingJob, type: :job do
|
|||
|
||||
described_class.perform_now(start_at: custom_start, end_at: custom_end)
|
||||
end
|
||||
|
||||
context 'when visits suggestions are disabled' do
|
||||
before do
|
||||
allow_any_instance_of(Users::SafeSettings).to receive(:visits_suggestions_enabled?).and_return(false)
|
||||
end
|
||||
|
||||
it 'does not schedule jobs' do
|
||||
expect(VisitSuggestingJob).not_to receive(:perform_later)
|
||||
|
||||
described_class.perform_now
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -28,5 +28,13 @@ RSpec.describe Owntracks::PointCreatingJob, type: :job do
|
|||
expect { perform }.not_to(change { Point.count })
|
||||
end
|
||||
end
|
||||
|
||||
context 'when point is invalid' do
|
||||
let(:point_params) { { lat: 1.0, lon: 1.0, tid: 'test', tst: nil, topic: 'iPhone 12 pro' } }
|
||||
|
||||
it 'does not create a point' do
|
||||
expect { perform }.not_to(change { Point.count })
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
26
spec/jobs/places/bulk_name_fetching_job_spec.rb
Normal file
26
spec/jobs/places/bulk_name_fetching_job_spec.rb
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Places::BulkNameFetchingJob, type: :job do
|
||||
describe '#perform' do
|
||||
let!(:place1) { create(:place, name: Place::DEFAULT_NAME) }
|
||||
let!(:place2) { create(:place, name: Place::DEFAULT_NAME) }
|
||||
let!(:place3) { create(:place, name: 'Other place') }
|
||||
|
||||
it 'enqueues name fetching job for each place with default name' do
|
||||
expect { described_class.perform_now }.to \
|
||||
have_enqueued_job(Places::NameFetchingJob).exactly(2).times
|
||||
end
|
||||
|
||||
it 'does not process places with custom names' do
|
||||
expect { described_class.perform_now }.not_to \
|
||||
have_enqueued_job(Places::NameFetchingJob).with(place3.id)
|
||||
end
|
||||
|
||||
it 'can be enqueued' do
|
||||
expect { described_class.perform_later }.to have_enqueued_job(described_class)
|
||||
.on_queue('places')
|
||||
end
|
||||
end
|
||||
end
|
||||
29
spec/jobs/places/name_fetching_job_spec.rb
Normal file
29
spec/jobs/places/name_fetching_job_spec.rb
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Places::NameFetchingJob, type: :job do
|
||||
describe '#perform' do
|
||||
let(:place) { create(:place, name: Place::DEFAULT_NAME) }
|
||||
let(:name_fetcher) { instance_double(Places::NameFetcher) }
|
||||
|
||||
before do
|
||||
allow(Places::NameFetcher).to receive(:new).with(place).and_return(name_fetcher)
|
||||
allow(name_fetcher).to receive(:call)
|
||||
end
|
||||
|
||||
it 'finds the place and calls NameFetcher' do
|
||||
expect(Place).to receive(:find).with(place.id).and_return(place)
|
||||
expect(Places::NameFetcher).to receive(:new).with(place)
|
||||
expect(name_fetcher).to receive(:call)
|
||||
|
||||
described_class.perform_now(place.id)
|
||||
end
|
||||
|
||||
it 'can be enqueued' do
|
||||
expect { described_class.perform_later(place.id) }.to have_enqueued_job(described_class)
|
||||
.with(place.id)
|
||||
.on_queue('places')
|
||||
end
|
||||
end
|
||||
end
|
||||
88
spec/jobs/tracks/cleanup_job_spec.rb
Normal file
88
spec/jobs/tracks/cleanup_job_spec.rb
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Tracks::CleanupJob, type: :job do
|
||||
let(:user) { create(:user) }
|
||||
|
||||
describe '#perform' do
|
||||
context 'with old untracked points' do
|
||||
let!(:old_points) do
|
||||
create_points_around(user: user, count: 1, base_lat: 20.0, timestamp: 2.days.ago.to_i)
|
||||
create_points_around(user: user, count: 1, base_lat: 20.0, timestamp: 1.day.ago.to_i)
|
||||
end
|
||||
let!(:recent_points) do
|
||||
create_points_around(user: user, count: 2, base_lat: 20.0, timestamp: 1.hour.ago.to_i)
|
||||
end
|
||||
let(:generator) { instance_double(Tracks::Generator) }
|
||||
|
||||
it 'processes only old untracked points' do
|
||||
expect(Tracks::Generator).to receive(:new)
|
||||
.and_return(generator)
|
||||
|
||||
expect(generator).to receive(:call)
|
||||
|
||||
described_class.new.perform(older_than: 1.day.ago)
|
||||
end
|
||||
|
||||
it 'logs processing information' do
|
||||
allow(Tracks::Generator).to receive(:new).and_return(double(call: nil))
|
||||
|
||||
expect(Rails.logger).to receive(:info).with(/Processing missed tracks for user #{user.id}/)
|
||||
|
||||
described_class.new.perform(older_than: 1.day.ago)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with users having insufficient points' do
|
||||
let!(:single_point) do
|
||||
create_points_around(user: user, count: 1, base_lat: 20.0, timestamp: 2.days.ago.to_i)
|
||||
end
|
||||
|
||||
it 'skips users with less than 2 points' do
|
||||
expect(Tracks::Generator).not_to receive(:new)
|
||||
|
||||
described_class.new.perform(older_than: 1.day.ago)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with no old untracked points' do
|
||||
let(:track) { create(:track, user: user) }
|
||||
let!(:tracked_points) do
|
||||
create_points_around(user: user, count: 3, base_lat: 20.0, timestamp: 2.days.ago.to_i, track: track)
|
||||
end
|
||||
|
||||
it 'does not process any users' do
|
||||
expect(Tracks::Generator).not_to receive(:new)
|
||||
|
||||
described_class.new.perform(older_than: 1.day.ago)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with custom older_than parameter' do
|
||||
let!(:points) do
|
||||
create_points_around(user: user, count: 3, base_lat: 20.0, timestamp: 3.days.ago.to_i)
|
||||
end
|
||||
let(:generator) { instance_double(Tracks::Generator) }
|
||||
|
||||
it 'uses custom threshold' do
|
||||
expect(Tracks::Generator).to receive(:new)
|
||||
.and_return(generator)
|
||||
|
||||
expect(generator).to receive(:call)
|
||||
|
||||
described_class.new.perform(older_than: 2.days.ago)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'job configuration' do
|
||||
it 'uses tracks queue' do
|
||||
expect(described_class.queue_name).to eq('tracks')
|
||||
end
|
||||
|
||||
it 'does not retry on failure' do
|
||||
expect(described_class.sidekiq_options_hash['retry']).to be false
|
||||
end
|
||||
end
|
||||
end
|
||||
200
spec/jobs/tracks/create_job_spec.rb
Normal file
200
spec/jobs/tracks/create_job_spec.rb
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Tracks::CreateJob, type: :job do
|
||||
let(:user) { create(:user) }
|
||||
|
||||
describe '#perform' do
|
||||
let(:generator_instance) { instance_double(Tracks::Generator) }
|
||||
let(:notification_service) { instance_double(Notifications::Create) }
|
||||
|
||||
before do
|
||||
allow(Tracks::Generator).to receive(:new).and_return(generator_instance)
|
||||
allow(generator_instance).to receive(:call)
|
||||
allow(Notifications::Create).to receive(:new).and_return(notification_service)
|
||||
allow(notification_service).to receive(:call)
|
||||
allow(generator_instance).to receive(:call).and_return(2)
|
||||
end
|
||||
|
||||
it 'calls the generator and creates a notification' do
|
||||
described_class.new.perform(user.id)
|
||||
|
||||
expect(Tracks::Generator).to have_received(:new).with(
|
||||
user,
|
||||
start_at: nil,
|
||||
end_at: nil,
|
||||
mode: :daily
|
||||
)
|
||||
expect(generator_instance).to have_received(:call)
|
||||
expect(Notifications::Create).to have_received(:new).with(
|
||||
user: user,
|
||||
kind: :info,
|
||||
title: 'Tracks Generated',
|
||||
content: 'Created 2 tracks from your location data. Check your tracks section to view them.'
|
||||
)
|
||||
expect(notification_service).to have_received(:call)
|
||||
end
|
||||
|
||||
context 'with custom parameters' do
|
||||
let(:start_at) { 1.day.ago.beginning_of_day.to_i }
|
||||
let(:end_at) { 1.day.ago.end_of_day.to_i }
|
||||
let(:mode) { :daily }
|
||||
|
||||
before do
|
||||
allow(Tracks::Generator).to receive(:new).and_return(generator_instance)
|
||||
allow(generator_instance).to receive(:call)
|
||||
allow(Notifications::Create).to receive(:new).and_return(notification_service)
|
||||
allow(notification_service).to receive(:call)
|
||||
allow(generator_instance).to receive(:call).and_return(1)
|
||||
end
|
||||
|
||||
it 'passes custom parameters to the generator' do
|
||||
described_class.new.perform(user.id, start_at: start_at, end_at: end_at, mode: mode)
|
||||
|
||||
expect(Tracks::Generator).to have_received(:new).with(
|
||||
user,
|
||||
start_at: start_at,
|
||||
end_at: end_at,
|
||||
mode: :daily
|
||||
)
|
||||
expect(generator_instance).to have_received(:call)
|
||||
expect(Notifications::Create).to have_received(:new).with(
|
||||
user: user,
|
||||
kind: :info,
|
||||
title: 'Tracks Generated',
|
||||
content: 'Created 1 tracks from your location data. Check your tracks section to view them.'
|
||||
)
|
||||
expect(notification_service).to have_received(:call)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when generator raises an error' do
|
||||
let(:error_message) { 'Something went wrong' }
|
||||
let(:notification_service) { instance_double(Notifications::Create) }
|
||||
|
||||
before do
|
||||
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 an error notification' 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
|
||||
|
||||
it 'reports the error using ExceptionReporter' do
|
||||
allow(ExceptionReporter).to receive(:call)
|
||||
|
||||
described_class.new.perform(user.id)
|
||||
|
||||
expect(ExceptionReporter).to have_received(:call).with(
|
||||
kind_of(StandardError),
|
||||
'Failed to create tracks for user'
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user does not exist' do
|
||||
before do
|
||||
allow(User).to receive(:find).with(999).and_raise(ActiveRecord::RecordNotFound)
|
||||
allow(ExceptionReporter).to receive(:call)
|
||||
allow(Notifications::Create).to receive(:new).and_return(instance_double(Notifications::Create, call: nil))
|
||||
end
|
||||
|
||||
it 'handles the error gracefully and creates error notification' do
|
||||
expect { described_class.new.perform(999) }.not_to raise_error
|
||||
|
||||
expect(ExceptionReporter).to have_received(:call)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when tracks are deleted and recreated' do
|
||||
let(:existing_tracks) { create_list(:track, 3, user: user) }
|
||||
|
||||
before do
|
||||
allow(generator_instance).to receive(:call).and_return(2)
|
||||
end
|
||||
|
||||
it 'returns the correct count of newly created tracks' do
|
||||
described_class.new.perform(user.id, mode: :incremental)
|
||||
|
||||
expect(Tracks::Generator).to have_received(:new).with(
|
||||
user,
|
||||
start_at: nil,
|
||||
end_at: nil,
|
||||
mode: :incremental
|
||||
)
|
||||
expect(generator_instance).to have_received(:call)
|
||||
expect(Notifications::Create).to have_received(:new).with(
|
||||
user: user,
|
||||
kind: :info,
|
||||
title: 'Tracks Generated',
|
||||
content: 'Created 2 tracks from your location data. Check your tracks section to view them.'
|
||||
)
|
||||
expect(notification_service).to have_received(:call)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'queue' do
|
||||
it 'is queued on tracks queue' 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
|
||||
39
spec/jobs/tracks/incremental_check_job_spec.rb
Normal file
39
spec/jobs/tracks/incremental_check_job_spec.rb
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Tracks::IncrementalCheckJob, type: :job do
|
||||
let(:user) { create(:user) }
|
||||
let(:point) { create(:point, user: user) }
|
||||
|
||||
describe '#perform' do
|
||||
context 'with valid parameters' do
|
||||
let(:processor) { instance_double(Tracks::IncrementalProcessor) }
|
||||
|
||||
it 'calls the incremental processor' do
|
||||
expect(Tracks::IncrementalProcessor).to receive(:new)
|
||||
.with(user, point)
|
||||
.and_return(processor)
|
||||
|
||||
expect(processor).to receive(:call)
|
||||
|
||||
described_class.new.perform(user.id, point.id)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'job configuration' do
|
||||
it 'uses tracks queue' do
|
||||
expect(described_class.queue_name).to eq('tracks')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'integration with ActiveJob' do
|
||||
it 'enqueues the job' do
|
||||
expect do
|
||||
described_class.perform_later(user.id, point.id)
|
||||
end.to have_enqueued_job(described_class)
|
||||
.with(user.id, point.id)
|
||||
end
|
||||
end
|
||||
end
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue