diff --git a/260605_fleetnow_v1.html b/260605_fleetnow_v1.html new file mode 100644 index 0000000..e5d35d9 --- /dev/null +++ b/260605_fleetnow_v1.html @@ -0,0 +1,343 @@ + + + + + +fleetnow.rahamafresh.com — Implementation Plan v1 · 2026-06-05 + + + + +
+
+

Fireside Communications · Tracksolid Fleet Intelligence

+

Merge Live Position + Fleet Intelligence into one map
fleetnow.rahamafresh.com

+

Implementation plan · v1 · 2026-06-05

+
+ New SPA — single self-contained HTML + Backend change — CORS only + Map — MapLibre GL 4.7.1 + Theme — full warm re-skin + Existing 2 maps — kept +
+
+
+ +
+ +

0Context

+
+

The fleet team currently runs two separate MapLibre dashboards off the same read API (fleetapi.rahamafresh.com):

+ +

Operators want a single console where they land on the live fleet, then drill into any vehicle's (or any cost-centre's) historical trips for a chosen period — without switching tabs/apps. This plan builds that merged console as a new third dashboard at fleetnow.rahamafresh.com, fully re-skinned to the warm palette in the reference image. The existing two dashboards stay running untouched.

+
+ +

Confirmed decisions

+
+
Single-canvas mode switch. Land on Live; selecting a vehicle (or applying filters) switches the map to Trips for that selection and hides the live dots; a Live button (or clearing the selection) returns to the live snapshot.
+
Filters drive fleet-wide trips. Cost-centre + period (no vehicle) draws all matching trips, exactly like Fleet Intelligence today; clicking a single vehicle narrows to just it.
+
Keep all three dashboards — fleetnow is additive.
+
Full re-skin to the warm palette (dark slate base, amber/orange accent, teal-green live).
+
+ +

1Key facts that shape the build

+

No new backend logic needed. The four existing endpoints cover the whole merged UX:

+ + + + + + + + +
EndpointReturns / source
GET /webhook/live-positionslive snapshot {summary, geojson}
GET /webhook/live-positions/track1 h trail (LineString Feature)
GET /webhook/fleet-dashboardfilter options {drivers, cost_centres, cities, vehicles}
POST /webhook/fleet-dashboardtrips {summary, geojson} via reporting.fn_trips_for_map(...)
+

Time presets already supported in dashboard_api_rev.py:252 _preset_to_range: today | 7d (="1 week") | 30d (="1 month") | custom.

+

One backend change only — CORS. dashboard_api_rev.py:53 _ALLOWED_ORIGINS (env DASHBOARD_CORS_ORIGINS) must include https://fleetnow.rahamafresh.com, otherwise the browser blocks every fetch.

+

Both SPAs already share: MapLibre GL JS 4.7.1, Carto Voyager basemap (Mapbox dark-v11 scaffolded but inactive), the same CSS design tokens, the same COST_CENTRE_PALETTE + colorForCostCentre() hash, the same SEQ_PALETTE/seqColor(), the Fireside HQ POI, the EAT clock, and escapeHtml. The merge is mostly composition + re-theme, not new algorithms.

+ +

2Deliverable

+

A single self-contained fleetnow.html (one file, inline CSS/JS, MapLibre from unpkg) — matching the existing single-file-SPA pattern so it drops straight into a rustfs bucket behind an nginx proxy. Keep it in the repo for source control, e.g. frontend/fleetnow.html (new frontend/ dir; the existing two live only in rustfs, so this also starts versioning the SPA source).

+ +

Layout (per the user's spec; palette only from the image)

+

No left panel. The map is full-bleed under the top bar; everything else floats over it as cards — filters bottom-right (always), trip list bottom-left (trips mode only).

+
┌───────────────────────────────────────────────────────────────────┐
+│ TOP BAR — metrics for current mode + [● Live] pill + EAT clock      │
+├───────────────────────────────────────────────────────────────────┤
+│  MAP (full-bleed, centred on East Africa)                          │
+│                                                                     │
+│  ┌───────────────────┐                   ┌──────────────────────┐  │
+│  │ TRIP LIST          │                   │ FILTERS (bottom-right)│ │
+│  │ (trips mode only,  │                   │  • Number plate      │  │
+│  │  bottom-left)      │                   │  • Cost centre       │  │
+│  │  ◀ Live            │                   │  • Time: Today▾      │  │
+│  │  #1 KDK 829A · 8km │                   │    (Today/1wk/1mo/   │  │
+│  │  #2 KDK 829A · 3km │                   │     Custom)          │  │
+│  └───────────────────┘                   └──────────────────────┘  │
+└───────────────────────────────────────────────────────────────────┘
+ + + +

State model (the merge core)

+

One mode variable: 'live' | 'trips'.

+ +

Keep live and trip layers as separate sources/layers toggled by visibility rather than a shared source — it mirrors how each SPA already manages its own source and avoids id collisions.

+ +

Palette re-skin (full)

+

Replace the CSS :root tokens. Derived from the reference image (warm dark ops console):

+
+
--bg
#161a23 base
+
--panel
#1e232e
+
--border
#2c333f
+
--text
#ECEFF4
+
--muted
#93a0b4
+
--accent
#E8954A amber
+
--live
#2dd4a7 teal
+
--parked
#6b7280
+
--offline
#b4791f
+
--danger
#ef5b5b
+
+

Keep the categorical COST_CENTRE_PALETTE and SEQ_PALETTE distinct/high-contrast for legibility (don't collapse them into the warm ramp) — but reorder so the first slots lead with the brand amber/teal. Re-tone marker outlines, popups, chips, buttons, and the HQ POI to the new tokens. "Moving/active" state colour → --live teal; arrows stay white-on-halo.

+ +

3Backend change

+

In dashboard_api_rev.py:53, add https://fleetnow.rahamafresh.com to the DASHBOARD_CORS_ORIGINS default list (durable, for when the service is folded into Coolify), AND set it live on the running standalone bridge container's env. Per the deploy notes, that container is not Coolify-managed:

+
# on twala host (user runs SSH — IP-whitelisted):
+#   update DASHBOARD_CORS_ORIGINS to include fleetnow, then:
+docker restart dashboard_api
+ +

4Deployment (user runs the SSH/host steps — sandbox IP isn't whitelisted)

+

Mirror the existing liveposition/fleetintelligence wiring (single index.html object in rustfs + nginx reverse-proxy + Traefik route on the coolify net + DNS):

+
    +
  1. Create rustfs bucket fleetnow; upload fleetnow.html as index.html (boto3 against https://s3.rahamafresh.com, path-style — same method already used to repoint the other two).
  2. +
  3. Add fleetnow-proxy nginx config (clone ~/fleetintelligence-proxy/nginx.conf, point at rustfs-...:9000/fleetnow/index.html); attach Traefik labels for https://fleetnow.rahamafresh.com (entrypoints http/https, certresolver letsencrypt, traefik.docker.network=coolify).
  4. +
  5. Ensure DNS fleetnow.rahamafresh.com31.97.44.246 (LE will issue on first hit).
  6. +
  7. Apply the CORS change above + docker restart dashboard_api.
  8. +
+

In the SPA, set const API_BASE = 'https://fleetapi.rahamafresh.com'; (rename from the legacy N8N_BASE for clarity).

+ +

5Verification (end to end)

+
    +
  1. Local smoke: open fleetnow.html from disk (or python -m http.server). Live dots load (CORS will block until fleetnow origin is allow-listed — for local dev, temporarily add http://localhost:* to DASHBOARD_CORS_ORIGINS, or test against a curl-proxied copy).
  2. +
  3. API contracts unchanged (curl, already-allowed origin): +
    curl https://fleetapi.rahamafresh.com/webhook/live-positions
    +# → {summary, geojson} with features
    +
    +curl -X POST https://fleetapi.rahamafresh.com/webhook/fleet-dashboard \
    +  -H 'Content-Type: application/x-www-form-urlencoded' \
    +  -d 'vehicle_numbers=KCA 542Q&period=30d'
    +# → trips for one vehicle (~232 trips for that plate per fix history)
    +
  4. +
  5. Mode switch: land on Live (dots + arrows + cost-centre colours + polling). Click a vehicle → map switches to that vehicle's trips for "Today", top bar shows trip KPIs, Live pill appears. Click a trip row → route animates. Click Live → returns to live snapshot, polling resumes.
  6. +
  7. Fleet-wide filters: pick a cost centre + "1 week", apply → all matching trips draw; clear → back to Live.
  8. +
  9. Post-deploy: load https://fleetnow.rahamafresh.com in a browser — no CORS errors in console; existing liveposition + fleetintelligence still load (regression check).
  10. +
+ +

6Critical files

+ + + + + + + + + +
FileRole
frontend/fleetnow.htmlNEWThe merged single-file SPA — the bulk of the work.
dashboard_api_rev.py (line 53)EDITAdd fleetnow to CORS default; redeploy bridge container env + restart.
Live SPA (liveposition)REUSECopy patterns: makeArrowImage, vehicleState, live layers, showPopup + button-wiring, geocode queue, trail. renderVehicleList intentionally not reused.
Trips SPA (fleetintelligence)REUSECopy patterns: periodToRange, loadFilters/VEHICLE_META, trip line/endpoint layers, animateTrip.
Deploy notes (memory)REFdashboard-api-map-fix + prod-twala-deploy-topology: rustfs S3 endpoint, nginx-proxy + Traefik convention, "scp + docker restart" gotcha for the bridge container.
+ +

7Phased implementation roadmap

+

Each phase is a reviewable checkpoint. The order de-risks the hard part (the live⇄trips mode switch) by getting each half working in isolation first. Phases 0–5 are local; Phase 6 is the only one touching prod and is run by you (host is IP-whitelisted). No production push until you confirm.

+ +
+
+
0Scaffold & theme foundationLOCAL
+

Create frontend/fleetnow.html: shell = top bar + full-bleed map + empty floating-card slots. Warm palette :root tokens, API_BASE, EAT clock, MapLibre init centred on East Africa ([37.5,-3.0], zoom ~5.2), Carto Voyager basemap, HQ POI.

+

✅ Map loads, themed, no data.

+
+
+
1Live mode (locked look)LOCAL
+

Live polling (GET /webhook/live-positions, 15s), the pin marker + direction chevron + plate-tail pill, cost-centre colouring, hover popup with the locked field set + trail button, live KPIs + staleness chip, reverse-geocoding queue.

+

✅ Live fleet renders matching the screenshot design (warm-toned).

+
+
+
2Filter card (bottom-right)LOCAL
+

loadFilters; plate / cost-centre / time-period dropdowns (default Today; 1 week / 1 month / Custom), vehicle→cc/city auto-fill.

+

✅ Dropdowns populate; no behaviour wired.

+
+
+
3Trips mode + mode switch (core)LOCAL
+

mode state machine (live ⇄ trips): stopPolling → hide live layers → POST /webhook/fleet-dashboard → seq-coloured trip lines + start/end markers; floating trip list bottom-left (◀ Live head) → click row → fit + animate; top bar → trip KPIs + bookends; ● Live pill = return. Wire all three entry paths (map-dot "Show trips" button, plate dropdown, fleet-wide cc/period).

+

✅ Full round trip works: Live → vehicle/filter → trips + animation → back to Live.

+
+
+
4Polish & resilienceLOCAL
+

Empty/error states (no trips, feed down), transitions, responsive floating cards, attribution, offline-vehicle styling. Final pass against locked design + palette.

+

✅ The merged SPA is done and self-reviewed.

+
+
+
5Backend CORS + local integration testEDIT
+

Add https://fleetnow.rahamafresh.com to DASHBOARD_CORS_ORIGINS default in dashboard_api_rev.py; test locally (temp localhost origin) against the live API.

+

✅ Every endpoint returns 200 to the new SPA, no CORS errors.

+
+
+
6Deploy (you run; I prep + verify)PROD
+

rustfs fleetnow bucket upload, fleetnow-proxy nginx + Traefik route, DNS, docker restart dashboard_api with the new origin.

+

✅ fleetnow live; liveposition + fleetintelligence still load (regression check).

+
+
+ + + +
+ + diff --git a/README.md b/README.md index aa320db..8f8f832 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,12 @@ trips** into one view for the Fireside Communications / Tracksolid fleet. - **● circle** (full colour + heading arrow) — moving right now - **■ square** (pastel colour, no arrow, ~half the size of a moving marker) — active within the last 24h, now stopped - **grey ●** — offline (no fix in 24h) +- **One pin per vehicle (tracker + camera dedup).** Every vehicle carries a GPS + **tracker** (X3 / GT06E / AT4) *and* a **dashcam** (JC400P) that share the same + number plate. FleetNow collapses the pair to a single marker/dropdown entry: + the **tracker is primary**; if the tracker isn't reporting (>24h), it **falls + back to the camera**; if both report, the tracker wins. Pairing is by + normalised plate, so a stray space (`KDS 453 Y` vs `KDS 453Y`) still merges. - **Filters** (bottom-right card) apply to the live map *instantly*: - **Number plate** — multi-select, sorted A→Z; picking a vehicle auto-fills its cost centre + city. diff --git a/index.html b/index.html index bbae847..5f1e1c3 100644 --- a/index.html +++ b/index.html @@ -417,7 +417,9 @@ let map = null, popup = null; let pollTimer = null, inFlight = null; let lastLivePayload = null; const liveMarkers = new Map(); // imei → maplibregl.Marker +let liveFeatures = []; // deduped (one device per vehicle) — see dedupeLiveFeatures const VEHICLE_META = new Map(); // plate → {cost_centre, assigned_city} +const PLATE_KEYS = new Set(); // normalised plate keys already in the dropdown (tracker+camera collapse to one) let openPopupImei = null, popupStuck = false, popupCloseTimer = null; let trailedVehicle = null; let animFrame = 0; @@ -492,6 +494,49 @@ function plateTail(plate) { return String(plate).replace(/\s+/g, '').slice(-4); } +// Normalised plate key for pairing a vehicle's tracker + camera (which share +// the same plate, sometimes with a stray space e.g. "KDS 453 Y" vs "KDS 453Y"). +// Strip all whitespace + uppercase so the two devices collapse to one vehicle. +function normPlate(p) { return p ? String(p).replace(/\s+/g, '').toUpperCase() : ''; } + +function ageMsOf(p) { + return (typeof p.source_age_hours === 'number') + ? p.source_age_hours * 3600 * 1000 + : (p.gps_time_utc ? Date.now() - new Date(p.gps_time_utc).getTime() : Infinity); +} + +// Every vehicle carries a tracker (X3/GT06E/AT4) AND a camera (JC400P) under the +// same plate. The tracker is primary; the camera is the fallback when the tracker +// isn't reporting. Collapse the live feed to ONE device per vehicle (per plate): +// functioning (<24h) beats offline → tracker beats camera → freshest fix. +// Devices with no plate can't be paired, so they pass through individually. +function deviceKindRank(p) { + const k = p.device_kind || (p.mc_type === 'JC400P' ? 'camera' : 'tracker'); + return k === 'camera' ? 2 : (k === 'tracker' ? 0 : 1); +} +function dedupeLiveFeatures(features) { + const groups = new Map(); // normPlate → [features] + const loose = []; // no-plate features (kept as-is) + features.forEach(f => { + const key = normPlate(f.properties && f.properties.vehicle_number); + if (!key) { loose.push(f); return; } + (groups.get(key) || groups.set(key, []).get(key)).push(f); + }); + const winners = []; + for (const [, group] of groups) { + group.sort((a, b) => { + const pa = a.properties, pb = b.properties; + const oa = vehicleState(pa) === 'offline' ? 1 : 0, ob = vehicleState(pb) === 'offline' ? 1 : 0; + if (oa !== ob) return oa - ob; // functioning first + const ka = deviceKindRank(pa), kb = deviceKindRank(pb); + if (ka !== kb) return ka - kb; // tracker beats camera + return ageMsOf(pa) - ageMsOf(pb); // freshest fix + }); + winners.push(group[0]); + } + return winners.concat(loose); +} + // ============================================================================ // LIVE MODE — polling + DOM markers // ============================================================================ @@ -518,18 +563,10 @@ let firstFit = false; function renderLive() { if (!lastLivePayload) return; ensureMap(); - const features = (lastLivePayload.geojson && lastLivePayload.geojson.features) || []; - - // KPIs (recomputed client-side so they reflect what's drawn) - const active = features.filter(f => isVehicleActive(f.properties)); - const speeds = active.map(f => Number(f.properties.speed || 0)).filter(s => s > 0).sort((a, b) => a - b); - const median = speeds.length ? speeds[Math.floor(speeds.length / 2)] : null; - const offline = features.filter(f => vehicleState(f.properties) === 'offline').length; - renderLiveKPIs({ - total: features.length, moving: active.length, - parked: features.length - active.length - offline, offline, - median, last_batch_utc: lastLivePayload.summary?.last_batch_utc, - }); + const raw = (lastLivePayload.geojson && lastLivePayload.geojson.features) || []; + // Collapse tracker+camera pairs to one device per vehicle (tracker default, + // camera fallback). Everything below operates on the deduped list. + const features = liveFeatures = dedupeLiveFeatures(raw); // Populate filter dropdowns from the full fleet (never shrink them) populateFiltersFromLive(features); @@ -543,7 +580,7 @@ function renderLive() { seen.add(p.imei); upsertLiveMarker(p, c, f); }); - // Drop markers for vehicles no longer present + // Drop markers for vehicles no longer present (incl. the now-deduped twins) for (const [imei, m] of liveMarkers) { if (!seen.has(imei)) { m.remove(); liveMarkers.delete(imei); } } if (!firstFit && features.length) { @@ -621,7 +658,7 @@ function upsertLiveMarker(p, coords, feature) { el.style.zIndex = state === 'active' ? 3 : (state === 'parked' ? 2 : 1); } function currentLiveFeature(imei) { - return (lastLivePayload?.geojson?.features || []).find(f => f.properties.imei === imei); + return liveFeatures.find(f => f.properties.imei === imei); } // ── Live KPI bar ──────────────────────────────────────────────────────────── @@ -720,7 +757,7 @@ async function toggleTrail(vehicleNumber) { const f = currentLiveFeatureByPlate(vehicleNumber); if (f) showLivePopup(f); } catch (err) { showError(`Couldn't load trail: ${err.message}`); } } -function currentLiveFeatureByPlate(plate) { return (lastLivePayload?.geojson?.features || []).find(f => f.properties.vehicle_number === plate); } +function currentLiveFeatureByPlate(plate) { return liveFeatures.find(f => normPlate(f.properties.vehicle_number) === normPlate(plate)); } function drawTrail(feature) { const fc = { type: 'FeatureCollection', features: [feature] }; const src = map.getSource(TRAIL_SOURCE); @@ -756,7 +793,9 @@ function fillVehicleSelect(vehicles) { const sel = document.getElementById('f-vehicle'); vehicles.forEach(v => { const plate = v.vehicle_number; - if (!plate || VEHICLE_META.has(plate)) return; + const key = normPlate(plate); + if (!plate || PLATE_KEYS.has(key)) return; // one entry per vehicle (tracker+camera share a plate) + PLATE_KEYS.add(key); VEHICLE_META.set(plate, { cost_centre: v.cost_centre, assigned_city: v.assigned_city }); const opt = document.createElement('option'); opt.value = plate; opt.textContent = v.drivers ? `${plate} — ${v.drivers}` : plate; @@ -787,14 +826,14 @@ function sortSelect(id) { // Add any plate/cc/city seen in the live feed that the filter-options endpoint missed function populateFiltersFromLive(features) { const ccs = new Set(), cities = new Set(), vehSel = document.getElementById('f-vehicle'); - const havePlates = new Set(Array.from(vehSel.options).map(o => o.value)); let addedPlate = false; features.forEach(f => { const p = f.properties; if (p.cost_centre) ccs.add(p.cost_centre); if (p.assigned_city) cities.add(p.assigned_city); - if (p.vehicle_number && !havePlates.has(p.vehicle_number)) { - havePlates.add(p.vehicle_number); + const key = normPlate(p.vehicle_number); + if (p.vehicle_number && !PLATE_KEYS.has(key)) { // dedup by normalised plate (tracker+camera = one) + PLATE_KEYS.add(key); VEHICLE_META.set(p.vehicle_number, { cost_centre: p.cost_centre, assigned_city: p.assigned_city }); const o = document.createElement('option'); o.value = p.vehicle_number; o.textContent = p.vehicle_number; vehSel.appendChild(o); addedPlate = true; } @@ -826,13 +865,13 @@ function updateVehScale() { // the header KPIs to match. Time period only applies to trips, not live. function applyLiveFilters() { if (mode !== 'live') return; - const plates = new Set(Array.from(document.getElementById('f-vehicle').selectedOptions).map(o => o.value).filter(Boolean)); + const plates = new Set(Array.from(document.getElementById('f-vehicle').selectedOptions).map(o => normPlate(o.value)).filter(Boolean)); const cc = document.getElementById('f-cc').value; const city = document.getElementById('f-city').value; let total = 0, moving = 0, parked = 0, offline = 0; const speeds = []; - (lastLivePayload?.geojson?.features || []).forEach(f => { + liveFeatures.forEach(f => { const p = f.properties; const m = liveMarkers.get(p.imei); if (!m) return; - const pass = (plates.size === 0 || plates.has(p.vehicle_number)) && (!cc || p.cost_centre === cc) && (!city || p.assigned_city === city); + const pass = (plates.size === 0 || plates.has(normPlate(p.vehicle_number))) && (!cc || p.cost_centre === cc) && (!city || p.assigned_city === city); m.getElement().style.display = pass ? '' : 'none'; if (!pass) return; total++;