diff --git a/app/controllers/map_controller.rb b/app/controllers/map_controller.rb index 7147058a..d653c65e 100644 --- a/app/controllers/map_controller.rb +++ b/app/controllers/map_controller.rb @@ -7,9 +7,9 @@ class MapController < ApplicationController @points = points.where('timestamp >= ? AND timestamp <= ?', start_at, end_at) @coordinates = - @points.pluck(:lonlat, :battery, :altitude, :timestamp, :velocity, :id, :country) + @points.pluck(:lonlat, :battery, :altitude, :timestamp, :velocity, :id, :country, :track_id) .map { |lonlat, *rest| [lonlat.y, lonlat.x, *rest.map(&:to_s)] } - @tracks = TrackSerializer.new(current_user, start_at, end_at).call + @tracks = TrackSerializer.new(current_user, @coordinates).call @distance = distance @start_at = Time.zone.at(start_at) @end_at = Time.zone.at(end_at) diff --git a/app/javascript/controllers/maps_controller.js b/app/javascript/controllers/maps_controller.js index 7f269edc..05cd88f3 100644 --- a/app/javascript/controllers/maps_controller.js +++ b/app/javascript/controllers/maps_controller.js @@ -11,7 +11,9 @@ import { updatePolylinesColors, colorFormatEncode, colorFormatDecode, - colorStopsFallback + colorStopsFallback, + reestablishPolylineEventHandlers, + managePaneVisibility } from "../maps/polylines"; import { @@ -205,6 +207,9 @@ export default class extends BaseController { // Add the toggle panel button this.addTogglePanelButton(); + // Add routes/tracks selector + this.addRoutesTracksSelector(); + // 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'; @@ -553,6 +558,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) { @@ -1056,11 +1088,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); @@ -1154,6 +1202,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 = ` +
Display
+
+ + +
+ `; + + // 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'); @@ -1632,21 +1840,12 @@ export default class extends BaseController { // Track-related methods async initializeTracksLayer() { - console.log('DEBUG: Initializing tracks layer'); - console.log('DEBUG: this.tracksData:', this.tracksData); - console.log('DEBUG: tracksData type:', typeof this.tracksData); - console.log('DEBUG: tracksData length:', this.tracksData ? this.tracksData.length : 'undefined'); - // Use pre-loaded tracks data if available, otherwise fetch from API if (this.tracksData && this.tracksData.length > 0) { - console.log('DEBUG: Using pre-loaded tracks data'); this.createTracksFromData(this.tracksData); } else { - console.log('DEBUG: No pre-loaded tracks data, fetching from API'); await this.fetchTracks(); } - - console.log('DEBUG: Tracks layer after initialization:', this.tracksLayer); } async fetchTracks() { @@ -1683,14 +1882,7 @@ export default class extends BaseController { // Clear existing tracks this.tracksLayer.clearLayers(); - console.log('DEBUG: Creating tracks from data:', { - tracksData: tracksData, - tracksCount: tracksData ? tracksData.length : 0, - firstTrack: tracksData && tracksData.length > 0 ? tracksData[0] : null - }); - if (!tracksData || tracksData.length === 0) { - console.log('DEBUG: No tracks data available'); return; } @@ -1702,14 +1894,10 @@ export default class extends BaseController { this.distanceUnit ); - console.log('DEBUG: Created tracks layer:', newTracksLayer); - // Add all tracks to the existing tracks layer newTracksLayer.eachLayer((layer) => { this.tracksLayer.addLayer(layer); }); - - console.log('DEBUG: Final tracks layer with', Object.keys(this.tracksLayer._layers).length, 'layers'); } updateLayerControl() { diff --git a/app/javascript/maps/polylines.js b/app/javascript/maps/polylines.js index 67f2033d..3dba20f3 100644 --- a/app/javascript/maps/polylines.js +++ b/app/javascript/maps/polylines.js @@ -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 + } + } +} diff --git a/app/javascript/maps/tracks.js b/app/javascript/maps/tracks.js index 1b21069e..91c1ed0c 100644 --- a/app/javascript/maps/tracks.js +++ b/app/javascript/maps/tracks.js @@ -68,7 +68,9 @@ export function addTrackInteractions(trackGroup, map, track, userSettings, dista const endMarker = L.marker([endCoord[0], endCoord[1]], { icon: endIcon }); function handleTrackHover(e) { - if (isClicked) return; // Don't change hover state if clicked + if (isClicked) { + return; // Don't change hover state if clicked + } // Apply hover style to all segments in the track trackGroup.eachLayer((layer) => { @@ -185,36 +187,22 @@ export function addTrackInteractions(trackGroup, map, track, userSettings, dista } function getTrackCoordinates(track) { - // Add debugging to see what we're working with - console.log(`DEBUG: Parsing track ${track.id}:`, { - has_coordinates: !!(track.coordinates && Array.isArray(track.coordinates)), - has_path: !!(track.path && Array.isArray(track.path)), - original_path_type: typeof track.original_path, - original_path_length: track.original_path ? track.original_path.length : 0, - original_path_sample: track.original_path ? track.original_path.substring(0, 100) + '...' : null - }); - // First check if coordinates are already provided as an array if (track.coordinates && Array.isArray(track.coordinates)) { - console.log(`DEBUG: Using coordinates array for track ${track.id}`); 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)) { - console.log(`DEBUG: Using path array for track ${track.id}`); return track.path; } // Try to parse from original_path (PostGIS LineString format) if (track.original_path && typeof track.original_path === 'string') { try { - console.log(`DEBUG: Attempting to parse original_path for track ${track.id}: "${track.original_path}"`); - // 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) { - console.log(`DEBUG: LineString match found for track ${track.id}: "${match[1]}"`); const coordString = match[1]; const coordinates = coordString.split(',').map(pair => { const [lng, lat] = pair.trim().split(/\s+/).map(parseFloat); @@ -225,8 +213,6 @@ function getTrackCoordinates(track) { return [lat, lng]; // Return as [lat, lng] for Leaflet }).filter(Boolean); // Remove null entries - console.log(`DEBUG: Parsed ${coordinates.length} coordinates for track ${track.id}`); - if (coordinates.length >= 2) { return coordinates; } else { @@ -243,7 +229,6 @@ function getTrackCoordinates(track) { // For development/testing, create a simple line if we have start/end coordinates if (track.start_point && track.end_point) { - console.log(`DEBUG: Using start/end points for track ${track.id}`); return [ [track.start_point.lat, track.start_point.lng], [track.end_point.lat, track.end_point.lng] diff --git a/app/models/concerns/calculateable.rb b/app/models/concerns/calculateable.rb new file mode 100644 index 00000000..cb305a37 --- /dev/null +++ b/app/models/concerns/calculateable.rb @@ -0,0 +1,81 @@ +# 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 = calculate_distance_from_coordinates + self.distance = convert_distance_for_storage(calculated_distance) + 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 user_distance_unit + user.safe_settings.distance_unit + end + + def calculate_distance_from_coordinates + Point.total_distance(points, user_distance_unit) + end + + def convert_distance_for_storage(calculated_distance) + if track_model? + convert_distance_to_meters(calculated_distance) + else + # For Trip model - store rounded distance in user's preferred unit + calculated_distance.round + end + end + + def track_model? + self.class.name == 'Track' + end + + def convert_distance_to_meters(calculated_distance) + # For Track model - convert to meters for storage (Track expects distance in meters) + case user_distance_unit.to_s + when 'miles', 'mi' + (calculated_distance * 1609.344).round(2) # miles to meters + else + (calculated_distance * 1000).round(2) # km to meters + end + end + + def save_if_changed! + save! if changed? + end +end diff --git a/app/models/point.rb b/app/models/point.rb index 560cccce..a7c6a5ac 100644 --- a/app/models/point.rb +++ b/app/models/point.rb @@ -33,6 +33,7 @@ class Point < ApplicationRecord after_create :async_reverse_geocode, if: -> { DawarichSettings.store_geodata? && !reverse_geocoded? } after_create :set_country after_create_commit :broadcast_coordinates + after_commit :recalculate_track, on: :update def self.without_raw_data select(column_names - ['raw_data']) @@ -93,4 +94,10 @@ class Point < ApplicationRecord # 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 end diff --git a/app/models/track.rb b/app/models/track.rb index 7e1f7a46..b36e320e 100644 --- a/app/models/track.rb +++ b/app/models/track.rb @@ -1,15 +1,13 @@ # frozen_string_literal: true class Track < ApplicationRecord + include Calculateable + 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: 0 } - validates :elevation_gain, :elevation_loss, :elevation_max, :elevation_min, - numericality: { greater_than_or_equal_to: 0 } - def calculate_path - Tracks::BuildPath.new(points.pluck(:lonlat)).call - end + after_update :recalculate_path_and_distance!, if: -> { points.exists? && (saved_change_to_start_at? || saved_change_to_end_at?) } end diff --git a/app/models/trip.rb b/app/models/trip.rb index 809ce154..3178f0b5 100644 --- a/app/models/trip.rb +++ b/app/models/trip.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class Trip < ApplicationRecord + include Calculateable + has_rich_text :notes belongs_to :user @@ -32,17 +34,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 = diff --git a/app/serializers/track_serializer.rb b/app/serializers/track_serializer.rb index 78a4b1ea..56f00f26 100644 --- a/app/serializers/track_serializer.rb +++ b/app/serializers/track_serializer.rb @@ -1,29 +1,43 @@ # frozen_string_literal: true class TrackSerializer - def initialize(user, start_at, end_at) + def initialize(user, coordinates) @user = user - @start_at = start_at - @end_at = end_at + @coordinates = coordinates end def call + # Extract track IDs from the coordinates that are already filtered by timeframe + track_ids = extract_track_ids_from_coordinates + return [] if track_ids.empty? + + # Show only tracks that have points in the selected timeframe tracks_data = user.tracks - .where('start_at <= ? AND end_at >= ?', Time.zone.at(end_at), Time.zone.at(start_at)) + .where(id: track_ids) .order(start_at: :asc) .pluck(:id, :start_at, :end_at, :distance, :avg_speed, :duration, :elevation_gain, :elevation_loss, :elevation_max, :elevation_min, :original_path) tracks_data.map do |id, start_at, end_at, distance, avg_speed, duration, elevation_gain, elevation_loss, elevation_max, elevation_min, original_path| - serialize_track_data(id, start_at, end_at, distance, avg_speed, duration, - elevation_gain, elevation_loss, elevation_max, elevation_min, original_path) + serialize_track_data( + id, start_at, end_at, distance, avg_speed, duration, elevation_gain, + elevation_loss, elevation_max, elevation_min, original_path + ) end end private - attr_reader :user, :start_at, :end_at + attr_reader :user, :coordinates + + def extract_track_ids_from_coordinates + # Extract track_id from coordinates (index 8: [lat, lng, battery, altitude, timestamp, velocity, id, country, track_id]) + track_ids = coordinates.map { |coord| coord[8]&.to_i }.compact.uniq + track_ids.reject(&:zero?) # Remove any nil/zero track IDs + end + + def serialize_track_data( id, start_at, end_at, distance, avg_speed, duration, elevation_gain, diff --git a/app/services/tracks/create_from_points.rb b/app/services/tracks/create_from_points.rb index 9c3efeec..8ef63b00 100644 --- a/app/services/tracks/create_from_points.rb +++ b/app/services/tracks/create_from_points.rb @@ -1,22 +1,26 @@ # frozen_string_literal: true class Tracks::CreateFromPoints - attr_reader :user, :distance_threshold_meters, :time_threshold_minutes + attr_reader :user, :distance_threshold_meters, :time_threshold_minutes, :start_at, :end_at - def initialize(user) + def initialize(user, start_at: nil, end_at: nil) @user = user - @distance_threshold_meters = user.safe_settings.meters_between_routes || 500 - @time_threshold_minutes = user.safe_settings.minutes_between_routes || 30 + @start_at = start_at + @end_at = end_at + @distance_threshold_meters = user.safe_settings.meters_between_routes.to_i || 500 + @time_threshold_minutes = user.safe_settings.minutes_between_routes.to_i || 60 end def call - Rails.logger.info "Creating tracks for user #{user.id} with thresholds: #{distance_threshold_meters}m, #{time_threshold_minutes}min" + time_range_info = start_at || end_at ? " for time range #{start_at} - #{end_at}" : "" + Rails.logger.info "Creating tracks for user #{user.id} with thresholds: #{distance_threshold_meters}m, #{time_threshold_minutes}min#{time_range_info}" tracks_created = 0 Track.transaction do - # Clear existing tracks for this user to regenerate them - user.tracks.destroy_all + # Clear existing tracks for this user (optionally scoped to time range) + tracks_to_delete = start_at || end_at ? scoped_tracks_for_deletion : user.tracks + tracks_to_delete.destroy_all track_segments = split_points_into_tracks @@ -28,17 +32,36 @@ class Tracks::CreateFromPoints end end - Rails.logger.info "Created #{tracks_created} tracks for user #{user.id}" + Rails.logger.info "Created #{tracks_created} tracks for user #{user.id}#{time_range_info}" tracks_created end private - def user_points - @user_points ||= Point.where(user: user) - .where.not(lonlat: nil) - .where.not(timestamp: nil) - .order(:timestamp) + def user_points + @user_points ||= begin + points = Point.where(user: user) + .where.not(lonlat: nil) + .where.not(timestamp: nil) + + # Apply timestamp filtering if provided + if start_at.present? + points = points.where('timestamp >= ?', start_at) + end + + if end_at.present? + points = points.where('timestamp <= ?', end_at) + end + + points.order(:timestamp) + end + end + + def scoped_tracks_for_deletion + user.tracks.where( + 'start_at <= ? AND end_at >= ?', + Time.zone.at(end_at), Time.zone.at(start_at) + ) end def split_points_into_tracks @@ -47,7 +70,9 @@ class Tracks::CreateFromPoints track_segments = [] current_segment = [] - user_points.find_each do |point| + # Use .each instead of find_each to preserve sequential processing + # find_each processes in batches which breaks track segmentation logic + user_points.each do |point| if should_start_new_track?(point, current_segment.last) # Finalize current segment if it has enough points track_segments << current_segment if current_segment.size >= 2 @@ -72,26 +97,22 @@ class Tracks::CreateFromPoints return true if time_diff_seconds > time_threshold_seconds - # Check distance threshold - distance_meters = calculate_distance_meters(previous_point, current_point) - return true if distance_meters > distance_threshold_meters.to_i + # Check distance threshold - convert km to meters to match frontend logic + distance_km = calculate_distance_kilometers(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_meters(point1, point2) - # Use PostGIS to calculate distance in meters - distance_query = <<-SQL.squish - SELECT ST_Distance( - ST_GeomFromEWKT($1)::geography, - ST_GeomFromEWKT($2)::geography - ) - SQL - - Point.connection.select_value(distance_query, nil, [point1.lonlat, point2.lonlat]).to_f + def calculate_distance_kilometers(point1, point2) + # Use Geocoder to match behavior with frontend (same library used elsewhere in app) + Geocoder::Calculations.distance_between( + [point1.lat, point1.lon], [point2.lat, point2.lon], units: :km + ) end - def create_track_from_points(points) + def create_track_from_points(points) track = Track.new( user_id: user.id, start_at: Time.zone.at(points.first.timestamp), @@ -111,7 +132,7 @@ class Tracks::CreateFromPoints track.elevation_max = elevation_stats[:max] track.elevation_min = elevation_stats[:min] - if track.save + if track.save! Point.where(id: points.map(&:id)).update_all(track_id: track.id) track diff --git a/spec/models/track_spec.rb b/spec/models/track_spec.rb index 594459b8..c351a6ae 100644 --- a/spec/models/track_spec.rb +++ b/spec/models/track_spec.rb @@ -13,9 +13,80 @@ RSpec.describe Track, type: :model do it { is_expected.to validate_numericality_of(:distance).is_greater_than(0) } it { is_expected.to validate_numericality_of(:avg_speed).is_greater_than(0) } it { is_expected.to validate_numericality_of(:duration).is_greater_than(0) } - it { is_expected.to validate_numericality_of(:elevation_gain).is_greater_than(0) } - it { is_expected.to validate_numericality_of(:elevation_loss).is_greater_than(0) } - it { is_expected.to validate_numericality_of(:elevation_max).is_greater_than(0) } - it { is_expected.to validate_numericality_of(:elevation_min).is_greater_than(0) } + 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.404955 52.520009)', timestamp: 30.minutes.ago.to_i), + create(:point, user: user, track: track, lonlat: 'POINT(13.404956 52.520010)', 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(Float) + end + + it 'stores distance in meters for Track model' do + allow(user).to receive(:safe_settings).and_return(double(distance_unit: 'km')) + allow(Point).to receive(:total_distance).and_return(1.5) # 1.5 km + + track.calculate_distance + + expect(track.distance).to eq(1500.0) # Should be in meters + 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 diff --git a/spec/models/trip_spec.rb b/spec/models/trip_spec.rb index 7b2bf233..eecf3fb8 100644 --- a/spec/models/trip_spec.rb +++ b/spec/models/trip_spec.rb @@ -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