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
- ? `