Compare commits

...

69 commits

Author SHA1 Message Date
Evgenii Burmakin
3f22162cf0
Merge pull request #1527 from Freika/dev
0.29.2
2025-07-21 20:36:11 +02:00
Eugene Burmakin
2206622726 Release 0.30.0 2025-07-21 20:35:43 +02:00
Eugene Burmakin
9bcd522e25 Update specs 2025-07-21 20:22:18 +02:00
Eugene Burmakin
6a6c3c938f Fix distance calculation 2025-07-21 19:00:28 +02:00
Eugene Burmakin
59a4d760bf Fix owntracks points creation 2025-07-21 18:59:13 +02:00
Eugene Burmakin
fbdf630502 Update changelog 2025-07-20 22:23:08 +02:00
Eugene Burmakin
c74ba7d1fe Revert "Optimize bulk visits suggesting job" 2025-07-20 21:54:00 +02:00
Eugene Burmakin
6ec24ffc3d Optimize bulk visits suggesting job 2025-07-20 21:38:46 +02:00
Eugene Burmakin
b7aa05f4ea Fix specs 2025-07-20 21:29:05 +02:00
Eugene Burmakin
8b03b0c7f5 Recalculate stats after changing distance units 2025-07-20 19:14:20 +02:00
Eugene Burmakin
f969d5d3e6 Clean up some mess 2025-07-20 18:57:53 +02:00
Eugene Burmakin
708bca26eb Fix owntracks point creation 2025-07-20 17:43:55 +02:00
Eugene Burmakin
45713f46dc Fix domain in development and production 2025-07-20 17:31:31 +02:00
Evgenii Burmakin
3149767675
Merge pull request #1531 from Freika/fix/map-tracks-popup
Fix/map tracks popup
2025-07-20 17:31:26 +02:00
Eugene Burmakin
002b3bd635 Fix settings controller spec and tracks popup 2025-07-20 17:06:45 +02:00
Evgenii Burmakin
5ad660ccd4
Merge pull request #1525 from Freika/fix/tracks-refactoring
Refactor tracks jobs and services
2025-07-17 21:15:29 +02:00
Eugene Burmakin
9d616c7957 Remove logging from tracks generator 2025-07-17 21:02:45 +02:00
Eugene Burmakin
7cdb7d2f21 Add some more tests to make sure points are properly cleaned up 2025-07-17 20:57:55 +02:00
Eugene Burmakin
dc8460a948 Fix tracks create job spec 2025-07-17 20:46:07 +02:00
Eugene Burmakin
91f4cf7c7a Fix range objects in generator 2025-07-17 20:36:21 +02:00
Eugene Burmakin
f5ef2ab9ef Fix potential issue with time range data types 2025-07-17 20:20:14 +02:00
Eugene Burmakin
1f5325d9bb Remove doc file 2025-07-17 19:22:50 +02:00
Eugene Burmakin
10777714b1 Clean up a bit 2025-07-17 19:19:50 +02:00
Eugene Burmakin
eca09ce3eb Remove bulk generator job 2025-07-16 22:25:50 +02:00
Eugene Burmakin
c31d09e5c3 Refactor tracks jobs and services 2025-07-16 22:22:33 +02:00
Eugene Burmakin
54aaf03453 Merge branch 'master' into dev 2025-07-14 21:30:51 +02:00
Evgenii Burmakin
699504f4e9
Merge pull request #1517 from Freika/fix/api-user-serializer
Add user serializer and update CHANGELOG.md
2025-07-14 21:23:48 +02:00
Eugene Burmakin
878d863569 Fix some tests 2025-07-14 21:15:45 +02:00
Eugene Burmakin
24378b150d Add user serializer and update CHANGELOG.md 2025-07-13 12:50:24 +02:00
Evgenii Burmakin
d2e2e50298
Merge pull request #1515 from Freika/fix/existing-tracks-generation
Fixes for bulk creating job
2025-07-12 23:46:12 +02:00
Eugene Burmakin
7885374993 Refactor Tracks::BulkTrackCreator to use start_at and end_at as datetime objects 2025-07-12 23:45:43 +02:00
Eugene Burmakin
244fb2b192 Move bulk track creation to service 2025-07-12 23:04:15 +02:00
Eugene Burmakin
418df71c53 Fixes for bulk creating job 2025-07-12 22:04:14 +02:00
Evgenii Burmakin
2425b2423a
Merge pull request #1512 from Freika/fix/suggested-places
Fix/suggested places
2025-07-12 18:07:55 +02:00
Eugene Burmakin
43bc8c444c Fix name fetcher 2025-07-12 17:57:22 +02:00
Eugene Burmakin
6b96e1f0be Revert specs 2025-07-12 17:21:53 +02:00
Eugene Burmakin
0dff80e12b Fix some tests 2025-07-12 13:43:15 +02:00
Eugene Burmakin
58a7972976 Fix bulk name fetching job queue 2025-07-12 11:30:51 +02:00
Eugene Burmakin
cf50541be1 Update changelog 2025-07-12 11:23:58 +02:00
Eugene Burmakin
bc36882e73 Add name fetcher for places and visits 2025-07-12 11:21:38 +02:00
Eugene Burmakin
e9eeb6aae2 Add rails-ujs to manifest.js and application.js. 2025-07-10 22:14:52 +02:00
Evgenii Burmakin
bfeb936638
Merge pull request #1488 from Freika/feature/tracks
Feature/tracks
2025-07-09 22:11:31 +02:00
Eugene Burmakin
ee6666e7bf Skip some tests in map interaction spec. 2025-07-09 22:09:27 +02:00
Eugene Burmakin
ceef7702fa Add data migration to recalculate trips distance. 2025-07-09 21:51:48 +02:00
Eugene Burmakin
13fd9da1f9 Add a scheduled job to create tracks for all users for the past 24 hours. 2025-07-09 21:25:56 +02:00
Eugene Burmakin
9a326733c7 Return missing map buttons 2025-07-09 00:58:33 +02:00
Eugene Burmakin
0295d3f2a0 Fix year page charts 2025-07-08 21:23:55 +02:00
Eugene Burmakin
b7e5296235 Fix tracks layer 2025-07-08 21:14:46 +02:00
Eugene Burmakin
f4687a101c Remove unused helper methods 2025-07-08 20:51:51 +02:00
Eugene Burmakin
042696caeb Show correct miles value on the map 2025-07-08 20:31:25 +02:00
Eugene Burmakin
b3e8155e43 Don't use bang save 2025-07-08 20:24:07 +02:00
Eugene Burmakin
f4605989b6 Fix rest of failing tests 2025-07-08 20:04:19 +02:00
Eugene Burmakin
6dd048cee3 Fix a few tests 2025-07-08 19:23:08 +02:00
Eugene Burmakin
f1720b859b Store distance in meters in the database and convert to user's preferred unit on the fly. 2025-07-08 18:10:10 +02:00
Eugene Burmakin
81eb759fb8 Remove tracks api 2025-07-08 00:05:22 +02:00
Eugene Burmakin
e64e706b0f Unify timestamps 2025-07-07 23:38:10 +02:00
Eugene Burmakin
a66f41d9fb Add documentation 2025-07-07 23:12:02 +02:00
Eugene Burmakin
f33dcdfe21 Store track distance in user's preferred unit 2025-07-07 22:23:37 +02:00
Eugene Burmakin
0d657b9d6e Add incremental track generation 2025-07-07 21:48:07 +02:00
Eugene Burmakin
92a15c8ad3 Handle unfinished tracks 2025-07-07 18:59:42 +02:00
Eugene Burmakin
7619feff69 Add data migration to create tracks from points 2025-07-06 13:49:53 +02:00
Eugene Burmakin
15be46b604 Fix tests 2025-07-04 20:55:05 +02:00
Eugene Burmakin
1468f1f9dc Remove tracks api endpoint 2025-07-04 20:09:06 +02:00
Eugene Burmakin
565f92c463 Add tracks to map 2025-07-04 19:49:56 +02:00
Eugene Burmakin
7bd098b54f Extract tracks calculation to serializer 2025-07-03 20:34:41 +02:00
Eugene Burmakin
862f601e1d Add tracks calculation and storage in the database 2025-07-03 20:18:18 +02:00
Evgenii Burmakin
fd4b785a19
Merge pull request #1486 from Freika/feature/disable-visits-suggestion
Feature/disable visits suggestion
2025-07-02 23:53:09 +02:00
Eugene Burmakin
3b474704ea Fixes for visits suggestions. 2025-07-02 23:50:32 +02:00
Eugene Burmakin
12a53aac20 Don't check for new version in production. 2025-07-02 21:58:19 +02:00
138 changed files with 5762 additions and 726 deletions

View file

@ -1 +1 @@
0.29.1
0.30.0

7
.gitignore vendored
View file

@ -76,3 +76,10 @@ Makefile
/db/*.sqlite3
/db/*.sqlite3-shm
/db/*.sqlite3-wal
# Playwright
node_modules/
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/

View file

@ -4,6 +4,78 @@ 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.0] - 2025-07-21
⚠️ If you were using 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

3
Procfile.production Normal file
View 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

View file

@ -1,3 +1,4 @@
//= link rails-ujs.js
//= link_tree ../images
//= link_directory ../stylesheets .css
//= link_tree ../builds

View file

@ -0,0 +1,7 @@
# frozen_string_literal: true
class TracksChannel < ApplicationCable::Channel
def subscribed
stream_for current_user
end
end

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -12,3 +12,6 @@ import "./channels"
import "trix"
import "@rails/actiontext"
import "@rails/ujs"
Rails.start()

View file

@ -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();
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);
}
}
}

View file

@ -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', () => {

View file

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

View file

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

View 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);
}
}

View file

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

View file

@ -1,7 +1,7 @@
# frozen_string_literal: true
class AreaVisitsCalculationSchedulingJob < ApplicationJob
queue_as :default
queue_as :visit_suggesting
sidekiq_options retry: false
def perform

View file

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

View file

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

View file

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

View 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

View 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

View 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

View file

@ -0,0 +1,37 @@
# 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)
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

View 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

View 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

View 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

View file

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

View file

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

View file

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

View file

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

View 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

View file

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

View 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

View 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

View file

@ -8,6 +8,8 @@ class CheckAppVersion
end
def call
return false if Rails.env.production?
latest_version != APP_VERSION
rescue StandardError
false

View file

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

View file

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

View 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

View file

@ -7,7 +7,7 @@ class PointsLimitExceeded
def call
return false if DawarichSettings.self_hosted?
return true if @user.points.count >= points_limit
return true if @user.tracked_points.count >= points_limit
false
end

View 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

View 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

View 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

View 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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 class="flex items-center space-x-2">
<%= link_to "Details", points_path(year: stat.year, month: stat.month),
class: "link link-primary" %>
</div>
</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">
<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>
<% end %>
<% if stat.daily_distance %>
<%= column_chart(
stat.daily_distance,
height: '100px',
<%= 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: 'Days',
xtitle: 'Day',
ytitle: 'Distance'
) %>
<% end %>
</div>
</div>

View file

@ -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),
Stat.year_distance(year, current_user).map { |month_name, distance_meters|
[month_name, Stat.convert_distance(distance_meters, current_user.safe_settings.distance_unit).round(2)]
},
height: '200px',
suffix: " #{current_user.safe_settings.distance_unit}",
xtitle: 'Days',

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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

View 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

View file

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

View file

@ -1 +1 @@
DataMigrate::Data.define(version: 20250518174305)
DataMigrate::Data.define(version: 20250720171241)

View 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

View 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

22
db/schema.rb generated
View file

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.0].define(version: 2025_06_27_184017) do
ActiveRecord::Schema[8.0].define(version: 2025_07_03_193657) do
# These are extensions that must be enabled in order to support this database
enable_extension "pg_catalog.plpgsql"
enable_extension "postgis"
@ -181,6 +181,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 +197,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 +218,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 +299,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
View file

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

View file

@ -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": {}
}

View 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

View file

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

View file

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

View 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}

View file

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

View file

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

View file

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

View 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

View 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

View 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

View file

@ -0,0 +1,154 @@
# 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
end

View 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

View file

@ -8,6 +8,7 @@ RSpec.describe Point, type: :model do
it { is_expected.to belong_to(:user) }
it { is_expected.to belong_to(:country).optional }
it { is_expected.to belong_to(:visit).optional }
it { is_expected.to belong_to(:track).optional }
end
describe 'validations' do
@ -28,6 +29,17 @@ RSpec.describe Point, type: :model do
expect(point.country_id).to eq(country.id)
end
end
describe '#recalculate_track' do
let(:point) { create(:point, track: track) }
let(:track) { create(:track) }
it 'recalculates the track' do
expect(track).to receive(:recalculate_path_and_distance!)
point.update(lonlat: 'POINT(-79.85581250721961 15.854775993302411)')
end
end
end
describe 'scopes' do
@ -108,5 +120,16 @@ RSpec.describe Point, type: :model do
expect(point.lat).to eq(2)
end
end
describe '#trigger_incremental_track_generation' do
let(:point) do
create(:point, track: track, import_id: nil, timestamp: 1.hour.ago.to_i, reverse_geocoded_at: 1.hour.ago)
end
let(:track) { create(:track) }
it 'enqueues Tracks::IncrementalCheckJob' do
expect { point.send(:trigger_incremental_track_generation) }.to have_enqueued_job(Tracks::IncrementalCheckJob).with(point.user_id, point.id)
end
end
end
end

View file

@ -29,7 +29,7 @@ RSpec.describe Stat, type: :model do
create(:point, user:, lonlat: 'POINT(2 2)', timestamp: DateTime.new(year, 1, 1, 2))
end
before { expected_distance[0][1] = 156.88 }
before { expected_distance[0][1] = 156_876 }
it 'returns distance by day' do
expect(subject).to eq(expected_distance)

193
spec/models/track_spec.rb Normal file
View file

@ -0,0 +1,193 @@
require 'rails_helper'
RSpec.describe Track, type: :model do
describe 'associations' do
it { is_expected.to belong_to(:user) }
it { is_expected.to have_many(:points).dependent(:nullify) }
end
describe 'validations' do
subject { build(:track) }
it { is_expected.to validate_presence_of(:start_at) }
it { is_expected.to validate_presence_of(:end_at) }
it { is_expected.to validate_presence_of(:original_path) }
it { is_expected.to validate_numericality_of(:distance).is_greater_than_or_equal_to(0) }
it { is_expected.to validate_numericality_of(:avg_speed).is_greater_than_or_equal_to(0) }
it { is_expected.to validate_numericality_of(:duration).is_greater_than_or_equal_to(0) }
end
describe '.last_for_day' do
let(:user) { create(:user) }
let(:other_user) { create(:user) }
let(:target_day) { Date.current }
context 'when user has tracks on the target day' do
let!(:early_track) do
create(:track, user: user,
start_at: target_day.beginning_of_day + 1.hour,
end_at: target_day.beginning_of_day + 2.hours)
end
let!(:late_track) do
create(:track, user: user,
start_at: target_day.beginning_of_day + 3.hours,
end_at: target_day.beginning_of_day + 4.hours)
end
let!(:other_user_track) do
create(:track, user: other_user,
start_at: target_day.beginning_of_day + 5.hours,
end_at: target_day.beginning_of_day + 6.hours)
end
it 'returns the track that ends latest on that day for the user' do
result = Track.last_for_day(user, target_day)
expect(result).to eq(late_track)
end
it 'does not return tracks from other users' do
result = Track.last_for_day(user, target_day)
expect(result).not_to eq(other_user_track)
end
end
context 'when user has tracks on different days' do
let!(:yesterday_track) do
create(:track, user: user,
start_at: target_day.yesterday.beginning_of_day + 1.hour,
end_at: target_day.yesterday.beginning_of_day + 2.hours)
end
let!(:tomorrow_track) do
create(:track, user: user,
start_at: target_day.tomorrow.beginning_of_day + 1.hour,
end_at: target_day.tomorrow.beginning_of_day + 2.hours)
end
let!(:target_day_track) do
create(:track, user: user,
start_at: target_day.beginning_of_day + 1.hour,
end_at: target_day.beginning_of_day + 2.hours)
end
it 'returns only the track from the target day' do
result = Track.last_for_day(user, target_day)
expect(result).to eq(target_day_track)
end
end
context 'when user has no tracks on the target day' do
let!(:yesterday_track) do
create(:track, user: user,
start_at: target_day.yesterday.beginning_of_day + 1.hour,
end_at: target_day.yesterday.beginning_of_day + 2.hours)
end
it 'returns nil' do
result = Track.last_for_day(user, target_day)
expect(result).to be_nil
end
end
context 'when passing a Time object instead of Date' do
let!(:track) do
create(:track, user: user,
start_at: target_day.beginning_of_day + 1.hour,
end_at: target_day.beginning_of_day + 2.hours)
end
it 'correctly handles Time objects' do
result = Track.last_for_day(user, target_day.to_time)
expect(result).to eq(track)
end
end
context 'when track spans midnight' do
let!(:spanning_track) do
create(:track, user: user,
start_at: target_day.beginning_of_day - 1.hour,
end_at: target_day.beginning_of_day + 1.hour)
end
it 'includes tracks that end on the target day' do
result = Track.last_for_day(user, target_day)
expect(result).to eq(spanning_track)
end
end
end
describe 'Calculateable concern' do
let(:user) { create(:user) }
let(:track) { create(:track, user: user, distance: 1000, avg_speed: 25, duration: 3600) }
let!(:points) do
[
create(:point, user: user, track: track, lonlat: 'POINT(13.404954 52.520008)', timestamp: 1.hour.ago.to_i),
create(:point, user: user, track: track, lonlat: 'POINT(13.405954 52.521008)', timestamp: 30.minutes.ago.to_i),
create(:point, user: user, track: track, lonlat: 'POINT(13.406954 52.522008)', timestamp: Time.current.to_i)
]
end
describe '#calculate_path' do
it 'updates the original_path with calculated path' do
original_path_before = track.original_path
track.calculate_path
expect(track.original_path).not_to eq(original_path_before)
expect(track.original_path).to be_present
end
end
describe '#calculate_distance' do
it 'updates the distance based on points' do
track.calculate_distance
expect(track.distance).to be > 0
expect(track.distance).to be_a(Numeric)
end
it 'stores distance in meters consistently' do
allow(Point).to receive(:total_distance).and_return(1500) # 1500 meters
track.calculate_distance
expect(track.distance).to eq(1500) # Should be stored as meters regardless of user unit preference
end
end
describe '#recalculate_distance!' do
it 'recalculates and saves the distance' do
original_distance = track.distance
track.recalculate_distance!
track.reload
expect(track.distance).not_to eq(original_distance)
end
end
describe '#recalculate_path!' do
it 'recalculates and saves the path' do
original_path = track.original_path
track.recalculate_path!
track.reload
expect(track.original_path).not_to eq(original_path)
end
end
describe '#recalculate_path_and_distance!' do
it 'recalculates both path and distance and saves' do
original_distance = track.distance
original_path = track.original_path
track.recalculate_path_and_distance!
track.reload
expect(track.distance).not_to eq(original_distance)
expect(track.original_path).not_to eq(original_path)
end
end
end
end

View file

@ -137,4 +137,49 @@ RSpec.describe Trip, type: :model do
end
end
end
describe 'Calculateable concern' do
let(:user) { create(:user) }
let(:trip) { create(:trip, user: user) }
let!(:points) do
[
create(:point, user: user, lonlat: 'POINT(13.404954 52.520008)', timestamp: trip.started_at.to_i + 1.hour),
create(:point, user: user, lonlat: 'POINT(13.404955 52.520009)', timestamp: trip.started_at.to_i + 2.hours),
create(:point, user: user, lonlat: 'POINT(13.404956 52.520010)', timestamp: trip.started_at.to_i + 3.hours)
]
end
describe '#calculate_distance' do
it 'stores distance in user preferred unit for Trip model' do
allow(user).to receive(:safe_settings).and_return(double(distance_unit: 'km'))
allow(Point).to receive(:total_distance).and_return(2.5) # 2.5 km
trip.calculate_distance
expect(trip.distance).to eq(3) # Should be rounded, in km
end
end
describe '#recalculate_distance!' do
it 'recalculates and saves the distance' do
original_distance = trip.distance
trip.recalculate_distance!
trip.reload
expect(trip.distance).not_to eq(original_distance)
end
end
describe '#recalculate_path!' do
it 'recalculates and saves the path' do
original_path = trip.path
trip.recalculate_path!
trip.reload
expect(trip.path).not_to eq(original_path)
end
end
end
end

View file

@ -14,6 +14,7 @@ RSpec.describe User, type: :model do
it { is_expected.to have_many(:visits).dependent(:destroy) }
it { is_expected.to have_many(:places).through(:visits) }
it { is_expected.to have_many(:trips).dependent(:destroy) }
it { is_expected.to have_many(:tracks).dependent(:destroy) }
end
describe 'enums' do
@ -87,11 +88,11 @@ RSpec.describe User, type: :model do
describe '#total_distance' do
subject { user.total_distance }
let!(:stat1) { create(:stat, user:, distance: 10) }
let!(:stat2) { create(:stat, user:, distance: 20) }
let!(:stat1) { create(:stat, user:, distance: 10_000) }
let!(:stat2) { create(:stat, user:, distance: 20_000) }
it 'returns sum of distances' do
expect(subject).to eq(30)
expect(subject).to eq(30) # 30 km
end
end

View file

@ -13,6 +13,10 @@ require 'super_diff/rspec-rails'
require 'rake'
Rails.application.load_tasks
# Ensure Devise is properly configured for tests
require 'devise'
# Add additional requires below this line. Rails is not loaded until this point!
Dir[Rails.root.join('spec/support/**/*.rb')].sort.each { |f| require f }
@ -32,11 +36,13 @@ RSpec.configure do |config|
config.filter_rails_from_backtrace!
config.include FactoryBot::Syntax::Methods
config.include Devise::Test::IntegrationHelpers, type: :request
config.include Devise::Test::IntegrationHelpers, type: :system
config.rswag_dry_run = false
config.before(:suite) do
Rails.application.reload_routes!
end
config.before do
ActiveJob::Base.queue_adapter = :test
allow(DawarichSettings).to receive(:store_geodata?).and_return(true)

View file

@ -21,7 +21,7 @@ RSpec.describe 'Api::V1::Stats', type: :request do
end
let(:expected_json) do
{
totalDistanceKm: stats_in_2020.map(&:distance).sum + stats_in_2021.map(&:distance).sum,
totalDistanceKm: (stats_in_2020.map(&:distance).sum + stats_in_2021.map(&:distance).sum) / 1000,
totalPointsTracked: points_in_2020.count + points_in_2021.count,
totalReverseGeocodedPoints: points_in_2020.count + points_in_2021.count,
totalCountriesVisited: 1,
@ -29,7 +29,7 @@ RSpec.describe 'Api::V1::Stats', type: :request do
yearlyStats: [
{
year: 2021,
totalDistanceKm: 12,
totalDistanceKm: (stats_in_2021.map(&:distance).sum / 1000).to_i,
totalCountriesVisited: 1,
totalCitiesVisited: 1,
monthlyDistanceKm: {
@ -49,7 +49,7 @@ RSpec.describe 'Api::V1::Stats', type: :request do
},
{
year: 2020,
totalDistanceKm: 12,
totalDistanceKm: (stats_in_2020.map(&:distance).sum / 1000).to_i,
totalCountriesVisited: 1,
totalCitiesVisited: 1,
monthlyDistanceKm: {

View file

@ -7,12 +7,28 @@ RSpec.describe 'Api::V1::Users', type: :request do
let(:user) { create(:user) }
let(:headers) { { 'Authorization' => "Bearer #{user.api_key}" } }
it 'returns http success' do
it 'returns success response' do
get '/api/v1/users/me', headers: headers
expect(response).to have_http_status(:success)
expect(response.body).to include(user.email)
expect(response.body).to include(user.id.to_s)
end
it 'returns only the keys and values stated in the serializer' do
get '/api/v1/users/me', headers: headers
json = JSON.parse(response.body, symbolize_names: true)
expect(json.keys).to eq([:user])
expect(json[:user].keys).to match_array(
%i[email theme created_at updated_at settings]
)
expect(json[:user][:settings].keys).to match_array(%i[
maps fog_of_war_meters meters_between_routes preferred_map_layer
speed_colored_routes points_rendering_mode minutes_between_routes
time_threshold_minutes merge_threshold_minutes live_map_enabled
route_opacity immich_url photoprism_url visits_suggestions_enabled
speed_color_scale fog_of_war_threshold
])
end
end
end

View file

@ -80,7 +80,9 @@ RSpec.describe 'Settings', type: :request do
it 'updates the user settings' do
patch '/settings', params: params
expect(user.reload.settings).to eq(params[:settings])
user.reload
expect(user.settings['meters_between_routes']).to eq('1000')
expect(user.settings['minutes_between_routes']).to eq('10')
end
context 'when user is inactive' do

View file

@ -0,0 +1,85 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Api::UserSerializer do
describe '#call' do
subject(:serializer) { described_class.new(user).call }
let(:user) { create(:user, email: 'test@example.com', theme: 'dark') }
it 'returns JSON with correct user attributes' do
expect(serializer[:user][:email]).to eq(user.email)
expect(serializer[:user][:theme]).to eq(user.theme)
expect(serializer[:user][:created_at]).to eq(user.created_at)
expect(serializer[:user][:updated_at]).to eq(user.updated_at)
end
it 'returns settings with expected keys and types' do
settings = serializer[:user][:settings]
expect(settings).to include(
:maps,
:fog_of_war_meters,
:meters_between_routes,
:preferred_map_layer,
:speed_colored_routes,
:points_rendering_mode,
:minutes_between_routes,
:time_threshold_minutes,
:merge_threshold_minutes,
:live_map_enabled,
:route_opacity,
:immich_url,
:photoprism_url,
:visits_suggestions_enabled,
:speed_color_scale,
:fog_of_war_threshold
)
end
context 'with custom settings' do
let(:custom_settings) do
{
'fog_of_war_meters' => 123,
'meters_between_routes' => 456,
'preferred_map_layer' => 'Satellite',
'speed_colored_routes' => true,
'points_rendering_mode' => 'cluster',
'minutes_between_routes' => 42,
'time_threshold_minutes' => 99,
'merge_threshold_minutes' => 77,
'live_map_enabled' => false,
'route_opacity' => 0.75,
'immich_url' => 'https://immich.example.com',
'photoprism_url' => 'https://photoprism.example.com',
'visits_suggestions_enabled' => 'false',
'speed_color_scale' => 'rainbow',
'fog_of_war_threshold' => 5,
'maps' => { 'distance_unit' => 'mi' }
}
end
let(:user) { create(:user, settings: custom_settings) }
it 'serializes custom settings correctly' do
settings = serializer[:user][:settings]
expect(settings[:fog_of_war_meters]).to eq(123)
expect(settings[:meters_between_routes]).to eq(456)
expect(settings[:preferred_map_layer]).to eq('Satellite')
expect(settings[:speed_colored_routes]).to eq(true)
expect(settings[:points_rendering_mode]).to eq('cluster')
expect(settings[:minutes_between_routes]).to eq(42)
expect(settings[:time_threshold_minutes]).to eq(99)
expect(settings[:merge_threshold_minutes]).to eq(77)
expect(settings[:live_map_enabled]).to eq(false)
expect(settings[:route_opacity]).to eq(0.75)
expect(settings[:immich_url]).to eq('https://immich.example.com')
expect(settings[:photoprism_url]).to eq('https://photoprism.example.com')
expect(settings[:visits_suggestions_enabled]).to eq(false)
expect(settings[:speed_color_scale]).to eq('rainbow')
expect(settings[:fog_of_war_threshold]).to eq(5)
expect(settings[:maps]).to eq({ 'distance_unit' => 'mi' })
end
end
end
end

View file

@ -29,11 +29,12 @@ RSpec.describe PointSerializer do
'inrids' => point.inrids,
'in_regions' => point.in_regions,
'city' => point.city,
'country' => point.country,
'country' => point.read_attribute(:country),
'geodata' => point.geodata,
'course' => point.course,
'course_accuracy' => point.course_accuracy,
'external_track_id' => point.external_track_id
'external_track_id' => point.external_track_id,
'track_id' => point.track_id
}
end

View file

@ -40,7 +40,7 @@ RSpec.describe StatsSerializer do
end
let(:expected_json) do
{
"totalDistanceKm": stats_in_2020.map(&:distance).sum + stats_in_2021.map(&:distance).sum,
"totalDistanceKm": (stats_in_2020.map(&:distance).sum + stats_in_2021.map(&:distance).sum) / 1000,
"totalPointsTracked": points_in_2020.count + points_in_2021.count,
"totalReverseGeocodedPoints": points_in_2020.count + points_in_2021.count,
"totalCountriesVisited": 1,
@ -48,7 +48,7 @@ RSpec.describe StatsSerializer do
"yearlyStats": [
{
"year": 2021,
"totalDistanceKm": 12,
"totalDistanceKm": (stats_in_2021.map(&:distance).sum / 1000).to_i,
"totalCountriesVisited": 1,
"totalCitiesVisited": 1,
"monthlyDistanceKm": {
@ -68,7 +68,7 @@ RSpec.describe StatsSerializer do
},
{
"year": 2020,
"totalDistanceKm": 12,
"totalDistanceKm": (stats_in_2020.map(&:distance).sum / 1000).to_i,
"totalCountriesVisited": 1,
"totalCitiesVisited": 1,
"monthlyDistanceKm": {

View file

@ -0,0 +1,170 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe TrackSerializer do
describe '#call' do
let(:user) { create(:user) }
let(:track) { create(:track, user: user) }
let(:serializer) { described_class.new(track) }
subject(:serialized_track) { serializer.call }
it 'returns a hash with all required attributes' do
expect(serialized_track).to be_a(Hash)
expect(serialized_track.keys).to contain_exactly(
:id, :start_at, :end_at, :distance, :avg_speed, :duration,
:elevation_gain, :elevation_loss, :elevation_max, :elevation_min, :original_path
)
end
it 'serializes the track ID correctly' do
expect(serialized_track[:id]).to eq(track.id)
end
it 'formats start_at as ISO8601 timestamp' do
expect(serialized_track[:start_at]).to eq(track.start_at.iso8601)
expect(serialized_track[:start_at]).to match(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/)
end
it 'formats end_at as ISO8601 timestamp' do
expect(serialized_track[:end_at]).to eq(track.end_at.iso8601)
expect(serialized_track[:end_at]).to match(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/)
end
it 'converts distance to integer' do
expect(serialized_track[:distance]).to eq(track.distance.to_i)
expect(serialized_track[:distance]).to be_a(Integer)
end
it 'converts avg_speed to float' do
expect(serialized_track[:avg_speed]).to eq(track.avg_speed.to_f)
expect(serialized_track[:avg_speed]).to be_a(Float)
end
it 'serializes duration as numeric value' do
expect(serialized_track[:duration]).to eq(track.duration)
expect(serialized_track[:duration]).to be_a(Numeric)
end
it 'serializes elevation_gain as numeric value' do
expect(serialized_track[:elevation_gain]).to eq(track.elevation_gain)
expect(serialized_track[:elevation_gain]).to be_a(Numeric)
end
it 'serializes elevation_loss as numeric value' do
expect(serialized_track[:elevation_loss]).to eq(track.elevation_loss)
expect(serialized_track[:elevation_loss]).to be_a(Numeric)
end
it 'serializes elevation_max as numeric value' do
expect(serialized_track[:elevation_max]).to eq(track.elevation_max)
expect(serialized_track[:elevation_max]).to be_a(Numeric)
end
it 'serializes elevation_min as numeric value' do
expect(serialized_track[:elevation_min]).to eq(track.elevation_min)
expect(serialized_track[:elevation_min]).to be_a(Numeric)
end
it 'converts original_path to string' do
expect(serialized_track[:original_path]).to eq(track.original_path.to_s)
expect(serialized_track[:original_path]).to be_a(String)
end
context 'with decimal distance values' do
let(:track) { create(:track, user: user, distance: 1234.56) }
it 'truncates distance to integer' do
expect(serialized_track[:distance]).to eq(1234)
end
end
context 'with decimal avg_speed values' do
let(:track) { create(:track, user: user, avg_speed: 25.75) }
it 'converts avg_speed to float' do
expect(serialized_track[:avg_speed]).to eq(25.75)
end
end
context 'with different original_path formats' do
let(:track) { create(:track, user: user, original_path: 'LINESTRING(0 0, 1 1, 2 2)') }
it 'converts geometry to WKT string format' do
expect(serialized_track[:original_path]).to match(/LINESTRING \(0(\.0)? 0(\.0)?, 1(\.0)? 1(\.0)?, 2(\.0)? 2(\.0)?\)/)
expect(serialized_track[:original_path]).to be_a(String)
end
end
context 'with zero values' do
let(:track) do
create(:track, user: user,
distance: 0,
avg_speed: 0.0,
duration: 0,
elevation_gain: 0,
elevation_loss: 0,
elevation_max: 0,
elevation_min: 0)
end
it 'handles zero values correctly' do
expect(serialized_track[:distance]).to eq(0)
expect(serialized_track[:avg_speed]).to eq(0.0)
expect(serialized_track[:duration]).to eq(0)
expect(serialized_track[:elevation_gain]).to eq(0)
expect(serialized_track[:elevation_loss]).to eq(0)
expect(serialized_track[:elevation_max]).to eq(0)
expect(serialized_track[:elevation_min]).to eq(0)
end
end
context 'with very large values' do
let(:track) do
create(:track, user: user,
distance: 1_000_000.0,
avg_speed: 999.99,
duration: 86_400, # 24 hours in seconds
elevation_gain: 10_000,
elevation_loss: 8_000,
elevation_max: 5_000,
elevation_min: 0)
end
it 'handles large values correctly' do
expect(serialized_track[:distance]).to eq(1_000_000)
expect(serialized_track[:avg_speed]).to eq(999.99)
expect(serialized_track[:duration]).to eq(86_400)
expect(serialized_track[:elevation_gain]).to eq(10_000)
expect(serialized_track[:elevation_loss]).to eq(8_000)
expect(serialized_track[:elevation_max]).to eq(5_000)
expect(serialized_track[:elevation_min]).to eq(0)
end
end
context 'with different timestamp formats' do
let(:start_time) { Time.current }
let(:end_time) { start_time + 1.hour }
let(:track) { create(:track, user: user, start_at: start_time, end_at: end_time) }
it 'formats timestamps consistently' do
expect(serialized_track[:start_at]).to eq(start_time.iso8601)
expect(serialized_track[:end_at]).to eq(end_time.iso8601)
end
end
end
describe '#initialize' do
let(:track) { create(:track) }
it 'accepts a track parameter' do
expect { described_class.new(track) }.not_to raise_error
end
it 'stores the track instance' do
serializer = described_class.new(track)
expect(serializer.instance_variable_get(:@track)).to eq(track)
end
end
end

Some files were not shown because too many files have changed in this diff Show more