fleetnow/260605_fleetnow_v1.html
kianiadee 33401415ad 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>
2026-06-06 23:23:44 +03:00

343 lines
28 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!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>