// fleet-core.js // Shared client primitives for the fleet-platform dashboards. // MapLibre is the only external dependency; everything else is vanilla ES modules. // // All business logic lives server-side (PRD §8, arch §7). This module: // - authClient : token cache + login + apiFetch wrapper // - initMap : MapLibre instance + vehicle layer // - renderView : ingest {summary, geojson, slo_status} -> DOM + map // - initFilters : form-driven filter UI // - clockEAT : ticks the EAT clock element const API_BASE = ''; const STORAGE_ACCESS = 'fleet.accessToken'; const STORAGE_REFRESH = 'fleet.refreshToken'; const STORAGE_EXPIRES = 'fleet.expiresAt'; /* ---------- 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_BASE}/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); }, 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 ---------- */ const STYLE_CLASS_COLORS = { 'vehicle-moving': '#10b981', 'vehicle-parked': '#3b82f6', 'vehicle-offline': '#9ca3af', }; const VEHICLE_SOURCE = 'vehicles'; const VEHICLE_LAYER = 'vehicles-circle'; 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/voyager-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', () => { map.addSource(VEHICLE_SOURCE, { type: 'geojson', data: { type: 'FeatureCollection', features: [] }, }); map.addLayer({ id: VEHICLE_LAYER, type: 'circle', source: VEHICLE_SOURCE, paint: { 'circle-radius': 7, 'circle-color': [ 'match', ['get', 'style_class'], 'vehicle-moving', STYLE_CLASS_COLORS['vehicle-moving'], 'vehicle-parked', STYLE_CLASS_COLORS['vehicle-parked'], 'vehicle-offline', STYLE_CLASS_COLORS['vehicle-offline'], '#6b7280', ], 'circle-stroke-color': '#ffffff', 'circle-stroke-width': 1.5, }, }); }); map.on('click', VEHICLE_LAYER, (e) => { if (!e.features || !e.features[0]) return; const p = e.features[0].properties; // eslint-disable-next-line no-undef new maplibregl.Popup() .setLngLat(e.features[0].geometry.coordinates) .setHTML(_popupHtml(p)) .addTo(map); }); map.on('mouseenter', VEHICLE_LAYER, () => (map.getCanvas().style.cursor = 'pointer')); map.on('mouseleave', VEHICLE_LAYER, () => (map.getCanvas().style.cursor = '')); return map; } function _popupHtml(props) { const safe = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`); return `
${safe(props.plate)} · ${safe(props.operational_state)}
Cost centre: ${safe(props.cost_centre || '—')}
City: ${safe(props.assigned_city || '—')}
Speed: ${props.speed_kmh ?? '—'} km/h
Last fix: ${safe(props.occurred_at)}
`; } /* ---------- render ---------- */ export function renderView(map, payload, { summaryRoot, sloRoot } = {}) { if (!payload || !payload.geojson) return; const src = map.getSource(VEHICLE_SOURCE); if (src) src.setData(payload.geojson); if (summaryRoot) _renderSummary(summaryRoot, payload.summary || {}); if (sloRoot) _renderSlos(sloRoot, payload.slo_status || {}); } 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 ?? '—' }, { label: 'Below freshness SLO', value: summary.below_freshness_slo ?? '—' }, ]; root.innerHTML = tiles .map( (t) => `
${t.label}
${t.value}
`, ) .join(''); } function _renderSlos(root, slos) { const entries = Object.entries(slos); if (entries.length === 0) { root.innerHTML = '
SLO data not yet available
'; return; } root.innerHTML = entries .map(([metric, info]) => { const status = info.status || 'unknown'; const current = info.current ?? '—'; const threshold = info.threshold ?? '—'; return `
${metric} ${current} / ${threshold} ${status}
`; }) .join(''); } /* ---------- filters ---------- */ export function initFilters(formEl, onChange) { const handler = () => { const fd = new FormData(formEl); const filters = {}; for (const [k, v] of fd.entries()) { if (v) filters[k] = v; } onChange(filters); }; formEl.addEventListener('change', handler); formEl.addEventListener('submit', (e) => { e.preventDefault(); handler(); }); } /* ---------- clockEAT ---------- */ export function clockEAT(elementId) { const el = document.getElementById(elementId); if (!el) return; const tick = () => { const now = new Date(); const eat = new Date(now.getTime()); 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(eat)} EAT`; }; tick(); setInterval(tick, 1000); }