Commit fd0ff14
Fix/misc fixes (#71)
* fix(api): raise FSM-narrator window cap to 7d to match SPA default
The StateMachineDebuggerPage's RangePicker defaults to the '7d' preset
(see useRangeState defaultPresetId in StateMachineDebuggerPage.tsx,
explicitly chosen because 24h was misleading whenever the last
transition was older than a day). The Helix FSM narrator handler
POST /api/v1/ai/system/fsm/narrate capped the requested window at
24 hours and rejected anything wider with HTTP 400
'window (604800 s) exceeds cap 86400 s'.
The SPA surfaced that as 'Helix error: stream_http_400' in the Helix
narrator panel, so the default operator workflow — open page, click
'Ask Helix' — never produced a narration on a fresh install.
Raise the cap to 7 days so the default range works. The narrator's
production tools.FSMTraceSource (AIFSMTraceSource) returns a
deterministic empty envelope regardless of window size and a future
real reader is still bounded by transition density, so the wider cap
does not change the source's load profile.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* fix(web): tire pressure page — table sorted DESC, chart chronological, Last Updated from history
User reported tire pressure showing data only through May 9 even though
TpmsPressureFl/Fr/Rl/Rr signals were arriving for May 22 (visible on the
Live Signals page which reads the same signal_log table). The data was
present in the API response — three independent display bugs combined to
hide it:
1. **Chart x-axis was reversed.** `chartData` used
`[...history].reverse()` to flip the ASC API response, so the chart
plotted newest on the LEFT and oldest on the RIGHT. The cliff drop
at the rightmost edge — actually the OLDEST data with leading zeros
— looked like 'today's reading collapsed'. Removed the reverse so
the time axis reads oldest→newest left-to-right (canonical).
2. **History table sorted ASC.** DataTable rendered rows in array
order, so page 1 of 11 showed the first 25 rows = oldest = all May 9.
Users reading page 1 concluded the data stopped there. Wired
`useSortToggle('created_at', 'desc')` so the table defaults to
newest-first AND the previously-misleading `sortable: true` column
headers now actually function (DataTable does not sort internally —
it only emits onSort callbacks). The numeric tire columns use a
sortAccessor that returns the normalised Pa value so Badge-wrapped
cells sort by magnitude, not Badge label text.
3. **'Last Updated' always showed —.** The MetricCard read
`latest.created_at` but `/tire-pressure/latest` returns only field
values (no timestamp). Derive the freshness label from the newest
row in the history response instead. Documented as range-bound
(freshness of the visible window, not necessarily global).
Defensive sort: history is sorted by created_at ASC on the client into
`historyAsc` before being consumed by chart / table / lastUpdatedAt,
so a future backend reorder cannot silently break the chart axis.
Frontend-only change. The backend tire pressure handler and its
underlying StateReader.Timeline contract are unchanged — other handlers
(climate, motor, security, …) also return ASC and leave display-order
decisions to callers per the documented contract on timelineRowsToFlat.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* fix(api): re-wire firmware version writes to software_updates table
The Software Updates page was showing a stale CURRENT VERSION (the most
recent insert was the May-4 firmware) because nothing was writing to
the software_updates table anymore — even though Fleet Telemetry was
still emitting newer SoftwareUpdateVersion values (visible on
/signals/{id}/live).
Root cause is a two-step delete chain:
1. f31a173 'chore(phase-42a): delete dead legacy ingest code'
removed the legacy MQTT ingest path that called
(*TelemetryHandler).trackVehicleConfig per payload.
2. fa7440a 'lint: delete pre-existing dead helpers' then deleted
trackVehicleConfig itself because it had zero callers — but the
Phase-42 rewrite never re-wired the firmware-version write
through the new normalize.Pipeline / Router / writers stack.
Result: SoftwareUpdateRepo.InsertIfChanged is defined but has zero
production callers, the software_updates table stops receiving rows,
and the page's 'CURRENT VERSION' / 'Update Timeline' both go stale.
Fix:
- New AtomicsObserver in internal/tesla_pipeline:
SoftwareUpdateObserver scans each post-route atomics batch for a
SoftwareUpdateVersion (preferred) or Version atomic and forwards
it to SoftwareUpdateRepo.InsertIfChanged(..., 'installed') with a
2s timeout. Idempotency is owned by the existing
ON CONFLICT (vehicle_id, version) DO NOTHING from migration
000197, so every payload retries safely and only true version
transitions produce a new row.
- Registered as a second observer alongside SideEffectsObserver in
initPipelineSubscriber. No ordering dependency between the two —
they target independent stores.
- Startup backfill in initPipelineSubscriber iterates
SignalStore.VehicleIDs() (already hydrated from signal_log via
stateReader a few lines earlier) and InsertIfChanged's the
current firmware version per vehicle. This covers the
'version-already-changed-before-deploy' edge case where the
observer alone would have to wait for the next emission. The
backfill and the observer share PickFirmwareVersionFromSignals
so their precedence rule cannot drift.
Precedence: SoftwareUpdateVersion wins over Version. Live Signals
proves SoftwareUpdateVersion (Field 220, vehicle_state) is the field
being emitted today; Version (Field 68, config) is retained as a
fallback for installations that emit only the legacy field.
Failure semantics: per the AtomicsObserver contract, observer errors
MUST NOT fail the payload. Recorder errors are logged at WARN and
swallowed. The backfill loop logs WARN on per-vehicle errors and
continues — it never aborts boot.
Verified:
go build ./... ok
go test -race ./internal/tesla_pipeline/... ./internal/tesla/normalize/...
./internal/app/... ./internal/api/... ./internal/database/... ok
docker compose up -d --build teslasync-api healthy
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* fix(web): compact dashboard layouts to eliminate blank-space accumulation
Widgets in saved dashboards accumulated vertical gaps over time because
removing a widget left behind its y/h slot and react-grid-layout v2's
'findOrGenerateResponsiveLayout' early-returns saved breakpoint layouts
without applying the compactor. Each removed widget contributed one
permanent gap to the layout JSON, surviving every reload.
Add 'compactLayouts' helper using react-grid-layout's verticalCompactor
and apply it from 'reconcileLayouts' so every flow that produces a new
layout (load, addWidget, removeWidget, dashboard switch, import,
duplicate) ends up vertically compacted. Preserves user-resized w/h and
relative ordering — compactor only changes y.
Also wire duplicateDashboard, undo, and redo through reconcileLayouts so
those paths cannot reintroduce gaps (per rubber-duck critique).
Existing dashboards with accumulated gaps will self-heal on next load
because useDashboardLayouts -> hook state goes through reconcileLayouts.
Verified clean: npx tsc --noEmit, npx eslint on changed file, web
container rebuild healthy.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* fix(api): persist geocoded place names to SI canonical drive columns
Visited Locations page showed 0 places because every reverse-geocode
call silently no-op'd. resolveAndUpdateAddress wrote to columns
'start_address' / 'end_address' but Phase-48 slice-1 (commit f916031)
renamed those columns to 'start_place' / 'end_place' per the SI
canonical drives schema (migration 000185_drives_si).
buildPartialUpdate iterates the drivePartialAllowed allowlist and
filters unknown field keys out. The keys 'start_address' / 'end_address'
were never added to the allowlist after the rename, so every call
returned an empty UPDATE statement and PartialUpdate returned nil
without writing anything. end_place stayed NULL on every drive forever
and the visited_location_repo (which derives places via
'WHERE end_place IS NOT NULL AND end_place != ""') returned an empty
result set.
Fix: write to the SI canonical column names that drivePartialAllowed
already permits. Read path was unaffected — scanDrive selects
'start_place' / 'end_place' positionally into d.StartAddress /
d.EndAddress (Go field names are stale-but-stable JSON shape).
Existing drives self-heal on next api restart via BackfillAddresses,
which already iterates DriveRepo.FindMissingAddresses and re-runs the
geocoder; the loop was running but writing into a column that didn't
exist on the allowlist, so the rows kept resurfacing in
FindMissingAddresses forever — once the writer is fixed, the backfill
loop completes them.
Verified clean: go build ./..., go test -race -timeout 180s
./internal/api/... + ./internal/database/... pass.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* fix(api,web): restore /mileage/daily endpoint and rewire MileagePage to live shapes
The Mileage page (web/src/features/analytics/pages/MileagePage.tsx) was
404ing because Phase-42/0077 deleted the /mileage/* handler family and
Phase-43a/0004 only restored /mileage/{stats,monthly}. The page also
expected the pre-deletion camelCase MileageStats shape
({totalDistance, avgDaily, maxDaily, daysTracked}) which does not
match the restored snake_case MileageStatsResponse
({lifetime_km, last_30d_km, drive_count_lifetime, …}).
Backend:
* Add MileageRepo.Daily() with SI-canonical dailySelectSQL aggregating
drives per UTC calendar day (drive_count, total_km from distance_m,
end_odometer_km from end_odometer_m). Same NULL-distance skip + ASC
ordering as Monthly. SQL-shape regression test pins column names.
* Add MileageHandler.Daily handler + MileageDailyBucket /
MileageDailyResponse envelope. days param defaults 90, max 730.
end_odometer_km is *float64 so days with all-NULL odometer rows
surface JSON null instead of a fabricated zero.
* Register GET /mileage/daily on the same /mileage route block
(60/min admin rate limit) and add daysAgo() snap-to-midnight helper.
* New tests: TestMileage_Daily_{DaysClamp,BadVehicleID,UnknownVehicle_404,
EmptyVehicle_200,RepoError_500,GroupingPassThrough} +
TestDaysAgo_SnapsToMidnightUTC + TestDailySelectSQL_Shape.
Frontend:
* Replace stale MileageStats / MonthlyStat types with the actual
MileageStats / MonthlyMileageBucket{Response} / DailyMileageBucket{Response}
shapes returned by the live backend (snake_case, _km / _wh).
* Drop @deprecated banners on useMileageStats / useMonthlyMileage — the
endpoints have been live since Phase-43a/0004. useMonthlyMileage now
unwraps {vehicle_id, months} → MonthlyMileageBucket[].
* Add useDailyMileage(vehicleId, days=90) hook.
* MileagePage: stop calling request() directly. Derive 4 summary cards
from lifetime_km / last_30d_km / drive_count_lifetime; Odometer Over
Time chart from end_odometer_km; Daily Distance bar chart from
total_km; Monthly Summary table from /mileage/monthly buckets.
* MileageStatsWidget, MonthlyMileageWidget, StatisticsPage,
FleetComparePage: update field accesses to the live shapes
(lifetime_km, last_30d_km, year_month, total_km, drive_count).
Verified:
* go build + vet + go test -race ./internal/database/ ./internal/api/
all PASS (3.8s + 5.8s).
* npx tsc --noEmit clean.
* npx eslint clean on all changed frontend files.
* GET /mileage/daily?vehicle_id=abc → 400 (vehicle_id must be positive int)
* GET /mileage/daily?vehicle_id=1&days=999 → 400 (days exceeds maximum, max=730)
* Route is registered (OPTIONS returns 405 not 404).
* docker compose up -d --build teslasync-api web → both healthy.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* fix(api,web): populate Speed Profile hero gauges with SI-canonical aggregates
The /analytics/speed-profile handler returned only {distribution,
categories, points} but SpeedProfilePage rendered three RadialGauges
wired to data.avgSpeedKmh / peakSpeedKmh / optimalSpeedKmh — fields the
backend never emitted. The page also passed those (would-be) km/h values
through convertSpeedFromSI (which expects m/s), so even if backend had
emitted km/h the gauges would have over-converted by 3.6x.
Fix:
- Backend: compute and emit avg_speed_mps, peak_speed_mps,
optimal_speed_mps (SI canonical per Phase-48). Optimal = midpoint of
the speed bucket with the lowest mean Wh/km, mapped to m/s. Window-
aware (mirrors the existing hasRange branching).
- Frontend types (driving.ts + api/types.ts): rename SpeedProfileData
fields to avgSpeedMps / peakSpeedMps / optimalSpeedMps.
- SpeedProfilePage: read new fields; gauge max literals also moved to
m/s (55.56 m/s ~ 200 km/h, 69.44 m/s ~ 250 km/h) so the gauges no
longer underfill at 1/3.6 of their nominal range.
- SpeedProfileWidget: drop the broken km/h->mph workaround; pass m/s
straight through to convertSpeedFromSI.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* fix(web): correct Speed Profile bucket cards and efficiency formula
Two compounding display bugs visible after pushing the gauge fix
(bf480e9):
1. Bucket card 'Avg Speed' was double-converted. bucketEfficiency
stored the speed already converted to display units (mph or km/h),
then the card re-applied toSpeedDisplay = convertSpeedFromSI to it,
which treats the value as m/s and multiplies by 3.6 (km/h pref) or
2.237 (mph pref). A 7.5 mph drive was rendered as 16.82 mph and
placed in the '0-15' bucket label that no longer fit the displayed
value. Fix: store SI m/s in the accumulator (avgSpeedMps) and run
toSpeedDisplay only at render. Bucket-assignment loop keeps using
the display-unit value to match the backend's mph bucket labels.
2. getEfficiency used a battery-pct heuristic (battUsed * 0.75 kWh per
percent) that produces absurd Wh/mi when a drive has tiny distance
+ non-trivial battery delta. The scatter chart showed dots at
65,000-130,000 Wh/mi which crowded out the real cluster around
200-300 Wh/mi. Prefer the SI energyUsedWh field that the Drive
type already carries (Phase-42 SI canonical); fall back to the
battery-pct heuristic only when energyUsedWh is null.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* fix(api): show routes whose place names haven't been geocoded yet
Route Efficiency page rendered 0 routes / 0 trips / 'No data available'
because the SQL filter required start_place AND end_place to be non-null.
BackfillAddresses is rate-limited to 1 req/sec per Nominatim policy so
historical drives lag the geocoded set — every drive without a place
name was silently dropped from the aggregation even when full GPS
coordinates were present.
Fix the List + Detail queries to accept drives with either:
- geocoded place names (the original happy path), OR
- non-null start/end coordinates (new fallback)
When start_place / end_place is null/empty, synthesise a label from
the rounded lat/lng (3 decimals ~= 110 m precision) inside a CTE and
GROUP BY the same expression. Routes with real place names continue
to group exactly as before; un-geocoded routes now appear under a
'37.123, -122.456' style label instead of being filtered out.
Detail handler uses the same labelled CTE so click-through from a
coordinate-labelled route in the summary matches the underlying
drives.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* fix(api): wire MQTT PipelineSubscriber to streaming health recorder
Bug: MQTT Inspector page showed 0 vehicles / 0 signals / 0 batches
even when Connected ✓ and signal_log + drives were updating from live
fleet telemetry.
Root cause: TelemetryHandler.streamingState (read by /api/v1/telemetry
for the Inspector page) was only populated by the HTTP TelemetryIngest
spine. After the Phase-42 per-field MQTT cutover, telemetry flows
through mqtt.PipelineSubscriber → normalize.Pipeline.ProcessAtomics,
which never touched recordStreamingHealth, so the inspector silently
zeroed out for all production traffic.
Fix:
- Add mqtt.StreamingHealthRecorder interface (RecordStream callback)
plus a non-nil StreamingRecorder field on PipelineSubscriberConfig.
- PipelineSubscriber.handlePayload invokes RecordStream exactly once
per successfully dispatched batch — codec drops and pipeline errors
do NOT count, so the inspector only reflects signals that actually
persisted.
- TelemetryHandler.RecordStream adapts []codec.Atomic to the existing
recordStreamingHealth(vin, count, signals) contract, building a
compact one-key-per-field LastSignals snapshot.
- internal/app/new.go threads a.TelemetryHandler into the subscriber
config; compile-time guard in telemetry_handler.go pins the
interface implementation.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---------
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>1 parent b7235b7 commit fd0ff14
28 files changed
Lines changed: 1586 additions & 183 deletions
File tree
- internal
- api
- app
- database
- mqtt
- tesla_pipeline
- web/src
- api
- hooks
- features
- analytics/pages
- dashboard
- hooks
- widgets
- driving/pages
- vehicle-systems/pages
- types
Lines changed: 10 additions & 4 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
105 | 105 | | |
106 | 106 | | |
107 | 107 | | |
108 | | - | |
109 | | - | |
110 | | - | |
111 | | - | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
112 | 118 | | |
113 | 119 | | |
114 | 120 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
34 | 34 | | |
35 | 35 | | |
36 | 36 | | |
| 37 | + | |
37 | 38 | | |
38 | 39 | | |
39 | 40 | | |
| |||
62 | 63 | | |
63 | 64 | | |
64 | 65 | | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
65 | 75 | | |
66 | 76 | | |
67 | 77 | | |
| |||
266 | 276 | | |
267 | 277 | | |
268 | 278 | | |
| 279 | + | |
| 280 | + | |
| 281 | + | |
| 282 | + | |
| 283 | + | |
| 284 | + | |
| 285 | + | |
| 286 | + | |
| 287 | + | |
| 288 | + | |
| 289 | + | |
| 290 | + | |
| 291 | + | |
| 292 | + | |
| 293 | + | |
| 294 | + | |
| 295 | + | |
| 296 | + | |
| 297 | + | |
| 298 | + | |
| 299 | + | |
| 300 | + | |
| 301 | + | |
| 302 | + | |
| 303 | + | |
| 304 | + | |
| 305 | + | |
| 306 | + | |
| 307 | + | |
| 308 | + | |
| 309 | + | |
| 310 | + | |
| 311 | + | |
| 312 | + | |
| 313 | + | |
| 314 | + | |
| 315 | + | |
| 316 | + | |
| 317 | + | |
| 318 | + | |
| 319 | + | |
| 320 | + | |
| 321 | + | |
| 322 | + | |
| 323 | + | |
| 324 | + | |
| 325 | + | |
| 326 | + | |
| 327 | + | |
| 328 | + | |
| 329 | + | |
| 330 | + | |
| 331 | + | |
| 332 | + | |
| 333 | + | |
| 334 | + | |
| 335 | + | |
| 336 | + | |
| 337 | + | |
| 338 | + | |
| 339 | + | |
| 340 | + | |
| 341 | + | |
| 342 | + | |
| 343 | + | |
| 344 | + | |
| 345 | + | |
| 346 | + | |
| 347 | + | |
| 348 | + | |
| 349 | + | |
| 350 | + | |
| 351 | + | |
| 352 | + | |
| 353 | + | |
| 354 | + | |
| 355 | + | |
| 356 | + | |
| 357 | + | |
| 358 | + | |
| 359 | + | |
| 360 | + | |
| 361 | + | |
| 362 | + | |
| 363 | + | |
| 364 | + | |
| 365 | + | |
| 366 | + | |
| 367 | + | |
| 368 | + | |
| 369 | + | |
| 370 | + | |
| 371 | + | |
| 372 | + | |
| 373 | + | |
| 374 | + | |
| 375 | + | |
| 376 | + | |
| 377 | + | |
| 378 | + | |
| 379 | + | |
| 380 | + | |
| 381 | + | |
| 382 | + | |
| 383 | + | |
| 384 | + | |
| 385 | + | |
| 386 | + | |
| 387 | + | |
| 388 | + | |
| 389 | + | |
| 390 | + | |
| 391 | + | |
| 392 | + | |
269 | 393 | | |
270 | 394 | | |
271 | 395 | | |
| |||
291 | 415 | | |
292 | 416 | | |
293 | 417 | | |
| 418 | + | |
| 419 | + | |
| 420 | + | |
| 421 | + | |
| 422 | + | |
| 423 | + | |
| 424 | + | |
| 425 | + | |
| 426 | + | |
| 427 | + | |
| 428 | + | |
0 commit comments