From b6eeba437052a5b8b936d98d6c2b10a8c7592de3 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Tue, 6 Jan 2026 20:07:20 +0100 Subject: [PATCH] Implement routes behaviour in map v2 to match map v1 --- CHANGELOG.md | 11 + CLAUDE.md | 41 ++ .../controllers/maps/maplibre/data_loader.js | 2 +- .../maps/maplibre/event_handlers.js | 193 +++++++ .../maps/maplibre/layer_manager.js | 20 + .../maps/maplibre/map_data_manager.js | 6 +- .../controllers/maps/maplibre_controller.js | 48 +- app/javascript/maps/marker_factory.js | 2 +- .../maps_maplibre/layers/routes_layer.js | 130 ++++- .../maps_maplibre/services/api_client.js | 76 ++- .../maps_maplibre/utils/settings_manager.js | 2 +- app/jobs/users/mailer_sending_job.rb | 20 +- .../map/maplibre/_settings_panel.html.erb | 30 + e2e/v2/map/interactions.spec.js | 538 ++++++++++++++++++ 14 files changed, 1071 insertions(+), 48 deletions(-) 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 @@ + + +