Rewrite history.js: loading spinner, active-row highlight, unified state, 300ms debounce, signal guards throughout trip loader

This commit is contained in:
Mathieu Moran
2026-08-11 05:37:15 -04:00
parent 3d6955f20f
commit fa93384a8e
2 changed files with 225 additions and 343 deletions
+18
View File
@@ -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-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-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:** **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). - 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).
+207 -343
View File
@@ -18,383 +18,247 @@
const maxZoom = parseInt(mapEl.dataset.maxZoom, 10) || 19; 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 map = L.map("history-map", { minZoom: minZoom, maxZoom: maxZoom }).setView([48.505, -0.09], 12);
const tiles = L.tileLayer(tileUrl, { const tiles = L.tileLayer(tileUrl, {
minZoom: minZoom, minZoom: minZoom, maxZoom: maxZoom,
maxZoom: maxZoom, subdomains: tileSubdomains, attribution: tileAttrib,
subdomains: tileSubdomains,
attribution: tileAttrib,
}).addTo(map); }).addTo(map);
let routeLayer = null;
let vehicleMarker = null; // ── Loading spinner on map ──────────────────────────────────────────
let replayPoints = []; const spinner = L.control({position: "topright"});
let replayTimer = null; spinner.onAdd = function () {
let replayController = null; var d = L.DomUtil.create("div", "history-spinner");
let _replayDebounce = null; d.innerHTML = '<div class="spinner-border spinner-border-sm text-primary" role="status"><span class="visually-hidden">Loading…</span></div>';
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 recenterBtn = document.getElementById("btn-recenter-history");
const replayLocation = document.getElementById("trip-replay-location"); const locEl = document.getElementById("trip-replay-location");
let _geoTimer = null; // ── Helpers ──────────────────────────────────────────────────────────
let _lastGeoKey = null; function gk(lat, lon) {
let _geoCache = {}; var a = typeof lat === "number" ? lat : parseFloat(lat);
let _preloadController = null; var b = typeof lon === "number" ? lon : parseFloat(lon);
if (isNaN(a) || isNaN(b)) return null;
function _geoKey(lat, lon) { return a.toFixed(3) + "," + b.toFixed(3);
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)}`;
} }
function showErr(m) { error.textContent = m; error.hidden = false; }
function preloadGeocodes(points) { function stopTm() {
// Abort any in-flight preload from a previous trip selection. if (st.timer) { clearInterval(st.timer); st.timer = null; }
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;
}
playButton.innerHTML = '<i class="fas fa-play" aria-hidden="true"></i>'; playButton.innerHTML = '<i class="fas fa-play" aria-hidden="true"></i>';
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) { // ── Geocode ──────────────────────────────────────────────────────────
const point = replayPoints[index]; function preload(pts) {
if (!point) { if (_geoCtrl) _geoCtrl.abort();
return; _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]; if (!q.length) return;
vehicleMarker.setLatLng(coordinate); var C = 2, idx = 0;
scrubber.value = index; function nx() {
replayTime.textContent = formatDate(point.datetime); if (idx >= q.length || sig.aborted) return;
const source = point.speed_source === "obd" ? "OBD wheel" : "GPS"; var it = q[idx++], c = new AbortController();
replaySpeed.textContent = `${source} speed: ${formatSpeed(point.speed)}`; var t = setTimeout(function () { c.abort(); }, 3000);
scheduleGeocode(point.latitude, point.longitude); 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) { // ── Format ───────────────────────────────────────────────────────────
stopReplay(); 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"; }
replayPoints = points; function fT(v) { return new Date(v.replace(" ", "T")).toLocaleString(); }
scrubber.max = Math.max(0, points.length - 1); function fK(km) { return km == null ? "\u2014" : km.toFixed(km < 1 ? 2 : 1) + " km"; }
scrubber.value = 0; function fS(kmh) { return kmh == null ? "\u2014" : kmh.toFixed(1) + " km/h"; }
timeline.hidden = points.length === 0;
replayLocation.textContent = ''; // ── Timeline ─────────────────────────────────────────────────────────
_lastGeoKey = null; function showPt(i) {
if (vehicleMarker) { var p = st.points[i]; if (!p) return;
vehicleMarker.remove(); st.marker.setLatLng([p.latitude, p.longitude]);
} scrubber.value = i; replayTime.textContent = fT(p.datetime);
if (!points.length) { replaySpeed.textContent = (p.speed_source === "obd" ? "OBD" : "GPS") + ": " + fS(p.speed);
vehicleMarker = null; schedLoc(p.latitude, p.longitude);
return; }
} function initTL(pts) {
vehicleMarker = L.circleMarker( stopTm(); st.points = pts;
[points[0].latitude, points[0].longitude], scrubber.max = Math.max(0, pts.length - 1); scrubber.value = 0;
{ timeline.hidden = !pts.length; locEl.textContent = ""; _lastKey = null;
radius: 8, if (st.marker) { map.removeLayer(st.marker); st.marker = null; }
color: "#fff", if (!pts.length) return;
weight: 3, st.marker = L.circleMarker(
fillColor: "#e74a3b", [pts[0].latitude, pts[0].longitude],
fillOpacity: 1, {radius: 8, color: "#fff", weight: 3, fillColor: "#e74a3b", fillOpacity: 1}
}
).bindTooltip("Vehicle position").addTo(map); ).bindTooltip("Vehicle position").addTo(map);
showReplayPoint(0); showPt(0);
} }
async function replayTrip(tripId) { // ── Trip loader ─────────────────────────────────────────────────────
error.hidden = true; var _deb;
if (replayController) { function loadTrip(id) {
replayController.abort(); clearTimeout(_deb);
} _deb = setTimeout(function () { _load(id); }, 300);
replayController = new AbortController(); }
const signal = replayController.signal; 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 { try {
const response = await fetch(`/api/trip/${tripId}`, { signal }); var r = await fetch("/api/trip/" + id, {signal: sig});
const result = await response.json(); var d = await r.json();
if (!response.ok) throw new Error(result.error || "Could not load trip route."); if (!r.ok) throw new Error(d.error || "Cannot load trip route.");
if (signal.aborted) return; if (sig.aborted) return;
preload(d.points);
if (routeLayer) routeLayer.remove(); var coords = d.points.map(function (p) { return [p.latitude, p.longitude]; });
const coordinates = result.points.map(p => [p.latitude, p.longitude]); st.route = L.featureGroup();
routeLayer = L.featureGroup(); L.polyline(coords, {color: "#4e73df", weight: 5}).addTo(st.route);
L.polyline(coordinates, { color: "#4e73df", weight: 5 }).addTo(routeLayer); if (coords.length) {
if (coordinates.length) { L.marker(coords[0]).bindPopup("Start").addTo(st.route);
L.marker(coordinates[0]).bindPopup("Trip start").addTo(routeLayer); L.marker(coords[coords.length - 1]).bindPopup("End").addTo(st.route);
L.marker(coordinates[coordinates.length - 1]).bindPopup("Trip end").addTo(routeLayer);
} }
routeLayer.addTo(map); st.route.addTo(map);
if (signal.aborted) { routeLayer.remove(); return; } if (sig.aborted) { cleanup(); return; }
if (coordinates.length > 1) { if (coords.length > 1) map.fitBounds(st.route.getBounds(), {padding: [24, 24], maxZoom: 15});
map.fitBounds(routeLayer.getBounds(), { padding: [24, 24] }); else if (coords.length === 1) map.setView(coords[0], 15);
} else if (coordinates.length === 1) { if (sig.aborted) { cleanup(); return; }
map.setView(coordinates[0], 15);
}
if (signal.aborted) { routeLayer.remove(); return; }
recenterBtn.hidden = false; recenterBtn.hidden = false;
recenterBtn.onclick = function () { recenterBtn.onclick = function () {
if (coordinates.length > 1) { if (coords.length > 1) map.fitBounds(st.route.getBounds(), {padding: [24, 24], maxZoom: 15});
map.fitBounds(routeLayer.getBounds(), { padding: [24, 24] }); else if (coords.length === 1) map.setView(coords[0], 15);
} else if (coordinates.length === 1) {
map.setView(coordinates[0], 15);
}
}; };
summary.textContent = `${formatDistance(result.trip.distance_km)} · ` 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";
+ `${formatDuration(result.trip.duration_seconds)} · ` initTL(d.points);
+ `avg ${formatSpeed(result.trip.average_speed_kmh)} · ` } catch (e) { if (e.name !== "AbortError") showErr(e.message); }
+ `max ${formatSpeed(result.trip.max_speed_kmh)} · ` finally { if (!sig.aborted) setLoading(false); }
+ `${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);
}
} }
const tripList = document.getElementById("trip-list"); // ── Scrubber & play ─────────────────────────────────────────────────
const deletable = tripList && tripList.dataset.deletable === "1"; scrubber.addEventListener("input", function () { stopTm(); showPt(Number(scrubber.value)); });
const deleteUrl = (tripList && tripList.dataset.deleteUrl) || "/api/trip/"; playButton.addEventListener("click", function () {
const batchDeleteUrl = tripList && tripList.dataset.batchDeleteUrl; if (st.timer) { stopTm(); return; }
const deleteSelectedBtn = document.getElementById("delete-selected-btn"); if (Number(scrubber.value) >= st.points.length - 1) showPt(0);
playButton.innerHTML = '<i class="fas fa-pause" aria-hidden="true"></i>';
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() { // ── Trip list ────────────────────────────────────────────────────────
return Array.from(body.querySelectorAll("input.trip-checkbox:checked")) var tl = document.getElementById("trip-list");
.map(cb => parseInt(cb.value)); 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();
} }
async function delSel() {
function updateDeleteSelectedBtn() { var ids = cIds(); if (!ids.length || !bUrl) return;
if (!deleteSelectedBtn) return; if (!(await window.confirmDelete("Delete " + ids.length + " trip(s)? Raw GPS data is kept."))) 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;
try { try {
const res = await fetch(batchDeleteUrl, { var r = await fetch(bUrl, {method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify({ids: ids})});
method: "POST", if (!r.ok) throw new Error("Delete failed");
headers: {"Content-Type": "application/json"}, ids.forEach(function (id) { var row = body.querySelector('tr[data-trip-id="' + id + '"]'); if (row) row.remove(); });
body: JSON.stringify({ids: ids}), upBt();
}); } catch (e) { showErr(e.message); }
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); }
} }
if (btD) btD.addEventListener("click", delSel);
if (deleteSelectedBtn) { function render(tt) {
deleteSelectedBtn.addEventListener("click", deleteSelected);
}
function renderTrips(trips) {
body.replaceChildren(); body.replaceChildren();
trips.forEach(function (trip) { tt.forEach(function (t) {
const row = document.createElement("tr"); var row = document.createElement("tr");
row.dataset.started = trip.start_datetime; row.dataset.started = t.start_datetime; row.dataset.distance = t.distance_km;
row.dataset.distance = trip.distance_km; row.dataset.duration = t.duration_seconds; row.dataset.maxSpeed = t.max_speed_kmh;
row.dataset.duration = trip.duration_seconds; row.dataset.tripId = t.id; row.style.cursor = "pointer";
row.dataset.maxSpeed = trip.max_speed_kmh; var db = del ? '<button class="btn btn-sm btn-outline-danger ms-1" type="button" data-delete-trip-id="' + t.id + '" title="Delete"><i class="fas fa-trash fa-fw"></i></button>' : "";
row.dataset.tripId = trip.id; var cb = bUrl ? '<td class="text-center" onclick="event.stopPropagation()"><input type="checkbox" class="trip-checkbox form-check-input" value="' + t.id + '"></td>' : "";
row.style.cursor = "pointer"; row.innerHTML = cb + "<td>" + fT(t.start_datetime) + "</td><td>" + fK(t.distance_km) + "</td><td>" + fD(t.duration_seconds) + "</td><td>" + fS(t.max_speed_kmh) + "</td><td class=\"text-muted small\">" + (t.odo_start_km != null ? t.odo_start_km.toFixed(0) + " \u2192 " + t.odo_end_km.toFixed(0) + " km" : "\u2014") + "</td><td class=\"text-end text-nowrap d-none d-sm-table-cell\">" + db + "</td>";
const deleteBtn = deletable
? `<button class="btn btn-sm btn-outline-danger ms-1" type="button" data-delete-trip-id="${trip.id}" title="Delete trip">
<i class="fas fa-trash fa-fw" aria-hidden="true"></i>
</button>`
: "";
const checkboxCol = batchDeleteUrl
? `<td class="text-center" onclick="event.stopPropagation()"><input type="checkbox" class="trip-checkbox form-check-input" value="${trip.id}"></td>`
: "";
row.innerHTML = `
${checkboxCol}
<td>${formatDate(trip.start_datetime)}</td>
<td>${formatDistance(trip.distance_km)}</td>
<td>${formatDuration(trip.duration_seconds)}</td>
<td>${formatSpeed(trip.max_speed_kmh)}</td>
<td class="text-muted small">${trip.odo_start_km != null ? trip.odo_start_km.toFixed(0) + ' → ' + trip.odo_end_km.toFixed(0) + ' km' : '—'}</td>
<td class="text-end text-nowrap d-none d-sm-table-cell">${deleteBtn}</td>`;
body.appendChild(row); body.appendChild(row);
}); });
body.querySelectorAll("input.trip-checkbox").forEach(function (c) { c.addEventListener("change", upBt); });
// Wire checkbox listeners upBt();
body.querySelectorAll("input.trip-checkbox").forEach(cb => {
cb.addEventListener("change", updateDeleteSelectedBtn);
});
updateDeleteSelectedBtn();
} }
body.addEventListener("click", function (e) {
body.addEventListener("click", function (event) { if (del) {
// Delete button — must check before replay to prevent row click triggering replay var db = e.target.closest("[data-delete-trip-id]");
if (deletable) { if (db) {
const deleteBtn = event.target.closest("[data-delete-trip-id]"); e.stopPropagation(); e.preventDefault();
if (deleteBtn) { var dt = db.closest("tr").querySelector("td").textContent.trim();
event.stopPropagation(); window.confirmDelete("Delete trip from " + dt + "?\n\nRaw GPS data is kept.")
event.preventDefault(); .then(function (ok) {
const tripId = deleteBtn.dataset.deleteTripId; if (!ok) return;
const date = deleteBtn.closest("tr").querySelector("td").textContent.trim(); return fetch(dUrl + db.dataset.deleteTripId, {method: "DELETE"})
window.confirmDelete(`Delete trip from ${date}?\n\nThe raw GPS data is kept — only this computed trip record is removed.`) .then(function (r) { if (!r.ok) throw new Error("Delete failed"); db.closest("tr").remove(); })
.then(function(confirmed) { .catch(function (err) { showErr(err.message); });
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); });
}); });
return; return;
} }
} }
var row = e.target.closest("tr[data-trip-id]");
// Row click triggers replay — debounced
const row = event.target.closest("tr[data-trip-id]");
if (!row) return; if (!row) return;
const tripId = row.dataset.tripId; hlRow(row);
clearTimeout(_replayDebounce); loadTrip(row.dataset.tripId);
_replayDebounce = setTimeout(() => replayTrip(tripId), 100);
}); });
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 = '<i class="fas fa-pause" aria-hidden="true"></i>';
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") fetch("/api/trips")
.then(function (response) { .then(function (r) { if (!r.ok) throw new Error("Cannot load trips."); return r.json(); })
if (!response.ok) { .then(function (d) { render(d.trips); })
throw new Error("Could not load trip history."); .catch(function (e) { showErr(e.message); })
} .finally(function () { loading.hidden = true; });
return response.json();
})
.then(function (result) {
renderTrips(result.trips);
})
.catch(function (loadError) {
showError(loadError.message);
})
.finally(function () {
loading.hidden = true;
});
}()); }());