fleet-platform/web/status.html
kianiadee cbf40bd32a
Some checks are pending
build / lint-test (push) Waiting to run
build / build-push (push) Blocked by required conditions
Add web/status.html — full project status / docs page
Single self-contained HTML doc covering:
  - Headline metrics + timeline
  - All capabilities live today (map, filters, trip dock, auth)
  - Architecture (3 roles, data flow ASCII diagram)
  - Coolify deployment notes + the migration apply gotcha
  - Data model (events/state/domain/serve/slo/ops/auth schemas)
  - API endpoints
  - All 20 migrations
  - Trip detection algorithm + calibration table
  - Known issues
  - P1-P4 roadmap
  - Push receiver cut-over plan (P3 scope, what's built, what's needed)
  - Decisions log

Served at /status.html alongside the dashboard.
2026-05-28 02:45:00 +03:00

562 lines
34 KiB
HTML
Raw 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>Fleet-platform · status · 2026-05-28</title>
<style>
:root {
--bg: #0f172a;
--panel: #1e293b;
--panel-2: #0b1220;
--text: #f1f5f9;
--muted: #94a3b8;
--accent: #10b981;
--warn: #f59e0b;
--bad: #ef4444;
--link: #38bdf8;
}
* { box-sizing: border-box; }
html, body {
margin: 0; padding: 0;
background: var(--bg); color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, system-ui, sans-serif;
font-size: 14.5px; line-height: 1.55;
}
a { color: var(--link); text-decoration: none; }
a:hover { text-decoration: underline; }
code, pre {
font-family: ui-monospace, "SF Mono", Consolas, "Liberation Mono", monospace;
font-size: 12.5px;
}
code { background: var(--panel-2); padding: 1px 5px; border-radius: 3px; color: #e2e8f0; }
pre {
background: var(--panel-2); padding: 12px 14px; border-radius: 6px;
overflow-x: auto; border: 1px solid #1f2a40;
color: #cbd5e1;
}
/* layout */
.wrap { display: grid; grid-template-columns: 220px 1fr; min-height: 100vh; }
nav {
position: sticky; top: 0; align-self: start; height: 100vh; overflow-y: auto;
background: var(--panel); padding: 18px 14px; border-right: 1px solid var(--panel-2);
}
nav .brand { font-size: 13px; letter-spacing: 0.08em; text-transform: uppercase; color: var(--muted); margin-bottom: 16px; font-weight: 600; }
nav ul { list-style: none; padding: 0; margin: 0; }
nav li { margin: 4px 0; }
nav a { color: var(--muted); display: block; padding: 4px 6px; border-radius: 3px; font-size: 12.5px; }
nav a:hover { background: var(--panel-2); color: var(--text); text-decoration: none; }
nav .updated { font-size: 11px; color: var(--muted); margin-top: 24px; padding-top: 14px; border-top: 1px solid var(--panel-2); }
main { padding: 32px 48px 80px; max-width: 1100px; }
h1 {
font-size: 24px; font-weight: 700; margin: 0 0 4px;
letter-spacing: -0.01em;
}
.subhead { color: var(--muted); font-size: 13px; margin-bottom: 28px; }
h2 {
font-size: 18px; font-weight: 600; margin: 36px 0 12px;
padding-bottom: 6px; border-bottom: 1px solid var(--panel-2);
letter-spacing: -0.005em;
}
h3 { font-size: 15px; font-weight: 600; margin: 22px 0 8px; color: #e2e8f0; }
p { margin: 8px 0; }
ul, ol { padding-left: 22px; margin: 8px 0; }
li { margin: 3px 0; }
/* tables */
table {
border-collapse: collapse; width: 100%; margin: 10px 0;
font-size: 13px;
}
th, td {
text-align: left; padding: 7px 10px;
border-bottom: 1px solid var(--panel-2);
vertical-align: top;
}
th { background: var(--panel); font-weight: 600; color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em; }
td code { font-size: 12px; }
.num { text-align: right; font-variant-numeric: tabular-nums; }
/* badges */
.badge {
display: inline-block; padding: 2px 8px; border-radius: 4px;
font-size: 10.5px; text-transform: uppercase; letter-spacing: 0.04em; font-weight: 600;
}
.b-done { background: rgba(16,185,129,0.18); color: var(--accent); }
.b-wip { background: rgba(56,189,248,0.16); color: var(--link); }
.b-pending { background: rgba(245,158,11,0.18); color: var(--warn); }
.b-blocked { background: rgba(239,68,68,0.18); color: var(--bad); }
.b-info { background: rgba(148,163,184,0.15); color: var(--muted); }
.b-cut { background: rgba(148,163,184,0.10); color: var(--muted); text-decoration: line-through; }
/* callouts */
.callout {
background: var(--panel); border-left: 3px solid var(--accent);
padding: 12px 16px; margin: 14px 0; border-radius: 0 6px 6px 0;
font-size: 13.5px;
}
.callout.warn { border-left-color: var(--warn); }
.callout.info { border-left-color: var(--link); }
.callout .tag { font-size: 10px; text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); font-weight: 600; }
/* topbar tiles */
.stat-row { display: grid; grid-template-columns: repeat(4,1fr); gap: 12px; margin: 14px 0 24px; }
.stat-tile { background: var(--panel-2); padding: 12px 14px; border-radius: 6px; border: 1px solid #1f2a40; }
.stat-label { font-size: 10px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.06em; }
.stat-value { font-size: 22px; font-weight: 600; margin-top: 4px; }
.stat-sub { font-size: 11px; color: var(--muted); margin-top: 2px; }
/* simple ascii diagram */
.diagram {
background: var(--panel-2); padding: 16px 18px; border-radius: 6px;
white-space: pre; color: #cbd5e1; font-family: ui-monospace, monospace;
font-size: 12px; line-height: 1.5; overflow-x: auto;
border: 1px solid #1f2a40;
}
</style>
</head>
<body>
<div class="wrap">
<nav>
<div class="brand">fleet-platform</div>
<ul>
<li><a href="#summary">Summary</a></li>
<li><a href="#headline">Headline metrics</a></li>
<li><a href="#timeline">Timeline &amp; pacing</a></li>
<li><a href="#capabilities">Capabilities live</a></li>
<li><a href="#architecture">Architecture</a></li>
<li><a href="#deployment">Deployment</a></li>
<li><a href="#data-model">Data model</a></li>
<li><a href="#api">API endpoints</a></li>
<li><a href="#migrations">Migrations</a></li>
<li><a href="#trips">Trip detection</a></li>
<li><a href="#issues">Known issues</a></li>
<li><a href="#roadmap">Roadmap</a></li>
<li><a href="#push-cutover">Push cut-over plan</a></li>
<li><a href="#decisions">Decisions log</a></li>
</ul>
<div class="updated">Last updated 2026-05-28<br />Commit: <code>281a5ec</code><br /><a href="/">← Dashboard</a></div>
</nav>
<main>
<!-- ────────────────────────────────────────────────────────── -->
<h1 id="summary">Fleet-platform status — week 1 of 5</h1>
<p class="subhead">Greenfield rebuild of the Fireside telematics stack · Solo engineer · Started 2026-05-22</p>
<div class="callout">
<div class="tag">Where we are</div>
All Week-1 and Week-2 work is shipped. Week-3 is 80% done — only ntfy alerts, parity check, and the 7-day soak remain.
Two large scope additions (trip detection backend + UI, multi-vehicle overlay) have landed on top of the original plan,
and the platform was migrated to a fully Coolify-managed deployment two days ago.
</div>
<!-- ────────────────────────────────────────────────────────── -->
<h2 id="headline">Headline metrics</h2>
<div class="stat-row">
<div class="stat-tile">
<div class="stat-label">Vehicles live</div>
<div class="stat-value">~144</div>
<div class="stat-sub">across 4 Tracksolid subaccounts</div>
</div>
<div class="stat-tile">
<div class="stat-label">Polling cadence</div>
<div class="stat-value">30 s</div>
<div class="stat-sub">main poll · 10 m stale sweep</div>
</div>
<div class="stat-tile">
<div class="stat-label">SQL migrations</div>
<div class="stat-value">20</div>
<div class="stat-sub">forward-only, all applied</div>
</div>
<div class="stat-tile">
<div class="stat-label">Live URL</div>
<div class="stat-value"><a href="https://api.rahamafresh.com/">api.rahamafresh.com</a></div>
<div class="stat-sub">JWT login at /login.html</div>
</div>
</div>
<!-- ────────────────────────────────────────────────────────── -->
<h2 id="timeline">Timeline &amp; pacing</h2>
<table>
<thead><tr><th>Phase</th><th>Window</th><th>Status</th><th>Notes</th></tr></thead>
<tbody>
<tr><td>P1 — Foundation + live tracking</td><td>weeks 1-5</td><td><span class="badge b-wip">In progress</span></td><td>Day 6 of 35. Well ahead of plan.</td></tr>
<tr><td>P2 — Trips + history + geocoding</td><td>weeks 6-8</td><td><span class="badge b-info">Partially front-loaded</span></td><td>Trip detection + reverse-geocoder already shipped in P1.</td></tr>
<tr><td>P3 — Operations tooling + cutover</td><td>weeks 7-9</td><td><span class="badge b-pending">Not started</span></td><td>Push cut-over (this doc's last section) is the entry point.</td></tr>
<tr><td>P4 — Driver KPIs + cost allocation</td><td>weeks 10-12</td><td><span class="badge b-pending">Not started</span></td><td>Depends on P3 driver roster.</td></tr>
</tbody>
</table>
<!-- ────────────────────────────────────────────────────────── -->
<h2 id="capabilities">Capabilities live today</h2>
<h3>Live map</h3>
<ul>
<li>~144 vehicles refreshed every 15 s on a light Carto Positron basemap</li>
<li>Markers tinted by cost-centre always; opacity carries state (moving 1.0 / parked 0.75 / offline grey 0.55)</li>
<li>White directional arrow on every moving vehicle, zoom-scaled</li>
<li>Plate-tail label per vehicle, hidden below zoom 11 to declutter</li>
<li>Hover popup: plate, driver, speed, heading, reverse-geocoded address, age of last fix</li>
<li>Fireside Group HQ POI (red dot at -1.2409, 36.7288) with permanent label from zoom 9</li>
</ul>
<h3>Filters (multi-select dropdowns)</h3>
<ul>
<li>Cost centre + assigned city pickers with "All …" default + per-option checkboxes</li>
<li>Cost-centre options carry a colour swatch matching the marker tint — filter doubles as a live colour legend</li>
<li>Single selection → server-side filter (counts in <code>FLEET&nbsp;NOW</code> reflect the filter)</li>
<li>Multi-selection → client-side narrowing via <code>setFilter</code> (markers hide, counts stay full for population context)</li>
</ul>
<h3>Trip dock (click any vehicle)</h3>
<ul>
<li>Slides up from the bottom; day totals header (trips / km / drive·idle·stop minutes)</li>
<li>Per-trip cards with start→end times, distance, duration, idling minutes, end-reason badge</li>
<li>Each trip drawn on the map in its own colour from a 12-colour palette; matching swatch on the trip card's left edge</li>
<li>Click a trip card → animated marker traces the polyline over ~10 s</li>
<li>Date picker (defaults to today EAT, scrolls back through history)</li>
<li>CSV download (per-trip rows: date · plate · reporting_time · trip_id · start · end · duration · distance · idling · end_reason)</li>
<li><strong>⌘-click</strong> a second vehicle → multi-vehicle overlay; routes in distinct colours; dock switches to compact rows + aggregate KPIs</li>
</ul>
<h3>Auth + API</h3>
<ul>
<li>JWT login at <code>/login.html</code> (bcrypt + PyJWT, scopes <code>read:fleet</code> / <code>write:ops</code> / <code>admin:fleet</code>)</li>
<li>Rate limited (60/min dashboards, 30/min CSV)</li>
<li>All times displayed in <strong>EAT (Africa/Nairobi)</strong>; all storage in UTC; conversion at the serve layer</li>
</ul>
<!-- ────────────────────────────────────────────────────────── -->
<h2 id="architecture">Architecture</h2>
<p>One FastAPI image, three container roles selected by <code>APP_ROLE</code>. Fate isolation is the point — a heavy report in the worker doesn't stall the gateway.</p>
<div class="diagram"> Tracksolid Pro API Browser
│ │
│ poll every 30s │ HTTPS · JWT
▼ ▼
┌─────────┐ ┌─────────┐
│ cron │ │ gateway │
│ (poll) │ │ (HTTP) │
└────┬────┘ └────┬────┘
│ │
└──────────┐ ┌──────────────┘
▼ ▼
┌──────────────┐ LISTEN events_raw_new
│ events.raw │ ──────────────────────┐
└──────┬───────┘ │
│ ▼
▼ ┌────────┐
┌──────────────┐ │ worker │
│ events.parsed│ ◀───────parse────┤(parser)│
└──────┬───────┘ └────────┘
▼ project (single writer)
┌────────────────────┐
│ state.live_positions│
│ state.position_history│
└────────────────────┘
▼ read
serve.fn_live_view
serve.fn_vehicle_trips
Dashboard (browser)</div>
<table>
<thead><tr><th>Role</th><th>Container</th><th>Workload</th></tr></thead>
<tbody>
<tr><td><code>gateway</code></td><td>fleet-platform-gateway</td><td>HTTP: push receivers, dashboard read API, JWT issuance, static UI</td></tr>
<tr><td><code>worker</code></td><td>fleet-platform-worker</td><td>LISTEN events_raw_new → parser → projectors (single-writer)</td></tr>
<tr><td><code>cron</code></td><td>fleet-platform-cron</td><td>APScheduler: polling (30s/10m), reverse geocoder (30s), SLO worker (60s), contract checker (daily 02:00 UTC)</td></tr>
</tbody>
</table>
<!-- ────────────────────────────────────────────────────────── -->
<h2 id="deployment">Deployment</h2>
<ul>
<li><strong>Coolify-managed</strong> Docker Compose app since 2026-05-27 (was manual <code>docker run</code> before)</li>
<li>Compose file: <code>docker-compose.coolify.yml</code> at repo root</li>
<li><strong>Forgejo</strong> registry + git: <code>repo.rahamafresh.com/kianiadee/fleet-platform</code></li>
<li><strong>Networks</strong>: each container is attached to (a) its Coolify project network, (b) <code>bo3nov…</code> (the DB project's network — where <code>timescale_db</code> alias resolves), (c) gateway also on <code>coolify</code> shared network so Traefik can reach it</li>
<li><strong>TimescaleDB</strong>: separate Coolify project. DB user <code>postgres</code>, db <code>fleet_platform</code>. Read-only reporting role: <code>reporting_reader</code></li>
<li><strong>Domain</strong>: <code>api.rahamafresh.com</code> via Coolify-generated Traefik labels + Let's Encrypt</li>
<li><strong>Deploy flow</strong>: <code>git push origin main</code> → Coolify UI → <strong>Redeploy</strong> (auto-deploy webhook not wired yet)</li>
</ul>
<div class="callout warn">
<div class="tag">Migration gotcha</div>
When piping SQL migrations to <code>psql</code>, strip the <code>-- migrate:down</code> section first (psql ignores the comment marker and runs everything). Use:
<pre>awk "/^-- migrate:down/{exit} {print}" db/migrations/NNNN.sql \
| docker exec -i "$PG" psql -U postgres -d fleet_platform -v ON_ERROR_STOP=1</pre>
</div>
<!-- ────────────────────────────────────────────────────────── -->
<h2 id="data-model">Data model</h2>
<p>Layered by purpose, not by feature. Read top-down: events are truth, state is derived.</p>
<table>
<thead><tr><th>Schema</th><th>Tables</th><th>Purpose</th></tr></thead>
<tbody>
<tr><td><code>events</code></td><td>raw · parsed · parser_errors</td><td>Immutable log (hypertable). Every push and every poll lands here verbatim before any interpretation.</td></tr>
<tr><td><code>state</code></td><td>live_positions · position_history · geocoded_positions</td><td>Derived projections. Single-writer (the projector). Rebuildable from <code>events</code>.</td></tr>
<tr><td><code>domain</code></td><td>accounts · vehicles · devices</td><td>Business entities. Auto-provisioned by the projector on first-sight; CSV/admin edits later.</td></tr>
<tr><td><code>serve</code></td><td>fn_live_view · fn_vehicle_trips · helper fns</td><td>Read-side SQL functions. Dashboard payloads are computed here, not in Python.</td></tr>
<tr><td><code>slo</code></td><td>targets · measurements · v_current_status</td><td>SLO-as-data. Worker writes measurements every 60s. UI surface removed at user request; data still populates.</td></tr>
<tr><td><code>ops</code></td><td>contract_check_log</td><td>Daily Tracksolid contract probe log; drives the <code>contract_drift_days</code> SLO.</td></tr>
<tr><td><code>auth</code></td><td>accounts · tokens</td><td>JWT issuance + scope.</td></tr>
</tbody>
</table>
<!-- ────────────────────────────────────────────────────────── -->
<h2 id="api">API endpoints</h2>
<table>
<thead><tr><th>Method</th><th>Path</th><th>Auth</th><th>What it returns</th></tr></thead>
<tbody>
<tr><td><code>POST</code></td><td><code>/api/auth/token</code></td><td>Form login</td><td>JWT access + refresh</td></tr>
<tr><td><code>GET</code></td><td><code>/api/views/live</code></td><td><code>read:fleet</code></td><td>FleetNow counters + GeoJSON of all active vehicles + SLO snapshot</td></tr>
<tr><td><code>GET</code></td><td><code>/api/views/vehicle/{id}/trips?date=YYYY-MM-DD</code></td><td><code>read:fleet</code></td><td>Per-day trip breakdown (totals + trips[] with paths + stops)</td></tr>
<tr><td><code>GET</code></td><td><code>/api/views/vehicle/{id}/trips.csv?date=YYYY-MM-DD</code></td><td><code>read:fleet</code></td><td>One row per trip, downloadable</td></tr>
<tr><td><code>POST</code></td><td><code>/push/jimi/{pushgps,pushalarm,pushhb,pushobd,…}</code></td><td>Shared token (form body)</td><td>Verbatim INSERT into <code>events.raw</code> + NOTIFY. Receivers built; Tracksolid still pushes to the legacy URL. See <a href="#push-cutover">push cut-over plan</a>.</td></tr>
<tr><td><code>GET</code></td><td><code>/health/{gateway,worker,cron}</code></td><td>Open</td><td>Container + DB liveness</td></tr>
</tbody>
</table>
<!-- ────────────────────────────────────────────────────────── -->
<h2 id="migrations">Migrations history</h2>
<table>
<thead><tr><th>#</th><th>File</th><th>What it adds</th></tr></thead>
<tbody>
<tr><td>01</td><td>init_schemas</td><td>Schemas + Postgres extensions (Timescale, PostGIS)</td></tr>
<tr><td>02</td><td>events</td><td>events.raw / parsed / parser_errors hypertables + NOTIFY triggers</td></tr>
<tr><td>03</td><td>domain</td><td>accounts, vehicles, devices</td></tr>
<tr><td>04</td><td>state_live</td><td>live_positions + position_history hypertable</td></tr>
<tr><td>05</td><td>slo</td><td>slo.targets / measurements / v_current_status</td></tr>
<tr><td>06</td><td>auth</td><td>auth.accounts (bcrypt) + tokens</td></tr>
<tr><td>0709, 11</td><td>serve_fn_live_view v1→v3</td><td>Dashboard read function — evolved with each UI iteration</td></tr>
<tr><td>08</td><td>live_positions_richer</td><td>Added mc_type, mileage, gps_signal, satellites, device_name, pos_type</td></tr>
<tr><td>10</td><td>geocoded_positions</td><td>Nominatim cache table</td></tr>
<tr><td>12</td><td>label_short_from_plate</td><td>serve._label_short — plate-tail extraction</td></tr>
<tr><td>13</td><td>driver_from_device_name</td><td>serve._driver_name — heuristic driver-name parse</td></tr>
<tr><td>14</td><td>real_plates_consolidate</td><td>One-shot dedup of plate-equivalent vehicle rows</td></tr>
<tr><td>15-16</td><td><span class="badge b-cut">CSV import</span></td><td>Removed — rolled back by mig 17</td></tr>
<tr><td>17</td><td>rollback_csv_import</td><td>Full CSV revert (re-split vehicles, drop CSV cols, restore fn_live_view v3)</td></tr>
<tr><td>18</td><td>ops_contract_check_log</td><td>Daily Tracksolid endpoint probe log</td></tr>
<tr><td>19</td><td>fn_vehicle_trips</td><td>PL/pgSQL state machine for trip detection</td></tr>
<tr><td>20</td><td>normalize_assigned_city</td><td>Data hygiene — collapsed <code>Nairobi</code>/<code>nairobi</code></td></tr>
</tbody>
</table>
<!-- ────────────────────────────────────────────────────────── -->
<h2 id="trips">Trip detection algorithm</h2>
<p>One server-side function (<code>serve.fn_vehicle_trips(vehicle_id, date_eat)</code>), single forward pass over <code>state.position_history</code> for the day.</p>
<h3>Rules</h3>
<ul>
<li><strong>Reporting time</strong> = first <code>acc_state=1</code> fix of the day in EAT</li>
<li><strong>Trip starts</strong> at every ACC_ON transition (or first fix if already on/moving)</li>
<li><strong>Trip ends</strong> when ONE of:
<ul>
<li>ACC_OFF + stationary (&lt; 5 km/h) for ≥ 5 min → <code>work_stop</code></li>
<li>No new fix for ≥ 5 min (engine assumed off) → <code>nofix_stop</code></li>
<li>Fix gap &gt; 30 min → <code>long_gap</code></li>
<li>End of day's data → <code>day_end</code></li>
</ul>
</li>
<li><strong>Within a trip</strong>: ACC_ON + stationary ≥ 5 min logged as an idling segment (no split — engine still running)</li>
<li><strong>Distance</strong> only accumulates when current fix is &gt; 5 km/h (excludes GPS jitter at standstill)</li>
<li><strong>Fallback</strong>: when <code>acc_state</code> is null across the day (some Tracksolid devices don't expose it), algorithm degrades to speed-only segmentation; <code>data_quality.has_acc_data: false</code> flagged in the response</li>
</ul>
<h3>Calibration vs legacy DB</h3>
<table>
<thead><tr><th>Vehicle</th><th>Pattern</th><th class="num">Legacy trips</th><th class="num">New algo</th><th>Verdict</th></tr></thead>
<tbody>
<tr><td>KDE 638J</td><td>full day, clean reporting</td><td class="num">15</td><td class="num">15</td><td>Perfect alignment</td></tr>
<tr><td>KDK 728K</td><td>half day, noisy stop-and-go</td><td class="num">33</td><td class="num">9</td><td>Cleaner — legacy over-segments traffic stops</td></tr>
<tr><td>KMGW 538W</td><td>half day</td><td class="num">20</td><td class="num">8</td><td>Legacy splits on sub-minute gaps</td></tr>
<tr><td>KDB 585E</td><td>busy day, many short trips</td><td class="num">21</td><td class="num">18</td><td>Close — most boundaries match</td></tr>
<tr><td>KDV 683Z</td><td>moderate</td><td class="num">13</td><td class="num">7</td><td>Same pattern as 538W</td></tr>
</tbody>
</table>
<p class="subhead">5-min thresholds (work stop + no-fix stop) locked in. Sim tool at <code>scripts/simulate_trips_from_legacy.py</code> replays any legacy JSON dump through the algorithm offline.</p>
<!-- ────────────────────────────────────────────────────────── -->
<h2 id="issues">Known issues &amp; follow-ups</h2>
<table>
<thead><tr><th>Issue</th><th>Severity</th><th>Status</th><th>Notes</th></tr></thead>
<tbody>
<tr>
<td>Polyline straight-line artifacts between fixes</td>
<td>Visual</td>
<td><span class="badge b-info">Mitigated</span></td>
<td>Dropped polling 60s→30s. Permanent fix is push cut-over (denser stream) or map-matching (OSRM/Valhalla) — both deferred</td>
</tr>
<tr>
<td><code>APP_GIT_SHA</code> shows <code>unknown</code> in containers</td>
<td>Cosmetic</td>
<td><span class="badge b-pending">Open</span></td>
<td>Coolify isn't injecting <code>SOURCE_COMMIT</code>; need compose tweak</td>
</tr>
<tr>
<td>Some vehicles report <code>has_acc_data=false</code></td>
<td>Data quality</td>
<td><span class="badge b-info">Accepted</span></td>
<td>Algorithm falls back to speed-only detection; flagged in response</td>
</tr>
<tr>
<td><code>state.position_history</code> has no (imei, occurred_at) unique constraint</td>
<td>Latent</td>
<td><span class="badge b-pending">Address before push cut-over</span></td>
<td>Bites only if push + polling overlap; needed for ingest idempotency. See <a href="#push-cutover">push plan</a></td>
</tr>
<tr>
<td>Auto-deploy webhook not wired Forgejo → Coolify</td>
<td>DX</td>
<td><span class="badge b-pending">Open</span></td>
<td>Manual "Redeploy" click required after each push</td>
</tr>
</tbody>
</table>
<!-- ────────────────────────────────────────────────────────── -->
<h2 id="roadmap">Roadmap</h2>
<h3>P1 — remaining</h3>
<table>
<thead><tr><th>#</th><th>Item</th><th>Status</th><th>Effort</th></tr></thead>
<tbody>
<tr><td>05</td><td>Coolify rollback smoke test</td><td><span class="badge b-pending">Pending</span></td><td>~1 h</td></tr>
<tr><td>14</td><td>ntfy.sh container + SLO breach alerts</td><td><span class="badge b-pending">Pending</span></td><td>~half day</td></tr>
<tr><td>16</td><td><code>parity_check.py</code> vs legacy DB</td><td><span class="badge b-pending">Pending</span></td><td>~half day</td></tr>
<tr><td>17</td><td>7-day soak + dispatcher sign-off</td><td><span class="badge b-pending">Pending</span></td><td>7 days calendar</td></tr>
</tbody>
</table>
<h3>P2 — next</h3>
<ul>
<li>History page (per-vehicle timeline)</li>
<li>Routes page (per-trip detail + KPIs)</li>
<li>Geofence ingest + entry/exit events</li>
<li>Trips/idling/stops projector (materialized — currently on-demand)</li>
</ul>
<h3>P3 — operations + cut-over</h3>
<ul>
<li>Push receiver cut-over (see <a href="#push-cutover">next section</a>)</li>
<li>Driver roster (<code>domain.drivers</code> + <code>domain.driver_assignments</code> with effective dates)</li>
<li>Device lifecycle admin UI</li>
<li>Alarm console</li>
<li>Legacy decommission</li>
</ul>
<h3>P4 — driver KPIs + cost allocation</h3>
<ul>
<li>Driver scorecards, shift attribution</li>
<li>Fuel ingest from existing WhatsApp microservice</li>
<li>Cost allocation by cost-centre</li>
<li>Executive summary dashboards</li>
</ul>
<!-- ────────────────────────────────────────────────────────── -->
<h2 id="push-cutover">Push-receiver cut-over plan (P3)</h2>
<p>Currently Tracksolid posts to a legacy endpoint at <code>https://tshook.rahamafresh.com/pushalarm</code> (a separate project that no longer benefits us). We poll every 30 s as a workaround. The cut-over moves us off polling and onto real-time push.</p>
<h3>Why it's worth doing</h3>
<table>
<thead><tr><th>Today (polling)</th><th>After (push)</th></tr></thead>
<tbody>
<tr><td>~30 s minimum lag from event to dashboard</td><td>~1-5 s (push is event-driven)</td></tr>
<tr><td>~1 fix/min/vehicle when stationary, ~2/min when moving</td><td>~5-15 s between fixes on motion; immediate for ACC/alarm events</td></tr>
<tr><td>Polyline cuts straight across roads (low-density fixes)</td><td>Polyline traces actual movement (high-density fixes)</td></tr>
<tr><td>Alarms (ACC ON/OFF, SOS, geofence) buried in the polled snapshot</td><td>Alarms arrive as their own typed events instantly</td></tr>
<tr><td>~4 Tracksolid API calls every 30 s = 11,520/day</td><td>Zero outbound API calls for the main fix stream</td></tr>
</tbody>
</table>
<h3>What's already in place</h3>
<ul>
<li>7 push receivers wired at <code>/push/jimi/{pushgps,pushalarm,pushhb,pushobd,pushfaultinfo,pushtripreport,pushevent}</code></li>
<li>Shared-token auth via <code>TRACKSOLID_PUSH_TOKEN</code> in form body (matches Tracksolid's documented push pattern)</li>
<li>Gateway contract honoured: form parse + token verify + INSERT <code>events.raw</code> + NOTIFY + return <code>{code:0, msg:"success"}</code>. Nothing else.</li>
<li>Parsers for 4 of 7 types: <code>pushgps</code>, <code>pushalarm</code>, <code>pushhb</code>, <code>pushevent</code> (the other 3 ingest to <code>events.raw</code> but aren't parsed yet — fine, can parse later)</li>
<li>Rate limit 1000/min per endpoint via <code>slowapi</code></li>
</ul>
<h3>What needs to happen (in order)</h3>
<ol>
<li><strong>Add dedup to <code>state.position_history</code></strong> — migration 21. Add a unique index on <code>(imei, occurred_at, source)</code> (or insert with ON CONFLICT DO NOTHING). Without this, push + polling overlap will duplicate fixes during the mirror window and inflate trip distances.</li>
<li><strong>Synthetic-payload smoke test</strong> — curl a realistic Tracksolid push body at each receiver, confirm <code>events.raw</code> row appears, parser produces an <code>events.parsed</code> row, projector updates <code>state.live_positions</code>. Validates the path end-to-end before depending on real traffic.</li>
<li><strong>Tracksolid console: add the new URL alongside the legacy URL</strong> — this is a vendor-portal step, done by whoever manages the Fireside Tracksolid account. The exact URL list to paste:
<pre>https://api.rahamafresh.com/push/jimi/pushgps
https://api.rahamafresh.com/push/jimi/pushalarm
https://api.rahamafresh.com/push/jimi/pushhb
https://api.rahamafresh.com/push/jimi/pushevent
https://api.rahamafresh.com/push/jimi/pushobd
https://api.rahamafresh.com/push/jimi/pushfaultinfo
https://api.rahamafresh.com/push/jimi/pushtripreport</pre>
Token: the value of <code>TRACKSOLID_PUSH_TOKEN</code> (set in Coolify env).
</li>
<li><strong>Mirror window (≥3 days)</strong> — both push and polling run. Compare daily counts per IMEI between push-derived and poll-derived fixes. Watch for: parser errors, auth failures, payload-shape surprises, dedup hit rate.</li>
<li><strong>Cut polling cadence</strong> — once mirror data shows push is delivering &gt;95% of fixes, drop main polling from 30 s → 10 min as a sparse safety net (or disable entirely). Keep the stale-IMEI sweep for offline-recovery.</li>
<li><strong>Tracksolid console: remove legacy URL</strong> — once dispatchers confirm the new dashboard is showing identical or better real-time data, drop the legacy URL from Tracksolid. Hot-standby on our side for 48 h as fallback.</li>
<li><strong>Decommission legacy receiver project</strong> — final step; the old project at <code>tshook.rahamafresh.com</code> can be shut down.</li>
</ol>
<h3>What needs decisions before starting</h3>
<ul>
<li><strong>Tracksolid admin access</strong> — who edits the push URL list, what's their lead time</li>
<li><strong>Mirror duration</strong> — 3 days (faster ship), 7 days per PRD (full week), or indefinite (no commit to a cut-over)</li>
<li><strong>Polling fate after push</strong> — disable entirely / keep at 5-min sparse / keep at 30 s belt-and-braces</li>
<li><strong>Auth scheme check</strong> — current impl uses form-body <code>token</code>; PRD specifies HMAC <code>X-Jimi-Signature</code>. Either the PRD spec is aspirational and Tracksolid only offers shared-token (likely), OR Tracksolid does offer HMAC and we should switch. Verify before the mirror starts.</li>
</ul>
<h3>Expected outcomes</h3>
<ul>
<li>Dashboard latency drops from ~30 s to ~5 s for live position updates</li>
<li>Trip polylines visually trace real routes (no more straight-line shortcuts)</li>
<li>Alarm and ACC events become first-class instead of derived from polling snapshots</li>
<li>Trip detection becomes more accurate (denser fix stream, fewer false <code>nofix_stop</code> boundaries)</li>
<li>Tracksolid API call budget freed up for the contract checker + ad-hoc queries</li>
</ul>
<!-- ────────────────────────────────────────────────────────── -->
<h2 id="decisions">Decisions log (significant ones)</h2>
<table>
<thead><tr><th>Date</th><th>Decision</th><th>Rationale</th></tr></thead>
<tbody>
<tr><td>2026-05-22</td><td>Greenfield rebuild, no legacy reuse</td><td>Branch divergence + race conditions in legacy made incremental patching unviable</td></tr>
<tr><td>2026-05-23</td><td>Three container roles from one image</td><td>Fate isolation without microservices overhead</td></tr>
<tr><td>2026-05-24</td><td>CSV roster import</td><td>To enrich devices with real plates/drivers/cost-centres</td></tr>
<tr><td>2026-05-25</td><td>CSV import fully rolled back</td><td>Suffix-merge regression dropped vehicle count 144 → 124; underlying merge problem must be solved before any retry</td></tr>
<tr><td>2026-05-26</td><td>5-min thresholds (work stop, no-fix stop)</td><td>Calibrated against 5 legacy report dumps; matches dispatcher mental model on clean data, cleaner on noisy data</td></tr>
<tr><td>2026-05-27</td><td>Migrate from manual <code>docker run</code> → Coolify Compose</td><td>Ad-hoc deploys were brittle; needed permanent infrastructure</td></tr>
<tr><td>2026-05-27</td><td>Polling 60 s → 30 s</td><td>Mitigation for sparse polyline artifacts pending push cut-over</td></tr>
<tr><td>2026-05-27</td><td>Remove SLO panel from top bar</td><td>User pref — backend still computes, UI just hides</td></tr>
<tr><td>2026-05-27</td><td>Light Carto Positron basemap + HQ POI</td><td>Higher contrast for cost-centre marker tints; reference landmark</td></tr>
<tr><td>2026-05-27</td><td>Per-trip colour coding in single-vehicle mode</td><td>Trip cards ↔ map polylines pair visually at a glance</td></tr>
</tbody>
</table>
<p class="subhead" style="margin-top:48px">— end —</p>
</main>
</div>
</body>
</html>