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.
562 lines
34 KiB
HTML
562 lines
34 KiB
HTML
<!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 & 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 & 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 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>07–09, 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 (< 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 > 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 > 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 & 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 >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>
|