feat(live): pair tracker+camera per vehicle — tracker default, camera fallback

Every vehicle has a GPS tracker (X3/GT06E/AT4) and a JC400P dashcam sharing the
same plate. dedupeLiveFeatures() collapses the pair to one device per normalised
plate: functioning (<24h) beats offline → tracker beats camera → freshest fix.
So a vehicle shows once — tracker by default, camera only when the tracker is
dark. Dropdown + live filter also dedup by normalised plate (merges stray-space
variants like 'KDS 453 Y' vs 'KDS 453Y'). Unit-verified all precedence cases.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
kianiadee 2026-06-06 23:23:44 +03:00
parent 525360f204
commit 33401415ad
3 changed files with 410 additions and 22 deletions

343
260605_fleetnow_v1.html Normal file
View file

@ -0,0 +1,343 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>fleetnow.rahamafresh.com — Implementation Plan v1 · 2026-06-05</title>
<style>
:root {
--bg: #161a23;
--panel: #1e232e;
--panel-2: #232a36;
--border: #2c333f;
--text: #ECEFF4;
--muted: #93a0b4;
--accent: #E8954A; /* amber/orange */
--accent-hover:#d97b2c;
--live: #2dd4a7; /* teal-green */
--offline: #b4791f;
--warn: #f0a93b;
--danger: #ef5b5b;
--code-bg: #11151c;
}
* { box-sizing: border-box; }
html, body { margin: 0; }
body {
background: var(--bg);
color: var(--text);
font: 15px/1.65 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
padding: 0 0 80px;
}
.wrap { max-width: 920px; margin: 0 auto; padding: 0 24px; }
header.doc {
border-bottom: 1px solid var(--border);
background: linear-gradient(180deg, var(--panel) 0%, var(--bg) 100%);
padding: 40px 0 28px;
margin-bottom: 8px;
}
header.doc .wrap { padding-top: 0; padding-bottom: 0; }
.eyebrow {
text-transform: uppercase; letter-spacing: 2px; font-size: 11px;
color: var(--accent); font-weight: 700; margin: 0 0 10px;
}
h1 {
font-size: 30px; line-height: 1.2; margin: 0 0 14px; font-weight: 700;
}
h1 .url { color: var(--accent); }
.sub { color: var(--muted); font-size: 14px; margin: 0; }
.pills { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 18px; }
.pill {
font-size: 12px; padding: 5px 11px; border-radius: 999px;
border: 1px solid var(--border); background: var(--panel-2); color: var(--muted);
}
.pill b { color: var(--text); font-weight: 600; }
h2 {
font-size: 20px; margin: 38px 0 12px; padding-top: 10px;
border-top: 1px solid var(--border); color: var(--text);
}
h2 .n {
display: inline-grid; place-items: center;
width: 26px; height: 26px; border-radius: 7px;
background: rgba(232,149,74,.15); color: var(--accent);
font-size: 13px; font-weight: 700; margin-right: 10px; vertical-align: 2px;
}
h3 { font-size: 15px; margin: 24px 0 8px; color: var(--accent); }
p { margin: 10px 0; }
a { color: var(--accent); }
code {
background: var(--code-bg); border: 1px solid var(--border);
border-radius: 4px; padding: 1px 6px; font-size: 13px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
color: #e6c9a8;
}
pre {
background: var(--code-bg); border: 1px solid var(--border);
border-left: 3px solid var(--accent);
border-radius: 8px; padding: 16px 18px; overflow-x: auto;
font-size: 12.5px; line-height: 1.55;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
color: #cdd6e4;
}
pre code { background: none; border: 0; padding: 0; color: inherit; }
ul, ol { margin: 10px 0; padding-left: 24px; }
li { margin: 6px 0; }
strong { color: var(--text); }
.muted { color: var(--muted); }
.card {
background: var(--panel); border: 1px solid var(--border);
border-radius: 10px; padding: 18px 20px; margin: 16px 0;
}
.card.context { border-left: 3px solid var(--live); }
.decisions { display: grid; gap: 10px; counter-reset: d; }
.decision {
background: var(--panel); border: 1px solid var(--border);
border-radius: 8px; padding: 12px 14px 12px 46px; position: relative;
}
.decision::before {
counter-increment: d; content: counter(d);
position: absolute; left: 12px; top: 12px;
width: 22px; height: 22px; border-radius: 50%;
background: var(--accent); color: #1a1009; font-weight: 700; font-size: 12px;
display: grid; place-items: center;
}
.decision b { color: var(--accent); }
table { width: 100%; border-collapse: collapse; margin: 14px 0; font-size: 13.5px; }
th, td { text-align: left; padding: 9px 12px; border-bottom: 1px solid var(--border); vertical-align: top; }
th { color: var(--muted); text-transform: uppercase; font-size: 11px; letter-spacing: .6px; }
td code { white-space: nowrap; }
.swatches { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px,1fr)); gap: 8px; margin: 12px 0; }
.sw { display: flex; align-items: center; gap: 9px; font-size: 12px; padding: 7px 9px; background: var(--panel); border: 1px solid var(--border); border-radius: 7px; }
.sw .chip { width: 22px; height: 22px; border-radius: 5px; border: 1px solid rgba(255,255,255,.15); flex: none; }
.sw .hex { color: var(--muted); font-family: ui-monospace, monospace; font-size: 11px; }
.ascii { font-size: 11.5px; line-height: 1.35; }
.phases { display: grid; gap: 0; margin: 16px 0; position: relative; }
.phase {
position: relative; padding: 4px 0 18px 0; margin-left: 13px;
border-left: 2px solid var(--border); padding-left: 26px;
}
.phase:last-child { border-left-color: transparent; }
.ph-head { display: flex; align-items: center; gap: 10px; margin-bottom: 4px; }
.ph-head b { font-size: 15px; }
.ph-n {
position: absolute; left: -15px; top: 2px;
width: 28px; height: 28px; border-radius: 50%;
background: var(--panel-2); border: 2px solid var(--accent); color: var(--accent);
display: grid; place-items: center; font-weight: 700; font-size: 13px;
}
.phase p { margin: 6px 0; }
.ck { color: var(--live); font-size: 13px; font-weight: 500; }
footer.doc { margin-top: 50px; padding-top: 18px; border-top: 1px solid var(--border); color: var(--muted); font-size: 12.5px; }
.tag { display:inline-block; font-size:11px; padding:2px 8px; border-radius:5px; background:rgba(45,212,167,.14); color:var(--live); border:1px solid rgba(45,212,167,.3); }
.tag.edit { background:rgba(240,169,59,.14); color:var(--warn); border-color:rgba(240,169,59,.3); }
.tag.new { background:rgba(232,149,74,.16); color:var(--accent); border-color:rgba(232,149,74,.3); }
</style>
</head>
<body>
<header class="doc">
<div class="wrap">
<p class="eyebrow">Fireside Communications · Tracksolid Fleet Intelligence</p>
<h1>Merge Live Position + Fleet Intelligence into one map<br><span class="url">fleetnow.rahamafresh.com</span></h1>
<p class="sub">Implementation plan · v1 · 2026-06-05</p>
<div class="pills">
<span class="pill"><b>New SPA</b> — single self-contained HTML</span>
<span class="pill"><b>Backend change</b> — CORS only</span>
<span class="pill"><b>Map</b> — MapLibre GL 4.7.1</span>
<span class="pill"><b>Theme</b> — full warm re-skin</span>
<span class="pill"><b>Existing 2 maps</b> — kept</span>
</div>
</div>
</header>
<div class="wrap">
<h2><span class="n">0</span>Context</h2>
<div class="card context">
<p>The fleet team currently runs <strong>two separate MapLibre dashboards</strong> off the same read API (<code>fleetapi.rahamafresh.com</code>):</p>
<ul>
<li><strong>liveposition.rahamafresh.com</strong> — real-time snapshot: one dot per vehicle, coloured by cost centre, direction arrow, plate-tail label, hover popup with reverse-geocoded address, polls <code>GET /webhook/live-positions</code> every 15&nbsp;s, optional 1&nbsp;h trail.</li>
<li><strong>fleetintelligence.rahamafresh.com</strong> — historical trips: pick vehicle(s)/cost-centre/city + time period, <code>POST /webhook/fleet-dashboard</code> draws seq-coloured trip lines with start/end markers and an animated dot that runs the route when a trip row is clicked.</li>
</ul>
<p>Operators want a <strong>single console</strong> 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 <strong>fleetnow.rahamafresh.com</strong>, fully re-skinned to the warm palette in the reference image. The existing two dashboards stay running untouched.</p>
</div>
<h3>Confirmed decisions</h3>
<div class="decisions">
<div class="decision"><b>Single-canvas mode switch.</b> Land on <strong>Live</strong>; selecting a vehicle (or applying filters) switches the map to <strong>Trips</strong> for that selection and hides the live dots; a <strong>Live</strong> button (or clearing the selection) returns to the live snapshot.</div>
<div class="decision"><b>Filters drive fleet-wide trips.</b> Cost-centre + period (no vehicle) draws <em>all</em> matching trips, exactly like Fleet Intelligence today; clicking a single vehicle narrows to just it.</div>
<div class="decision"><b>Keep all three</b> dashboards — fleetnow is additive.</div>
<div class="decision"><b>Full re-skin</b> to the warm palette (dark slate base, amber/orange accent, teal-green live).</div>
</div>
<h2><span class="n">1</span>Key facts that shape the build</h2>
<p><strong>No new backend logic needed.</strong> The four existing endpoints cover the whole merged UX:</p>
<table>
<thead><tr><th>Endpoint</th><th>Returns / source</th></tr></thead>
<tbody>
<tr><td><code>GET /webhook/live-positions</code></td><td>live snapshot <code>{summary, geojson}</code></td></tr>
<tr><td><code>GET /webhook/live-positions/track</code></td><td>1&nbsp;h trail (LineString Feature)</td></tr>
<tr><td><code>GET /webhook/fleet-dashboard</code></td><td>filter options <code>{drivers, cost_centres, cities, vehicles}</code></td></tr>
<tr><td><code>POST /webhook/fleet-dashboard</code></td><td>trips <code>{summary, geojson}</code> via <code>reporting.fn_trips_for_map(...)</code></td></tr>
</tbody>
</table>
<p>Time presets already supported in <code>dashboard_api_rev.py:252</code> <code>_preset_to_range</code>: <code>today</code> | <code>7d</code> (="1 week") | <code>30d</code> (="1 month") | <code>custom</code>.</p>
<p><strong>One backend change only — CORS.</strong> <code>dashboard_api_rev.py:53</code> <code>_ALLOWED_ORIGINS</code> (env <code>DASHBOARD_CORS_ORIGINS</code>) must include <code>https://fleetnow.rahamafresh.com</code>, otherwise the browser blocks every fetch.</p>
<p>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 <code>COST_CENTRE_PALETTE</code> + <code>colorForCostCentre()</code> hash, the same <code>SEQ_PALETTE</code>/<code>seqColor()</code>, the Fireside HQ POI, the EAT clock, and <code>escapeHtml</code>. The merge is mostly composition + re-theme, not new algorithms.</p>
<h2><span class="n">2</span>Deliverable</h2>
<p>A single self-contained <code>fleetnow.html</code> (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. <code>frontend/fleetnow.html</code> (new <code>frontend/</code> dir; the existing two live only in rustfs, so this also starts versioning the SPA source).</p>
<h3>Layout <span class="muted">(per the user's spec; palette only from the image)</span></h3>
<p><strong>No left panel.</strong> 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).</p>
<pre class="ascii"><code>┌───────────────────────────────────────────────────────────────────┐
│ 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) │ │
│ └───────────────────┘ └──────────────────────┘ │
└───────────────────────────────────────────────────────────────────┘</code></pre>
<ul>
<li><strong>Top bar</strong> swaps with mode:
<ul>
<li><em>Live:</em> Vehicles · Moving · Parked · Offline 24h+ · Median kmh + last-batch staleness chip (reuse live SPA's <code>applyFilters</code> summary recompute).</li>
<li><em>Trips:</em> Trips · Distance · Driving&nbsp;h · Idle&nbsp;h · Vehicles · Drivers + the date-range / "first trip → last trip" bookend context (reuse trips SPA's <code>renderResult</code> summary block).</li>
</ul>
</li>
<li><strong>Live marker + hover popup — preserve existing look &amp; feel <span class="tag">LOCKED</span></strong> Keep the deployed live-map design: a rounded <strong>pin</strong> filled with the <strong>cost-centre colour</strong> containing a <strong>white direction chevron rotated to heading</strong>, with the <strong>last-4-of-plate</strong> in a small dark pill below the pin (e.g. <code>690F</code>, <code>453Y</code>). The hover popup keeps its exact field set + order: plate (bold) + <code>MOVING · NN KMH</code> badge, driver, <code>costcentre · city</code>, reverse-geocoded address, <code>heading NNN° · gps signal N</code>, <code>NN km on the clock</code>, <code>last fix Ns ago · timestamp</code>, <code>source &lt;mc_type&gt; · &lt;device_kind&gt;</code>, and the action button. Only re-map colour tokens onto the warm theme (status green → <code>--live</code> teal, button blue → <code>--accent</code> amber); structure/feel unchanged. <span class="muted">(Ref screenshot 2026-06-05.)</span></li>
<li><strong>Selecting a single vehicle (both paths):</strong>
<ul>
<li><em>Map-click:</em> click a live dot → reuse the live SPA's <code>showPopup</code>; add a <strong>"Show trips"</strong> button (same pattern as its existing "Show last 1&nbsp;h trail" button) → enters Trips mode for that vehicle at the selected period.</li>
<li><em>Plate dropdown:</em> pick the plate in the bottom-right filter card → same result.</li>
</ul>
</li>
<li><strong>Floating filter card (bottom-right, always visible):</strong> Number plate (single/multi select), Cost centre, Time period (default <strong>Today</strong>). Applying a cost-centre/period (no plate) → fleet-wide Trips mode; picking a plate → that vehicle's Trips. Reuse <code>loadFilters</code>, <code>fillVehicleSelect</code>/<code>VEHICLE_META</code> auto-fill, <code>periodToRange</code> (labels: Today / 1 week / 1 month / Custom).</li>
<li><strong>Floating trip list (bottom-left, trips mode only):</strong> a back affordance (<code>◀ Live</code>) at its head + the seq-coloured trip list (reuse <code>renderTripList</code>); clicking a row fits + animates the route (reuse <code>animateTrip</code>). Hidden in Live mode.</li>
<li class="muted">The live "Tracked Fleet" side-list (<code>renderVehicleList</code>) is <strong>dropped</strong> — with no left panel, live vehicle selection is map-dot + plate-dropdown only.</li>
</ul>
<h3>State model <span class="muted">(the merge core)</span></h3>
<p>One <code>mode</code> variable: <code>'live'</code> | <code>'trips'</code>.</p>
<ul>
<li><strong>Boot →</strong> <code>mode='live'</code>. Start the 15&nbsp;s poll (<code>startPolling</code>), map centred on East Africa (<code>center:[37.5,-3.0], zoom:5.2</code> so Kenya + Uganda + Mombasa + Kampala all fit). Render live layers (<code>live-body</code> circles, <code>live-arrow</code> symbols, <code>live-label</code> text — copied verbatim).</li>
<li><strong>Enter trips</strong> (vehicle row click, or filter "apply"): <code>stopPolling()</code>, hide/remove the live source+layers (or toggle <code>visibility:none</code>), <code>POST /webhook/fleet-dashboard</code> with the selection, draw <code>trip-lines</code> + <code>trip-starts</code>/<code>trip-ends</code> (copied verbatim), populate the left trip list, swap the top bar to trip KPIs, light up the <code>[● Live]</code> pill as a clickable "return" control.</li>
<li><strong>Return to live</strong> (<code>Live</code> pill / clear selection): cancel any animation (<code>clearAnim</code>), remove trip layers, restore live layers, <code>startPolling()</code> again.</li>
</ul>
<p>Keep live and trip layers as <strong>separate sources/layers</strong> toggled by visibility rather than a shared source — it mirrors how each SPA already manages its own source and avoids id collisions.</p>
<h3>Palette re-skin <span class="muted">(full)</span></h3>
<p>Replace the CSS <code>:root</code> tokens. Derived from the reference image (warm dark ops console):</p>
<div class="swatches">
<div class="sw"><span class="chip" style="background:#161a23"></span><div><div>--bg</div><div class="hex">#161a23 base</div></div></div>
<div class="sw"><span class="chip" style="background:#1e232e"></span><div><div>--panel</div><div class="hex">#1e232e</div></div></div>
<div class="sw"><span class="chip" style="background:#2c333f"></span><div><div>--border</div><div class="hex">#2c333f</div></div></div>
<div class="sw"><span class="chip" style="background:#ECEFF4"></span><div><div>--text</div><div class="hex">#ECEFF4</div></div></div>
<div class="sw"><span class="chip" style="background:#93a0b4"></span><div><div>--muted</div><div class="hex">#93a0b4</div></div></div>
<div class="sw"><span class="chip" style="background:#E8954A"></span><div><div>--accent</div><div class="hex">#E8954A amber</div></div></div>
<div class="sw"><span class="chip" style="background:#2dd4a7"></span><div><div>--live</div><div class="hex">#2dd4a7 teal</div></div></div>
<div class="sw"><span class="chip" style="background:#6b7280"></span><div><div>--parked</div><div class="hex">#6b7280</div></div></div>
<div class="sw"><span class="chip" style="background:#b4791f"></span><div><div>--offline</div><div class="hex">#b4791f</div></div></div>
<div class="sw"><span class="chip" style="background:#ef5b5b"></span><div><div>--danger</div><div class="hex">#ef5b5b</div></div></div>
</div>
<p>Keep the <strong>categorical</strong> <code>COST_CENTRE_PALETTE</code> and <code>SEQ_PALETTE</code> 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 → <code>--live</code> teal; arrows stay white-on-halo.</p>
<h2><span class="n">3</span>Backend change</h2>
<p>In <code>dashboard_api_rev.py:53</code>, add <code>https://fleetnow.rahamafresh.com</code> to the <code>DASHBOARD_CORS_ORIGINS</code> 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 <strong>not</strong> Coolify-managed:</p>
<pre><code># on twala host (user runs SSH — IP-whitelisted):
# update DASHBOARD_CORS_ORIGINS to include fleetnow, then:
docker restart dashboard_api</code></pre>
<h2><span class="n">4</span>Deployment <span class="muted">(user runs the SSH/host steps — sandbox IP isn't whitelisted)</span></h2>
<p>Mirror the existing liveposition/fleetintelligence wiring (single <code>index.html</code> object in rustfs + nginx reverse-proxy + Traefik route on the <code>coolify</code> net + DNS):</p>
<ol>
<li>Create rustfs bucket <code>fleetnow</code>; upload <code>fleetnow.html</code> as <code>index.html</code> (boto3 against <code>https://s3.rahamafresh.com</code>, path-style — same method already used to repoint the other two).</li>
<li>Add <code>fleetnow-proxy</code> nginx config (clone <code>~/fleetintelligence-proxy/nginx.conf</code>, point at <code>rustfs-...:9000/fleetnow/index.html</code>); attach Traefik labels for <code>https://fleetnow.rahamafresh.com</code> (entrypoints http/https, certresolver <code>letsencrypt</code>, <code>traefik.docker.network=coolify</code>).</li>
<li>Ensure DNS <code>fleetnow.rahamafresh.com</code><code>31.97.44.246</code> (LE will issue on first hit).</li>
<li>Apply the CORS change above + <code>docker restart dashboard_api</code>.</li>
</ol>
<p>In the SPA, set <code>const API_BASE = 'https://fleetapi.rahamafresh.com';</code> (rename from the legacy <code>N8N_BASE</code> for clarity).</p>
<h2><span class="n">5</span>Verification <span class="muted">(end to end)</span></h2>
<ol>
<li><strong>Local smoke:</strong> open <code>fleetnow.html</code> from disk (or <code>python -m http.server</code>). Live dots load (CORS will block until fleetnow origin is allow-listed — for local dev, temporarily add <code>http://localhost:*</code> to <code>DASHBOARD_CORS_ORIGINS</code>, or test against a curl-proxied copy).</li>
<li><strong>API contracts unchanged</strong> (curl, already-allowed origin):
<pre><code>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)</code></pre>
</li>
<li><strong>Mode switch:</strong> 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, <code>Live</code> pill appears. Click a trip row → route animates. Click <code>Live</code> → returns to live snapshot, polling resumes.</li>
<li><strong>Fleet-wide filters:</strong> pick a cost centre + "1 week", apply → all matching trips draw; clear → back to Live.</li>
<li><strong>Post-deploy:</strong> load <code>https://fleetnow.rahamafresh.com</code> in a browser — no CORS errors in console; existing liveposition + fleetintelligence still load (regression check).</li>
</ol>
<h2><span class="n">6</span>Critical files</h2>
<table>
<thead><tr><th>File</th><th></th><th>Role</th></tr></thead>
<tbody>
<tr><td><code>frontend/fleetnow.html</code></td><td><span class="tag new">NEW</span></td><td>The merged single-file SPA — the bulk of the work.</td></tr>
<tr><td><code>dashboard_api_rev.py</code> <span class="muted">(line 53)</span></td><td><span class="tag edit">EDIT</span></td><td>Add fleetnow to CORS default; redeploy bridge container env + restart.</td></tr>
<tr><td>Live SPA <span class="muted">(liveposition)</span></td><td><span class="tag">REUSE</span></td><td>Copy patterns: <code>makeArrowImage</code>, <code>vehicleState</code>, live layers, <code>showPopup</code> + button-wiring, geocode queue, trail. <code>renderVehicleList</code> intentionally <em>not</em> reused.</td></tr>
<tr><td>Trips SPA <span class="muted">(fleetintelligence)</span></td><td><span class="tag">REUSE</span></td><td>Copy patterns: <code>periodToRange</code>, <code>loadFilters</code>/<code>VEHICLE_META</code>, trip line/endpoint layers, <code>animateTrip</code>.</td></tr>
<tr><td>Deploy notes <span class="muted">(memory)</span></td><td><span class="tag">REF</span></td><td><code>dashboard-api-map-fix</code> + <code>prod-twala-deploy-topology</code>: rustfs S3 endpoint, nginx-proxy + Traefik convention, "scp + docker restart" gotcha for the bridge container.</td></tr>
</tbody>
</table>
<h2><span class="n">7</span>Phased implementation roadmap</h2>
<p>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. <strong>Phases 05 are local; Phase 6 is the only one touching prod</strong> and is run by you (host is IP-whitelisted). No production push until you confirm.</p>
<div class="phases">
<div class="phase">
<div class="ph-head"><span class="ph-n">0</span><b>Scaffold &amp; theme foundation</b><span class="tag new">LOCAL</span></div>
<p>Create <code>frontend/fleetnow.html</code>: shell = top bar + full-bleed map + empty floating-card slots. Warm palette <code>:root</code> tokens, <code>API_BASE</code>, EAT clock, MapLibre init centred on East Africa (<code>[37.5,-3.0]</code>, zoom ~5.2), Carto Voyager basemap, HQ POI.</p>
<p class="ck">✅ Map loads, themed, no data.</p>
</div>
<div class="phase">
<div class="ph-head"><span class="ph-n">1</span><b>Live mode (locked look)</b><span class="tag new">LOCAL</span></div>
<p>Live polling (<code>GET /webhook/live-positions</code>, 15s), the pin marker + direction chevron + plate-tail pill, cost-centre colouring, hover popup with the <strong>locked field set</strong> + trail button, live KPIs + staleness chip, reverse-geocoding queue.</p>
<p class="ck">✅ Live fleet renders matching the screenshot design (warm-toned).</p>
</div>
<div class="phase">
<div class="ph-head"><span class="ph-n">2</span><b>Filter card (bottom-right)</b><span class="tag new">LOCAL</span></div>
<p><code>loadFilters</code>; plate / cost-centre / time-period dropdowns (default <strong>Today</strong>; 1 week / 1 month / Custom), vehicle→cc/city auto-fill.</p>
<p class="ck">✅ Dropdowns populate; no behaviour wired.</p>
</div>
<div class="phase">
<div class="ph-head"><span class="ph-n">3</span><b>Trips mode + mode switch <span class="muted" style="font-weight:400">(core)</span></b><span class="tag new">LOCAL</span></div>
<p><code>mode</code> state machine (<code>live ⇄ trips</code>): <code>stopPolling</code> → hide live layers → <code>POST /webhook/fleet-dashboard</code> → seq-coloured trip lines + start/end markers; floating trip list bottom-left (<code>◀ Live</code> head) → click row → fit + animate; top bar → trip KPIs + bookends; <code>● Live</code> pill = return. Wire <strong>all three entry paths</strong> (map-dot "Show trips" button, plate dropdown, fleet-wide cc/period).</p>
<p class="ck">✅ Full round trip works: Live → vehicle/filter → trips + animation → back to Live.</p>
</div>
<div class="phase">
<div class="ph-head"><span class="ph-n">4</span><b>Polish &amp; resilience</b><span class="tag new">LOCAL</span></div>
<p>Empty/error states (no trips, feed down), transitions, responsive floating cards, attribution, offline-vehicle styling. Final pass against locked design + palette.</p>
<p class="ck">✅ The merged SPA is done and self-reviewed.</p>
</div>
<div class="phase">
<div class="ph-head"><span class="ph-n">5</span><b>Backend CORS + local integration test</b><span class="tag edit">EDIT</span></div>
<p>Add <code>https://fleetnow.rahamafresh.com</code> to <code>DASHBOARD_CORS_ORIGINS</code> default in <code>dashboard_api_rev.py</code>; test locally (temp <code>localhost</code> origin) against the live API.</p>
<p class="ck">✅ Every endpoint returns 200 to the new SPA, no CORS errors.</p>
</div>
<div class="phase">
<div class="ph-head"><span class="ph-n">6</span><b>Deploy <span class="muted" style="font-weight:400">(you run; I prep + verify)</span></b><span class="tag" style="background:rgba(239,91,91,.14);color:var(--danger);border-color:rgba(239,91,91,.3)">PROD</span></div>
<p>rustfs <code>fleetnow</code> bucket upload, <code>fleetnow-proxy</code> nginx + Traefik route, DNS, <code>docker restart dashboard_api</code> with the new origin.</p>
<p class="ck">✅ fleetnow live; liveposition + fleetintelligence still load (regression check).</p>
</div>
</div>
<footer class="doc">
Generated 2026-06-05 · Plan v1 · fleetnow.rahamafresh.com · No production push without explicit confirmation.
</footer>
</div>
</body>
</html>

View file

@ -17,6 +17,12 @@ trips** into one view for the Fireside Communications / Tracksolid fleet.
- **● circle** (full colour + heading arrow) — moving right now - **● 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 - **■ 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) - **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*: - **Filters** (bottom-right card) apply to the live map *instantly*:
- **Number plate** — multi-select, sorted A→Z; picking a vehicle auto-fills its - **Number plate** — multi-select, sorted A→Z; picking a vehicle auto-fills its
cost centre + city. cost centre + city.

View file

@ -417,7 +417,9 @@ let map = null, popup = null;
let pollTimer = null, inFlight = null; let pollTimer = null, inFlight = null;
let lastLivePayload = null; let lastLivePayload = null;
const liveMarkers = new Map(); // imei → maplibregl.Marker 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 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 openPopupImei = null, popupStuck = false, popupCloseTimer = null;
let trailedVehicle = null; let trailedVehicle = null;
let animFrame = 0; let animFrame = 0;
@ -492,6 +494,49 @@ function plateTail(plate) {
return String(plate).replace(/\s+/g, '').slice(-4); 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 // LIVE MODE — polling + DOM markers
// ============================================================================ // ============================================================================
@ -518,18 +563,10 @@ let firstFit = false;
function renderLive() { function renderLive() {
if (!lastLivePayload) return; if (!lastLivePayload) return;
ensureMap(); ensureMap();
const features = (lastLivePayload.geojson && lastLivePayload.geojson.features) || []; const raw = (lastLivePayload.geojson && lastLivePayload.geojson.features) || [];
// Collapse tracker+camera pairs to one device per vehicle (tracker default,
// KPIs (recomputed client-side so they reflect what's drawn) // camera fallback). Everything below operates on the deduped list.
const active = features.filter(f => isVehicleActive(f.properties)); const features = liveFeatures = dedupeLiveFeatures(raw);
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,
});
// Populate filter dropdowns from the full fleet (never shrink them) // Populate filter dropdowns from the full fleet (never shrink them)
populateFiltersFromLive(features); populateFiltersFromLive(features);
@ -543,7 +580,7 @@ function renderLive() {
seen.add(p.imei); seen.add(p.imei);
upsertLiveMarker(p, c, f); 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); } } for (const [imei, m] of liveMarkers) { if (!seen.has(imei)) { m.remove(); liveMarkers.delete(imei); } }
if (!firstFit && features.length) { if (!firstFit && features.length) {
@ -621,7 +658,7 @@ function upsertLiveMarker(p, coords, feature) {
el.style.zIndex = state === 'active' ? 3 : (state === 'parked' ? 2 : 1); el.style.zIndex = state === 'active' ? 3 : (state === 'parked' ? 2 : 1);
} }
function currentLiveFeature(imei) { function currentLiveFeature(imei) {
return (lastLivePayload?.geojson?.features || []).find(f => f.properties.imei === imei); return liveFeatures.find(f => f.properties.imei === imei);
} }
// ── Live KPI bar ──────────────────────────────────────────────────────────── // ── Live KPI bar ────────────────────────────────────────────────────────────
@ -720,7 +757,7 @@ async function toggleTrail(vehicleNumber) {
const f = currentLiveFeatureByPlate(vehicleNumber); if (f) showLivePopup(f); const f = currentLiveFeatureByPlate(vehicleNumber); if (f) showLivePopup(f);
} catch (err) { showError(`Couldn't load trail: ${err.message}`); } } 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) { function drawTrail(feature) {
const fc = { type: 'FeatureCollection', features: [feature] }; const fc = { type: 'FeatureCollection', features: [feature] };
const src = map.getSource(TRAIL_SOURCE); const src = map.getSource(TRAIL_SOURCE);
@ -756,7 +793,9 @@ function fillVehicleSelect(vehicles) {
const sel = document.getElementById('f-vehicle'); const sel = document.getElementById('f-vehicle');
vehicles.forEach(v => { vehicles.forEach(v => {
const plate = v.vehicle_number; 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 }); VEHICLE_META.set(plate, { cost_centre: v.cost_centre, assigned_city: v.assigned_city });
const opt = document.createElement('option'); const opt = document.createElement('option');
opt.value = plate; opt.textContent = v.drivers ? `${plate} — ${v.drivers}` : plate; 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 // Add any plate/cc/city seen in the live feed that the filter-options endpoint missed
function populateFiltersFromLive(features) { function populateFiltersFromLive(features) {
const ccs = new Set(), cities = new Set(), vehSel = document.getElementById('f-vehicle'); 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; let addedPlate = false;
features.forEach(f => { features.forEach(f => {
const p = f.properties; const p = f.properties;
if (p.cost_centre) ccs.add(p.cost_centre); if (p.cost_centre) ccs.add(p.cost_centre);
if (p.assigned_city) cities.add(p.assigned_city); if (p.assigned_city) cities.add(p.assigned_city);
if (p.vehicle_number && !havePlates.has(p.vehicle_number)) { const key = normPlate(p.vehicle_number);
havePlates.add(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 }); 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; 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. // the header KPIs to match. Time period only applies to trips, not live.
function applyLiveFilters() { function applyLiveFilters() {
if (mode !== 'live') return; 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 cc = document.getElementById('f-cc').value;
const city = document.getElementById('f-city').value; const city = document.getElementById('f-city').value;
let total = 0, moving = 0, parked = 0, offline = 0; const speeds = []; 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 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'; m.getElement().style.display = pass ? '' : 'none';
if (!pass) return; if (!pass) return;
total++; total++;