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-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).
+214 -350
View File
@@ -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 = '<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 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)}`;
}
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;
// ── 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 showErr(m) { error.textContent = m; error.hidden = false; }
function stopTm() {
if (st.timer) { clearInterval(st.timer); st.timer = null; }
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) {
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();
// ── Trip loader ─────────────────────────────────────────────────────
var _deb;
function loadTrip(id) {
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 {
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");
function getCheckedIds() {
return Array.from(body.querySelectorAll("input.trip-checkbox:checked"))
.map(cb => parseInt(cb.value));
}
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;
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); }
}
if (deleteSelectedBtn) {
deleteSelectedBtn.addEventListener("click", deleteSelected);
}
function renderTrips(trips) {
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
? `<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);
});
// Wire checkbox listeners
body.querySelectorAll("input.trip-checkbox").forEach(cb => {
cb.addEventListener("change", updateDeleteSelectedBtn);
});
updateDeleteSelectedBtn();
}
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); });
});
return;
}
}
// Row click triggers replay — debounced
const row = event.target.closest("tr[data-trip-id]");
if (!row) return;
const tripId = row.dataset.tripId;
clearTimeout(_replayDebounce);
_replayDebounce = setTimeout(() => replayTrip(tripId), 100);
});
scrubber.addEventListener("input", function () {
stopReplay();
showReplayPoint(Number(scrubber.value));
});
// ── Scrubber & play ─────────────────────────────────────────────────
scrubber.addEventListener("input", function () { stopTm(); showPt(Number(scrubber.value)); });
playButton.addEventListener("click", function () {
if (replayTimer) {
stopReplay();
return;
}
if (Number(scrubber.value) >= replayPoints.length - 1) {
showReplayPoint(0);
}
if (st.timer) { stopTm(); return; }
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 trip replay");
replayTimer = window.setInterval(function () {
const nextIndex = Number(scrubber.value) + 1;
if (nextIndex >= replayPoints.length) {
stopReplay();
return;
}
showReplayPoint(nextIndex);
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);
});
fetch("/api/trips")
.then(function (response) {
if (!response.ok) {
throw new Error("Could not load trip history.");
// ── 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();
}
return response.json();
})
.then(function (result) {
renderTrips(result.trips);
})
.catch(function (loadError) {
showError(loadError.message);
})
.finally(function () {
loading.hidden = true;
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 {
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);
function render(tt) {
body.replaceChildren();
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 ? '<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>' : "";
var cb = bUrl ? '<td class="text-center" onclick="event.stopPropagation()"><input type="checkbox" class="trip-checkbox form-check-input" value="' + t.id + '"></td>' : "";
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>";
body.appendChild(row);
});
body.querySelectorAll("input.trip-checkbox").forEach(function (c) { c.addEventListener("change", upBt); });
upBt();
}
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;
}
}
var row = e.target.closest("tr[data-trip-id]");
if (!row) return;
hlRow(row);
loadTrip(row.dataset.tripId);
});
fetch("/api/trips")
.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; });
}());