diff --git a/web/fleet-core.js b/web/fleet-core.js
index 50178e7..9cdbdd9 100644
--- a/web/fleet-core.js
+++ b/web/fleet-core.js
@@ -48,6 +48,10 @@ export const authClient = {
localStorage.removeItem(STORAGE_EXPIRES);
},
+ getToken() {
+ return localStorage.getItem(STORAGE_ACCESS);
+ },
+
requireSession({ loginPath = '/login.html' } = {}) {
if (!this.isAuthenticated()) {
window.location.href = loginPath;
@@ -357,6 +361,292 @@ export function initFilters(formEl, onChange) {
formEl.addEventListener('submit', (e) => { e.preventDefault(); handler(); });
}
+/* ---------- trip panel ---------- */
+
+const TRIP_PATH_SOURCE = 'trip-path-source';
+const TRIP_MARKER_SOURCE = 'trip-marker-source';
+const TRIP_PATH_LAYER = 'trip-path-line';
+const TRIP_MARKER_LAYER = 'trip-marker-dot';
+const TRIP_ANIM_MS = 10000;
+
+let _tripAnimRAF = null;
+let _tripState = { vehicleId: null, date: null, payload: null };
+
+export function initTripPanel(map, panelRoot) {
+ const els = {
+ plate: panelRoot.querySelector('#trip-plate'),
+ driver: panelRoot.querySelector('#trip-driver'),
+ date: panelRoot.querySelector('#trip-date'),
+ csv: panelRoot.querySelector('#trip-csv'),
+ totals: panelRoot.querySelector('#trip-totals'),
+ list: panelRoot.querySelector('#trip-list'),
+ close: panelRoot.querySelector('#trip-close'),
+ };
+
+ els.close.addEventListener('click', () => _closeTripPanel(map, panelRoot, els));
+ els.date.addEventListener('change', () => {
+ if (_tripState.vehicleId) {
+ _loadTrips(map, panelRoot, els, _tripState.vehicleId, els.date.value);
+ }
+ });
+ els.csv.addEventListener('click', () => {
+ if (_tripState.vehicleId) _downloadTripsCsv(_tripState.vehicleId, els.date.value);
+ });
+
+ const layers = ['vehicles-circle', 'vehicles-arrow', 'vehicles-label'];
+ map.on('click', (e) => {
+ const features = map.queryRenderedFeatures(e.point, { layers });
+ if (!features || features.length === 0) return;
+ const f = features[0];
+ const vid = f.properties.vehicle_id;
+ if (!vid) return;
+ els.plate.textContent = f.properties.plate || `Vehicle ${vid}`;
+ els.driver.textContent = f.properties.driver_name || '';
+ if (!els.date.value) els.date.value = _todayEat();
+ panelRoot.classList.add('open');
+ panelRoot.setAttribute('aria-hidden', 'false');
+ _loadTrips(map, panelRoot, els, vid, els.date.value);
+ });
+}
+
+async function _loadTrips(map, panelRoot, els, vehicleId, dateStr) {
+ _tripState.vehicleId = vehicleId;
+ _tripState.date = dateStr;
+ _tripState.payload = null;
+ _cancelTripAnim();
+ _clearTripLayers(map);
+ els.totals.innerHTML = 'Loading…';
+ els.list.innerHTML = '';
+ try {
+ const payload = await apiFetch(
+ `/api/views/vehicle/${vehicleId}/trips`,
+ { params: { date: dateStr } },
+ );
+ _tripState.payload = payload;
+ _renderTripPanel(map, els, payload);
+ } catch (err) {
+ els.totals.innerHTML = `${_esc(err.message || err)}`;
+ }
+}
+
+function _renderTripPanel(map, els, payload) {
+ const t = payload.totals || {};
+ const q = payload.data_quality || {};
+ const reportingTime = payload.reporting_time
+ ? _formatTimeOnly(payload.reporting_time) : '—';
+ els.totals.innerHTML = `
+
Reporting time: ${_esc(reportingTime)}
+
+ ${t.trip_count ?? 0} trips ·
+ ${_fmtNum(t.distance_km, 1)} km
+
+
+ drive ${_fmtNum(t.driving_min, 0)}m · idle ${_fmtNum(t.idling_min, 0)}m · stop ${_fmtNum(t.stopped_min, 0)}m
+
+ ${q.fix_count ?? 0} fixes · ACC ${q.has_acc_data ? 'on' : 'off'}
+ `;
+
+ const trips = payload.trips || [];
+ if (trips.length === 0) {
+ els.list.innerHTML = 'No trips on this day.
';
+ return;
+ }
+ els.list.innerHTML = trips.map(trip => `
+
+
+ Trip ${trip.trip_id}
+ ${_fmtNum(trip.distance_km, 1)} km
+
+
+ ${_formatTimeOnly(trip.started_at)} → ${_formatTimeOnly(trip.ended_at)}
+ ${_fmtNum(trip.duration_min, 0)} min
+ ${trip.idling_min > 0 ? `idle ${_fmtNum(trip.idling_min, 0)}m` : ''}
+
+
+ ${_reasonLabel(trip.end_reason)}
+
+
+ `).join('');
+ for (const row of els.list.querySelectorAll('.trip-row')) {
+ row.addEventListener('click', () => {
+ els.list.querySelectorAll('.trip-row.selected')
+ .forEach(r => r.classList.remove('selected'));
+ row.classList.add('selected');
+ const tid = Number(row.dataset.tripId);
+ const trip = trips.find(x => x.trip_id === tid);
+ if (trip) _showAndAnimateTrip(map, trip);
+ });
+ }
+}
+
+function _reasonClass(r) {
+ if (r === 'work_stop') return 'work-stop';
+ if (r === 'nofix_stop') return 'nofix-stop';
+ if (r === 'long_gap') return 'long-gap';
+ return '';
+}
+function _reasonLabel(r) {
+ return ({
+ work_stop: 'Stopped for work',
+ nofix_stop: 'Reporting silence',
+ long_gap: 'Long gap',
+ day_end: 'Day end',
+ })[r] || (r || '—');
+}
+
+function _showAndAnimateTrip(map, trip) {
+ _cancelTripAnim();
+ _clearTripLayers(map);
+ if (!trip.path || !trip.path.coordinates || trip.path.coordinates.length < 2) return;
+ const coords = trip.path.coordinates;
+
+ map.addSource(TRIP_PATH_SOURCE, { type: 'geojson', data: trip.path });
+ map.addLayer({
+ id: TRIP_PATH_LAYER,
+ type: 'line',
+ source: TRIP_PATH_SOURCE,
+ layout: { 'line-join': 'round', 'line-cap': 'round' },
+ paint: {
+ 'line-color': '#10b981',
+ 'line-width': ['interpolate', ['linear'], ['zoom'], 8, 2, 14, 4, 17, 6],
+ 'line-opacity': 0.85,
+ },
+ });
+
+ map.addSource(TRIP_MARKER_SOURCE, {
+ type: 'geojson',
+ data: { type: 'Feature', geometry: { type: 'Point', coordinates: coords[0] }, properties: {} },
+ });
+ map.addLayer({
+ id: TRIP_MARKER_LAYER,
+ type: 'circle',
+ source: TRIP_MARKER_SOURCE,
+ paint: {
+ 'circle-radius': ['interpolate', ['linear'], ['zoom'], 8, 4, 14, 8, 17, 12],
+ 'circle-color': '#ffffff',
+ 'circle-stroke-color': '#10b981',
+ 'circle-stroke-width': 3,
+ },
+ });
+
+ // eslint-disable-next-line no-undef
+ const bounds = coords.reduce(
+ (b, c) => b.extend(c),
+ // eslint-disable-next-line no-undef
+ new maplibregl.LngLatBounds(coords[0], coords[0]),
+ );
+ map.fitBounds(bounds, { padding: { top: 80, right: 380, bottom: 60, left: 60 }, duration: 600 });
+
+ _animatePathMarker(map, coords, TRIP_ANIM_MS);
+}
+
+function _animatePathMarker(map, coords, durationMs) {
+ // Pre-compute cumulative segment lengths (planar — fine for animation interpolation).
+ let total = 0;
+ const cum = [0];
+ for (let i = 1; i < coords.length; i++) {
+ const dx = coords[i][0] - coords[i - 1][0];
+ const dy = coords[i][1] - coords[i - 1][1];
+ total += Math.sqrt(dx * dx + dy * dy);
+ cum.push(total);
+ }
+ if (total === 0) return;
+ const startMs = performance.now();
+ const src = map.getSource(TRIP_MARKER_SOURCE);
+ const frame = (now) => {
+ if (!map.getSource(TRIP_MARKER_SOURCE)) return; // panel closed
+ const t = Math.min(1, (now - startMs) / durationMs);
+ const target = total * t;
+ // Binary search the segment containing `target`
+ let lo = 1, hi = cum.length - 1;
+ while (lo < hi) {
+ const mid = (lo + hi) >> 1;
+ if (cum[mid] < target) lo = mid + 1; else hi = mid;
+ }
+ const i = lo;
+ const segStart = cum[i - 1], segEnd = cum[i];
+ const segT = segEnd > segStart ? (target - segStart) / (segEnd - segStart) : 0;
+ const a = coords[i - 1], b = coords[i];
+ const pos = [a[0] + (b[0] - a[0]) * segT, a[1] + (b[1] - a[1]) * segT];
+ src.setData({ type: 'Feature', geometry: { type: 'Point', coordinates: pos }, properties: {} });
+ if (t < 1) {
+ _tripAnimRAF = requestAnimationFrame(frame);
+ } else {
+ _tripAnimRAF = null;
+ }
+ };
+ _tripAnimRAF = requestAnimationFrame(frame);
+}
+
+function _cancelTripAnim() {
+ if (_tripAnimRAF !== null) {
+ cancelAnimationFrame(_tripAnimRAF);
+ _tripAnimRAF = null;
+ }
+}
+
+function _clearTripLayers(map) {
+ for (const id of [TRIP_MARKER_LAYER, TRIP_PATH_LAYER]) {
+ if (map.getLayer(id)) map.removeLayer(id);
+ }
+ for (const id of [TRIP_MARKER_SOURCE, TRIP_PATH_SOURCE]) {
+ if (map.getSource(id)) map.removeSource(id);
+ }
+}
+
+function _closeTripPanel(map, panelRoot, els) {
+ _cancelTripAnim();
+ _clearTripLayers(map);
+ panelRoot.classList.remove('open');
+ panelRoot.setAttribute('aria-hidden', 'true');
+ els.totals.innerHTML = 'Click a vehicle to see its trips.';
+ els.list.innerHTML = '';
+ _tripState = { vehicleId: null, date: null, payload: null };
+}
+
+function _todayEat() {
+ const now = new Date();
+ const eat = new Date(now.getTime() + 3 * 3600 * 1000);
+ return eat.toISOString().slice(0, 10);
+}
+
+function _formatTimeOnly(iso) {
+ if (!iso) return '—';
+ const d = new Date(iso);
+ if (isNaN(d.getTime())) return iso;
+ return d.toLocaleTimeString('en-GB', {
+ timeZone: 'Africa/Nairobi', hour: '2-digit', minute: '2-digit', hour12: false,
+ });
+}
+
+function _fmtNum(v, digits) {
+ if (v == null || isNaN(Number(v))) return '—';
+ return Number(v).toFixed(digits);
+}
+
+async function _downloadTripsCsv(vehicleId, dateStr) {
+ const url = `/api/views/vehicle/${vehicleId}/trips.csv?date=${encodeURIComponent(dateStr)}`;
+ try {
+ const r = await fetch(url, {
+ headers: { Authorization: `Bearer ${authClient.getToken()}` },
+ });
+ if (!r.ok) throw new Error(`HTTP ${r.status}`);
+ const blob = await r.blob();
+ const cd = r.headers.get('Content-Disposition') || '';
+ const m = cd.match(/filename="([^"]+)"/);
+ const filename = (m && m[1]) || `trips_${vehicleId}_${dateStr}.csv`;
+ const a = document.createElement('a');
+ a.href = URL.createObjectURL(blob);
+ a.download = filename;
+ document.body.appendChild(a);
+ a.click();
+ a.remove();
+ URL.revokeObjectURL(a.href);
+ } catch (err) {
+ alert(`CSV download failed: ${err.message || err}`);
+ }
+}
+
/* ---------- clockEAT ---------- */
export function clockEAT(elementId) {
diff --git a/web/index-live.html b/web/index-live.html
index a922a15..313d518 100644
--- a/web/index-live.html
+++ b/web/index-live.html
@@ -72,6 +72,88 @@
form.filters label { font-size: 11px; text-transform: uppercase; color: var(--muted); letter-spacing: 0.06em; }
button.logout { background: transparent; color: var(--muted); border: 1px solid var(--muted); padding: 4px 10px; border-radius: 4px; cursor: pointer; font-size: 12px; }
button.logout:hover { color: var(--text); border-color: var(--text); }
+
+ /* trip panel */
+ #map-container { position: relative; height: 100%; }
+ #map { position: absolute; inset: 0; }
+ .trip-panel {
+ position: absolute; top: 0; right: 0; bottom: 0;
+ width: 360px;
+ background: var(--panel);
+ border-left: 1px solid #0b1220;
+ color: var(--text);
+ display: flex; flex-direction: column;
+ transform: translateX(100%);
+ transition: transform 0.22s ease;
+ z-index: 2;
+ overflow: hidden;
+ box-shadow: -8px 0 24px rgba(0,0,0,0.35);
+ }
+ .trip-panel.open { transform: translateX(0); }
+ .trip-panel-header {
+ display: flex; justify-content: space-between; align-items: flex-start;
+ padding: 14px 16px 10px; border-bottom: 1px solid #0b1220;
+ }
+ .trip-plate { font-size: 18px; font-weight: 700; letter-spacing: 0.01em; }
+ .trip-driver { font-size: 12px; color: var(--muted); margin-top: 2px; }
+ .trip-close {
+ background: transparent; color: var(--muted); border: 0;
+ font-size: 24px; cursor: pointer; line-height: 1; padding: 0 4px;
+ }
+ .trip-close:hover { color: var(--text); }
+ .trip-controls {
+ display: flex; justify-content: space-between; align-items: center;
+ padding: 10px 16px; gap: 8px; border-bottom: 1px solid #0b1220;
+ }
+ .trip-controls label { font-size: 11px; text-transform: uppercase; color: var(--muted); letter-spacing: 0.06em; display: flex; align-items: center; gap: 8px; }
+ .trip-controls input[type="date"] {
+ background: #0b1220; color: var(--text);
+ border: 1px solid #0b1220; border-radius: 4px;
+ padding: 4px 6px; font-size: 12px; font-family: inherit;
+ color-scheme: dark;
+ }
+ .trip-csv {
+ background: transparent; color: var(--muted);
+ border: 1px solid var(--muted); border-radius: 4px;
+ padding: 4px 10px; cursor: pointer;
+ font-size: 11px; letter-spacing: 0.04em; text-transform: uppercase;
+ }
+ .trip-csv:hover { color: var(--text); border-color: var(--text); }
+ .trip-totals {
+ padding: 12px 16px; font-size: 12.5px; color: var(--muted);
+ border-bottom: 1px solid #0b1220; line-height: 1.5;
+ }
+ .trip-totals strong { color: var(--text); font-weight: 600; }
+ .trip-totals .quality { font-size: 11px; margin-top: 6px; }
+ .trip-list { overflow: auto; flex: 1; }
+ .trip-list-empty { padding: 16px; color: var(--muted); font-size: 12.5px; }
+ .trip-row {
+ padding: 10px 16px;
+ border-bottom: 1px solid #0b1220;
+ cursor: pointer;
+ }
+ .trip-row:hover { background: #0b1220; }
+ .trip-row.selected {
+ background: #0b1220;
+ border-left: 3px solid var(--accent);
+ padding-left: 13px;
+ }
+ .trip-row-top {
+ display: flex; justify-content: space-between;
+ font-size: 13px; font-weight: 600;
+ }
+ .trip-row-meta {
+ display: flex; gap: 10px; flex-wrap: wrap;
+ font-size: 11.5px; color: var(--muted); margin-top: 4px;
+ }
+ .trip-row-reason {
+ display: inline-block; margin-top: 6px;
+ font-size: 10px; text-transform: uppercase; letter-spacing: 0.06em;
+ color: var(--muted);
+ }
+ .trip-row-reason.work-stop { color: var(--accent); }
+ .trip-row-reason.nofix-stop { color: var(--warn); }
+ .trip-row-reason.long-gap { color: var(--bad); }
@@ -98,12 +180,29 @@
-
+