Plain-click on a vehicle marker: single-vehicle mode, full trip-card
list, click a card → animated playback (unchanged behaviour).
⌘/Ctrl/Shift-click: add/remove the vehicle from the selection. Each
selected vehicle's day routes are drawn on the map as a polyline in a
distinct colour from an 8-colour selection palette. Trip dock switches
to a compact per-vehicle row layout with ✕ remove buttons; the header
shows aggregate trip count + distance + drive / idle / stop minutes
summed across the selection.
Date change re-fetches every selected vehicle; CSV button downloads one
file per selected vehicle. Map auto-fits to the union of bounds.
Click a vehicle row in multi-mode → map flies to just that vehicle's
trips. Removing the last vehicle empties the dock; the X button closes
it entirely.
Internals: replaced singular `_tripState` with a `_selection` Map keyed
by vehicle_id. Single-trip animation layers still exist for the
single-mode trip-card playback; multi-mode uses per-vehicle line-only
layers (vroute-line-{id}) with no marker animation.
Traefik runs on the `coolify` shared network. Without the gateway also
being on it, Traefik logs "Could not find network 'coolify' for
container, defaulting to first available" and picks an IP on the
project-local network it can't reach → intermittent 504 Gateway Timeout
(30s) in the browser.
Hot-patched the running container via `docker network connect coolify`;
this commit makes the fix permanent so the next Coolify redeploy doesn't
reintroduce the regression.
Worker and cron don't need it — they don't serve external HTTP.
- Basemap switched from dark-matter to Positron — light grey, minimal,
higher contrast for the cost-centre coloured markers.
- HQ POI: red dot + soft halo + 'Fireside Group HQ' label at
-1.24089, 36.72880. Layered above vehicles so it stays visible when
vehicles park there.
- Flipped vehicle plate-label colours to dark text + white halo for
readability on the light map (was light-on-dark).
- Migration 20: collapse `Nairobi`/`nairobi` in domain.vehicles → 'nairobi'
- Remove the SLO panel from the top band (filter + tile rows stay)
- Offline vehicles render as solid grey instead of dim-cost-centre tint;
opacity now only differentiates moving (1.0) vs parked (0.75) vs
offline (0.55) so colour carries identity + state cleanly
Restructure:
- FLEET NOW tiles + SLOs + Filters in a horizontal top band; full-width map
- Trip panel moved to a bottom dock that slides up; trips render as a
horizontally-scrollable card strip instead of a vertical right-panel list
Multi-select filter widgets:
- cost_centre + assigned_city are now dropdowns with an "All …" toggle
and per-option checkboxes
- cost_centre options carry a colour swatch matching the marker tint —
the filter doubles as a live colour legend
- Server-side filter still applies when exactly one option is picked;
multi-selection within a widget is narrowed client-side via setFilter
so the existing serve.fn_live_view contract is unchanged
Cost-centre tint always visible:
- circle-color now uses cost_centre_color unconditionally
- operational_state is shown via opacity (moving=1.0 / parked=0.7 /
offline=0.35), keeping colour as a stable identity cue
applyClientFilter() is a new exported helper called by the page after
each refresh to narrow markers by multi-selection state.
`timescale_db` alias resolves. Without this the new app's PG pool
times out — DB only has the alias on its own project network, not on
`coolify` shared.
Three services (gateway/worker/cron) from one build, env-driven, no
explicit Traefik labels (Coolify generates them from the Domain
setting). Replaces the manual `docker run` containers that have been
running since the first VPS deploy.
PG function overloading is type-strict: integer→bigint isn't implicit. The
prior signature (integer, date) didn't match callers passing vehicle_id
directly. Also prepends a DROP for the integer-signature in case the
previous migration ran and left a now-orphan function around.
Click any vehicle on the map to open a 360px slide-in panel showing:
- reporting time (first ACC_ON of the day)
- day totals: trip count, distance, drive/idle/stop minutes
- per-trip rows with start/end/duration/distance/idling, click to
select; selected trip renders its polyline + animates a marker
along it over 10 seconds
- end-reason badge per trip (work stop, reporting silence, long gap,
day end) with colour-coded accent
- date picker (defaults to today EAT)
- CSV download button → /trips.csv?date=...
Map clicks query rendered features across circle/arrow/label layers and
take the topmost — single click handler, no per-layer duplicates. The
existing hover popup remains untouched.
Wraps #map in #map-container so the panel can absolute-position over
the right side without disturbing the existing left-aside grid layout.
authClient gets a getToken() helper so the CSV download path can attach
the Authorization header for a plain fetch (apiFetch returns JSON only).
Calibrated against a full-day legacy report for vehicle KDE 638J:
without this rule we collapsed 15 dispatcher-visible trips into 3
(the algorithm waited for explicit ACC_OFF + stationary fixes that never
came in the polled data — the device just stops reporting between trips).
New rule: if mid-trip and the next fix arrives >=5 min after the previous
one (but <=30 min, which is still long_gap), close the trip at the prior
fix with end_reason='nofix_stop'.
Validation:
638J full day: legacy 15 trips, mine 15 trips, perfect alignment
728K half day: legacy 33 noisy trips, mine 9 clean trips
(stop-and-go traffic still consolidates because fixes
keep coming, just slow/stationary — not silence)
Also commits scripts/simulate_trips_from_legacy.py which runs the same
state machine in Python against REPORTS/*.json dumps for offline tuning.
Migration 19: serve.fn_vehicle_trips(vehicle_id, date) — PL/pgSQL state
machine that walks state.position_history for one vehicle on one EAT day
and emits the trip breakdown. Rules:
- reporting_time = first ACC_ON of the day
- trip starts at ACC_ON (or first fix if already ACC_ON / moving)
- trip ends:
* ACC_OFF + stationary (<5 km/h) for >=5 min → end_reason 'work_stop'
* fix gap >30 min → end_reason 'long_gap'
* end of day's data → end_reason 'day_end'
- within a trip, ACC_ON + stationary >=5 min is logged as an idling
segment (no trip split — engine still on)
- distance only accumulates when speed >= 5 km/h (excludes GPS jitter)
- falls back to movement-only segmentation when acc_state is null
across the day (has_acc_data=false in the response)
Returns one jsonb document: vehicle, date, reporting_time, day totals
(distance, driving/idling/stopped/unknown minutes), data_quality flags,
trips[] with start/end/duration/distance/idling/end_reason/stops/path
where path is a GeoJSON LineString ready for the map.
New endpoints (read:fleet, rate-limited):
GET /api/views/vehicle/{id}/trips?date=YYYY-MM-DD JSON
GET /api/views/vehicle/{id}/trips.csv?date=YYYY-MM-DD one row per trip
Defaults date to today in EAT (UTC+3) regardless of host TZ.
Migration 18: ops.contract_check_log table — append-only log of probes
against the Tracksolid Pro endpoints we depend on.
New worker app/workers/contract_check.py — per run:
- jimi.oauth.token.get (token refresh succeeds)
- jimi.user.device.location.list per configured target (parse first item
with JimiPollFix)
- jimi.device.location.get with a sample IMEI from the list (parse first
item with JimiPollFix)
Each probe logs success or {error_class, error_detail, sample}; failures
are recorded, not raised.
slo_metrics now also computes contract_drift_days = days since the most-
recent successful probe of the laggard endpoint. With threshold 1d (from
mig 5), a single failed daily run flips the badge red within 24h.
cron entrypoint registers the check daily at 02:00 UTC plus once on
startup, gated on TRACKSOLID_APP_KEY + a configured target.
The CSV-based roster import (mig 15+16 and scripts/import_csv_roster.py)
merged vehicle rows that differed only by _Track / _CAM suffix, dropping
the active fleet count from 144 to 124. Reverting the whole thing.
Mig 17 in one transaction:
- Re-splits devices by parsed plate from device_name (same regex as
mig 14, preserving _Track as separate vehicle)
- Restores serve.fn_live_view to its v3 body (no d.driver_name/phone
refs that would break once the columns are gone)
- Drops the six CSV-only columns from domain.devices
- Deletes schema_migrations rows for the deleted 15/16
- Logs final counts via RAISE NOTICE
Apply on VPS: psql -f db/migrations/20260601000017_rollback_csv_import.sql