// fleet-core.js // Shared client primitives for the fleet-platform dashboards. // // Server-driven rendering per PRD §8: the API attaches `marker_color`, // `show_arrow`, `style_class`, etc. The JS just paints what it's told. // // Markers (three MapLibre layers stacked on one source): // 1. circle — `marker_color` (cost-centre colour when moving, grey otherwise) // 2. arrow — white SVG icon, rotated by `heading_deg`, visible only when // `show_arrow == true` // 3. label — `plate_short` (last 4 chars of plate), below the circle const STORAGE_ACCESS = 'fleet.accessToken'; const STORAGE_REFRESH = 'fleet.refreshToken'; const STORAGE_EXPIRES = 'fleet.expiresAt'; const VEHICLE_SOURCE = 'vehicles'; /* ---------- authClient ---------- */ export const authClient = { isAuthenticated() { const expiresAt = Number(localStorage.getItem(STORAGE_EXPIRES) || 0); return localStorage.getItem(STORAGE_ACCESS) !== null && Date.now() < expiresAt * 1000; }, async login(username, password) { const body = new URLSearchParams({ username, password }); const res = await fetch('/api/auth/token', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body, }); if (!res.ok) { const detail = await res.json().catch(() => ({ detail: 'login failed' })); throw new Error(detail.detail || 'login failed'); } const payload = await res.json(); const expiresAt = Math.floor(Date.now() / 1000) + Number(payload.expires_in || 900); localStorage.setItem(STORAGE_ACCESS, payload.access_token); localStorage.setItem(STORAGE_REFRESH, payload.refresh_token); localStorage.setItem(STORAGE_EXPIRES, String(expiresAt)); }, logout() { localStorage.removeItem(STORAGE_ACCESS); localStorage.removeItem(STORAGE_REFRESH); localStorage.removeItem(STORAGE_EXPIRES); }, getToken() { return localStorage.getItem(STORAGE_ACCESS); }, requireSession({ loginPath = '/login.html' } = {}) { if (!this.isAuthenticated()) { window.location.href = loginPath; return false; } return true; }, }; export async function apiFetch(path, { params, ...opts } = {}) { const url = new URL(path, window.location.origin); if (params) { for (const [k, v] of Object.entries(params)) { if (v !== undefined && v !== null && v !== '') { url.searchParams.set(k, typeof v === 'string' ? v : JSON.stringify(v)); } } } const token = localStorage.getItem(STORAGE_ACCESS); const res = await fetch(url.toString(), { ...opts, headers: { ...(opts.headers || {}), ...(token ? { Authorization: `Bearer ${token}` } : {}), Accept: 'application/json', }, }); if (res.status === 401) { authClient.logout(); window.location.href = '/login.html'; throw new Error('unauthorized'); } if (!res.ok) throw new Error(`${res.status} ${res.statusText}`); return res.json(); } /* ---------- map setup ---------- */ function _addArrowImage(map) { if (map.hasImage('arrow-white')) return; const svg = ` `; const img = new Image(24, 24); img.onload = () => { if (!map.hasImage('arrow-white')) map.addImage('arrow-white', img); }; img.src = 'data:image/svg+xml;utf8,' + encodeURIComponent(svg); } export function initMap(elementId, opts = {}) { const center = opts.center || [36.8172, -1.2864]; // Nairobi const zoom = opts.zoom ?? 7; const styleUrl = opts.styleUrl || 'https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json'; // eslint-disable-next-line no-undef const map = new maplibregl.Map({ container: elementId, style: styleUrl, center, zoom, attributionControl: true, }); map.on('load', () => { _addArrowImage(map); map.addSource(VEHICLE_SOURCE, { type: 'geojson', data: { type: 'FeatureCollection', features: [] }, }); map.addLayer({ id: 'vehicles-circle', type: 'circle', source: VEHICLE_SOURCE, paint: { // Scale dot size with zoom — small at country/city overview to keep // the Nairobi cluster legible, full size once you're zoomed into a // neighbourhood. text-offset on the label layer is in ems so it // scales naturally with text-size; no extra interpolation needed. 'circle-radius': [ 'interpolate', ['linear'], ['zoom'], 5, 2, 9, 4, 12, 7, 15, 13, 18, 20, ], // Cost-centre tint for moving + parked; offline goes solid grey // (no cost-centre signal worth showing when we haven't heard from // the device). Opacity differentiates moving vs parked. 'circle-color': [ 'case', ['==', ['get', 'operational_state'], 'offline'], '#9ca3af', ['coalesce', ['get', 'cost_centre_color'], '#94a3b8'], ], 'circle-opacity': [ 'case', ['==', ['get', 'operational_state'], 'moving'], 1.0, ['==', ['get', 'operational_state'], 'parked'], 0.75, 0.55, ], 'circle-stroke-color': '#0b1220', 'circle-stroke-width': [ 'interpolate', ['linear'], ['zoom'], 5, 0.5, 12, 1.5, 16, 2, ], }, }); map.addLayer({ id: 'vehicles-arrow', type: 'symbol', source: VEHICLE_SOURCE, filter: ['==', ['get', 'show_arrow'], true], layout: { 'icon-image': 'arrow-white', 'icon-rotate': ['coalesce', ['get', 'heading_deg'], 0], 'icon-rotation-alignment': 'map', 'icon-allow-overlap': true, 'icon-ignore-placement': true, 'icon-size': [ 'interpolate', ['linear'], ['zoom'], 5, 0.2, 12, 0.45, 15, 0.7, 18, 1.0, ], }, }); map.addLayer({ id: 'vehicles-label', type: 'symbol', source: VEHICLE_SOURCE, // Plate-tail labels add nothing but clutter at city-overview zoom — // skip them until you're zoomed in enough that they don't overlap. minzoom: 11, layout: { 'text-field': ['get', 'plate_short'], 'text-font': ['Open Sans Regular', 'Arial Unicode MS Regular'], 'text-size': [ 'interpolate', ['linear'], ['zoom'], 11, 8, 14, 11, 17, 14, ], 'text-offset': [0, 1.7], 'text-anchor': 'top', 'text-allow-overlap': true, 'text-ignore-placement': true, 'text-letter-spacing': 0.04, }, paint: { 'text-color': '#f1f5f9', 'text-halo-color': '#0f172a', 'text-halo-width': 2, }, }); _wireHoverPopup(map); }); return map; } /* ---------- hover popup ---------- */ function _wireHoverPopup(map) { // eslint-disable-next-line no-undef const popup = new maplibregl.Popup({ closeButton: false, closeOnClick: false, offset: 16, className: 'fleet-popup', }); const show = (e) => { const f = e.features && e.features[0]; if (!f) return; map.getCanvas().style.cursor = 'pointer'; popup.setLngLat(f.geometry.coordinates).setHTML(_popupHtml(f.properties)).addTo(map); }; const hide = () => { map.getCanvas().style.cursor = ''; popup.remove(); }; for (const layer of ['vehicles-circle', 'vehicles-arrow', 'vehicles-label']) { map.on('mouseenter', layer, show); map.on('mousemove', layer, show); map.on('mouseleave', layer, hide); } } function _esc(s) { return String(s ?? '').replace(/[&<>"']/g, c => `&#${c.charCodeAt(0)};`); } function _formatAge(sec) { if (sec == null) return '—'; const s = Math.max(0, Math.round(Number(sec))); if (s < 60) return `${s}s ago`; if (s < 3600) return `${Math.round(s / 60)}m ago`; if (s < 86400) return `${Math.round(s / 3600)}h ago`; return `${Math.round(s / 86400)}d ago`; } function _formatLocal(iso) { if (!iso) return '—'; const d = new Date(iso); if (isNaN(d.getTime())) return iso; return d.toLocaleString('en-GB', { timeZone: 'Africa/Nairobi', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false, }).replace(',', ''); } function _popupHtml(props) { const state = String(props.operational_state || 'unknown').toLowerCase(); const speed = Math.round(Number(props.speed_kmh || 0)); const pillText = state === 'moving' ? `MOVING · ${speed} KMH` : (state === 'parked' ? 'PARKED' : state.toUpperCase()); const tagLine = [props.cost_centre, props.assigned_city] .filter(Boolean).join(' · ').toLowerCase(); const addressLine = props.address_short || props.address || null; const headingPart = (props.heading_deg != null) ? `heading ${Math.round(Number(props.heading_deg))}°` : null; const sigPart = (props.gps_signal != null) ? `gps signal ${props.gps_signal}` : null; const headingLine = [headingPart, sigPart].filter(Boolean).join(' · '); let mileageLine = null; if (props.current_mileage_km != null) { const km = Number(props.current_mileage_km); if (!Number.isNaN(km)) { mileageLine = `${km.toLocaleString('en-US', { maximumFractionDigits: 2 })} km on the clock`; } } const sourceLine = [props.mc_type, props.device_type].filter(Boolean).join(' · '); const ageLine = `last fix ${_formatAge(props.age_sec)} · ${_formatLocal(props.occurred_at)}`; return ` `; } /* ---------- render ---------- */ export function renderView(map, payload, { summaryRoot } = {}) { if (!payload || !payload.geojson) return; const src = map.getSource(VEHICLE_SOURCE); if (src) src.setData(payload.geojson); if (summaryRoot) _renderSummary(summaryRoot, payload.summary || {}); } function _renderSummary(root, summary) { const tiles = [ { label: 'Active', value: summary.total_active ?? '—' }, { label: 'Moving', value: summary.moving ?? '—' }, { label: 'Parked', value: summary.parked ?? '—' }, { label: 'Offline', value: summary.offline ?? '—' }, ]; root.innerHTML = tiles .map(t => `
${t.label}
${t.value}
`).join(''); } /* ---------- filters (multi-select dropdowns) ---------- */ /** * Wire the cost-centre + assigned-city filter widgets. * * Returns an `updateOptions(features)` callback that the caller invokes * after each /api/views/live refresh — the widgets repopulate from the * loaded feature properties, so we don't need a separate enum endpoint. * * The widget itself is a button that toggles a popover. The popover has * an "All …" checkbox at the top and a checkbox per distinct value. * `cost_centre_color` from the feature is rendered as a swatch beside the * cost-centre options — turning the filter into a live colour legend. * * onChange receives a flat filter object compatible with serve.fn_live_view: * { cost_centre: 'isp', assigned_city: 'Nairobi' } ← single select OR * no key ← when "All" picked * * Multiple selections within one widget collapse to the first value (the * SQL function takes one) — we treat additional selections client-side * as an OR by sending no filter and letting the renderer hide the rest. * That keeps the SQL contract unchanged for P1. */ export function initFilters(root, onChange) { const ccWidget = _buildMultiSelect( root.querySelector('#flt-cost-centre'), { label: 'cost centre', plural: 'cost centres', showSwatch: true }, ); const cityWidget = _buildMultiSelect( root.querySelector('#flt-assigned-city'), { label: 'assigned city', plural: 'cities', showSwatch: false }, ); const emit = () => { const filters = {}; const cc = ccWidget.getValues(); const city = cityWidget.getValues(); if (cc.length === 1) filters.cost_centre = cc[0]; if (city.length === 1) filters.assigned_city = city[0]; // For multi-selection client-side narrowing, stash so renderer can filter onChange(filters, { costCentres: cc, cities: city }); }; ccWidget.onChange(emit); cityWidget.onChange(emit); return { updateOptions(features) { const cc = new Map(); const city = new Set(); for (const f of features) { const p = f.properties || {}; if (p.cost_centre) cc.set(p.cost_centre, p.cost_centre_color || '#94a3b8'); if (p.assigned_city) city.add(p.assigned_city); } ccWidget.setOptions([...cc.entries()].sort() .map(([value, color]) => ({ value, color }))); cityWidget.setOptions([...city].sort() .map(value => ({ value }))); }, getActive() { return { costCentres: ccWidget.getValues(), cities: cityWidget.getValues() }; }, }; } function _buildMultiSelect(root, { label, plural, showSwatch }) { // root is a
we own. Render: a button + a hidden popover. root.classList.add('ms'); root.innerHTML = ` `; const btn = root.querySelector('.ms-btn'); const btnLabel = root.querySelector('.ms-btn-label'); const pop = root.querySelector('.ms-pop'); const allBox = root.querySelector('.ms-all'); const optsRoot = root.querySelector('.ms-options'); const listeners = []; let options = []; // [{value, color?}] const updateLabel = () => { const checked = [...optsRoot.querySelectorAll('input:checked')]; if (checked.length === 0 || checked.length === options.length) { btnLabel.textContent = `All ${plural}`; allBox.checked = true; } else if (checked.length === 1) { btnLabel.textContent = checked[0].value; allBox.checked = false; } else { btnLabel.textContent = `${checked.length} ${plural}`; allBox.checked = false; } }; const fire = () => listeners.forEach(fn => fn()); btn.addEventListener('click', (e) => { e.stopPropagation(); const open = pop.hasAttribute('hidden'); if (open) { pop.removeAttribute('hidden'); btn.setAttribute('aria-expanded', 'true'); } else { pop.setAttribute('hidden', ''); btn.setAttribute('aria-expanded', 'false'); } }); document.addEventListener('click', (e) => { if (!root.contains(e.target) && !pop.hasAttribute('hidden')) { pop.setAttribute('hidden', ''); btn.setAttribute('aria-expanded', 'false'); } }); allBox.addEventListener('change', () => { const checked = allBox.checked; optsRoot.querySelectorAll('input').forEach(cb => { cb.checked = checked; }); if (!checked) allBox.checked = false; // "All" un-check = clear updateLabel(); fire(); }); return { setOptions(opts) { options = opts; // Preserve current selections by value when re-rendering const prevChecked = new Set( [...optsRoot.querySelectorAll('input:checked')].map(cb => cb.value), ); const wasAll = prevChecked.size === 0 || allBox.checked; optsRoot.innerHTML = opts.map(({ value, color }) => ` `).join(''); optsRoot.querySelectorAll('input').forEach(cb => { cb.addEventListener('change', () => { updateLabel(); fire(); }); }); updateLabel(); }, getValues() { const checked = [...optsRoot.querySelectorAll('input:checked')]; if (checked.length === 0 || checked.length === options.length) return []; return checked.map(cb => cb.value); }, onChange(fn) { listeners.push(fn); }, }; } /** * Client-side narrowing: after rendering, hide markers whose cost_centre * or assigned_city isn't in the multi-select set. Used when the user picks * a subset (the SQL accepts only single-value filters in P1). * * Note: this hides via MapLibre `setFilter` so the source data is intact — * counts in the FLEET NOW tiles still reflect everything fetched. */ export function applyClientFilter(map, { costCentres = [], cities = [] } = {}) { const layers = ['vehicles-circle', 'vehicles-arrow', 'vehicles-label']; const conds = []; if (costCentres.length > 0 && costCentres.length > 1) { conds.push(['in', ['get', 'cost_centre'], ['literal', costCentres]]); } if (cities.length > 0 && cities.length > 1) { conds.push(['in', ['get', 'assigned_city'], ['literal', cities]]); } const filter = conds.length === 0 ? null : conds.length === 1 ? conds[0] : ['all', ...conds]; // Preserve the show_arrow filter on the arrow layer for (const id of layers) { if (!map.getLayer(id)) continue; if (id === 'vehicles-arrow') { const base = ['==', ['get', 'show_arrow'], true]; map.setFilter(id, filter ? ['all', base, filter] : base); } else { map.setFilter(id, filter); } } } /* ---------- 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 ${_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 card of els.list.querySelectorAll('.trip-card')) { card.addEventListener('click', () => { els.list.querySelectorAll('.trip-card.selected') .forEach(c => c.classList.remove('selected')); card.classList.add('selected'); const tid = Number(card.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]), ); // Bottom panel takes ~60% height when open; pad the south more so the // polyline isn't hidden under the trip cards. map.fitBounds(bounds, { padding: { top: 60, right: 60, bottom: 360, 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) { const el = document.getElementById(elementId); if (!el) return; const tick = () => { const fmt = new Intl.DateTimeFormat('en-GB', { timeZone: 'Africa/Nairobi', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false, }); el.textContent = `${fmt.format(new Date())} EAT`; }; tick(); setInterval(tick, 1000); }