diff --git a/e2e/tests/timeband.spec.js b/e2e/tests/timeband.spec.js index d57b7212..01f0875b 100644 --- a/e2e/tests/timeband.spec.js +++ b/e2e/tests/timeband.spec.js @@ -31,7 +31,7 @@ test.describe('Date Picker Tests', () => { }); test('should select single date when startDate is given', async ({page}) => { - await page.goto('/?start-date=2018-12-30') + await page.goto('/?startDate=2018-12-30') await expect(page.locator('.date-day.range-start')).toBeVisible(); await expect(page.locator('.date-day.range-start .day-number')).toHaveText('30'); await expect(page.locator('.date-day.range-start .month-year')).toHaveText('Dec 2018'); @@ -45,7 +45,7 @@ test.describe('Date Picker Tests', () => { }); test('should select date range', async ({page}) => { - await page.goto('/?start-date=2018-12-31&end-date=2019-01-01') + await page.goto('/?startDate=2018-12-31&endDate=2019-01-01') await expect(page.locator('.date-day.range-start')).toBeVisible(); await expect(page.locator('.date-day.range-start .day-number')).toHaveText('31'); await expect(page.locator('.date-day.range-start .month-year')).toHaveText('Dec 2018'); diff --git a/src/main/java/com/dedicatedcode/reitti/controller/MemoryController.java b/src/main/java/com/dedicatedcode/reitti/controller/MemoryController.java index 5bab9bd9..1b587ff7 100644 --- a/src/main/java/com/dedicatedcode/reitti/controller/MemoryController.java +++ b/src/main/java/com/dedicatedcode/reitti/controller/MemoryController.java @@ -211,7 +211,7 @@ public class MemoryController { Memory created = memoryService.createMemory(user, memory); this.memoryService.recalculateMemory(user, created.getId(), timezone); - response.setHeader("HX-Redirect", "/memories/" + created.getId()); + response.setHeader("HX-Redirect", "/memories/" + created.getId() + "?timezone=" + timezone.getId()); return "memories/fragments :: empty"; } catch (Exception e) { diff --git a/src/main/resources/static/js/raw-location-loader.js b/src/main/resources/static/js/raw-location-loader.js index 803ed216..18736d58 100644 --- a/src/main/resources/static/js/raw-location-loader.js +++ b/src/main/resources/static/js/raw-location-loader.js @@ -6,10 +6,14 @@ class RawLocationLoader { this.map = map; this.userSettings = userSettings; this.rawPointPaths = []; + this.selectedRangePaths = []; this.pulsatingMarkers = []; this.currentZoomLevel = null; this.userConfigs = []; this.isFittingBounds = false; + this.allSegments = []; // Store all loaded segments + this.selectedStartTime = null; + this.selectedEndTime = null; // Configuration for map bounds fitting this.fitToBoundsConfig = fitToBoundsConfig || {}; // Listen for map events @@ -196,6 +200,11 @@ class RawLocationLoader { bounds.extend(fetchBounds); } }); + + // Re-render selected range if it exists + if (this.selectedStartTime && this.selectedEndTime) { + this.renderSelectedRange(); + } }); } @@ -206,7 +215,15 @@ class RawLocationLoader { const bounds = L.latLngBounds(); if (rawPointsData && rawPointsData.segments && rawPointsData.segments.length > 0) { + // Store segments with metadata for later filtering for (const segment of rawPointsData.segments) { + const segmentWithMetadata = { + ...segment, + userConfig: rawPointsData.config, + color: color == null ? '#f1ba63' : color + }; + this.allSegments.push(segmentWithMetadata); + const rawPointsPath = L.geodesic([], { color: color == null ? '#f1ba63' : color, weight: 6, @@ -242,9 +259,153 @@ class RawLocationLoader { } } + // Re-render selected range if it exists + if (this.selectedStartTime && this.selectedEndTime) { + this.renderSelectedRange(); + } + return bounds; } + /** + * Set selected time range for highlighting specific segments + */ + setSelectedTimeRange(startTime, endTime) { + this.selectedStartTime = startTime; + this.selectedEndTime = endTime; + + // Reload data without bounds to get the complete path for the selected range + return this.reloadForSelectedRange(); + } + + /** + * Reload raw location points specifically for selected range (without bounds) + */ + reloadForSelectedRange() { + let bounds = L.latLngBounds(); + const fetchPromises = []; + + for (let i = 0; i < this.userConfigs.length; i++) { + const config = this.userConfigs[i]; + if (config.url) { + // Get current zoom level + const currentZoom = Math.round(this.map.getZoom()); + + // Build URL without bounding box parameters to get complete path + const separator = config.url.includes('?') ? '&' : '?'; + const urlWithParams = config.url + separator + 'zoom=' + currentZoom; + + // Create fetch promise for raw location points with index to maintain order + const fetchPromise = fetch(urlWithParams).then(response => { + if (!response.ok) { + console.warn('Could not fetch raw location points'); + return { points: [], index: i, config: config }; + } + return response.json(); + }).then(rawPointsData => { + return { ...rawPointsData, index: i, config: config }; + }).catch(error => { + console.warn('Error fetching raw location points:', error); + return { points: [], index: i, config: config }; + }); + + fetchPromises.push(fetchPromise); + } + } + + // Wait for all fetch operations to complete, then update map in correct order + return Promise.all(fetchPromises).then(results => { + this.clearPaths(); + + results.sort((a, b) => a.index - b.index); + + // Process results in order + results.forEach(result => { + const fetchBounds = this.updateMapWithRawPoints(result, result.config.color); + if (fetchBounds.isValid()) { + bounds.extend(fetchBounds); + } + }); + + // Render selected range and return its bounds + const selectedRangeBounds = this.renderSelectedRange(); + if (selectedRangeBounds && selectedRangeBounds.isValid()) { + return selectedRangeBounds; + } + + return bounds; + }); + } + + /** + * Clear selected time range + */ + clearSelectedTimeRange() { + this.selectedStartTime = null; + this.selectedEndTime = null; + this.clearSelectedRangePaths(); + // Reload with bounds to return to normal view + this.reloadForCurrentView(true); + } + + /** + * Render segments within the selected time range with different color + */ + renderSelectedRange() { + // Clear existing selected range paths + this.clearSelectedRangePaths(); + + if (!this.selectedStartTime || !this.selectedEndTime) { + return L.latLngBounds(); + } + + const bounds = L.latLngBounds(); + // Extend the time range by 2 minutes (120,000 milliseconds) on each side + const startTimeUtc = new Date(this.selectedStartTime).getTime() - 150000; + const endTimeUtc = new Date(this.selectedEndTime).getTime() + 150000; + + // Filter segments that fall within the selected time range + for (const segment of this.allSegments) { + if (segment.points && segment.points.length > 0) { + // Filter points within the time range using UTC timestamps + const filteredPoints = segment.points.filter(point => { + const pointTimeUtc = new Date(point.timestamp).getTime(); + return pointTimeUtc >= startTimeUtc && pointTimeUtc <= endTimeUtc; + }); + + // Only render if we have points within the time range + if (filteredPoints.length > 0) { + const selectedPath = L.geodesic([], { + color: '#ff984f', // Orange color for selected range + weight: 8, + opacity: 1, + lineJoin: 'round', + lineCap: 'round', + steps: 2 + }); + + const coords = filteredPoints.map(point => [point.latitude, point.longitude]); + bounds.extend(coords); + selectedPath.setLatLngs(coords); + selectedPath.addTo(this.map); + this.selectedRangePaths.push(selectedPath); + } + } + } + + return bounds; + } + + /** + * Clear selected range paths from the map + */ + clearSelectedRangePaths() { + for (const path of this.selectedRangePaths) { + path.remove(); + } + this.selectedRangePaths.length = 0; + } + /** * Clear all raw location paths from the map */ @@ -253,6 +414,7 @@ class RawLocationLoader { path.remove(); } this.rawPointPaths.length = 0; + this.allSegments.length = 0; // Clear stored segments } /** diff --git a/src/main/resources/templates/fragments/timeline.html b/src/main/resources/templates/fragments/timeline.html index a407e248..ae70ad5e 100644 --- a/src/main/resources/templates/fragments/timeline.html +++ b/src/main/resources/templates/fragments/timeline.html @@ -44,7 +44,8 @@ th:data-id="${entry.id}" th:data-lat="${entry.place?.latitudeCentroid}" th:data-lng="${entry.place?.longitudeCentroid}" - th:data-path="${entry.path}" + th:data-start="${entry.startTime}" + th:data-end="${entry.endTime}" th:data-user-id="${userData.userId}"> diff --git a/src/main/resources/templates/index.html b/src/main/resources/templates/index.html index f855ce67..81b8a4fd 100644 --- a/src/main/resources/templates/index.html +++ b/src/main/resources/templates/index.html @@ -513,33 +513,44 @@ // Remove active class from all entries document.querySelectorAll('.timeline-container .timeline-entry') .forEach(e => e.classList.remove('active')); - + const isTrip = entry.classList.contains('trip'); // Remove any selected path selectedPath.remove(); if (isCurrentlyActive) { - // Deselection: zoom back to original bounds showing all data + // Deselection: clear selected time range and zoom back to original bounds + rawLocationLoader.clearSelectedTimeRange(); if (window.originalBounds && window.originalBounds.isValid()) { map.flyToBounds(window.originalBounds, rawLocationLoader.fitToBoundsConfig); } } else { - // Selection: zoom to specific entry + // Selection: set time range and zoom to specific entry entry.classList.add('active'); const newBounds = L.latLngBounds(); const lat = parseFloat(entry.dataset.lat); const lng = parseFloat(entry.dataset.lng); + const startTime = entry.dataset.start; + const endTime = entry.dataset.end; - if (entry.dataset.path) { - const pathData = JSON.parse(entry.dataset.path); - const latlngs = pathData.map(coord => [coord.latitude, coord.longitude]); - latlngs.forEach(latlng => { - newBounds.extend(latlng); + // Set selected time range in raw location loader + if (isTrip && startTime && endTime) { + rawLocationLoader.setSelectedTimeRange(startTime, endTime).then(selectedRangeBounds => { + if (selectedRangeBounds && selectedRangeBounds.isValid()) { + newBounds.extend(selectedRangeBounds); + } + + if (!isNaN(lat) && !isNaN(lng)) { + newBounds.extend([lat, lng]); + } + + if (newBounds.isValid()) { + map.flyToBounds(newBounds, rawLocationLoader.fitToBoundsConfig); + } }); - selectedPath.setLatLngs(latlngs); - selectedPath.addTo(map); + return; // Exit early since we handle bounds in the promise } - + if (!isNaN(lat) && !isNaN(lng)) { newBounds.extend([lat, lng]); } @@ -592,8 +603,8 @@ const urlParams = new URLSearchParams(window.location.search); const newestData = window.userSettings.newestData; - let startDate = urlParams.get('start-date'); - let endDate = urlParams.get('end-date'); + let startDate = urlParams.get('startDate'); + let endDate = urlParams.get('endDate'); if (startDate) { // Validate date format (YYYY-MM-DD) if (!/^\d{4}-\d{2}-\d{2}$/.test(startDate)) { diff --git a/src/main/resources/templates/memories/fragments.html b/src/main/resources/templates/memories/fragments.html index 4d15ace5..bccbc07c 100644 --- a/src/main/resources/templates/memories/fragments.html +++ b/src/main/resources/templates/memories/fragments.html @@ -284,6 +284,7 @@ hx-get="/memories/all" hx-target=".memories-overview" hx-trigger="load, click[shouldTrigger(this)]" + hx-vals="js:{timezone: getUserTimezone()}" hx-swap="outerHTML">
All
@@ -291,6 +292,7 @@ th:attr="hx-get='/memories/year/'+ ${year}, data-year=${year}" hx-target=".memories-overview" hx-trigger="click[shouldTrigger(this)]" + hx-vals="js:{timezone: getUserTimezone()}" hx-swap="outerHTML">
2024