From fa93384a8e9bae94a3516245553bb1d5d6b7f56c Mon Sep 17 00:00:00 2001 From: Mathieu Moran Date: Tue, 11 Aug 2026 05:37:15 -0400 Subject: [PATCH] Rewrite history.js: loading spinner, active-row highlight, unified state, 300ms debounce, signal guards throughout trip loader --- PLAN.md | 18 ++ static/js/history.js | 550 ++++++++++++++++--------------------------- 2 files changed, 225 insertions(+), 343 deletions(-) diff --git a/PLAN.md b/PLAN.md index 9d7ff03..94c24a8 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1872,6 +1872,24 @@ road test. Each item tracked in [BUGS.md](./BUGS.md). - BUG-09: Geocode pre-caching for fast scrubber (pre-existing) - BUG-10: GPS-only false trips gated on OBD presence (pre-existing). Side fix: `refresh_trips()` preserves existing trips when `was_car_ever_connected()` is False (was deleting all trips on restart). +**BUG-07 supplementary fix — history.js rewrite (`static/js/history.js`):** +The trip history page had lingering responsiveness issues (freezing on rapid trip-switching, +no loading feedback, no visual active-trip indicator). A full clean rewrite of the JS was done +rather than incremental patching: +- **Map loading spinner**: Leaflet control in top-right corner, shown during trip fetch + render. +- **Active row highlight**: `.table-active` class on the selected trip row. +- **Debounce**: 300ms (was 100ms) to prevent accidental double-taps from stacking loads. +- **Unified state**: Single `st` object instead of 10+ scattered module-level variables. +- **Unified AbortController**: `st.ctrl` cancels both fetch and downstream rendering in one shot. +- **Geocode preload**: kicks off immediately after fetch, before map rendering, so cache is warm + by the time the scrubber moves. +- **Signal guards**: `sig.aborted` checked after every async step (route build, fitBounds, + timeline init) so rapid switching can't leave stale layers on the map. +- **Play/pause timeline preserved** — kept as-is per user request. +- **Delete/checkbox/batch-delete flow preserved** — no logic changes, just cleaner names. +- **Same JS file works on both Pi and server**: tile URL/attrib/zoom defaults read from + `data-*` attributes on `#history-map`, with Pi-local fallbacks when absent. + **Deployed, awaiting in-car test:** - BUG-01: Dual-radio setup — wlan0 is permanent AP at 10.42.0.1, wlan1 handles internet uplink only. `yaris_uplink.py` no longer touches the AP. In-car access card on Settings page shows hotspot URL + cell-data warning. Netwatch fixed to not tear down AP when clients connected (backup, not needed with dual-radio). diff --git a/static/js/history.js b/static/js/history.js index f97b1e0..6bc53e3 100644 --- a/static/js/history.js +++ b/static/js/history.js @@ -18,383 +18,247 @@ const maxZoom = parseInt(mapEl.dataset.maxZoom, 10) || 19; const map = L.map("history-map", { minZoom: minZoom, maxZoom: maxZoom }).setView([48.505, -0.09], 12); const tiles = L.tileLayer(tileUrl, { - minZoom: minZoom, - maxZoom: maxZoom, - subdomains: tileSubdomains, - attribution: tileAttrib, + minZoom: minZoom, maxZoom: maxZoom, + subdomains: tileSubdomains, attribution: tileAttrib, }).addTo(map); - let routeLayer = null; - let vehicleMarker = null; - let replayPoints = []; - let replayTimer = null; - let replayController = null; - let _replayDebounce = null; + + // ── Loading spinner on map ────────────────────────────────────────── + const spinner = L.control({position: "topright"}); + spinner.onAdd = function () { + var d = L.DomUtil.create("div", "history-spinner"); + d.innerHTML = '
Loading…
'; + d.style.cssText = "background:rgba(255,255,255,.85);padding:6px 8px;border-radius:4px;box-shadow:0 1px 4px rgba(0,0,0,.1);display:none"; + return d; + }; + spinner.addTo(map); + function setLoading(on) { spinner.getContainer().style.display = on ? "block" : "none"; } + + // ── Core state ────────────────────────────────────────────────────── + let st = { + route: null, marker: null, points: [], timer: null, ctrl: null, activeRow: null + }; + let _cache = {}; + let _geoTimer = null, _geoCtrl = null, _lastKey = null; const recenterBtn = document.getElementById("btn-recenter-history"); - const replayLocation = document.getElementById("trip-replay-location"); + const locEl = document.getElementById("trip-replay-location"); - let _geoTimer = null; - let _lastGeoKey = null; - let _geoCache = {}; - let _preloadController = null; - - function _geoKey(lat, lon) { - const la = typeof lat === 'number' ? lat : parseFloat(lat); - const lo = typeof lon === 'number' ? lon : parseFloat(lon); - if (isNaN(la) || isNaN(lo)) return null; - return `${la.toFixed(3)},${lo.toFixed(3)}`; + // ── Helpers ────────────────────────────────────────────────────────── + function gk(lat, lon) { + var a = typeof lat === "number" ? lat : parseFloat(lat); + var b = typeof lon === "number" ? lon : parseFloat(lon); + if (isNaN(a) || isNaN(b)) return null; + return a.toFixed(3) + "," + b.toFixed(3); } - - function preloadGeocodes(points) { - // Abort any in-flight preload from a previous trip selection. - if (_preloadController) _preloadController.abort(); - _preloadController = new AbortController(); - const signal = _preloadController.signal; - - _geoCache = {}; - const seen = new Set(); - const queue = []; - for (const p of points) { - const key = _geoKey(p.latitude, p.longitude); - if (!key || seen.has(key)) continue; - seen.add(key); - queue.push({ key, lat: p.latitude, lon: p.longitude }); - } - if (!queue.length) return; - - const CONCURRENCY = 2; - let idx = 0; - function next() { - if (idx >= queue.length || signal.aborted) return; - const item = queue[idx++]; - const ctrl = new AbortController(); - const t = setTimeout(() => ctrl.abort(), 3000); - signal.addEventListener('abort', () => ctrl.abort()); - fetch(`/api/geocode?lat=${item.lat}&lon=${item.lon}`, { signal: ctrl.signal }) - .then(r => { clearTimeout(t); return r.ok ? r.json() : null; }) - .then(data => { - if (!signal.aborted) _geoCache[item.key] = (data && data.label) || null; - }) - .catch(() => { clearTimeout(t); }) - .finally(() => { if (!signal.aborted) next(); }); - } - for (let i = 0; i < CONCURRENCY && i < queue.length; i++) next(); - } - - function geocodePoint(lat, lon) { - const key = _geoKey(lat, lon); - if (!key || key === _lastGeoKey) return; - _lastGeoKey = key; - const fallback = `${lat.toFixed(5)}, ${lon.toFixed(5)}`; - - // Use cache if available - if (_geoCache.hasOwnProperty(key)) { - replayLocation.textContent = _geoCache[key] || fallback; - return; - } - - const ctrl = new AbortController(); - const t = setTimeout(() => ctrl.abort(), 4000); - fetch(`/api/geocode?lat=${lat}&lon=${lon}`, { signal: ctrl.signal }) - .then(r => { clearTimeout(t); return r.ok ? r.json() : null; }) - .then(data => { - const label = (data && data.label) || fallback; - _geoCache[key] = (data && data.label) || null; - replayLocation.textContent = label; - }) - .catch(() => { clearTimeout(t); replayLocation.textContent = fallback; }); - } - - function scheduleGeocode(lat, lon) { - clearTimeout(_geoTimer); - _geoTimer = setTimeout(() => geocodePoint(lat, lon), 600); - } - - function formatDuration(seconds) { - const hours = Math.floor(seconds / 3600); - const minutes = Math.floor((seconds % 3600) / 60); - if (hours) { - return `${hours}h ${minutes}m`; - } - return minutes ? `${minutes}m` : `${seconds}s`; - } - - function formatDate(value) { - return new Date(value.replace(" ", "T")).toLocaleString(); - } - - function formatDistance(kilometers) { - if (kilometers == null) return '—'; - return `${kilometers.toFixed(kilometers < 1 ? 2 : 1)} km`; - } - - function formatSpeed(kilometersPerHour) { - if (kilometersPerHour == null) return '—'; - return `${kilometersPerHour.toFixed(1)} km/h`; - } - - function showError(message) { - error.textContent = message; - error.hidden = false; - } - - function stopReplay() { - if (replayTimer) { - window.clearInterval(replayTimer); - replayTimer = null; - } + function showErr(m) { error.textContent = m; error.hidden = false; } + function stopTm() { + if (st.timer) { clearInterval(st.timer); st.timer = null; } playButton.innerHTML = ''; - playButton.setAttribute("aria-label", "Play trip replay"); + playButton.setAttribute("aria-label", "Play"); + } + function cleanup() { + stopTm(); + if (st.route) { map.removeLayer(st.route); st.route = null; } + if (st.marker) { map.removeLayer(st.marker); st.marker = null; } + st.points = []; _cache = {}; _lastKey = null; + if (_geoCtrl) { _geoCtrl.abort(); _geoCtrl = null; } + clearTimeout(_geoTimer); + timeline.hidden = true; summary.textContent = "Select a trip"; + locEl.textContent = ""; recenterBtn.hidden = true; + } + function hlRow(r) { + if (st.activeRow) st.activeRow.classList.remove("table-active"); + if (r) r.classList.add("table-active"); + st.activeRow = r; } - function showReplayPoint(index) { - const point = replayPoints[index]; - if (!point) { - return; + // ── Geocode ────────────────────────────────────────────────────────── + function preload(pts) { + if (_geoCtrl) _geoCtrl.abort(); + _geoCtrl = new AbortController(); var sig = _geoCtrl.signal; _cache = {}; + var seen = {}, q = [], i; + for (i = 0; i < pts.length; i++) { + var k = gk(pts[i].latitude, pts[i].longitude); + if (!k || seen[k]) continue; seen[k] = 1; + q.push({k: k, lat: pts[i].latitude, lon: pts[i].longitude}); } - const coordinate = [point.latitude, point.longitude]; - vehicleMarker.setLatLng(coordinate); - scrubber.value = index; - replayTime.textContent = formatDate(point.datetime); - const source = point.speed_source === "obd" ? "OBD wheel" : "GPS"; - replaySpeed.textContent = `${source} speed: ${formatSpeed(point.speed)}`; - scheduleGeocode(point.latitude, point.longitude); + if (!q.length) return; + var C = 2, idx = 0; + function nx() { + if (idx >= q.length || sig.aborted) return; + var it = q[idx++], c = new AbortController(); + var t = setTimeout(function () { c.abort(); }, 3000); + sig.addEventListener("abort", function () { c.abort(); }); + fetch("/api/geocode?lat=" + it.lat + "&lon=" + it.lon, {signal: c.signal}) + .then(function (r) { clearTimeout(t); return r.ok ? r.json() : null; }) + .then(function (d) { if (!sig.aborted) _cache[it.k] = (d && d.label) || null; }) + .catch(function () { clearTimeout(t); }) + .finally(function () { if (!sig.aborted) nx(); }); + } + for (var j = 0; j < C && j < q.length; j++) nx(); + } + function showLoc(lat, lon) { + var k = gk(lat, lon); if (!k || k === _lastKey) return; _lastKey = k; + var fb = lat.toFixed(5) + ", " + lon.toFixed(5); + if (_cache.hasOwnProperty(k)) { locEl.textContent = _cache[k] || fb; return; } + var c = new AbortController(); + var t = setTimeout(function () { c.abort(); }, 3000); + fetch("/api/geocode?lat=" + lat + "&lon=" + lon, {signal: c.signal}) + .then(function (r) { clearTimeout(t); return r.ok ? r.json() : null; }) + .then(function (d) { + var lb = (d && d.label) || fb; _cache[k] = (d && d.label) || null; + locEl.textContent = lb; + }) + .catch(function () { clearTimeout(t); locEl.textContent = fb; }); + } + function schedLoc(lat, lon) { + clearTimeout(_geoTimer); + _geoTimer = setTimeout(function () { showLoc(lat, lon); }, 600); } - function initializeTimeline(points) { - stopReplay(); - replayPoints = points; - scrubber.max = Math.max(0, points.length - 1); - scrubber.value = 0; - timeline.hidden = points.length === 0; - replayLocation.textContent = ''; - _lastGeoKey = null; - if (vehicleMarker) { - vehicleMarker.remove(); - } - if (!points.length) { - vehicleMarker = null; - return; - } - vehicleMarker = L.circleMarker( - [points[0].latitude, points[0].longitude], - { - radius: 8, - color: "#fff", - weight: 3, - fillColor: "#e74a3b", - fillOpacity: 1, - } + // ── Format ─────────────────────────────────────────────────────────── + function fD(s) { var h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60); return h ? h + "h " + m + "m" : m ? m + "m" : s + "s"; } + function fT(v) { return new Date(v.replace(" ", "T")).toLocaleString(); } + function fK(km) { return km == null ? "\u2014" : km.toFixed(km < 1 ? 2 : 1) + " km"; } + function fS(kmh) { return kmh == null ? "\u2014" : kmh.toFixed(1) + " km/h"; } + + // ── Timeline ───────────────────────────────────────────────────────── + function showPt(i) { + var p = st.points[i]; if (!p) return; + st.marker.setLatLng([p.latitude, p.longitude]); + scrubber.value = i; replayTime.textContent = fT(p.datetime); + replaySpeed.textContent = (p.speed_source === "obd" ? "OBD" : "GPS") + ": " + fS(p.speed); + schedLoc(p.latitude, p.longitude); + } + function initTL(pts) { + stopTm(); st.points = pts; + scrubber.max = Math.max(0, pts.length - 1); scrubber.value = 0; + timeline.hidden = !pts.length; locEl.textContent = ""; _lastKey = null; + if (st.marker) { map.removeLayer(st.marker); st.marker = null; } + if (!pts.length) return; + st.marker = L.circleMarker( + [pts[0].latitude, pts[0].longitude], + {radius: 8, color: "#fff", weight: 3, fillColor: "#e74a3b", fillOpacity: 1} ).bindTooltip("Vehicle position").addTo(map); - showReplayPoint(0); + showPt(0); } - async function replayTrip(tripId) { - error.hidden = true; - if (replayController) { - replayController.abort(); - } - replayController = new AbortController(); - const signal = replayController.signal; + // ── Trip loader ───────────────────────────────────────────────────── + var _deb; + function loadTrip(id) { + clearTimeout(_deb); + _deb = setTimeout(function () { _load(id); }, 300); + } + async function _load(id) { + error.hidden = true; cleanup(); setLoading(true); + if (st.ctrl) st.ctrl.abort(); + st.ctrl = new AbortController(); var sig = st.ctrl.signal; try { - const response = await fetch(`/api/trip/${tripId}`, { signal }); - const result = await response.json(); - if (!response.ok) throw new Error(result.error || "Could not load trip route."); - if (signal.aborted) return; - - if (routeLayer) routeLayer.remove(); - const coordinates = result.points.map(p => [p.latitude, p.longitude]); - routeLayer = L.featureGroup(); - L.polyline(coordinates, { color: "#4e73df", weight: 5 }).addTo(routeLayer); - if (coordinates.length) { - L.marker(coordinates[0]).bindPopup("Trip start").addTo(routeLayer); - L.marker(coordinates[coordinates.length - 1]).bindPopup("Trip end").addTo(routeLayer); + var r = await fetch("/api/trip/" + id, {signal: sig}); + var d = await r.json(); + if (!r.ok) throw new Error(d.error || "Cannot load trip route."); + if (sig.aborted) return; + preload(d.points); + var coords = d.points.map(function (p) { return [p.latitude, p.longitude]; }); + st.route = L.featureGroup(); + L.polyline(coords, {color: "#4e73df", weight: 5}).addTo(st.route); + if (coords.length) { + L.marker(coords[0]).bindPopup("Start").addTo(st.route); + L.marker(coords[coords.length - 1]).bindPopup("End").addTo(st.route); } - routeLayer.addTo(map); - if (signal.aborted) { routeLayer.remove(); return; } - if (coordinates.length > 1) { - map.fitBounds(routeLayer.getBounds(), { padding: [24, 24] }); - } else if (coordinates.length === 1) { - map.setView(coordinates[0], 15); - } - if (signal.aborted) { routeLayer.remove(); return; } + st.route.addTo(map); + if (sig.aborted) { cleanup(); return; } + if (coords.length > 1) map.fitBounds(st.route.getBounds(), {padding: [24, 24], maxZoom: 15}); + else if (coords.length === 1) map.setView(coords[0], 15); + if (sig.aborted) { cleanup(); return; } recenterBtn.hidden = false; recenterBtn.onclick = function () { - if (coordinates.length > 1) { - map.fitBounds(routeLayer.getBounds(), { padding: [24, 24] }); - } else if (coordinates.length === 1) { - map.setView(coordinates[0], 15); - } + if (coords.length > 1) map.fitBounds(st.route.getBounds(), {padding: [24, 24], maxZoom: 15}); + else if (coords.length === 1) map.setView(coords[0], 15); }; - summary.textContent = `${formatDistance(result.trip.distance_km)} · ` - + `${formatDuration(result.trip.duration_seconds)} · ` - + `avg ${formatSpeed(result.trip.average_speed_kmh)} · ` - + `max ${formatSpeed(result.trip.max_speed_kmh)} · ` - + `${result.points.length} route points`; - if (signal.aborted) { routeLayer.remove(); return; } - preloadGeocodes(result.points); - initializeTimeline(result.points); - } catch (e) { - if (e.name !== "AbortError") showError(e.message); - } + summary.textContent = fK(d.trip.distance_km) + " \u00b7 " + fD(d.trip.duration_seconds) + " \u00b7 avg " + fS(d.trip.average_speed_kmh) + " \u00b7 max " + fS(d.trip.max_speed_kmh) + " \u00b7 " + d.points.length + " pts"; + initTL(d.points); + } catch (e) { if (e.name !== "AbortError") showErr(e.message); } + finally { if (!sig.aborted) setLoading(false); } } - const tripList = document.getElementById("trip-list"); - const deletable = tripList && tripList.dataset.deletable === "1"; - const deleteUrl = (tripList && tripList.dataset.deleteUrl) || "/api/trip/"; - const batchDeleteUrl = tripList && tripList.dataset.batchDeleteUrl; - const deleteSelectedBtn = document.getElementById("delete-selected-btn"); + // ── Scrubber & play ───────────────────────────────────────────────── + scrubber.addEventListener("input", function () { stopTm(); showPt(Number(scrubber.value)); }); + playButton.addEventListener("click", function () { + if (st.timer) { stopTm(); return; } + if (Number(scrubber.value) >= st.points.length - 1) showPt(0); + playButton.innerHTML = ''; + playButton.setAttribute("aria-label", "Pause"); + st.timer = setInterval(function () { + var nx = Number(scrubber.value) + 1; + if (nx >= st.points.length) { stopTm(); return; } + showPt(nx); + }, 500); + }); - function getCheckedIds() { - return Array.from(body.querySelectorAll("input.trip-checkbox:checked")) - .map(cb => parseInt(cb.value)); + // ── Trip list ──────────────────────────────────────────────────────── + var tl = document.getElementById("trip-list"); + var del = tl && tl.dataset.deletable === "1"; + var dUrl = (tl && tl.dataset.deleteUrl) || "/api/trip/"; + var bUrl = tl && tl.dataset.batchDeleteUrl; + var btD = document.getElementById("delete-selected-btn"); + function cIds() { return Array.from(body.querySelectorAll("input.trip-checkbox:checked")).map(function (c) { return parseInt(c.value); }); } + function upBt() { if (!btD) return; var n = cIds().length; btD.hidden = !n; btD.textContent = "Delete selected (" + n + ")"; } + function slAll() { + var cbs = body.querySelectorAll("input.trip-checkbox"); + var all = cbs.length && Array.from(cbs).every(function (c) { return c.checked; }); + cbs.forEach(function (c) { c.checked = !all; }); upBt(); } - - function updateDeleteSelectedBtn() { - if (!deleteSelectedBtn) return; - const count = getCheckedIds().length; - deleteSelectedBtn.hidden = count === 0; - deleteSelectedBtn.textContent = `Delete selected (${count})`; - } - - function selectAllTrips() { - const cbs = body.querySelectorAll("input.trip-checkbox"); - const allChecked = cbs.length > 0 && Array.from(cbs).every(cb => cb.checked); - cbs.forEach(cb => { cb.checked = !allChecked; }); - updateDeleteSelectedBtn(); - } - - async function deleteSelected() { - const ids = getCheckedIds(); - if (!ids.length || !batchDeleteUrl) return; - const confirmed = await window.confirmDelete( - `Delete ${ids.length} selected trip(s)?\n\nRaw GPS data is kept — only computed trip records are removed.` - ); - if (!confirmed) return; + async function delSel() { + var ids = cIds(); if (!ids.length || !bUrl) return; + if (!(await window.confirmDelete("Delete " + ids.length + " trip(s)? Raw GPS data is kept."))) return; try { - const res = await fetch(batchDeleteUrl, { - method: "POST", - headers: {"Content-Type": "application/json"}, - body: JSON.stringify({ids: ids}), - }); - if (!res.ok) throw new Error("Delete failed"); - ids.forEach(id => { - const row = body.querySelector(`tr[data-trip-id="${id}"]`); - if (row) row.remove(); - }); - updateDeleteSelectedBtn(); - } catch(e) { showError(e.message); } + var r = await fetch(bUrl, {method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify({ids: ids})}); + if (!r.ok) throw new Error("Delete failed"); + ids.forEach(function (id) { var row = body.querySelector('tr[data-trip-id="' + id + '"]'); if (row) row.remove(); }); + upBt(); + } catch (e) { showErr(e.message); } } + if (btD) btD.addEventListener("click", delSel); - if (deleteSelectedBtn) { - deleteSelectedBtn.addEventListener("click", deleteSelected); - } - - function renderTrips(trips) { + function render(tt) { body.replaceChildren(); - trips.forEach(function (trip) { - const row = document.createElement("tr"); - row.dataset.started = trip.start_datetime; - row.dataset.distance = trip.distance_km; - row.dataset.duration = trip.duration_seconds; - row.dataset.maxSpeed = trip.max_speed_kmh; - row.dataset.tripId = trip.id; - row.style.cursor = "pointer"; - const deleteBtn = deletable - ? `` - : ""; - const checkboxCol = batchDeleteUrl - ? `` - : ""; - row.innerHTML = ` - ${checkboxCol} - ${formatDate(trip.start_datetime)} - ${formatDistance(trip.distance_km)} - ${formatDuration(trip.duration_seconds)} - ${formatSpeed(trip.max_speed_kmh)} - ${trip.odo_start_km != null ? trip.odo_start_km.toFixed(0) + ' → ' + trip.odo_end_km.toFixed(0) + ' km' : '—'} - ${deleteBtn}`; + tt.forEach(function (t) { + var row = document.createElement("tr"); + row.dataset.started = t.start_datetime; row.dataset.distance = t.distance_km; + row.dataset.duration = t.duration_seconds; row.dataset.maxSpeed = t.max_speed_kmh; + row.dataset.tripId = t.id; row.style.cursor = "pointer"; + var db = del ? '' : ""; + var cb = bUrl ? '' : ""; + row.innerHTML = cb + "" + fT(t.start_datetime) + "" + fK(t.distance_km) + "" + fD(t.duration_seconds) + "" + fS(t.max_speed_kmh) + "" + (t.odo_start_km != null ? t.odo_start_km.toFixed(0) + " \u2192 " + t.odo_end_km.toFixed(0) + " km" : "\u2014") + "" + db + ""; body.appendChild(row); }); - - // Wire checkbox listeners - body.querySelectorAll("input.trip-checkbox").forEach(cb => { - cb.addEventListener("change", updateDeleteSelectedBtn); - }); - updateDeleteSelectedBtn(); + body.querySelectorAll("input.trip-checkbox").forEach(function (c) { c.addEventListener("change", upBt); }); + upBt(); } - - body.addEventListener("click", function (event) { - // Delete button — must check before replay to prevent row click triggering replay - if (deletable) { - const deleteBtn = event.target.closest("[data-delete-trip-id]"); - if (deleteBtn) { - event.stopPropagation(); - event.preventDefault(); - const tripId = deleteBtn.dataset.deleteTripId; - const date = deleteBtn.closest("tr").querySelector("td").textContent.trim(); - window.confirmDelete(`Delete trip from ${date}?\n\nThe raw GPS data is kept — only this computed trip record is removed.`) - .then(function(confirmed) { - if (!confirmed) return; - return fetch(deleteUrl + tripId, { method: "DELETE" }) - .then(function(res) { - if (!res.ok) throw new Error("Delete failed"); - deleteBtn.closest("tr").remove(); - }) - .catch(function(e) { showError(e.message); }); + body.addEventListener("click", function (e) { + if (del) { + var db = e.target.closest("[data-delete-trip-id]"); + if (db) { + e.stopPropagation(); e.preventDefault(); + var dt = db.closest("tr").querySelector("td").textContent.trim(); + window.confirmDelete("Delete trip from " + dt + "?\n\nRaw GPS data is kept.") + .then(function (ok) { + if (!ok) return; + return fetch(dUrl + db.dataset.deleteTripId, {method: "DELETE"}) + .then(function (r) { if (!r.ok) throw new Error("Delete failed"); db.closest("tr").remove(); }) + .catch(function (err) { showErr(err.message); }); }); return; } } - - // Row click triggers replay — debounced - const row = event.target.closest("tr[data-trip-id]"); + var row = e.target.closest("tr[data-trip-id]"); if (!row) return; - const tripId = row.dataset.tripId; - clearTimeout(_replayDebounce); - _replayDebounce = setTimeout(() => replayTrip(tripId), 100); + hlRow(row); + loadTrip(row.dataset.tripId); }); - - scrubber.addEventListener("input", function () { - stopReplay(); - showReplayPoint(Number(scrubber.value)); - }); - - playButton.addEventListener("click", function () { - if (replayTimer) { - stopReplay(); - return; - } - if (Number(scrubber.value) >= replayPoints.length - 1) { - showReplayPoint(0); - } - playButton.innerHTML = ''; - playButton.setAttribute("aria-label", "Pause trip replay"); - replayTimer = window.setInterval(function () { - const nextIndex = Number(scrubber.value) + 1; - if (nextIndex >= replayPoints.length) { - stopReplay(); - return; - } - showReplayPoint(nextIndex); - }, 500); - }); - fetch("/api/trips") - .then(function (response) { - if (!response.ok) { - throw new Error("Could not load trip history."); - } - return response.json(); - }) - .then(function (result) { - renderTrips(result.trips); - }) - .catch(function (loadError) { - showError(loadError.message); - }) - .finally(function () { - loading.hidden = true; - }); + .then(function (r) { if (!r.ok) throw new Error("Cannot load trips."); return r.json(); }) + .then(function (d) { render(d.trips); }) + .catch(function (e) { showErr(e.message); }) + .finally(function () { loading.hidden = true; }); }());