This commit is contained in:
Evgenii Burmakin 2025-07-08 20:24:18 +02:00 committed by GitHub
commit b5aaaffb67
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
71 changed files with 4218 additions and 305 deletions

View file

@ -4,16 +4,20 @@ 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.29.2] - UNRELEASED
## Added
- In the User Settings -> Background Jobs, you can now 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.
- 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.
## Fixed

File diff suppressed because one or more lines are too long

View file

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

View file

@ -4,20 +4,65 @@ 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
TrackSerializer.new(current_user, track_ids).call
end
def calculate_distance
total_distance_meters = 0
@coordinates.each_cons(2) do
distance_km = Geocoder::Calculations.distance_between(
[_1[0], _1[1]], [_2[0], _2[1]], units: :km
)
total_distance_meters += distance_km * 1000 # Convert km to meters
end
total_distance_meters.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 +77,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

@ -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,10 +99,13 @@ module ApplicationHelper
current_user&.theme == 'light' ? 'light' : 'dark'
end
def sidebar_distance(distance)
return unless distance
def sidebar_distance(distance_meters)
return unless distance_meters
"#{distance} #{current_user.safe_settings.distance_unit}"
# Convert from stored meters to user's preferred unit for display
user_unit = current_user.safe_settings.distance_unit
converted_distance = Stat.convert_distance(distance_meters, user_unit)
"#{converted_distance.round(2)} #{user_unit}"
end
def sidebar_points(points)

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);
@ -102,6 +150,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 +193,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 +203,54 @@ 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.map, 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
});
}
});
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
this.visitsManager.addDrawerButton();
// Fetch and display visits when map loads
this.visitsManager.fetchAndDisplayVisits();
// Add right panel toggle
this.addTogglePanelButton();
}
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 +266,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 +489,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) {
@ -801,6 +812,17 @@ export default class extends BaseController {
<input type="checkbox" id="speed_colored_routes" name="speed_colored_routes" class='w-4' style="width: 20px;" ${this.speedColoredRoutesChecked()} />
</label>
<hr class="my-2">
<h4 style="font-weight: bold; margin: 8px 0;">Track Settings</h4>
<label for="tracks_visible">
Show Tracks
<input type="checkbox" id="tracks_visible" name="tracks_visible" class='w-4' style="width: 20px;" ${this.tracksVisible ? 'checked' : ''} />
</label>
<label for="speed_color_scale">Speed color scale</label>
<div class="join">
<input type="text" class="join-item input input-ghost focus:input-ghost input-xs input-bordered w-full max-w-xs" id="speed_color_scale" name="speed_color_scale" min="5" max="100" step="1" value="${this.speedColorScale}">
@ -829,6 +851,14 @@ export default class extends BaseController {
editBtn.addEventListener("click", this.showGradientEditor.bind(this));
}
// Add track control event listeners
const tracksVisibleCheckbox = div.querySelector("#tracks_visible");
if (tracksVisibleCheckbox) {
tracksVisibleCheckbox.addEventListener("change", this.toggleTracksVisibility.bind(this));
}
// Add event listener to the form submission
div.querySelector('#settings-form').addEventListener(
'submit', this.updateSettings.bind(this)
@ -953,6 +983,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 +1000,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 +1016,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 +1130,166 @@ 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');
}
}
toggleRightPanel() {
if (this.rightPanel) {
const panel = document.querySelector('.leaflet-right-panel');
@ -1557,4 +1765,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

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

@ -0,0 +1,36 @@
# frozen_string_literal: true
class Tracks::CreateJob < ApplicationJob
queue_as :default
def perform(user_id)
user = User.find(user_id)
tracks_created = Tracks::CreateFromPoints.new(user).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,30 @@
# frozen_string_literal: true
class Tracks::IncrementalGeneratorJob < ApplicationJob
queue_as :default
sidekiq_options retry: 3
def perform(user_id, day = nil, grace_period_minutes = 5)
user = User.find(user_id)
day = day ? Date.parse(day.to_s) : Date.current
Rails.logger.info "Starting incremental track generation for user #{user.id}, day #{day}"
generator(user, day, grace_period_minutes).call
rescue StandardError => e
ExceptionReporter.call(e, 'Incremental track generation failed')
raise e
end
private
def generator(user, day, grace_period_minutes)
@generator ||= Tracks::Generator.new(
user,
point_loader: Tracks::PointLoaders::IncrementalLoader.new(user, day),
incomplete_segment_handler: Tracks::IncompleteSegmentHandlers::BufferHandler.new(user, day, grace_period_minutes),
track_cleaner: Tracks::TrackCleaners::NoOpCleaner.new(user)
)
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,75 @@
# 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)
# track.formatted_distance('km') # => "5.0 km"
#
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 formatted_distance(unit, precision: 2)
converted_distance = distance_in_unit(unit)
"#{converted_distance.round(precision)} #{unit}"
end
def distance_for_user(user)
user_unit = user.safe_settings.distance_unit
distance_in_unit(user_unit)
end
def formatted_distance_for_user(user, precision: 2)
user_unit = user.safe_settings.distance_unit
formatted_distance(user_unit, precision: precision)
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
def format_distance(distance_meters, unit, precision: 2)
converted = convert_distance(distance_meters, unit)
"#{converted.round(precision)} #{unit}"
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
def self.without_raw_data
select(column_names - ['raw_data'])
@ -63,6 +66,20 @@ class Point < ApplicationRecord
Country.containing_point(lon, lat)
end
def self.normalize_timestamp(timestamp)
case timestamp
when Integer
timestamp
when String, Numeric, DateTime, Time
timestamp.to_i
when nil
raise ArgumentError, 'Timestamp cannot be nil'
else
raise ArgumentError, "Cannot convert timestamp to integer: #{timestamp.class}"
end
end
private
# rubocop:disable Metrics/MethodLength Metrics/AbcSize
@ -89,7 +106,19 @@ class Point < ApplicationRecord
end
def country_name
# Safely get country name from association or attribute
self.country&.name || read_attribute(:country) || ''
end
def recalculate_track
return unless track.present?
track.recalculate_path_and_distance!
end
def trigger_incremental_track_generation
point_date = Time.zone.at(timestamp).to_date
return if point_date < 1.day.ago.to_date
Tracks::IncrementalGeneratorJob.perform_later(user_id, point_date.to_s, 5)
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

@ -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,38 @@
# frozen_string_literal: true
class TrackSerializer
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| serialize_track_data(track) }
end
private
attr_reader :user, :track_ids
def serialize_track_data(track)
{
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

@ -56,7 +56,7 @@ class Immich::ImportGeodata
latitude: asset['exifInfo']['latitude'],
longitude: asset['exifInfo']['longitude'],
lonlat: "SRID=4326;POINT(#{asset['exifInfo']['longitude']} #{asset['exifInfo']['latitude']})",
timestamp: Time.zone.parse(asset['exifInfo']['dateTimeOriginal']).to_i
timestamp: Point.normalize_timestamp(asset['exifInfo']['dateTimeOriginal'])
}
end

View file

@ -66,7 +66,7 @@ class Photoprism::ImportGeodata
latitude: asset['Lat'],
longitude: asset['Lng'],
lonlat: "SRID=4326;POINT(#{asset['Lng']} #{asset['Lat']})",
timestamp: Time.zone.parse(asset['TakenAt']).to_i
timestamp: Point.normalize_timestamp(asset['TakenAt'])
}
end

View file

@ -0,0 +1,64 @@
# frozen_string_literal: true
class Tracks::CreateFromPoints
include Tracks::Segmentation
include Tracks::TrackBuilder
attr_reader :user, :start_at, :end_at
def initialize(user, start_at: nil, end_at: nil)
@user = user
@start_at = start_at
@end_at = end_at
end
def call
generator = Tracks::Generator.new(
user,
point_loader: point_loader,
incomplete_segment_handler: incomplete_segment_handler,
track_cleaner: track_cleaner
)
generator.call
end
# Expose threshold properties for tests
def distance_threshold_meters
@distance_threshold_meters ||= user.safe_settings.meters_between_routes.to_i || 500
end
def time_threshold_minutes
@time_threshold_minutes ||= user.safe_settings.minutes_between_routes.to_i || 60
end
private
def point_loader
@point_loader ||=
Tracks::PointLoaders::BulkLoader.new(
user, start_at: start_at, end_at: end_at
)
end
def incomplete_segment_handler
@incomplete_segment_handler ||=
Tracks::IncompleteSegmentHandlers::IgnoreHandler.new(user)
end
def track_cleaner
@track_cleaner ||= Tracks::TrackCleaners::ReplaceCleaner.new(user, start_at: start_at, end_at: end_at)
end
# Legacy method for backward compatibility with tests
# Delegates to segmentation module logic
def should_start_new_track?(current_point, previous_point)
should_start_new_segment?(current_point, previous_point)
end
# Legacy method for backward compatibility with tests
# Delegates to segmentation module logic
def calculate_distance_kilometers(point1, point2)
calculate_distance_kilometers_between_points(point1, point2)
end
end

View file

@ -0,0 +1,108 @@
# frozen_string_literal: true
# The core track generation engine that orchestrates the entire process of creating tracks from GPS points.
#
# This class uses a flexible strategy pattern to handle different track generation scenarios:
# - Bulk processing: Generate all tracks at once from existing points
# - Incremental processing: Generate tracks as new points arrive
#
# How it works:
# 1. Uses a PointLoader strategy to load points from the database
# 2. Applies segmentation logic to split points into track segments based on time/distance gaps
# 3. Determines which segments should be finalized into tracks vs buffered for later
# 4. Creates Track records from finalized segments with calculated statistics
# 5. Manages cleanup of existing tracks based on the chosen strategy
#
# Strategy Components:
# - point_loader: Loads points from database (BulkLoader, IncrementalLoader)
# - incomplete_segment_handler: Handles segments that aren't ready to finalize (IgnoreHandler, BufferHandler)
# - track_cleaner: Manages existing tracks when regenerating (ReplaceCleaner, NoOpCleaner)
#
# The class includes Tracks::Segmentation for splitting logic and Tracks::TrackBuilder for track creation.
# Distance and time thresholds are configurable per user via their settings.
#
# Example usage:
# generator = Tracks::Generator.new(
# user,
# point_loader: Tracks::PointLoaders::BulkLoader.new(user),
# incomplete_segment_handler: Tracks::IncompleteSegmentHandlers::IgnoreHandler.new(user),
# track_cleaner: Tracks::TrackCleaners::ReplaceCleaner.new(user)
# )
# tracks_created = generator.call
#
module Tracks
class Generator
include Tracks::Segmentation
include Tracks::TrackBuilder
attr_reader :user, :point_loader, :incomplete_segment_handler, :track_cleaner
def initialize(user, point_loader:, incomplete_segment_handler:, track_cleaner:)
@user = user
@point_loader = point_loader
@incomplete_segment_handler = incomplete_segment_handler
@track_cleaner = track_cleaner
end
def call
Rails.logger.info "Starting track generation for user #{user.id}"
tracks_created = 0
Point.transaction do
# Clean up existing tracks if needed
track_cleaner.cleanup
# Load points using the configured strategy
points = point_loader.load_points
if points.empty?
Rails.logger.info "No points to process for user #{user.id}"
return 0
end
Rails.logger.info "Processing #{points.size} points for user #{user.id}"
# Apply segmentation logic
segments = split_points_into_segments(points)
Rails.logger.info "Created #{segments.size} segments for user #{user.id}"
# Process each segment
segments.each do |segment_points|
next if segment_points.size < 2
if incomplete_segment_handler.should_finalize_segment?(segment_points)
# Create track from finalized segment
track = create_track_from_points(segment_points)
if track&.persisted?
tracks_created += 1
Rails.logger.debug "Created track #{track.id} with #{segment_points.size} points"
end
else
# Handle incomplete segment according to strategy
incomplete_segment_handler.handle_incomplete_segment(segment_points)
Rails.logger.debug "Stored #{segment_points.size} points as incomplete segment"
end
end
# Cleanup any processed buffered data
incomplete_segment_handler.cleanup_processed_data
end
Rails.logger.info "Completed track generation for user #{user.id}: #{tracks_created} tracks created"
tracks_created
end
private
# Required by Tracks::Segmentation module
def distance_threshold_meters
@distance_threshold_meters ||= user.safe_settings.meters_between_routes.to_i || 500
end
def time_threshold_minutes
@time_threshold_minutes ||= user.safe_settings.minutes_between_routes.to_i || 60
end
end
end

View file

@ -0,0 +1,36 @@
# frozen_string_literal: true
module Tracks
module IncompleteSegmentHandlers
class BufferHandler
attr_reader :user, :day, :grace_period_minutes, :redis_buffer
def initialize(user, day = nil, grace_period_minutes = 5)
@user = user
@day = day || Date.current
@grace_period_minutes = grace_period_minutes
@redis_buffer = Tracks::RedisBuffer.new(user.id, @day)
end
def should_finalize_segment?(segment_points)
return false if segment_points.empty?
# Check if the last point is old enough (grace period)
last_point_time = Time.zone.at(segment_points.last.timestamp)
grace_period_cutoff = Time.current - grace_period_minutes.minutes
last_point_time < grace_period_cutoff
end
def handle_incomplete_segment(segment_points)
redis_buffer.store(segment_points)
Rails.logger.debug "Stored #{segment_points.size} points in buffer for user #{user.id}, day #{day}"
end
def cleanup_processed_data
redis_buffer.clear
Rails.logger.debug "Cleared buffer for user #{user.id}, day #{day}"
end
end
end
end

View file

@ -0,0 +1,48 @@
# frozen_string_literal: true
# Incomplete segment handling strategy for bulk track generation.
#
# This handler always finalizes segments immediately without buffering,
# making it suitable for bulk processing where all data is historical
# and no segments are expected to grow with new incoming points.
#
# How it works:
# 1. Always returns true for should_finalize_segment? - every segment becomes a track
# 2. Ignores any incomplete segments (logs them but takes no action)
# 3. Requires no cleanup since no data is buffered
#
# Used primarily for:
# - Bulk track generation from historical data
# - One-time processing where all points are already available
# - Scenarios where you want to create tracks from every valid segment
#
# This strategy is efficient for bulk operations but not suitable for
# real-time processing where segments may grow as new points arrive.
#
# Example usage:
# handler = Tracks::IncompleteSegmentHandlers::IgnoreHandler.new(user)
# should_create_track = handler.should_finalize_segment?(segment_points)
#
module Tracks
module IncompleteSegmentHandlers
class IgnoreHandler
def initialize(user)
@user = user
end
def should_finalize_segment?(segment_points)
# Always finalize segments in bulk processing
true
end
def handle_incomplete_segment(segment_points)
# Ignore incomplete segments in bulk processing
Rails.logger.debug "Ignoring incomplete segment with #{segment_points.size} points"
end
def cleanup_processed_data
# No cleanup needed for ignore strategy
end
end
end
end

View file

@ -0,0 +1,54 @@
# frozen_string_literal: true
# Point loading strategy for bulk track generation from existing GPS points.
#
# This loader retrieves all valid points for a user within an optional time range,
# suitable for regenerating all tracks at once or processing historical data.
#
# How it works:
# 1. Queries all points belonging to the user
# 2. Filters out points without valid coordinates or timestamps
# 3. Optionally filters by start_at/end_at time range if provided
# 4. Returns points ordered by timestamp for sequential processing
#
# Used primarily for:
# - Initial track generation when a user first enables tracks
# - Bulk regeneration of all tracks after settings changes
# - Processing historical data imports
#
# The loader is designed to be efficient for large datasets while ensuring
# data integrity by filtering out invalid points upfront.
#
# Example usage:
# loader = Tracks::PointLoaders::BulkLoader.new(user, start_at: 1.week.ago, end_at: Time.current)
# points = loader.load_points
#
module Tracks
module PointLoaders
class BulkLoader
attr_reader :user, :start_at, :end_at
def initialize(user, start_at: nil, end_at: nil)
@user = user
@start_at = start_at
@end_at = end_at
end
def load_points
scope = Point.where(user: user)
.where.not(lonlat: nil)
.where.not(timestamp: nil)
if start_at.present?
scope = scope.where('timestamp >= ?', start_at)
end
if end_at.present?
scope = scope.where('timestamp <= ?', end_at)
end
scope.order(:timestamp)
end
end
end
end

View file

@ -0,0 +1,72 @@
# frozen_string_literal: true
module Tracks
module PointLoaders
class IncrementalLoader
attr_reader :user, :day, :redis_buffer
def initialize(user, day = nil)
@user = user
@day = day || Date.current
@redis_buffer = Tracks::RedisBuffer.new(user.id, @day)
end
def load_points
# Get buffered points from Redis
buffered_points = redis_buffer.retrieve
# Find the last track for this day to determine where to start
last_track = Track.last_for_day(user, day)
# Load new points since last track
new_points = load_new_points_since_last_track(last_track)
# Combine buffered points with new points
combined_points = merge_points(buffered_points, new_points)
Rails.logger.debug "Loaded #{buffered_points.size} buffered points and #{new_points.size} new points for user #{user.id}"
combined_points
end
private
def load_new_points_since_last_track(last_track)
scope = user.points
.where.not(lonlat: nil)
.where.not(timestamp: nil)
.where(track_id: nil) # Only process points not already assigned to tracks
if last_track
scope = scope.where('timestamp > ?', last_track.end_at.to_i)
else
# If no last track, load all points for the day
day_start = day.beginning_of_day.to_i
day_end = day.end_of_day.to_i
scope = scope.where('timestamp >= ? AND timestamp <= ?', day_start, day_end)
end
scope.order(:timestamp)
end
def merge_points(buffered_points, new_points)
# Convert buffered point hashes back to Point objects if needed
buffered_point_objects = buffered_points.map do |point_data|
# If it's already a Point object, use it directly
if point_data.is_a?(Point)
point_data
else
# Create a Point-like object from the hash
Point.new(point_data.except('id').symbolize_keys)
end
end
# Combine and sort by timestamp
all_points = (buffered_point_objects + new_points.to_a).sort_by(&:timestamp)
# Remove duplicates based on timestamp and coordinates
all_points.uniq { |point| [point.timestamp, point.lat, point.lon] }
end
end
end
end

View file

@ -0,0 +1,72 @@
# frozen_string_literal: true
class Tracks::RedisBuffer
BUFFER_PREFIX = 'track_buffer'
BUFFER_EXPIRY = 7.days
attr_reader :user_id, :day
def initialize(user_id, day)
@user_id = user_id
@day = day.is_a?(Date) ? day : Date.parse(day.to_s)
end
def store(points)
return if points.empty?
points_data = serialize_points(points)
redis_key = buffer_key
Rails.cache.write(redis_key, points_data, expires_in: BUFFER_EXPIRY)
Rails.logger.debug "Stored #{points.size} points in buffer for user #{user_id}, day #{day}"
end
def retrieve
redis_key = buffer_key
cached_data = Rails.cache.read(redis_key)
return [] unless cached_data
deserialize_points(cached_data)
rescue StandardError => e
Rails.logger.error "Failed to retrieve buffered points for user #{user_id}, day #{day}: #{e.message}"
[]
end
# Clear the buffer for the user/day combination
def clear
redis_key = buffer_key
Rails.cache.delete(redis_key)
Rails.logger.debug "Cleared buffer for user #{user_id}, day #{day}"
end
def exists?
Rails.cache.exist?(buffer_key)
end
private
def buffer_key
"#{BUFFER_PREFIX}:#{user_id}:#{day.strftime('%Y-%m-%d')}"
end
def serialize_points(points)
points.map do |point|
{
id: point.id,
lonlat: point.lonlat.to_s,
timestamp: point.timestamp,
lat: point.lat,
lon: point.lon,
altitude: point.altitude,
velocity: point.velocity,
battery: point.battery,
user_id: point.user_id
}
end
end
def deserialize_points(points_data)
points_data || []
end
end

View file

@ -0,0 +1,140 @@
# 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 = point_timestamp(current_point)
previous_timestamp = point_timestamp(previous_point)
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_distance_kilometers_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_distance_kilometers_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 = point_timestamp(last_point)
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_timestamp(point)
if point.respond_to?(:timestamp)
# Point objects from database always have integer timestamps
point.timestamp
elsif point.is_a?(Hash)
# Hash might come from Redis buffer or test data
timestamp = point[:timestamp] || point['timestamp']
timestamp.to_i
else
raise ArgumentError, "Invalid point type: #{point.class}"
end
end
def point_coordinates(point)
if point.respond_to?(:lat) && point.respond_to?(:lon)
[point.lat, point.lon]
elsif point.is_a?(Hash)
[point[:lat] || point['lat'], point[:lon] || point['lon']]
else
raise ArgumentError, "Invalid point type: #{point.class}"
end
end
# These methods need to be implemented by the including class
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,147 @@
# 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.map(&:lonlat)).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

@ -0,0 +1,16 @@
# frozen_string_literal: true
module Tracks
module TrackCleaners
class NoOpCleaner
def initialize(user)
@user = user
end
def cleanup
# No cleanup needed for incremental processing
# We only append new tracks, don't remove existing ones
end
end
end
end

View file

@ -0,0 +1,69 @@
# frozen_string_literal: true
# Track cleaning strategy for bulk track regeneration.
#
# This cleaner removes existing tracks before generating new ones,
# ensuring a clean slate for bulk processing without duplicate tracks.
#
# How it works:
# 1. Finds all existing tracks for the user within the specified time range
# 2. Detaches all points from these tracks (sets track_id to nil)
# 3. Destroys the existing track records
# 4. Allows the generator to create fresh tracks from the same points
#
# Used primarily for:
# - Bulk track regeneration after settings changes
# - Reprocessing historical data with updated algorithms
# - Ensuring consistency when tracks need to be rebuilt
#
# The cleaner respects optional time boundaries (start_at/end_at) to enable
# partial regeneration of tracks within specific time windows.
#
# This strategy is essential for bulk operations but should not be used
# for incremental processing where existing tracks should be preserved.
#
# Example usage:
# cleaner = Tracks::TrackCleaners::ReplaceCleaner.new(user, start_at: 1.week.ago, end_at: Time.current)
# cleaner.cleanup
#
module Tracks
module TrackCleaners
class ReplaceCleaner
attr_reader :user, :start_at, :end_at
def initialize(user, start_at: nil, end_at: nil)
@user = user
@start_at = start_at
@end_at = end_at
end
def cleanup
tracks_to_remove = find_tracks_to_remove
if tracks_to_remove.any?
Rails.logger.info "Removing #{tracks_to_remove.count} existing tracks for user #{user.id}"
Point.where(track_id: tracks_to_remove.ids).update_all(track_id: nil)
tracks_to_remove.destroy_all
end
end
private
def find_tracks_to_remove
scope = user.tracks
if start_at.present?
scope = scope.where('start_at >= ?', Time.zone.at(start_at))
end
if end_at.present?
scope = scope.where('end_at <= ?', Time.zone.at(end_at))
end
scope
end
end
end
end

View file

@ -111,6 +111,7 @@ class Users::SafeSettings
end
def distance_unit
# km or mi
settings.dig('maps', 'distance_unit')
end

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

@ -56,9 +56,9 @@
<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' %>
<%= 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' %>
<%= 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>

View file

@ -1,31 +1,28 @@
<div id="<%= dom_id stat %>" class="card w-full bg-base-200 shadow-xl">
<div class="card-body">
<div class="flex justify-between items-center">
<h2 class="card-title">
<%= link_to map_url(timespan(stat.month, stat.year)), class: "underline hover:no-underline text-#{header_colors.sample}" do %>
<%= Date::MONTHNAMES[stat.month] %>
<% end %>
</h2>
<div class="border border-gray-500 rounded-md border-opacity-30 bg-gray-100 dark:bg-gray-800 p-3">
<div class="flex justify-between">
<h4 class="stat-title text-left"><%= Date::MONTHNAMES[stat.month] %> <%= stat.year %></h4>
<div class="gap-2">
<span class='text-xs text-gray-500'>Last update <%= human_date(stat.updated_at) %></span>
<%= link_to '🔄', update_year_month_stats_path(stat.year, stat.month), data: { turbo_method: :put }, class: 'text-sm text-gray-500 hover:underline' %>
</div>
<div class="flex items-center space-x-2">
<%= link_to "Details", points_path(year: stat.year, month: stat.month),
class: "link link-primary" %>
</div>
<p><%= number_with_delimiter stat.distance %><%= current_user.safe_settings.distance_unit %></p>
<% if DawarichSettings.reverse_geocoding_enabled? %>
<div class="card-actions justify-end">
<%= countries_and_cities_stat_for_month(stat) %>
</div>
<% end %>
<% if stat.daily_distance %>
<%= column_chart(
stat.daily_distance,
height: '100px',
suffix: " #{current_user.safe_settings.distance_unit}",
xtitle: 'Days',
ytitle: 'Distance'
) %>
<% end %>
</div>
<div class="flex">
<div class="stat-value">
<p><%= number_with_delimiter stat.distance_in_unit(current_user.safe_settings.distance_unit).round %><%= current_user.safe_settings.distance_unit %></p>
</div>
</div>
<div class="stat-desc">
<%= countries_and_cities_stat_for_month(stat) %>
</div>
<canvas id="distance-chart-<%= stat.id %>"
data-daily-distance="<%= stat.daily_distance %>"
data-distance-type="monthly"
data-title="<%= Date::MONTHNAMES[stat.month] %> <%= stat.year %>"
data-y-axis-title="Distance"
suffix: " <%= current_user.safe_settings.distance_unit %>",
data-user-settings="<%= current_user.safe_settings.default_settings.to_json %>"></canvas>
</div>

View file

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

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

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,13 @@
# frozen_string_literal: true
class CreateTracksFromPoints < ActiveRecord::Migration[8.0]
def up
User.find_each do |user|
Tracks::CreateJob.perform_later(user.id)
end
end
def down
raise ActiveRecord::IrreversibleMigration
end
end

View file

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

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"

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

@ -14,11 +14,11 @@ FactoryBot.define do
settings do
{
'route_opacity' => '0.5',
'meters_between_routes' => '100',
'minutes_between_routes' => '100',
'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,88 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Tracks::CreateJob, type: :job do
let(:user) { create(:user) }
describe '#perform' do
let(:service_instance) { instance_double(Tracks::CreateFromPoints) }
let(:notification_service) { instance_double(Notifications::Create) }
before do
allow(Tracks::CreateFromPoints).to receive(:new).with(user).and_return(service_instance)
allow(service_instance).to receive(:call).and_return(3)
allow(Notifications::Create).to receive(:new).and_return(notification_service)
allow(notification_service).to receive(:call)
end
it 'calls the service and creates a notification' do
described_class.new.perform(user.id)
expect(Tracks::CreateFromPoints).to have_received(:new).with(user)
expect(service_instance).to have_received(:call)
expect(Notifications::Create).to have_received(:new).with(
user: user,
kind: :info,
title: 'Tracks Generated',
content: 'Created 3 tracks from your location data. Check your tracks section to view them.'
)
expect(notification_service).to have_received(:call)
end
context 'when service raises an error' do
let(:error_message) { 'Something went wrong' }
let(:service_instance) { instance_double(Tracks::CreateFromPoints) }
let(:notification_service) { instance_double(Notifications::Create) }
before do
allow(Tracks::CreateFromPoints).to receive(:new).with(user).and_return(service_instance)
allow(service_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
it 'handles the error gracefully and creates error notification' 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))
# Should not raise an error because it's caught by the rescue block
expect { described_class.new.perform(999) }.not_to raise_error
expect(ExceptionReporter).to have_received(:call)
end
end
end
describe 'queue' do
it 'is queued on default queue' do
expect(described_class.new.queue_name).to eq('default')
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,27 @@ RSpec.describe Point, type: :model do
expect(point.lat).to eq(2)
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
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::IncrementalGeneratorJob' do
expect { point.send(:trigger_incremental_track_generation) }.to have_enqueued_job(Tracks::IncrementalGeneratorJob).with(point.user_id, point.recorded_at.to_date.to_s, 5)
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

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

@ -33,7 +33,8 @@ RSpec.describe PointSerializer do
'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,99 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe TrackSerializer do
describe '#call' do
let(:user) { create(:user) }
context 'when serializing user tracks with track IDs' do
subject(:serializer) { described_class.new(user, track_ids).call }
let!(:track1) { create(:track, user: user, start_at: 2.hours.ago, end_at: 1.hour.ago) }
let!(:track2) { create(:track, user: user, start_at: 4.hours.ago, end_at: 3.hours.ago) }
let!(:track3) { create(:track, user: user, start_at: 6.hours.ago, end_at: 5.hours.ago) }
let(:track_ids) { [track1.id, track2.id] }
it 'returns an array of serialized tracks' do
expect(serializer).to be_an(Array)
expect(serializer.length).to eq(2)
end
it 'serializes each track correctly' do
serialized_ids = serializer.map { |track| track[:id] }
expect(serialized_ids).to contain_exactly(track1.id, track2.id)
expect(serialized_ids).not_to include(track3.id)
end
it 'formats timestamps as ISO8601 for all tracks' do
serializer.each do |track|
expect(track[:start_at]).to match(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/)
expect(track[:end_at]).to match(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/)
end
end
it 'includes all required fields for each track' do
serializer.each do |track|
expect(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
end
it 'handles numeric values correctly' do
serializer.each do |track|
expect(track[:distance]).to be_a(Numeric)
expect(track[:avg_speed]).to be_a(Numeric)
expect(track[:duration]).to be_a(Numeric)
expect(track[:elevation_gain]).to be_a(Numeric)
expect(track[:elevation_loss]).to be_a(Numeric)
expect(track[:elevation_max]).to be_a(Numeric)
expect(track[:elevation_min]).to be_a(Numeric)
end
end
it 'orders tracks by start_at in ascending order' do
serialized_tracks = serializer
expect(serialized_tracks.first[:id]).to eq(track2.id) # Started 4 hours ago
expect(serialized_tracks.second[:id]).to eq(track1.id) # Started 2 hours ago
end
end
context 'when track IDs belong to different users' do
subject(:serializer) { described_class.new(user, track_ids).call }
let(:other_user) { create(:user) }
let!(:user_track) { create(:track, user: user) }
let!(:other_user_track) { create(:track, user: other_user) }
let(:track_ids) { [user_track.id, other_user_track.id] }
it 'only returns tracks belonging to the specified user' do
serialized_ids = serializer.map { |track| track[:id] }
expect(serialized_ids).to contain_exactly(user_track.id)
expect(serialized_ids).not_to include(other_user_track.id)
end
end
context 'when track IDs array is empty' do
subject(:serializer) { described_class.new(user, []).call }
it 'returns an empty array' do
expect(serializer).to eq([])
end
end
context 'when track IDs contain non-existent IDs' do
subject(:serializer) { described_class.new(user, track_ids).call }
let!(:existing_track) { create(:track, user: user) }
let(:track_ids) { [existing_track.id, 999999] }
it 'only returns existing tracks' do
serialized_ids = serializer.map { |track| track[:id] }
expect(serialized_ids).to contain_exactly(existing_track.id)
expect(serializer.length).to eq(1)
end
end
end
end

View file

@ -53,15 +53,17 @@ RSpec.describe Stats::CalculateMonth do
lonlat: 'POINT(9.77973105800526 52.72859111523629)')
end
context 'when units are kilometers' do
context 'when calculating distance' do
it 'creates stats' do
expect { calculate_stats }.to change { Stat.count }.by(1)
end
it 'calculates distance' do
it 'calculates distance in meters consistently' do
calculate_stats
expect(user.stats.last.distance).to eq(339)
# Distance should be calculated in meters regardless of user unit preference
# The actual distance between the test points is approximately 340 km = 340,000 meters
expect(user.stats.last.distance).to be_within(1000).of(340_000)
end
context 'when there is an error' do
@ -79,33 +81,16 @@ RSpec.describe Stats::CalculateMonth do
end
end
context 'when units are miles' do
context 'when user prefers miles' do
before do
user.update(settings: { maps: { distance_unit: 'mi' } })
end
it 'creates stats' do
expect { calculate_stats }.to change { Stat.count }.by(1)
end
it 'calculates distance' do
it 'still stores distance in meters (same as km users)' do
calculate_stats
expect(user.stats.last.distance).to eq(211)
end
context 'when there is an error' do
before do
allow(Stat).to receive(:find_or_initialize_by).and_raise(StandardError)
end
it 'does not create stats' do
expect { calculate_stats }.not_to(change { Stat.count })
end
it 'creates a notification' do
expect { calculate_stats }.to change { Notification.count }.by(1)
end
# Distance stored should be the same regardless of user preference (meters)
expect(user.stats.last.distance).to be_within(1000).of(340_000)
end
end
end

View file

@ -0,0 +1,279 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Tracks::CreateFromPoints do
let(:user) { create(:user) }
let(:service) { described_class.new(user) }
describe '#initialize' do
it 'sets user and thresholds from user settings' do
expect(service.user).to eq(user)
expect(service.distance_threshold_meters).to eq(user.safe_settings.meters_between_routes.to_i)
expect(service.time_threshold_minutes).to eq(user.safe_settings.minutes_between_routes.to_i)
end
context 'with custom user settings' do
before do
user.update!(settings: user.settings.merge({
'meters_between_routes' => 1000,
'minutes_between_routes' => 60
}))
end
it 'uses custom settings' do
service = described_class.new(user)
expect(service.distance_threshold_meters).to eq(1000)
expect(service.time_threshold_minutes).to eq(60)
end
end
end
describe '#call' do
context 'with no points' do
it 'returns 0 tracks created' do
expect(service.call).to eq(0)
end
end
context 'with insufficient points' do
let!(:single_point) { create(:point, user: user, timestamp: 1.hour.ago.to_i) }
it 'returns 0 tracks created' do
expect(service.call).to eq(0)
end
end
context 'with points that form a single track' do
let(:base_time) { 1.hour.ago }
let!(:points) do
[
create(:point, user: user, timestamp: base_time.to_i,
lonlat: 'POINT(-74.0060 40.7128)', altitude: 10),
create(:point, user: user, timestamp: (base_time + 5.minutes).to_i,
lonlat: 'POINT(-74.0070 40.7130)', altitude: 15),
create(:point, user: user, timestamp: (base_time + 10.minutes).to_i,
lonlat: 'POINT(-74.0080 40.7132)', altitude: 20)
]
end
it 'creates one track' do
expect { service.call }.to change(Track, :count).by(1)
end
it 'returns 1 track created' do
expect(service.call).to eq(1)
end
it 'sets track attributes correctly' do
service.call
track = Track.last
expect(track.user).to eq(user)
expect(track.start_at).to be_within(1.second).of(base_time)
expect(track.end_at).to be_within(1.second).of(base_time + 10.minutes)
expect(track.duration).to eq(600) # 10 minutes in seconds
expect(track.original_path).to be_present
expect(track.distance).to be > 0
expect(track.avg_speed).to be > 0
expect(track.elevation_gain).to eq(10) # 20 - 10
expect(track.elevation_loss).to eq(0)
expect(track.elevation_max).to eq(20)
expect(track.elevation_min).to eq(10)
end
it 'associates points with the track' do
service.call
track = Track.last
expect(points.map(&:reload).map(&:track)).to all(eq(track))
end
end
context 'with points that should be split by time' do
let(:base_time) { 2.hours.ago }
let!(:points) do
[
# First track
create(:point, user: user, timestamp: base_time.to_i,
lonlat: 'POINT(-74.0060 40.7128)'),
create(:point, user: user, timestamp: (base_time + 5.minutes).to_i,
lonlat: 'POINT(-74.0070 40.7130)'),
# Gap > time threshold (default 30 minutes)
create(:point, user: user, timestamp: (base_time + 45.minutes).to_i,
lonlat: 'POINT(-74.0080 40.7132)'),
create(:point, user: user, timestamp: (base_time + 50.minutes).to_i,
lonlat: 'POINT(-74.0090 40.7134)')
]
end
it 'creates two tracks' do
expect { service.call }.to change(Track, :count).by(2)
end
it 'returns 2 tracks created' do
expect(service.call).to eq(2)
end
end
context 'with points that should be split by distance' do
let(:base_time) { 1.hour.ago }
let!(:points) do
[
# First track - close points
create(:point, user: user, timestamp: base_time.to_i,
lonlat: 'POINT(-74.0060 40.7128)'),
create(:point, user: user, timestamp: (base_time + 1.minute).to_i,
lonlat: 'POINT(-74.0061 40.7129)'),
# Far point (> distance threshold, but within time threshold)
create(:point, user: user, timestamp: (base_time + 2.minutes).to_i,
lonlat: 'POINT(-74.0500 40.7500)'), # ~5km away
create(:point, user: user, timestamp: (base_time + 3.minutes).to_i,
lonlat: 'POINT(-74.0501 40.7501)')
]
end
it 'creates two tracks' do
expect { service.call }.to change(Track, :count).by(2)
end
end
context 'with existing tracks' do
let!(:existing_track) { create(:track, user: user) }
let!(:points) do
[
create(:point, user: user, timestamp: 1.hour.ago.to_i,
lonlat: 'POINT(-74.0060 40.7128)'),
create(:point, user: user, timestamp: 50.minutes.ago.to_i,
lonlat: 'POINT(-74.0070 40.7130)')
]
end
it 'destroys existing tracks and creates new ones' do
expect { service.call }.to change(Track, :count).by(0) # -1 + 1
expect(Track.exists?(existing_track.id)).to be false
end
end
context 'with mixed elevation data' do
let!(:points) do
[
create(:point, user: user, timestamp: 1.hour.ago.to_i,
lonlat: 'POINT(-74.0060 40.7128)', altitude: 100),
create(:point, user: user, timestamp: 50.minutes.ago.to_i,
lonlat: 'POINT(-74.0070 40.7130)', altitude: 150),
create(:point, user: user, timestamp: 40.minutes.ago.to_i,
lonlat: 'POINT(-74.0080 40.7132)', altitude: 120)
]
end
it 'calculates elevation correctly' do
service.call
track = Track.last
expect(track.elevation_gain).to eq(50) # 150 - 100
expect(track.elevation_loss).to eq(30) # 150 - 120
expect(track.elevation_max).to eq(150)
expect(track.elevation_min).to eq(100)
end
end
context 'with points missing altitude data' do
let!(:points) do
[
create(:point, user: user, timestamp: 1.hour.ago.to_i,
lonlat: 'POINT(-74.0060 40.7128)', altitude: nil),
create(:point, user: user, timestamp: 50.minutes.ago.to_i,
lonlat: 'POINT(-74.0070 40.7130)', altitude: nil)
]
end
it 'uses default elevation values' do
service.call
track = Track.last
expect(track.elevation_gain).to eq(0)
expect(track.elevation_loss).to eq(0)
expect(track.elevation_max).to eq(0)
expect(track.elevation_min).to eq(0)
end
end
end
describe 'private methods' do
describe '#should_start_new_track?' do
let(:point1) { build(:point, timestamp: 1.hour.ago.to_i, lonlat: 'POINT(-74.0060 40.7128)') }
let(:point2) { build(:point, timestamp: 50.minutes.ago.to_i, lonlat: 'POINT(-74.0070 40.7130)') }
it 'returns false when previous point is nil' do
result = service.send(:should_start_new_track?, point1, nil)
expect(result).to be false
end
it 'returns true when time threshold is exceeded' do
# Create a point > 30 minutes later (default threshold)
later_point = build(:point, timestamp: 29.minutes.ago.to_i, lonlat: 'POINT(-74.0070 40.7130)')
result = service.send(:should_start_new_track?, later_point, point1)
expect(result).to be true
end
it 'returns true when distance threshold is exceeded' do
# Create a point far away (> 500m default threshold)
far_point = build(:point, timestamp: 59.minutes.ago.to_i, lonlat: 'POINT(-74.0500 40.7500)')
result = service.send(:should_start_new_track?, far_point, point1)
expect(result).to be true
end
it 'returns false when both thresholds are not exceeded' do
result = service.send(:should_start_new_track?, point2, point1)
expect(result).to be false
end
end
describe '#calculate_distance_kilometers' do
let(:point1) { build(:point, lonlat: 'POINT(-74.0060 40.7128)') }
let(:point2) { build(:point, lonlat: 'POINT(-74.0070 40.7130)') }
it 'calculates distance between two points in kilometers' do
distance = service.send(:calculate_distance_kilometers, point1, point2)
expect(distance).to be > 0
expect(distance).to be < 0.2 # Should be small distance for close points (in km)
end
end
describe '#calculate_average_speed' do
it 'calculates speed correctly' do
# 1000 meters in 100 seconds = 10 m/s = 36 km/h
speed = service.send(:calculate_average_speed, 1000, 100)
expect(speed).to eq(36.0)
end
it 'returns 0 for zero duration' do
speed = service.send(:calculate_average_speed, 1000, 0)
expect(speed).to eq(0.0)
end
it 'returns 0 for zero distance' do
speed = service.send(:calculate_average_speed, 0, 100)
expect(speed).to eq(0.0)
end
end
describe '#calculate_track_distance' do
let(:points) do
[
build(:point, lonlat: 'POINT(-74.0060 40.7128)'),
build(:point, lonlat: 'POINT(-74.0070 40.7130)')
]
end
it 'stores distance in meters by default' do
distance = service.send(:calculate_track_distance, points)
expect(distance).to eq(87)
end
end
end
end

View file

@ -0,0 +1,257 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Tracks::Generator do
let(:user) { create(:user) }
let(:point_loader) { double('PointLoader') }
let(:incomplete_segment_handler) { double('IncompleteSegmentHandler') }
let(:track_cleaner) { double('TrackCleaner') }
let(:generator) do
described_class.new(
user,
point_loader: point_loader,
incomplete_segment_handler: incomplete_segment_handler,
track_cleaner: track_cleaner
)
end
before do
allow_any_instance_of(Users::SafeSettings).to receive(:meters_between_routes).and_return(500)
allow_any_instance_of(Users::SafeSettings).to receive(:minutes_between_routes).and_return(60)
allow_any_instance_of(Users::SafeSettings).to receive(:distance_unit).and_return('km')
end
describe '#call' do
context 'with no points to process' do
before do
allow(track_cleaner).to receive(:cleanup)
allow(point_loader).to receive(:load_points).and_return([])
end
it 'returns 0 tracks created' do
result = generator.call
expect(result).to eq(0)
end
it 'does not call incomplete segment handler' do
expect(incomplete_segment_handler).not_to receive(:should_finalize_segment?)
expect(incomplete_segment_handler).not_to receive(:handle_incomplete_segment)
expect(incomplete_segment_handler).not_to receive(:cleanup_processed_data)
generator.call
end
end
context 'with points that create tracks' do
let!(:points) do
[
create(:point, user: user, lonlat: 'POINT(-74.0060 40.7128)', timestamp: 1.hour.ago.to_i, latitude: 40.7128, longitude: -74.0060),
create(:point, user: user, lonlat: 'POINT(-74.0050 40.7138)', timestamp: 30.minutes.ago.to_i, latitude: 40.7138, longitude: -74.0050),
create(:point, user: user, lonlat: 'POINT(-74.0040 40.7148)', timestamp: 10.minutes.ago.to_i, latitude: 40.7148, longitude: -74.0040)
]
end
before do
allow(track_cleaner).to receive(:cleanup)
allow(point_loader).to receive(:load_points).and_return(points)
allow(incomplete_segment_handler).to receive(:should_finalize_segment?).and_return(true)
allow(incomplete_segment_handler).to receive(:cleanup_processed_data)
end
it 'creates tracks from segments' do
expect { generator.call }.to change { Track.count }.by(1)
end
it 'returns the number of tracks created' do
result = generator.call
expect(result).to eq(1)
end
it 'calls cleanup on processed data' do
expect(incomplete_segment_handler).to receive(:cleanup_processed_data)
generator.call
end
it 'assigns points to the created track' do
generator.call
points.each(&:reload)
track_ids = points.map(&:track_id).uniq.compact
expect(track_ids.size).to eq(1)
end
end
context 'with incomplete segments' do
let!(:points) do
[
create(:point, user: user, lonlat: 'POINT(-74.0060 40.7128)', timestamp: 5.minutes.ago.to_i, latitude: 40.7128, longitude: -74.0060),
create(:point, user: user, lonlat: 'POINT(-74.0050 40.7138)', timestamp: 4.minutes.ago.to_i, latitude: 40.7138, longitude: -74.0050)
]
end
before do
allow(track_cleaner).to receive(:cleanup)
allow(point_loader).to receive(:load_points).and_return(points)
allow(incomplete_segment_handler).to receive(:should_finalize_segment?).and_return(false)
allow(incomplete_segment_handler).to receive(:handle_incomplete_segment)
allow(incomplete_segment_handler).to receive(:cleanup_processed_data)
end
it 'does not create tracks' do
expect { generator.call }.not_to change { Track.count }
end
it 'handles incomplete segments' do
expect(incomplete_segment_handler).to receive(:handle_incomplete_segment).with(points)
generator.call
end
it 'returns 0 tracks created' do
result = generator.call
expect(result).to eq(0)
end
end
context 'with mixed complete and incomplete segments' do
let!(:old_points) do
[
create(:point, user: user, lonlat: 'POINT(-74.0060 40.7128)', timestamp: 2.hours.ago.to_i, latitude: 40.7128, longitude: -74.0060),
create(:point, user: user, lonlat: 'POINT(-74.0050 40.7138)', timestamp: 1.hour.ago.to_i, latitude: 40.7138, longitude: -74.0050)
]
end
let!(:recent_points) do
[
create(:point, user: user, lonlat: 'POINT(-74.0040 40.7148)', timestamp: 3.minutes.ago.to_i, latitude: 40.7148, longitude: -74.0040),
create(:point, user: user, lonlat: 'POINT(-74.0030 40.7158)', timestamp: 2.minutes.ago.to_i, latitude: 40.7158, longitude: -74.0030)
]
end
before do
allow(track_cleaner).to receive(:cleanup)
allow(point_loader).to receive(:load_points).and_return(old_points + recent_points)
# First segment (old points) should be finalized
# Second segment (recent points) should be incomplete
call_count = 0
allow(incomplete_segment_handler).to receive(:should_finalize_segment?) do |segment_points|
call_count += 1
call_count == 1 # Only finalize first segment
end
allow(incomplete_segment_handler).to receive(:handle_incomplete_segment)
allow(incomplete_segment_handler).to receive(:cleanup_processed_data)
end
it 'creates tracks for complete segments only' do
expect { generator.call }.to change { Track.count }.by(1)
end
it 'handles incomplete segments' do
# Note: The exact behavior depends on segmentation logic
# The important thing is that the method can be called without errors
generator.call
# Test passes if no exceptions are raised
expect(true).to be_truthy
end
it 'returns the correct number of tracks created' do
result = generator.call
expect(result).to eq(1)
end
end
context 'with insufficient points for track creation' do
let!(:single_point) do
[create(:point, user: user, lonlat: 'POINT(-74.0060 40.7128)', timestamp: 1.hour.ago.to_i, latitude: 40.7128, longitude: -74.0060)]
end
before do
allow(track_cleaner).to receive(:cleanup)
allow(point_loader).to receive(:load_points).and_return(single_point)
allow(incomplete_segment_handler).to receive(:should_finalize_segment?).and_return(true)
allow(incomplete_segment_handler).to receive(:cleanup_processed_data)
end
it 'does not create tracks with less than 2 points' do
expect { generator.call }.not_to change { Track.count }
end
it 'returns 0 tracks created' do
result = generator.call
expect(result).to eq(0)
end
end
context 'error handling' do
before do
allow(track_cleaner).to receive(:cleanup)
allow(point_loader).to receive(:load_points).and_raise(StandardError, 'Point loading failed')
end
it 'propagates errors from point loading' do
expect { generator.call }.to raise_error(StandardError, 'Point loading failed')
end
end
end
describe 'strategy pattern integration' do
context 'with bulk processing strategies' do
let(:bulk_loader) { Tracks::PointLoaders::BulkLoader.new(user) }
let(:ignore_handler) { Tracks::IncompleteSegmentHandlers::IgnoreHandler.new(user) }
let(:replace_cleaner) { Tracks::TrackCleaners::ReplaceCleaner.new(user) }
let(:bulk_generator) do
described_class.new(
user,
point_loader: bulk_loader,
incomplete_segment_handler: ignore_handler,
track_cleaner: replace_cleaner
)
end
let!(:existing_track) { create(:track, user: user) }
let!(:points) do
[
create(:point, user: user, lonlat: 'POINT(-74.0060 40.7128)', timestamp: 1.hour.ago.to_i, latitude: 40.7128, longitude: -74.0060),
create(:point, user: user, lonlat: 'POINT(-74.0050 40.7138)', timestamp: 30.minutes.ago.to_i, latitude: 40.7138, longitude: -74.0050)
]
end
it 'behaves like bulk processing' do
initial_count = Track.count
bulk_generator.call
# Bulk processing replaces existing tracks with new ones
# The final count depends on how many valid tracks can be created from the points
expect(Track.count).to be >= 0
end
end
context 'with incremental processing strategies' do
let(:incremental_loader) { Tracks::PointLoaders::IncrementalLoader.new(user) }
let(:buffer_handler) { Tracks::IncompleteSegmentHandlers::BufferHandler.new(user, Date.current, 5) }
let(:noop_cleaner) { Tracks::TrackCleaners::NoOpCleaner.new(user) }
let(:incremental_generator) do
described_class.new(
user,
point_loader: incremental_loader,
incomplete_segment_handler: buffer_handler,
track_cleaner: noop_cleaner
)
end
let!(:existing_track) { create(:track, user: user) }
before do
# Mock the incremental loader to return some points
allow(incremental_loader).to receive(:load_points).and_return([])
end
it 'behaves like incremental processing' do
expect { incremental_generator.call }.not_to change { Track.count }
end
end
end
end

View file

@ -0,0 +1,238 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Tracks::RedisBuffer do
let(:user_id) { 123 }
let(:day) { Date.current }
let(:buffer) { described_class.new(user_id, day) }
describe '#initialize' do
it 'stores user_id and converts day to Date' do
expect(buffer.user_id).to eq(user_id)
expect(buffer.day).to eq(day)
expect(buffer.day).to be_a(Date)
end
it 'handles string date input' do
buffer = described_class.new(user_id, '2024-01-15')
expect(buffer.day).to eq(Date.parse('2024-01-15'))
end
it 'handles Time input' do
time = Time.current
buffer = described_class.new(user_id, time)
expect(buffer.day).to eq(time.to_date)
end
end
describe '#store' do
let(:user) { create(:user) }
let!(:points) do
[
create(:point, user: user, lonlat: 'POINT(-74.0060 40.7128)', timestamp: 1.hour.ago.to_i),
create(:point, user: user, lonlat: 'POINT(-74.0070 40.7130)', timestamp: 30.minutes.ago.to_i)
]
end
it 'stores points in Redis cache' do
expect(Rails.cache).to receive(:write).with(
"track_buffer:#{user_id}:#{day.strftime('%Y-%m-%d')}",
anything,
expires_in: 7.days
)
buffer.store(points)
end
it 'serializes points correctly' do
buffer.store(points)
stored_data = Rails.cache.read("track_buffer:#{user_id}:#{day.strftime('%Y-%m-%d')}")
expect(stored_data).to be_an(Array)
expect(stored_data.size).to eq(2)
first_point = stored_data.first
expect(first_point[:id]).to eq(points.first.id)
expect(first_point[:timestamp]).to eq(points.first.timestamp)
expect(first_point[:lat]).to eq(points.first.lat)
expect(first_point[:lon]).to eq(points.first.lon)
expect(first_point[:user_id]).to eq(points.first.user_id)
end
it 'does nothing when given empty array' do
expect(Rails.cache).not_to receive(:write)
buffer.store([])
end
it 'logs debug message when storing points' do
expect(Rails.logger).to receive(:debug).with(
"Stored 2 points in buffer for user #{user_id}, day #{day}"
)
buffer.store(points)
end
end
describe '#retrieve' do
context 'when buffer exists' do
let(:stored_data) do
[
{
id: 1,
lonlat: 'POINT(-74.0060 40.7128)',
timestamp: 1.hour.ago.to_i,
lat: 40.7128,
lon: -74.0060,
altitude: 100,
velocity: 5.0,
battery: 80,
user_id: user_id
},
{
id: 2,
lonlat: 'POINT(-74.0070 40.7130)',
timestamp: 30.minutes.ago.to_i,
lat: 40.7130,
lon: -74.0070,
altitude: 105,
velocity: 6.0,
battery: 75,
user_id: user_id
}
]
end
before do
Rails.cache.write(
"track_buffer:#{user_id}:#{day.strftime('%Y-%m-%d')}",
stored_data
)
end
it 'returns the stored point data' do
result = buffer.retrieve
expect(result).to eq(stored_data)
expect(result.size).to eq(2)
end
end
context 'when buffer does not exist' do
it 'returns empty array' do
result = buffer.retrieve
expect(result).to eq([])
end
end
context 'when Redis read fails' do
before do
allow(Rails.cache).to receive(:read).and_raise(StandardError.new('Redis error'))
end
it 'returns empty array and logs error' do
expect(Rails.logger).to receive(:error).with(
"Failed to retrieve buffered points for user #{user_id}, day #{day}: Redis error"
)
result = buffer.retrieve
expect(result).to eq([])
end
end
end
describe '#clear' do
before do
Rails.cache.write(
"track_buffer:#{user_id}:#{day.strftime('%Y-%m-%d')}",
[{ id: 1, timestamp: 1.hour.ago.to_i }]
)
end
it 'deletes the buffer from cache' do
buffer.clear
expect(Rails.cache.read("track_buffer:#{user_id}:#{day.strftime('%Y-%m-%d')}")).to be_nil
end
it 'logs debug message' do
expect(Rails.logger).to receive(:debug).with(
"Cleared buffer for user #{user_id}, day #{day}"
)
buffer.clear
end
end
describe '#exists?' do
context 'when buffer exists' do
before do
Rails.cache.write(
"track_buffer:#{user_id}:#{day.strftime('%Y-%m-%d')}",
[{ id: 1 }]
)
end
it 'returns true' do
expect(buffer.exists?).to be true
end
end
context 'when buffer does not exist' do
it 'returns false' do
expect(buffer.exists?).to be false
end
end
end
describe 'buffer key generation' do
it 'generates correct Redis key format' do
expected_key = "track_buffer:#{user_id}:#{day.strftime('%Y-%m-%d')}"
# Access private method for testing
actual_key = buffer.send(:buffer_key)
expect(actual_key).to eq(expected_key)
end
it 'handles different date formats consistently' do
date_as_string = '2024-03-15'
date_as_date = Date.parse(date_as_string)
buffer1 = described_class.new(user_id, date_as_string)
buffer2 = described_class.new(user_id, date_as_date)
expect(buffer1.send(:buffer_key)).to eq(buffer2.send(:buffer_key))
end
end
describe 'integration test' do
let(:user) { create(:user) }
let!(:points) do
[
create(:point, user: user, lonlat: 'POINT(-74.0060 40.7128)', timestamp: 2.hours.ago.to_i),
create(:point, user: user, lonlat: 'POINT(-74.0070 40.7130)', timestamp: 1.hour.ago.to_i)
]
end
it 'stores and retrieves points correctly' do
# Store points
buffer.store(points)
expect(buffer.exists?).to be true
# Retrieve points
retrieved_points = buffer.retrieve
expect(retrieved_points.size).to eq(2)
# Verify data integrity
expect(retrieved_points.first[:id]).to eq(points.first.id)
expect(retrieved_points.last[:id]).to eq(points.last.id)
# Clear buffer
buffer.clear
expect(buffer.exists?).to be false
expect(buffer.retrieve).to eq([])
end
end
end

View file

@ -0,0 +1,326 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Tracks::TrackBuilder do
# Create a test class that includes the concern for testing
let(:test_class) do
Class.new do
include Tracks::TrackBuilder
def initialize(user)
@user = user
end
private
attr_reader :user
end
end
let(:user) { create(:user) }
let(:builder) { test_class.new(user) }
before do
# Set up user settings for consistent testing
allow_any_instance_of(Users::SafeSettings).to receive(:distance_unit).and_return('km')
end
describe '#create_track_from_points' do
context 'with valid points' do
let!(:points) do
[
create(:point, user: user, lonlat: 'POINT(-74.0060 40.7128)',
timestamp: 2.hours.ago.to_i, altitude: 100),
create(:point, user: user, lonlat: 'POINT(-74.0070 40.7130)',
timestamp: 1.hour.ago.to_i, altitude: 110),
create(:point, user: user, lonlat: 'POINT(-74.0080 40.7132)',
timestamp: 30.minutes.ago.to_i, altitude: 105)
]
end
it 'creates a track with correct attributes' do
track = builder.create_track_from_points(points)
expect(track).to be_persisted
expect(track.user).to eq(user)
expect(track.start_at).to be_within(1.second).of(Time.zone.at(points.first.timestamp))
expect(track.end_at).to be_within(1.second).of(Time.zone.at(points.last.timestamp))
expect(track.distance).to be > 0
expect(track.duration).to eq(90.minutes.to_i)
expect(track.avg_speed).to be > 0
expect(track.original_path).to be_present
end
it 'calculates elevation statistics correctly' do
track = builder.create_track_from_points(points)
expect(track.elevation_gain).to eq(10) # 110 - 100
expect(track.elevation_loss).to eq(5) # 110 - 105
expect(track.elevation_max).to eq(110)
expect(track.elevation_min).to eq(100)
end
it 'associates points with the track' do
track = builder.create_track_from_points(points)
points.each(&:reload)
expect(points.map(&:track)).to all(eq(track))
end
end
context 'with insufficient points' do
let(:single_point) { [create(:point, user: user)] }
it 'returns nil for single point' do
result = builder.create_track_from_points(single_point)
expect(result).to be_nil
end
it 'returns nil for empty array' do
result = builder.create_track_from_points([])
expect(result).to be_nil
end
end
context 'when track save fails' do
let(:points) do
[
create(:point, user: user, timestamp: 1.hour.ago.to_i),
create(:point, user: user, timestamp: 30.minutes.ago.to_i)
]
end
before do
allow_any_instance_of(Track).to receive(:save!).and_return(false)
end
it 'returns nil and logs error' do
expect(Rails.logger).to receive(:error).with(
/Failed to create track for user #{user.id}/
)
result = builder.create_track_from_points(points)
expect(result).to be_nil
end
end
end
describe '#build_path' do
let(:points) do
[
create(:point, lonlat: 'POINT(-74.0060 40.7128)'),
create(:point, lonlat: 'POINT(-74.0070 40.7130)')
]
end
it 'builds path using Tracks::BuildPath service' do
expect(Tracks::BuildPath).to receive(:new).with(
points.map(&:lonlat)
).and_call_original
result = builder.build_path(points)
expect(result).to respond_to(:as_text) # RGeo geometry object
end
end
describe '#calculate_track_distance' do
let(:points) do
[
create(:point, user: user, lonlat: 'POINT(-74.0060 40.7128)'),
create(:point, user: user, lonlat: 'POINT(-74.0070 40.7130)')
]
end
before do
# Mock Point.total_distance to return distance in meters
allow(Point).to receive(:total_distance).and_return(1500) # 1500 meters
end
it 'stores distance in meters regardless of user unit preference' do
result = builder.calculate_track_distance(points)
expect(result).to eq(1500) # Always stored as meters
end
it 'rounds distance to nearest meter' do
allow(Point).to receive(:total_distance).and_return(1500.7)
result = builder.calculate_track_distance(points)
expect(result).to eq(1501) # Rounded to nearest meter
end
end
describe '#calculate_duration' do
let(:start_time) { 2.hours.ago.to_i }
let(:end_time) { 1.hour.ago.to_i }
let(:points) do
[
double(timestamp: start_time),
double(timestamp: end_time)
]
end
it 'calculates duration in seconds' do
result = builder.calculate_duration(points)
expect(result).to eq(1.hour.to_i)
end
end
describe '#calculate_average_speed' do
context 'with valid distance and duration' do
it 'calculates speed in km/h' do
distance_meters = 1000 # 1 km
duration_seconds = 3600 # 1 hour
result = builder.calculate_average_speed(distance_meters, duration_seconds)
expect(result).to eq(1.0) # 1 km/h
end
it 'rounds to 2 decimal places' do
distance_meters = 1500 # 1.5 km
duration_seconds = 1800 # 30 minutes
result = builder.calculate_average_speed(distance_meters, duration_seconds)
expect(result).to eq(3.0) # 3 km/h
end
end
context 'with invalid inputs' do
it 'returns 0.0 for zero duration' do
result = builder.calculate_average_speed(1000, 0)
expect(result).to eq(0.0)
end
it 'returns 0.0 for zero distance' do
result = builder.calculate_average_speed(0, 3600)
expect(result).to eq(0.0)
end
it 'returns 0.0 for negative duration' do
result = builder.calculate_average_speed(1000, -3600)
expect(result).to eq(0.0)
end
end
end
describe '#calculate_elevation_stats' do
context 'with elevation data' do
let(:points) do
[
double(altitude: 100),
double(altitude: 150),
double(altitude: 120),
double(altitude: 180),
double(altitude: 160)
]
end
it 'calculates elevation gain correctly' do
result = builder.calculate_elevation_stats(points)
expect(result[:gain]).to eq(110) # (150-100) + (180-120) = 50 + 60 = 110
end
it 'calculates elevation loss correctly' do
result = builder.calculate_elevation_stats(points)
expect(result[:loss]).to eq(50) # (150-120) + (180-160) = 30 + 20 = 50
end
it 'finds max elevation' do
result = builder.calculate_elevation_stats(points)
expect(result[:max]).to eq(180)
end
it 'finds min elevation' do
result = builder.calculate_elevation_stats(points)
expect(result[:min]).to eq(100)
end
end
context 'with no elevation data' do
let(:points) do
[
double(altitude: nil),
double(altitude: nil)
]
end
it 'returns default elevation stats' do
result = builder.calculate_elevation_stats(points)
expect(result).to eq({
gain: 0,
loss: 0,
max: 0,
min: 0
})
end
end
context 'with mixed elevation data' do
let(:points) do
[
double(altitude: 100),
double(altitude: nil),
double(altitude: 150)
]
end
it 'ignores nil values' do
result = builder.calculate_elevation_stats(points)
expect(result[:gain]).to eq(50) # 150 - 100
expect(result[:loss]).to eq(0)
expect(result[:max]).to eq(150)
expect(result[:min]).to eq(100)
end
end
end
describe '#default_elevation_stats' do
it 'returns hash with zero values' do
result = builder.default_elevation_stats
expect(result).to eq({
gain: 0,
loss: 0,
max: 0,
min: 0
})
end
end
describe 'user method requirement' do
let(:invalid_class) do
Class.new do
include Tracks::TrackBuilder
# Does not implement user method
end
end
it 'raises NotImplementedError when user method is not implemented' do
invalid_builder = invalid_class.new
expect { invalid_builder.send(:user) }.to raise_error(
NotImplementedError,
"Including class must implement user method"
)
end
end
describe 'integration test' do
let!(:points) do
[
create(:point, user: user, lonlat: 'POINT(-74.0060 40.7128)',
timestamp: 2.hours.ago.to_i, altitude: 100),
create(:point, user: user, lonlat: 'POINT(-74.0070 40.7130)',
timestamp: 1.hour.ago.to_i, altitude: 120)
]
end
it 'creates a complete track end-to-end' do
expect { builder.create_track_from_points(points) }.to change(Track, :count).by(1)
track = Track.last
expect(track.user).to eq(user)
expect(track.points).to match_array(points)
expect(track.distance).to be > 0
expect(track.duration).to eq(1.hour.to_i)
expect(track.elevation_gain).to eq(20)
end
end
end

View file

@ -8,29 +8,7 @@ RSpec.describe Visits::Suggest do
let(:start_at) { Time.zone.local(2020, 1, 1, 0, 0, 0) }
let(:end_at) { Time.zone.local(2020, 1, 1, 2, 0, 0) }
let!(:points) do
[
# first visit
create(:point, :with_known_location, user:, timestamp: start_at),
create(:point, :with_known_location, user:, timestamp: start_at + 5.minutes),
create(:point, :with_known_location, user:, timestamp: start_at + 10.minutes),
create(:point, :with_known_location, user:, timestamp: start_at + 15.minutes),
create(:point, :with_known_location, user:, timestamp: start_at + 20.minutes),
create(:point, :with_known_location, user:, timestamp: start_at + 25.minutes),
create(:point, :with_known_location, user:, timestamp: start_at + 30.minutes),
create(:point, :with_known_location, user:, timestamp: start_at + 35.minutes),
create(:point, :with_known_location, user:, timestamp: start_at + 40.minutes),
create(:point, :with_known_location, user:, timestamp: start_at + 45.minutes),
create(:point, :with_known_location, user:, timestamp: start_at + 50.minutes),
create(:point, :with_known_location, user:, timestamp: start_at + 55.minutes),
# end of first visit
# second visit
create(:point, :with_known_location, user:, timestamp: start_at + 95.minutes),
create(:point, :with_known_location, user:, timestamp: start_at + 100.minutes),
create(:point, :with_known_location, user:, timestamp: start_at + 105.minutes)
# end of second visit
]
end
let!(:points) { create_visit_points(user, start_at) }
let(:geocoder_struct) do
Struct.new(:data) do
@ -97,12 +75,23 @@ RSpec.describe Visits::Suggest do
end
context 'when reverse geocoding is enabled' do
# Use a different time range to avoid interference with main tests
let(:reverse_geocoding_start_at) { Time.zone.local(2020, 6, 1, 0, 0, 0) }
let(:reverse_geocoding_end_at) { Time.zone.local(2020, 6, 1, 2, 0, 0) }
before do
allow(DawarichSettings).to receive(:reverse_geocoding_enabled?).and_return(true)
# Create points for reverse geocoding test in a separate time range
create_visit_points(user, reverse_geocoding_start_at)
clear_enqueued_jobs
end
it 'reverse geocodes visits' do
expect { subject }.to have_enqueued_job(ReverseGeocodingJob).exactly(2).times
it 'enqueues reverse geocoding jobs for created visits' do
described_class.new(user, start_at: reverse_geocoding_start_at, end_at: reverse_geocoding_end_at).call
expect(enqueued_jobs.count).to eq(2)
expect(enqueued_jobs).to all(have_job_class('ReverseGeocodingJob'))
expect(enqueued_jobs).to all(have_arguments_starting_with('place'))
end
end
@ -113,9 +102,51 @@ RSpec.describe Visits::Suggest do
it 'does not reverse geocode visits' do
expect_any_instance_of(Visit).not_to receive(:async_reverse_geocode)
subject
end
end
end
private
def create_visit_points(user, start_time)
[
# first visit
create(:point, :with_known_location, user:, timestamp: start_time),
create(:point, :with_known_location, user:, timestamp: start_time + 5.minutes),
create(:point, :with_known_location, user:, timestamp: start_time + 10.minutes),
create(:point, :with_known_location, user:, timestamp: start_time + 15.minutes),
create(:point, :with_known_location, user:, timestamp: start_time + 20.minutes),
create(:point, :with_known_location, user:, timestamp: start_time + 25.minutes),
create(:point, :with_known_location, user:, timestamp: start_time + 30.minutes),
create(:point, :with_known_location, user:, timestamp: start_time + 35.minutes),
create(:point, :with_known_location, user:, timestamp: start_time + 40.minutes),
create(:point, :with_known_location, user:, timestamp: start_time + 45.minutes),
create(:point, :with_known_location, user:, timestamp: start_time + 50.minutes),
create(:point, :with_known_location, user:, timestamp: start_time + 55.minutes),
# end of first visit
# second visit
create(:point, :with_known_location, user:, timestamp: start_time + 95.minutes),
create(:point, :with_known_location, user:, timestamp: start_time + 100.minutes),
create(:point, :with_known_location, user:, timestamp: start_time + 105.minutes)
# end of second visit
]
end
def clear_enqueued_jobs
ActiveJob::Base.queue_adapter.enqueued_jobs.clear
end
def enqueued_jobs
ActiveJob::Base.queue_adapter.enqueued_jobs
end
def have_job_class(job_class)
satisfy { |job| job['job_class'] == job_class }
end
def have_arguments_starting_with(first_argument)
satisfy { |job| job['arguments'].first == first_argument }
end
end

8
spec/support/redis.rb Normal file
View file

@ -0,0 +1,8 @@
# frozen_string_literal: true
RSpec.configure do |config|
config.before(:each) do
# Clear the cache before each test
Rails.cache.clear
end
end