diff --git a/CHANGELOG.md b/CHANGELOG.md
index 21145b46..906dd1b8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,17 @@ 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.37.3] - Unreleased
+
+## Fixed
+
+- Routes are now being drawn the very same way on Map V2 as in Map V1. #2132 #2086
+
+## Changed
+
+- Map V2 points loading is significantly sped up.
+- Points size on Map V2 was reduced to prevent overlapping.
+
# [0.37.2] - 2026-01-04
## Fixed
diff --git a/CLAUDE.md b/CLAUDE.md
index bea64b39..c40f2e9c 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -238,6 +238,47 @@ bundle exec bundle-audit # Dependency security
- Respect expiration settings and disable sharing when expired
- Only expose minimal necessary data in public sharing contexts
+### Route Drawing Implementation (Critical)
+
+⚠️ **IMPORTANT: Unit Mismatch in Route Splitting Logic**
+
+Both Map v1 (Leaflet) and Map v2 (MapLibre) contain an **intentional unit mismatch** in route drawing that must be preserved for consistency:
+
+**The Issue**:
+- `haversineDistance()` function returns distance in **kilometers** (e.g., 0.5 km)
+- Route splitting threshold is stored and compared as **meters** (e.g., 500)
+- The code compares them directly: `0.5 > 500` = always **FALSE**
+
+**Result**:
+- The distance threshold (`meters_between_routes` setting) is **effectively disabled**
+- Routes only split on **time gaps** (default: 60 minutes between points)
+- This creates longer, more continuous routes that users expect
+
+**Code Locations**:
+- **Map v1**: `app/javascript/maps/polylines.js:390`
+ - Uses `haversineDistance()` from `maps/helpers.js` (returns km)
+ - Compares to `distanceThresholdMeters` variable (value in meters)
+
+- **Map v2**: `app/javascript/maps_maplibre/layers/routes_layer.js:82-104`
+ - Has built-in `haversineDistance()` method (returns km)
+ - Intentionally skips `/1000` conversion to replicate v1 behavior
+ - Comment explains this is matching v1's unit mismatch
+
+**Critical Rules**:
+1. ❌ **DO NOT "fix" the unit mismatch** - this would break user expectations
+2. ✅ **Keep both versions synchronized** - they must behave identically
+3. ✅ **Document any changes** - route drawing changes affect all users
+4. ⚠️ If you ever fix this bug:
+ - You MUST update both v1 and v2 simultaneously
+ - You MUST migrate user settings (multiply existing values by 1000 or divide by 1000 depending on direction)
+ - You MUST communicate the breaking change to users
+
+**Additional Route Drawing Details**:
+- **Time threshold**: 60 minutes (default) - actually functional
+- **Distance threshold**: 500 meters (default) - currently non-functional due to unit bug
+- **Sorting**: Map v2 sorts points by timestamp client-side; v1 relies on backend ASC order
+- **API ordering**: Map v2 must request `order: 'asc'` to match v1's chronological data flow
+
## Contributing
- **Main Branch**: `master`
diff --git a/app/javascript/controllers/maps/maplibre/data_loader.js b/app/javascript/controllers/maps/maplibre/data_loader.js
index f4e266fb..141bb148 100644
--- a/app/javascript/controllers/maps/maplibre/data_loader.js
+++ b/app/javascript/controllers/maps/maplibre/data_loader.js
@@ -39,7 +39,7 @@ export class DataLoader {
performanceMonitor.mark('transform-geojson')
data.pointsGeoJSON = pointsToGeoJSON(data.points)
data.routesGeoJSON = RoutesLayer.pointsToRoutes(data.points, {
- distanceThresholdMeters: this.settings.metersBetweenRoutes || 1000,
+ distanceThresholdMeters: this.settings.metersBetweenRoutes || 500,
timeThresholdMinutes: this.settings.minutesBetweenRoutes || 60
})
performanceMonitor.measure('transform-geojson')
diff --git a/app/javascript/controllers/maps/maplibre/event_handlers.js b/app/javascript/controllers/maps/maplibre/event_handlers.js
index be214d13..62ab3567 100644
--- a/app/javascript/controllers/maps/maplibre/event_handlers.js
+++ b/app/javascript/controllers/maps/maplibre/event_handlers.js
@@ -1,4 +1,6 @@
import { formatTimestamp } from 'maps_maplibre/utils/geojson_transformers'
+import { formatDistance, formatSpeed, minutesToDaysHoursMinutes } from 'maps/helpers'
+import maplibregl from 'maplibre-gl'
/**
* Handles map interaction events (clicks, info display)
@@ -7,6 +9,8 @@ export class EventHandlers {
constructor(map, controller) {
this.map = map
this.controller = controller
+ this.selectedRouteFeature = null
+ this.routeMarkers = [] // Store start/end markers for routes
}
/**
@@ -126,4 +130,193 @@ export class EventHandlers {
this.controller.showInfo(properties.name || 'Area', content, actions)
}
+
+ /**
+ * Handle route hover
+ */
+ handleRouteHover(e) {
+ const feature = e.features[0]
+ if (!feature) return
+
+ const routesLayer = this.controller.layerManager.getLayer('routes')
+ if (!routesLayer) return
+
+ // If a route is selected and we're hovering over a different route, show both
+ if (this.selectedRouteFeature) {
+ // Check if we're hovering over the same route that's selected
+ const isSameRoute = this._areFeaturesSame(this.selectedRouteFeature, feature)
+
+ if (!isSameRoute) {
+ // Show both selected and hovered routes
+ const features = [this.selectedRouteFeature, feature]
+ routesLayer.setHoverRoute({
+ type: 'FeatureCollection',
+ features: features
+ })
+ // Create markers for both routes
+ this._createRouteMarkers(features)
+ }
+ } else {
+ // No selection, just show hovered route
+ routesLayer.setHoverRoute(feature)
+ // Create markers for hovered route
+ this._createRouteMarkers(feature)
+ }
+ }
+
+ /**
+ * Handle route mouse leave
+ */
+ handleRouteMouseLeave(e) {
+ const routesLayer = this.controller.layerManager.getLayer('routes')
+ if (!routesLayer) return
+
+ // If a route is selected, keep showing only the selected route
+ if (this.selectedRouteFeature) {
+ routesLayer.setHoverRoute(this.selectedRouteFeature)
+ // Keep markers for selected route only
+ this._createRouteMarkers(this.selectedRouteFeature)
+ } else {
+ // No selection, clear hover and markers
+ routesLayer.setHoverRoute(null)
+ this._clearRouteMarkers()
+ }
+ }
+
+ /**
+ * Compare two features to see if they represent the same route
+ */
+ _areFeaturesSame(feature1, feature2) {
+ if (!feature1 || !feature2) return false
+
+ // Compare by start/end times and point count (unique enough for routes)
+ const props1 = feature1.properties
+ const props2 = feature2.properties
+
+ return props1.startTime === props2.startTime &&
+ props1.endTime === props2.endTime &&
+ props1.pointCount === props2.pointCount
+ }
+
+ /**
+ * Create start/end markers for route(s)
+ * @param {Array|Object} features - Single feature or array of features
+ */
+ _createRouteMarkers(features) {
+ // Clear existing markers first
+ this._clearRouteMarkers()
+
+ // Ensure we have an array
+ const featureArray = Array.isArray(features) ? features : [features]
+
+ featureArray.forEach(feature => {
+ if (!feature || !feature.geometry || feature.geometry.type !== 'LineString') return
+
+ const coords = feature.geometry.coordinates
+ if (coords.length < 2) return
+
+ // Start marker (🚥)
+ const startCoord = coords[0]
+ const startMarker = this._createEmojiMarker('🚥')
+ startMarker.setLngLat(startCoord).addTo(this.map)
+ this.routeMarkers.push(startMarker)
+
+ // End marker (🏁)
+ const endCoord = coords[coords.length - 1]
+ const endMarker = this._createEmojiMarker('🏁')
+ endMarker.setLngLat(endCoord).addTo(this.map)
+ this.routeMarkers.push(endMarker)
+ })
+ }
+
+ /**
+ * Create an emoji marker
+ * @param {String} emoji - The emoji to display
+ * @returns {maplibregl.Marker}
+ */
+ _createEmojiMarker(emoji) {
+ const el = document.createElement('div')
+ el.className = 'route-emoji-marker'
+ el.textContent = emoji
+ el.style.fontSize = '24px'
+ el.style.cursor = 'pointer'
+ el.style.userSelect = 'none'
+
+ return new maplibregl.Marker({ element: el, anchor: 'center' })
+ }
+
+ /**
+ * Clear all route markers
+ */
+ _clearRouteMarkers() {
+ this.routeMarkers.forEach(marker => marker.remove())
+ this.routeMarkers = []
+ }
+
+ /**
+ * Handle route click
+ */
+ handleRouteClick(e) {
+ const feature = e.features[0]
+ const properties = feature.properties
+
+ // Store selected route
+ this.selectedRouteFeature = feature
+
+ // Update hover layer to show selected route
+ const routesLayer = this.controller.layerManager.getLayer('routes')
+ if (routesLayer) {
+ routesLayer.setHoverRoute(feature)
+ }
+
+ // Create markers for selected route
+ this._createRouteMarkers(feature)
+
+ // Calculate duration
+ const durationSeconds = properties.endTime - properties.startTime
+ const durationMinutes = Math.floor(durationSeconds / 60)
+ const durationFormatted = minutesToDaysHoursMinutes(durationMinutes)
+
+ // Calculate average speed
+ let avgSpeed = properties.speed
+ if (!avgSpeed && properties.distance > 0 && durationSeconds > 0) {
+ avgSpeed = (properties.distance / durationSeconds) * 3600 // km/h
+ }
+
+ // Get user preferences
+ const distanceUnit = this.controller.settings.distance_unit || 'km'
+
+ // Prepare route data object
+ const routeData = {
+ startTime: formatTimestamp(properties.startTime, this.controller.timezoneValue),
+ endTime: formatTimestamp(properties.endTime, this.controller.timezoneValue),
+ duration: durationFormatted,
+ distance: formatDistance(properties.distance, distanceUnit),
+ speed: avgSpeed ? formatSpeed(avgSpeed, distanceUnit) : null,
+ pointCount: properties.pointCount
+ }
+
+ // Call controller method to display route info
+ this.controller.showRouteInfo(routeData)
+ }
+
+ /**
+ * Clear route selection
+ */
+ clearRouteSelection() {
+ if (!this.selectedRouteFeature) return
+
+ this.selectedRouteFeature = null
+
+ const routesLayer = this.controller.layerManager.getLayer('routes')
+ if (routesLayer) {
+ routesLayer.setHoverRoute(null)
+ }
+
+ // Clear markers
+ this._clearRouteMarkers()
+
+ // Close info panel
+ this.controller.closeInfo()
+ }
}
diff --git a/app/javascript/controllers/maps/maplibre/layer_manager.js b/app/javascript/controllers/maps/maplibre/layer_manager.js
index 2968713e..5633fd5a 100644
--- a/app/javascript/controllers/maps/maplibre/layer_manager.js
+++ b/app/javascript/controllers/maps/maplibre/layer_manager.js
@@ -69,6 +69,11 @@ export class LayerManager {
this.map.on('click', 'areas-outline', handlers.handleAreaClick)
this.map.on('click', 'areas-labels', handlers.handleAreaClick)
+ // Route handlers
+ this.map.on('click', 'routes', handlers.handleRouteClick)
+ this.map.on('mouseenter', 'routes', handlers.handleRouteHover)
+ this.map.on('mouseleave', 'routes', handlers.handleRouteMouseLeave)
+
// Cursor change on hover
this.map.on('mouseenter', 'points', () => {
this.map.getCanvas().style.cursor = 'pointer'
@@ -94,6 +99,13 @@ export class LayerManager {
this.map.on('mouseleave', 'places', () => {
this.map.getCanvas().style.cursor = ''
})
+ // Route cursor handlers
+ this.map.on('mouseenter', 'routes', () => {
+ this.map.getCanvas().style.cursor = 'pointer'
+ })
+ this.map.on('mouseleave', 'routes', () => {
+ this.map.getCanvas().style.cursor = ''
+ })
// Areas hover handlers for all sub-layers
const areaLayers = ['areas-fill', 'areas-outline', 'areas-labels']
areaLayers.forEach(layerId => {
@@ -107,6 +119,14 @@ export class LayerManager {
})
}
})
+
+ // Map-level click to deselect routes
+ this.map.on('click', (e) => {
+ const routeFeatures = this.map.queryRenderedFeatures(e.point, { layers: ['routes'] })
+ if (routeFeatures.length === 0) {
+ handlers.clearRouteSelection()
+ }
+ })
}
/**
diff --git a/app/javascript/controllers/maps/maplibre/map_data_manager.js b/app/javascript/controllers/maps/maplibre/map_data_manager.js
index 88d13462..f6874497 100644
--- a/app/javascript/controllers/maps/maplibre/map_data_manager.js
+++ b/app/javascript/controllers/maps/maplibre/map_data_manager.js
@@ -95,7 +95,11 @@ export class MapDataManager {
handleVisitClick: this.eventHandlers.handleVisitClick.bind(this.eventHandlers),
handlePhotoClick: this.eventHandlers.handlePhotoClick.bind(this.eventHandlers),
handlePlaceClick: this.eventHandlers.handlePlaceClick.bind(this.eventHandlers),
- handleAreaClick: this.eventHandlers.handleAreaClick.bind(this.eventHandlers)
+ handleAreaClick: this.eventHandlers.handleAreaClick.bind(this.eventHandlers),
+ handleRouteClick: this.eventHandlers.handleRouteClick.bind(this.eventHandlers),
+ handleRouteHover: this.eventHandlers.handleRouteHover.bind(this.eventHandlers),
+ handleRouteMouseLeave: this.eventHandlers.handleRouteMouseLeave.bind(this.eventHandlers),
+ clearRouteSelection: this.eventHandlers.clearRouteSelection.bind(this.eventHandlers)
})
}
diff --git a/app/javascript/controllers/maps/maplibre_controller.js b/app/javascript/controllers/maps/maplibre_controller.js
index cb19ca30..4924a07c 100644
--- a/app/javascript/controllers/maps/maplibre_controller.js
+++ b/app/javascript/controllers/maps/maplibre_controller.js
@@ -79,7 +79,16 @@ export default class extends Controller {
'infoDisplay',
'infoTitle',
'infoContent',
- 'infoActions'
+ 'infoActions',
+ // Route info template
+ 'routeInfoTemplate',
+ 'routeStartTime',
+ 'routeEndTime',
+ 'routeDuration',
+ 'routeDistance',
+ 'routeSpeed',
+ 'routeSpeedContainer',
+ 'routePoints'
]
async connect() {
@@ -467,9 +476,46 @@ export default class extends Controller {
this.switchToToolsTab()
}
+ showRouteInfo(routeData) {
+ if (!this.hasRouteInfoTemplateTarget) return
+
+ // Clone the template
+ const template = this.routeInfoTemplateTarget.content.cloneNode(true)
+
+ // Populate the template with data
+ const fragment = document.createDocumentFragment()
+ fragment.appendChild(template)
+
+ fragment.querySelector('[data-maps--maplibre-target="routeStartTime"]').textContent = routeData.startTime
+ fragment.querySelector('[data-maps--maplibre-target="routeEndTime"]').textContent = routeData.endTime
+ fragment.querySelector('[data-maps--maplibre-target="routeDuration"]').textContent = routeData.duration
+ fragment.querySelector('[data-maps--maplibre-target="routeDistance"]').textContent = routeData.distance
+ fragment.querySelector('[data-maps--maplibre-target="routePoints"]').textContent = routeData.pointCount
+
+ // Handle optional speed field
+ const speedContainer = fragment.querySelector('[data-maps--maplibre-target="routeSpeedContainer"]')
+ if (routeData.speed) {
+ fragment.querySelector('[data-maps--maplibre-target="routeSpeed"]').textContent = routeData.speed
+ speedContainer.style.display = ''
+ } else {
+ speedContainer.style.display = 'none'
+ }
+
+ // Convert fragment to HTML string for showInfo
+ const div = document.createElement('div')
+ div.appendChild(fragment)
+
+ this.showInfo('Route Information', div.innerHTML)
+ }
+
closeInfo() {
if (!this.hasInfoDisplayTarget) return
this.infoDisplayTarget.classList.add('hidden')
+
+ // Clear route selection when info panel is closed
+ if (this.eventHandlers) {
+ this.eventHandlers.clearRouteSelection()
+ }
}
/**
diff --git a/app/javascript/maps/marker_factory.js b/app/javascript/maps/marker_factory.js
index b4c257d5..8b7338b5 100644
--- a/app/javascript/maps/marker_factory.js
+++ b/app/javascript/maps/marker_factory.js
@@ -28,7 +28,7 @@ const MARKER_DATA_INDICES = {
* @param {number} size - Icon size in pixels (default: 8)
* @returns {L.DivIcon} Leaflet divIcon instance
*/
-export function createStandardIcon(color = 'blue', size = 8) {
+export function createStandardIcon(color = 'blue', size = 4) {
return L.divIcon({
className: 'custom-div-icon',
html: `
`,
diff --git a/app/javascript/maps_maplibre/layers/routes_layer.js b/app/javascript/maps_maplibre/layers/routes_layer.js
index 0539114e..cccac66e 100644
--- a/app/javascript/maps_maplibre/layers/routes_layer.js
+++ b/app/javascript/maps_maplibre/layers/routes_layer.js
@@ -8,6 +8,7 @@ export class RoutesLayer extends BaseLayer {
constructor(map, options = {}) {
super(map, { id: 'routes', ...options })
this.maxGapHours = options.maxGapHours || 5 // Max hours between points to connect
+ this.hoverSourceId = 'routes-hover-source'
}
getSourceConfig() {
@@ -20,6 +21,36 @@ export class RoutesLayer extends BaseLayer {
}
}
+ /**
+ * Override add() to create both main and hover sources
+ */
+ add(data) {
+ this.data = data
+
+ // Add main source
+ if (!this.map.getSource(this.sourceId)) {
+ this.map.addSource(this.sourceId, this.getSourceConfig())
+ }
+
+ // Add hover source (initially empty)
+ if (!this.map.getSource(this.hoverSourceId)) {
+ this.map.addSource(this.hoverSourceId, {
+ type: 'geojson',
+ data: { type: 'FeatureCollection', features: [] }
+ })
+ }
+
+ // Add layers
+ const layers = this.getLayerConfigs()
+ layers.forEach(layerConfig => {
+ if (!this.map.getLayer(layerConfig.id)) {
+ this.map.addLayer(layerConfig)
+ }
+ })
+
+ this.setVisibility(this.visible)
+ }
+
getLayerConfigs() {
return [
{
@@ -41,10 +72,71 @@ export class RoutesLayer extends BaseLayer {
'line-width': 3,
'line-opacity': 0.8
}
+ },
+ {
+ id: 'routes-hover',
+ type: 'line',
+ source: this.hoverSourceId,
+ layout: {
+ 'line-join': 'round',
+ 'line-cap': 'round'
+ },
+ paint: {
+ 'line-color': '#ffff00', // Yellow highlight
+ 'line-width': 8,
+ 'line-opacity': 1.0
+ }
}
]
}
+ /**
+ * Update hover layer with route geometry
+ * @param {Object|null} feature - Route feature, FeatureCollection, or null to clear
+ */
+ setHoverRoute(feature) {
+ const hoverSource = this.map.getSource(this.hoverSourceId)
+ if (!hoverSource) return
+
+ if (feature) {
+ // Handle both single feature and FeatureCollection
+ if (feature.type === 'FeatureCollection') {
+ hoverSource.setData(feature)
+ } else {
+ hoverSource.setData({
+ type: 'FeatureCollection',
+ features: [feature]
+ })
+ }
+ } else {
+ hoverSource.setData({ type: 'FeatureCollection', features: [] })
+ }
+ }
+
+ /**
+ * Override remove() to clean up hover source
+ */
+ remove() {
+ // Remove layers
+ this.getLayerIds().forEach(layerId => {
+ if (this.map.getLayer(layerId)) {
+ this.map.removeLayer(layerId)
+ }
+ })
+
+ // Remove main source
+ if (this.map.getSource(this.sourceId)) {
+ this.map.removeSource(this.sourceId)
+ }
+
+ // Remove hover source
+ if (this.map.getSource(this.hoverSourceId)) {
+ this.map.removeSource(this.hoverSourceId)
+ }
+
+ this.data = null
+ }
+
/**
* Calculate haversine distance between two points in kilometers
* @param {number} lat1 - First point latitude
@@ -67,6 +159,7 @@ export class RoutesLayer extends BaseLayer {
/**
* Convert points to route LineStrings with splitting
* Matches V1's route splitting logic for consistency
+ * Also handles International Date Line (IDL) crossings
* @param {Array} points - Points from API
* @param {Object} options - Splitting options
* @returns {Object} GeoJSON FeatureCollection
@@ -77,7 +170,9 @@ export class RoutesLayer extends BaseLayer {
}
// Default thresholds (matching V1 defaults from polylines.js)
- const distanceThresholdKm = (options.distanceThresholdMeters || 500) / 1000
+ // Note: V1 has a unit mismatch bug where it compares km to meters directly
+ // We replicate this behavior for consistency with V1
+ const distanceThresholdKm = options.distanceThresholdMeters || 500
const timeThresholdMinutes = options.timeThresholdMinutes || 60
// Sort by timestamp
@@ -100,7 +195,7 @@ export class RoutesLayer extends BaseLayer {
// Calculate time difference in minutes
const timeDiff = (curr.timestamp - prev.timestamp) / 60
- // Split if either threshold is exceeded (matching V1 logic)
+ // Split if any threshold is exceeded
if (distance > distanceThresholdKm || timeDiff > timeThresholdMinutes) {
if (currentSegment.length > 1) {
segments.push(currentSegment)
@@ -117,7 +212,36 @@ export class RoutesLayer extends BaseLayer {
// Convert segments to LineStrings
const features = segments.map(segment => {
- const coordinates = segment.map(p => [p.longitude, p.latitude])
+ // Unwrap coordinates to handle International Date Line (IDL) crossings
+ // This ensures routes draw the short way across IDL instead of wrapping around globe
+ const coordinates = []
+ let offset = 0 // Cumulative longitude offset for unwrapping
+
+ for (let i = 0; i < segment.length; i++) {
+ const point = segment[i]
+ let lon = point.longitude + offset
+
+ // Check for IDL crossing between consecutive points
+ if (i > 0) {
+ const prevLon = coordinates[i - 1][0]
+ const lonDiff = lon - prevLon
+
+ // If longitude jumps more than 180°, we crossed the IDL
+ if (lonDiff > 180) {
+ // Crossed from east to west (e.g., 170° to -170°)
+ // Subtract 360° to make it continuous (e.g., 170° to -170° becomes 170° to -170°-360° = -530°)
+ offset -= 360
+ lon -= 360
+ } else if (lonDiff < -180) {
+ // Crossed from west to east (e.g., -170° to 170°)
+ // Add 360° to make it continuous (e.g., -170° to 170° becomes -170° to 170°+360° = 530°)
+ offset += 360
+ lon += 360
+ }
+ }
+
+ coordinates.push([lon, point.latitude])
+ }
// Calculate total distance for the segment
let totalDistance = 0
diff --git a/app/javascript/maps_maplibre/services/api_client.js b/app/javascript/maps_maplibre/services/api_client.js
index f69563cb..33b49f82 100644
--- a/app/javascript/maps_maplibre/services/api_client.js
+++ b/app/javascript/maps_maplibre/services/api_client.js
@@ -19,7 +19,8 @@ export class ApiClient {
end_at,
page: page.toString(),
per_page: per_page.toString(),
- slim: 'true'
+ slim: 'true',
+ order: 'asc'
})
const response = await fetch(`${this.baseURL}/points?${params}`, {
@@ -40,36 +41,69 @@ export class ApiClient {
}
/**
- * Fetch all points for date range (handles pagination)
- * @param {Object} options - { start_at, end_at, onProgress }
+ * Fetch all points for date range (handles pagination with parallel requests)
+ * @param {Object} options - { start_at, end_at, onProgress, maxConcurrent }
* @returns {Promise} All points
*/
- async fetchAllPoints({ start_at, end_at, onProgress = null }) {
- const allPoints = []
- let page = 1
- let totalPages = 1
-
- do {
- const { points, currentPage, totalPages: total } =
- await this.fetchPoints({ start_at, end_at, page, per_page: 1000 })
-
- allPoints.push(...points)
- totalPages = total
- page++
+ async fetchAllPoints({ start_at, end_at, onProgress = null, maxConcurrent = 3 }) {
+ // First fetch to get total pages
+ const firstPage = await this.fetchPoints({ start_at, end_at, page: 1, per_page: 1000 })
+ const totalPages = firstPage.totalPages
+ // If only one page, return immediately
+ if (totalPages === 1) {
if (onProgress) {
- // Avoid division by zero - if no pages, progress is 100%
- const progress = totalPages > 0 ? currentPage / totalPages : 1.0
onProgress({
- loaded: allPoints.length,
- currentPage,
+ loaded: firstPage.points.length,
+ currentPage: 1,
+ totalPages: 1,
+ progress: 1.0
+ })
+ }
+ return firstPage.points
+ }
+
+ // Initialize results array with first page
+ const pageResults = [{ page: 1, points: firstPage.points }]
+ let completedPages = 1
+
+ // Create array of remaining page numbers
+ const remainingPages = Array.from(
+ { length: totalPages - 1 },
+ (_, i) => i + 2
+ )
+
+ // Process pages in batches of maxConcurrent
+ for (let i = 0; i < remainingPages.length; i += maxConcurrent) {
+ const batch = remainingPages.slice(i, i + maxConcurrent)
+
+ // Fetch batch in parallel
+ const batchPromises = batch.map(page =>
+ this.fetchPoints({ start_at, end_at, page, per_page: 1000 })
+ .then(result => ({ page, points: result.points }))
+ )
+
+ const batchResults = await Promise.all(batchPromises)
+ pageResults.push(...batchResults)
+ completedPages += batchResults.length
+
+ // Call progress callback after each batch
+ if (onProgress) {
+ const progress = totalPages > 0 ? completedPages / totalPages : 1.0
+ onProgress({
+ loaded: pageResults.reduce((sum, r) => sum + r.points.length, 0),
+ currentPage: completedPages,
totalPages,
progress
})
}
- } while (page <= totalPages)
+ }
- return allPoints
+ // Sort by page number to ensure correct order
+ pageResults.sort((a, b) => a.page - b.page)
+
+ // Flatten into single array
+ return pageResults.flatMap(r => r.points)
}
/**
diff --git a/app/javascript/maps_maplibre/utils/settings_manager.js b/app/javascript/maps_maplibre/utils/settings_manager.js
index aa12d3e8..0bf3a89a 100644
--- a/app/javascript/maps_maplibre/utils/settings_manager.js
+++ b/app/javascript/maps_maplibre/utils/settings_manager.js
@@ -10,7 +10,7 @@ const DEFAULT_SETTINGS = {
routeOpacity: 0.6,
fogOfWarRadius: 100,
fogOfWarThreshold: 1,
- metersBetweenRoutes: 1000,
+ metersBetweenRoutes: 500,
minutesBetweenRoutes: 60,
pointsRenderingMode: 'raw',
speedColoredRoutes: false,
diff --git a/app/jobs/users/mailer_sending_job.rb b/app/jobs/users/mailer_sending_job.rb
index 742db9eb..d8de83bf 100644
--- a/app/jobs/users/mailer_sending_job.rb
+++ b/app/jobs/users/mailer_sending_job.rb
@@ -6,14 +6,7 @@ class Users::MailerSendingJob < ApplicationJob
def perform(user_id, email_type, **options)
user = User.find(user_id)
- if should_skip_email?(user, email_type)
- ExceptionReporter.call(
- 'Users::MailerSendingJob',
- "Skipping #{email_type} email for user ID #{user_id} - #{skip_reason(user, email_type)}"
- )
-
- return
- end
+ return if should_skip_email?(user, email_type)
params = { user: user }.merge(options)
@@ -37,15 +30,4 @@ class Users::MailerSendingJob < ApplicationJob
false
end
end
-
- def skip_reason(user, email_type)
- case email_type.to_s
- when 'trial_expires_soon', 'trial_expired'
- 'user is already subscribed'
- when 'post_trial_reminder_early', 'post_trial_reminder_late'
- user.active? ? 'user is subscribed' : 'user is not in trial state'
- else
- 'unknown reason'
- end
- end
end
diff --git a/app/views/map/maplibre/_settings_panel.html.erb b/app/views/map/maplibre/_settings_panel.html.erb
index 143718f5..0a5cc062 100644
--- a/app/views/map/maplibre/_settings_panel.html.erb
+++ b/app/views/map/maplibre/_settings_panel.html.erb
@@ -620,6 +620,36 @@
+
+
+
+
+ Start:
+
+
+
+ End:
+
+
+
+ Duration:
+
+
+
+ Distance:
+
+
+
+ Avg Speed:
+
+
+
+ Points:
+
+
+
+
+