Skip to content

Refactor/filters - #64

Merged
atulmgupta merged 86 commits into
mainfrom
refactor/filters
May 14, 2026
Merged

atulmgupta merged 86 commits into
mainfrom
refactor/filters

Conversation

@atulmgupta

Copy link
Copy Markdown
Contributor

Description

Closes #

Type of Change

  • Bug fix (non-breaking change that fixes an issue)
  • New feature (non-breaking change that adds functionality)
  • Breaking change (fix or feature that would break existing functionality)
  • Documentation update
  • Infrastructure / CI change

Checklist

  • My code follows the project's style guidelines
  • I have performed a self-review of my code
  • I have added tests that prove my fix is effective or my feature works
  • New and existing tests pass locally
  • I have updated the documentation accordingly
  • My changes generate no new warnings

Screenshots (if applicable)

atulmgupta and others added 30 commits May 11, 2026 11:58
The existing date-filter UX has two problems:

1. The shared `<DateRangeFilter>` (used by 11 pages) renders two raw
   <input type=date> boxes + Apply button + 6 preset chips all inline.
   Wraps to 3 rows on phones, eats ~280px on desktop, and the chips/inputs
   feel disconnected. Modern equivalents (Stripe, Linear, Vercel) use a
   single trigger that opens a popover.

2. Four other pages bypass the shared component entirely with their own
   `TIME_RANGES` arrays, fragmenting the preset vocabulary across the app.

This commit ships the new infrastructure; consumer migration follows in
the next commit.

Components added:

* `<Popover>` (web/src/components/ui/Popover.tsx) β€” small primitive built
  on `createPortal`. Trigger-anchored positioning with viewport collision
  + auto side-flip, click-outside (pointerdown), Esc, focus-return on
  close. Intentionally NOT a focus trap β€” popovers should let users tab
  back to the trigger.

* `<RangePicker>` (web/src/components/forms/RangePicker.tsx) β€” single
  trigger with active-preset label + live date readout + day-count.
  Click opens the popover containing:
    - vertical preset list (left, ~180px on desktop; horizontal scroll on
      mobile)
    - 2-month react-day-picker calendar (right, single month on mobile)
    - footer: optional `[ ] Compare to previous period` + Cancel / Apply
  Behavior contract: preset clicks auto-apply and close; calendar
  selections stage internally and only commit on Apply. minDate clamps
  the "All time" preset to the user's first data point when supplied.

* `useRangeState` hook (web/src/hooks/useRangeState.ts) β€” page-level
  state: URL > localStorage > defaultPresetId precedence. URL keys
  default to `from` / `to` / `compare` but are configurable per page.
  localStorage restoration writes URL with `replace` (no history entry)
  and only happens when the URL is empty on mount. Comparison mode is
  opt-in; the previous-period window is computed inclusively
  (start = end_of_main - window_length, end = start_of_main - 1 day).

Library extensions:

* `lib/datePresets.ts` β€” added `resolveAllTimeStart(minDate?)` so the
  "All time" preset can clamp to the user's first data point rather
  than the hardcoded 2015-01-01 baseline.

* `i18n/en.json` β€” added `date.range.{cancel, trigger, compare,
  popoverLabel, summaryDays_one, summaryDays_other, ...}` keys.

* `index.css` β€” `.rdp-tesla` theme overrides to make react-day-picker
  match the dark glass surface tokens (accent colour, range-middle
  highlight, focus ring).

Deprecation:

* `<DateRangeFilter>` is marked deprecated in JSDoc but stays
  functional. The 11 current consumers will migrate explicitly in the
  follow-up commit so each page's `onApply` semantics (page resets,
  refetches, etc.) can be preserved or adjusted intentionally rather
  than silently rewired.

Dependencies:

* Adds react-day-picker ^8.10.1 (~30 KB gzipped). Chosen over
  hand-rolling a calendar because keyboard nav, RTL, locale,
  DST/month-boundary correctness, and screen-reader semantics are
  non-trivial; the saved engineering time + correctness is well worth
  the bundle cost for a cross-app filter.

Tests (38 passing):

* useRangeState β€” URL precedence, localStorage restoration, corrupt-
  storage tolerance, minDate clamping, preset id derivation, compare-
  prev computation, atomic setRange, custom URL keys (8 suites,
  18 tests).

* RangePicker β€” trigger label/readout, popover open/close, preset
  auto-apply (no Apply button), Apply disabled until staged dirty,
  Cancel discards, compare toggle hidden by default, Esc closes
  (11 tests).

* Popover β€” portal placement, Escape, click-outside, ignores
  inside/trigger pointerdowns (6 tests).

* DateRangeFilter existing tests still pass (3 tests) β€” back-compat
  preserved.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Replaces 11 inline <DateRangeFilter> usages and 4 page-local
TIME_RANGES/RANGE_OPTIONS arrays with the new <RangePicker> shipped in
5065dfc. Each page's existing onApply semantics (typically setPage(1)
for pagination reset) are preserved by calling them inside the new
onChange callback. Existing useUrlString('from'/'to') hooks are
preserved so URL contracts and SavedView definitions remain intact;
only the unused setStartDate/setEndDate destructure positions were
dropped.

Adds a presetsOnly prop to RangePicker that hides the calendar grid
and footer Apply/Cancel buttons. Used by EnergyFlowPage and
PowerFlowDashboardPage which back a trailing-window API
(?days=N / ?since=YYYY-MM-DD&until=YYYY-MM-DD with backend semantics
that only honor recent ranges) and where exposing a free calendar
would mislead users.

Rogue arrays removed:
* MediaPlayerPage         (24h/7d/15d/30d/All β€” drops 24h and 15d;
                           gains custom range, MTD, YTD)
* EnergyFlowPage          (24h/7d/30d β€” drops 24h, gains 90d/MTD/YTD,
                           presetsOnly)
* PowerFlowDashboardPage  (24h/7d/30d in <Select> β€” drops 24h, gains
                           yesterday/90d/MTD/YTD)
* TirePressurePage        (7d/30d/90d/All β€” gains MTD/YTD + custom
                           date selection)

DateRangeFilter consumers migrated:
* ChargingListPage, CostAnalysisPage, DrivesListPage, DriveScorePage,
  EnergyPage, EfficiencyPage, StatisticsPage, TripListPage,
  MyActivityPage, NotificationFilterBar, DriveAnalyticsSection

The deprecated <DateRangeFilter> component remains in place as a
no-op fallback for any extension/test that imports it; will be
deleted in a follow-up commit once we confirm zero callers.

Verification:
* npx tsc --noEmit clean
* 38 RangePicker/Popover/useRangeState tests pass
* Full vitest run: 3128/3136 pass; 8 failures are PRE-EXISTING in
  TripReplayMap (leaflet bounds mock) + useSignals.test (H3 housekeeping
  per plan) β€” none reference RangePicker or DateRangeFilter

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
… all pages

Promotes the date range filter from a body-row position to the
top-right header (PageContainer actions slot) on every page that
hosts a page-level RangePicker. This matches the Stripe/Linear
single-trigger header pattern and frees vertical space above the
content for the page's primary visualisation.

Pages where RangePicker moved from body to actions slot:
  * ChargingListPage     (was inside data-tour='charging-filters')
  * DriveScorePage       (was wrapped in own GlassPanel)
  * EfficiencyPage       (was inline FadeIn above hero gauges)
  * TripListPage         (was inline FadeIn above stats cards)
  * MyActivityPage       (was inside the activity GlassPanel header)
  * MediaPlayerPage      (was a chip-row above the Now Playing card)

Pages where RangePicker was already in actions slot (just gained
align='end' for right-anchored popover positioning):
  * CostAnalysisPage, EnergyPage, EnergyFlowPage, StatisticsPage

align='end' on every RangePicker ensures the popover (which is wider
than the trigger) anchors to the trigger's right edge so it never
overflows the right viewport boundary on the header bar.

Verification:
  * npx tsc --noEmit clean
  * 190/190 forms/popover/useRangeState tests pass

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
… right)

Replaces the implicit `no date filter'' on TimelinePage with the
canonical right-aligned RangePicker. Backend
GET /vehicle-states/timeline?vehicle_id=N&days=N (and the parallel
/vehicle-states/summary endpoint) only accepts a trailing ?days=N
window so the picker uses presetsOnly mode (calendar grid hidden) and
the page derives `days'' from the inclusive day-count of the
selected range. Range persists via localStorage key 'timeline.range'
and round-trips through ?from / ?to URL params via useRangeState.

Default preset: 7d. Available presets: today, yesterday, 7d, 30d,
90d, MTD, YTD.

Verification: tsc --noEmit clean.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Adds the unified <RangePicker> to the /locations page header (top-right via PageContainer.actions, align=end), matching the rollout across the rest of the app.

The backend /locations endpoint does not yet accept date params, so the picker filters client-side by last_visited. visit_count and total_duration_s remain LIFETIME aggregates -- the picker only narrows which places appear in the list (those last visited inside the window).

Default preset is 'all' so the page renders the same data as before by default; a non-default range narrows the list and re-derives the metric cards / charts since they all read from the filtered locations array.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ded windows)

Backend (internal/api/speed_profile_handler.go): /analytics/speed-profile now accepts standard ?start=YYYY-MM-DD&end=YYYY-MM-DD bounds via parseDateRange. When supplied, the bounds apply uniformly to all three sub-queries (distribution, categories, scatter points) so the picker controls every view consistently. When omitted, the handler returns the full historical dataset -- the previous hardcoded 'INTERVAL 30 days' / '90 days' fallbacks are gone.

Frontend: SpeedProfilePage gains a <RangePicker align=end> in PageContainer.actions (right side). useSpeedProfile now takes start/end and threads them into the URL + query key. The 'drives' array used for client-side per-bucket efficiency and the scatter cloud is filtered by startTs against the same window so all visuals stay in sync.

Default preset is 'all' so the page renders the full history by default; user-picked ranges narrow every view at once.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…dcoded windows)

Backend (internal/api/regen_handler.go): /analytics/regen now accepts standard ?start=YYYY-MM-DD&end=YYYY-MM-DD bounds via parseDateRange. When supplied, the bounds apply to all three sub-queries: per-drive list (was hardcoded 90 days), monthly summary (was hardcoded 12 months), AND the cagg_fleet_stats lifetime totals (scoped via the daily 'day' column). When omitted: full historical data, no trailing-window fallback.

Adds a new helpers.go nullableTime() utility so the same prepared statement expresses 'BETWEEN start AND end when supplied; full history when not' via '\::timestamptz IS NULL OR ... BETWEEN \ AND \'.

Frontend: RegenEfficiencyPage gains a <RangePicker align=end> in PageContainer.actions. useRegenEfficiency now takes start/end and threads them into the URL + query key. The drives feeding the client-side monthly trend chart and the recent-drives table are filtered client-side by startTs against the same window so all visuals stay in sync.

Default preset is 'all' so the page renders the full history by default.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…right)

Adds the unified <RangePicker align=end> to the /charging-curve page header. The /charging-sessions backend endpoint already accepts ?start=YYYY-MM-DD&end=YYYY-MM-DD via useChargingSessionsPaginated -- this is purely the frontend wiring.

Picker is rendered in BOTH the populated PageContainer.actions slot AND in the early-return empty state, so the user can widen the range from any state without getting stuck behind a 'No sessions' wall.

Default preset is 'all' so the page renders the same data as before by default. Range changes also clear the selectedSessionId since the picked session may fall outside the new window.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…der right)

Adds the unified <RangePicker align=end> to /charging-heatmap. The /charging-sessions backend endpoint already accepts ?start=YYYY-MM-DD&end=YYYY-MM-DD via useChargingSessionsPaginated -- pure frontend wiring.

Picker is rendered in BOTH the loading-state PageContainer.actions AND the populated PageContainer.actions slot so it remains visible during the initial query as well.

Default preset is 'all'.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
CommandHistoryPage: client-side filter on c.created_at, picker in
PageContainer.actions next to Commands link, range change resets page=1.

AlertsPage: scoped client-side filter applied before all downstream
derivations (tabFiltered, by-day, by-type, counts), picker in
PageContainer.actions before existing badges/freshness/saved-views,
range change resets alertPage=1.

Both use useRangeState({defaultPresetId:'all'}) β€” no hardcoded windows.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Backend: SoftwareUpdateRepo.GetByVehicle/GetAll now take start/end
time.Time; query gains '(\::timestamptz IS NULL OR created_at BETWEEN
\ AND \)' bound to satisfy 'no hardcoded windows' rule. Handler reads
parseDateRange(r) from ?start=&end=.

Frontend: SoftwareUpdatesPage adds useRangeState (persistKey
'software-updates.range', defaultPresetId 'all'), passes start/end via
URLSearchParams in queryKey + URL, picker rendered in PageContainer.actions
ahead of vehicle Select. Range change resets page=1.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Both Tesla charging pages (TeslaChargingHistoryPage,
TeslaChargingSessionsPage) gain RangePicker in PageContainer.actions.
Client-side filter on charge_start_datetime applied early so all
downstream views (summary, monthly chart, table, sort, export)
respect the selected window.

useRangeState({defaultPresetId:'all'}) β€” no hardcoded windows.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- RangePicker trigger collapses to one horizontal line with size-aware
  height (h-10 px-3 md, h-8 px-2.5 sm) so it lines up with adjacent
  Button/Select md (h-10) controls. Date span hides below sm. Full
  date+day-count text moved to the trigger title attribute.

- SecurityAccessPage gains a unified RangePicker in PageContainer.actions
  with vehicle-first ordering (Select then RangePicker). Client-side
  filter on e.createdAt scopes the on-page event list before all
  downstream derivations.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…24h window

Replaces the two legacy <UiInput type='datetime-local'> controls and the
defaultStart/defaultEnd useMemos (a hardcoded 24h trailing window) with
the unified <RangePicker> in PageContainer.actions. When no range is
chosen the backend query receives no start/end params and returns full
history (per the no-hardcoded-day-windows rule). Range change resets
page to 0 to keep the table consistent with the new window. The
localDateTimeToISO helper is retired in favor of inline conversion of
the picker's YYYY-MM-DD bounds to UTC ISO at the query call site.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
… pages

Promotes the vehicle <Select> ahead of the date <RangePicker> in the
actions slot so users always pick the higher-level scope (which
vehicle) before the time window that narrows it. Pages updated:

- TimelinePage
- RegenEfficiencyPage
- SpeedProfilePage
- LocationsPage
- SoftwareUpdatesPage

Other pages (Statistics, Energy, EnergyFlow, MediaPlayer, NotificationFilterBar)
already followed vehicle-first ordering and are untouched. Pages with
only a date filter (CommandHistory, AlertsPage, charging analytics
pages, EfficiencyPage, etc) are unaffected.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Across nine pages (SoftwareUpdates, SecurityAccess, MediaPlayer, Energy,
SpeedProfile, RegenEfficiency, Statistics, Timeline, Locations) replace
the legacy `useState/useUrlNumber` + `useVehicles` + first-vehicle
fallback pattern with a single `useSelectedVehicle()` call. Drop the
`vehicles.length > 1` guard so the picker is ALWAYS visible whenever
the fleet has at least one vehicle (it pre-selects the only one); only
hide it on a truly empty fleet.

useSelectedVehicle persists the selection in localStorage and bridges
URL > query > store > first-vehicle precedence so the choice survives
across pages and tabs. URL-bookmarkable pages (Timeline, Statistics,
Locations) dual-write the picker change to `?vehicle_id` so deep
links continue to work.

Side cleanups:
- MediaPlayerPage: drop the now-dead local Vehicle interface.
- LocationsPage: drop bare `useQuery(['vehicles'])` and dead Vehicle
  interface β€” use the canonical hook.
- SecurityAccessPage: replace the sibling `useQuery(['vehicles'])`
  piggy-back with `useVehicles()` directly (React Query dedupes by
  queryKey).

Validation: `npx tsc --noEmit` clean; RangePicker tests 11/11.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ed windows

Frontend:
- AnalyticsPage swaps the 7d/30d/90d/365d/All pill buttons for the
  unified `<RangePicker>` (preset list `7d, 30d, 90d, 1y, all`,
  persisted under `analytics.range`). Selection drives the new
  `useFleetAnalytics({ start, end })` shape.
- `useFleetAnalytics` accepts either the legacy positional
  `(days, start?)` (still used by DriveScoreWidget(7),
  FleetStatsWidget(30), FleetStatsBarWidget(30), StatisticsPage(30,
  startDate)) OR a new options object `{ start?, end?, days? }`.
  Querystring precedence matches the backend: start/end win over
  days; nothing supplied β†’ no params, full history.
- Remove dead `TIME_RANGES` const and `TimeRange` type from
  analytics constants and barrel.

Backend (`/analytics/fleet`):
- Parse optional `end=YYYY-MM-DD` (inclusive end-of-day).
- Drop the silent 30-day default β€” when no `start`/`end`/`days`
  is supplied we now return full history per the no-hardcoded-day-
  windows rule.
- `cagg_battery_daily` SQL conditionally appends `bucket >=` /
  `bucket <=` clauses only when the corresponding bound is set,
  avoiding the bug where passing `time.Time{}` would silently filter
  out every row.
- `period_days` now returns 0 (sentinel for "all time") when the
  cutoff is zero instead of the millions-of-days nonsense from
  `time.Since(time.Time{})`; when both bounds are set it computes
  inclusive day-count of the actual window.

Validation: `go build ./...` clean; analytics handler tests pass;
`npx tsc --noEmit` clean; RangePicker tests 11/11.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
… store

CommandHistoryPage now matches the rest of the app:

- Vehicle selector moves out of the in-page Filters panel and into the
  PageContainer.actions row, in vehicle-first order (vehicle β†’ range
  picker β†’ `Commands` link).
- Switches from a custom-styled `<ControlSelect>` to the canonical
  base styling so the trigger size/look matches Timeline / Statistics /
  Locations.
- Drops the `vehicleList.length > 1` guard β€” picker is always visible
  whenever the fleet has at least one vehicle (and pre-selects the only
  one).
- Replaces `useUrlString('vehicle_id') + first-vehicle fallback` with
  `useSelectedVehicle()` so the choice persists across pages via the
  shared localStorage store; the change handler dual-writes back to
  `?vehicle_id` (and resets `page` atomically via the existing
  useUrlBatch) so notification deep-links keep working.
- The Filters panel below now contains just the status TabNav and the
  search input.

Validation: `npx tsc --noEmit` clean.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…RangePicker

Previous state: both pages had a bespoke datetime-local From/To input pair
plus a custom hour-precision pill row (1h/6h/24h/7d/30d). I had briefly
introduced a sister <DateTimeRangePicker> component to wrap the same UX
in the canonical popover shell, but on review that fragments the date
picker surface β€” every other page in the app uses the canonical
<RangePicker>. UX consistency wins over preserving sub-day presets.

Both pages now use the same <RangePicker> as Drives / Charging /
Locations / Statistics / Analytics / etc., with day-precision presets
(today / yesterday / 7d / 30d / 90d / all). Range state lives in
useRangeState so each page persists its own window
(signal-log.range / signal-explorer.range) and supports URL deep-links.

The back-end /signals/{vid}/{sig}/history endpoint accepts ISO datetimes,
so the start (YYYY-MM-DD) is expanded to start-of-day and end is
expanded to end-of-day before the query, giving a closed inclusive
window.

Trade-off: hour-precision presets are gone. If a debug session needs
to scope to a specific hour, the user picks the day and gets the full
day; results are still capped at perPage * 10 so the table stays
responsive. If the absent sub-day precision is missed in practice, the
right answer is to extend RangePicker once (and let every page
benefit), not to fork a sister component.

Validation:
- npx tsc --noEmit clean
- vitest run src/components/forms src/features/telemetry β†’ 169/169
- audit script clean (3 prior orphan-allowlist hits unrelated)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…iewer

Previous: Signal Explorer had three vertical sections in the control
panel β€” Signals, Time Range (full row), and Per Page+Explore+Live
(separate row). Signal Log Viewer (committed previously) used a single
compact row for Time Range + Per Page + Query.

Now both telemetry pages share the same shape:
- Signals (full width)
- Single horizontal row: TimeRange picker (left) | Per Page +
  primary-action button + Live button + help (right-aligned)
- Live-mode hides only the time range; the right-side controls stay
  put so the layout doesn't shift when entering/exiting live mode.

This is purely a layout consolidation β€” no logic changes. Same
RangePicker, useRangeState, and per-page state.

Validation:
- npx tsc --noEmit clean
- vitest run src/features/telemetry β†’ 3/3 pass

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
….actions

Previous: vehicle selector lived in a separate Controls bar GlassPanel
below the page header, while the RangePicker was already in actions.
That split the two filter dimensions (vehicle + date) into two visually
disconnected places and contradicted the convention every other page
follows.

Now matches the established shape (CommandHistoryPage, etc.):
- actions row, in vehicle-first order:
    [Vehicle Select] [RangePicker] [Refresh from Tesla button]
- Removed the dedicated Controls bar GlassPanel
- 'Last synced' moved to a small inline note above the stats grid so
  the freshness signal is preserved without the panel chrome

The 'All Vehicles' option is preserved β€” this page is an account-level
billing view, not a single-vehicle context page, so the filter
genuinely supports a fleet-aggregate mode.

Validation:
- npx tsc --noEmit clean
- audit-violations: 0 in target file (3 unrelated orphan-allowlist
  pre-existing hits)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…t in form rows

Two issues, one root cause:

1. The slider TRACK was invisible in 4 pages β€” SmartChargePage,
   KioskSettingsModal (3x), and TripPlannerPage (2x) β€” because they
   used raw <input type="range"> (via the generic <Input> component)
   with bg-transparent on the dark theme. Only the cyan thumb
   showed, leaving users unable to see the track at all.

2. Even when the canonical <Slider> primitive was used, it visually
   misaligned with adjacent <Input>/<Select> form controls in the
   same grid row: different label style (uppercase tracking-wider
   muted vs. text-secondary sentence case) and a much shorter total
   height (~32px vs ~60px), so the track sat near the top of the
   cell while neighboring inputs filled the cell.

Fixes:
- Slider: label restyled to match Input/Select label
  ("text-sm font-medium text-[var(--text-secondary)]"); track wrapped
  in an h-9 (36px) flex-center container so total Slider height
  matches an md Input/Select. The value readout stays right-aligned
  on the label row but uses "text-xs text-muted tabular-nums".
- All 6 raw <input type="range"> callsites migrated to <Slider>:
  SmartChargePage, KioskSettingsModal x3, TripPlannerPage x2,
  ProjectedRangePage x2.
- TripPlannerPage: dropped the bespoke <div><label/><Select/></div>
  wrappers for Vehicle and Driving Speed in favor of <Select>'s
  built-in label prop, so all four cells in the form row use the
  canonical label style and same baseline.

Validation:
- npx tsc --noEmit clean
- vitest run src/components/ui/Slider -> 9/9 pass

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Sleep Efficiency page used a bespoke "30 days" Select dropdown plus a
locally-stated vehicle picker that was gated by `vehicles.length > 1`,
which broke the convention every other page now follows.

Changes:
- Replace local `useState(days)` with `useRangeState({ persistKey:
  'sleep-efficiency.range', defaultPresetId: '30d' })` + canonical
  `<RangePicker>`. Days count for the backend hook is derived
  inclusively from the selected window (`(end - start)/86_400_000 + 1`).
- Replace local `useState(selectedVehicle)` with the shared
  `useSelectedVehicle()` store so the picker is sticky across pages.
- Drop the `vehicles.length > 1` gate β€” vehicle picker now always
  renders when at least one vehicle exists, matching the
  vehicle-first canonical actions slot used everywhere else.
- Remove now-unused `DAYS_OPTIONS` import and `useState` import.

Validation: `tsc --noEmit` clean.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The Tire Pressure page kept the RangePicker tucked inside the
"Pressure History" GlassPanel (child level), so it only filtered that
one chart visually and was inconsistent with every other page where
filters live at the parent level alongside the vehicle picker.

Changes:
- Move <RangePicker> into PageContainer.actions and prepend the
  canonical vehicle <Select>, giving the canonical
  [Vehicle][RangePicker] vehicle-first ordering used app-wide.
- Source the vehicle list and setter from useSelectedVehicle()
  (already imported here for activeVehicleId) so the picker shares
  the sticky cross-page selection store.
- Remove the bespoke flex-justify-between header row inside
  "Pressure History" β€” only the icon+badge title remain, since the
  picker now lives at parent level and already drives the history
  query via { start, end } from useRangeState.
- Picker still always renders when vehicles.length > 0 (no
  >1 gate), matching the convention.

Validation: tsc --noEmit clean; audit (target file): 0 violations
across all 6 content checks.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The PageHeader's actions slot rendered children in a single
non-wrapping flex row (`flex items-center gap-2 sm:gap-3`). With the
canonical `[Vehicle Select][RangePicker][Refresh]` filter cluster,
combined width often exceeded a 360–400px mobile viewport β€” children
spilled past the right edge with no horizontal scroll, leaving the
RangePicker / refresh button completely off-screen and unreachable.

The fix is two-tier so it benefits every page automatically:

1. PageHeader.tsx β€” outer actions wrapper:
   `flex items-center gap-2 sm:gap-3`
   β†’  `flex flex-wrap items-center justify-end gap-2 sm:gap-3 min-w-0 max-w-full`

   `flex-wrap` lets sibling action groups (CopyLink + actions slot)
   drop to a second line when needed; `justify-end` keeps wrapped
   rows right-aligned (sm+); `min-w-0 max-w-full` prevents the slot
   from forcing the parent flex row into overflow.

2. All 26 page-level inner action wrappers β€” uniform pattern was
   `<div className="flex items-center gap-{N}">` directly under
   `actions={`. Swept to
   `<div className="flex flex-wrap items-center justify-end gap-{N}">`
   so individual filters (Vehicle, RangePicker, Refresh, etc.) wrap
   to multiple rows on narrow screens instead of overflowing.

Pages updated by sweep:
- admin/SecurityAccessPage
- analytics/TrueCostPage
- automations/AutomationsListPage
- battery/{Sleep,Vampire}*
- charging/{Charging{Curve,List},TeslaChargingHistory}*
- driving/{Drives,Regen,Speed}*
- maps/{Locations,MapOverview}*
- notifications/AlertsPage
- system/{Chatbot,CommandHistory,Commands,StateMachineDebugger}*
- telemetry/{MQTTInspector,SignalDiff}*
- trips/{TripList,TripReplay}*
- vehicle-systems/{Climate,SoftwareUpdates,TirePressure}*
- vehicles/VehicleListPage

Existing extra attributes on the wrapper div (e.g. `data-tour`) are
preserved by the regex sweep.

The RangePicker trigger already self-collapses on mobile (the
sublabel is `hidden sm:inline`), so wrapping plus the existing
`w-44` Vehicle Select keeps each row inside the viewport.

Validation:
- `tsc --noEmit` clean.
- `vitest run src/components/layout src/components/forms` β†’
  215/215 pass (includes 9 PageContainer tests, RangePicker tests).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The FSM Debugger had bespoke vehicle and time-range selectors locked
inside its own filter GlassPanel. They didn't share the global sticky
vehicle store (so navigating into the page wouldn't preserve the
header VehiclePicker selection), didn't use the canonical RangePicker
(so the time presets didn't match the rest of the app), and weren't
in PageContainer.actions where every other page now shows them.

Changes:
- Vehicle: replace local `useState` + `?vehicle=` URL param with the
  shared `useSelectedVehicle()` store. The picker now uses the global
  ?vehicle_id slot (Phase 40 / Prompt 16) and stays sticky across
  pages. The legacy `?vehicle` query param is stripped on first
  render so old permalinks don't leave stale data behind.
- Time range: replace the hours-Select dropdown (HOURS_OPTIONS) and
  `?range=` URL param with the canonical `<RangePicker>` driven by
  `useRangeState({ persistKey: 'fsm-debugger.range',
  defaultPresetId: '7d' })`. The hours count for the back-end hook
  is derived inclusively from the picked window
  (`(end-of-day - start-of-day) / 3_600_000`); empty range maps to
  `hours = 0` which the API treats as "all time". Range now persists
  in localStorage rather than the URL β€” this matches every other
  RangePicker page (signal-log, signal-explorer, sleep-efficiency,
  tire-pressure, charging-history, etc.).
- Move Vehicle <Select> + <RangePicker> into PageContainer.actions
  with vehicle-first ordering, alongside the existing "Live 10s" /
  "Share permalink" controls. The on-page filter bar shrinks from a
  4-column grid to a 2-column grid that holds only FSM Type + Per
  Page (genuinely page-specific filters that don't belong in the
  global slot).
- Update windowing test to wrap the page in <SelectedVehicleProvider>
  (now required by useSelectedVehicle) and extend the react-i18next
  mock with `i18n: { language: 'en' }` since RangePicker reads
  i18n.language for date formatting.
- Drop now-unused HOURS_OPTIONS and useVehicles imports.

Validation:
- `tsc --noEmit` clean.
- `vitest run src/features/system` β†’ 37/37 tests pass (incl. the
  3 windowing-reconciliation tests).
- audit-violations: target file 0 violations across all 6 content
  checks (3 reported violations are the global pre-existing noise
  in orphan-allowlist.ts and useLogStream.test.ts).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
… Efficiency

The FSM transitions handler and Sleep handler were both computing rolling-
from-now windows (NOW() - hours / NOW() - days), which silently returned
the wrong data for any RangePicker preset that doesn't end at "now":
yesterday, lastMonth, and any custom calendar pick all collapsed back to
"latest N hours/days" instead of the user's chosen historical window.

Make the canonical filter shape match Tire Pressure's reference impl:
explicit start/end (YYYY-MM-DD) parsed via parseDateRange takes precedence
over the legacy rolling param. The legacy param remains as a fallback so
dashboard widgets (FSMDistributionWidget, SleepEfficiencyWidget) and any
older permalinks keep working unchanged.

Backend
- internal/api/router.go (/fsm/transitions): try parseDateRange first;
  fall back to existing hours logic when start/end absent.
- internal/api/sleep_handler.go (/analytics/sleep): try parseDateRange
  first; fall back to existing days logic. SQL switched from
  `NOW() - make_interval(days => $2)` to bounded `ts > $2 AND ts <= $3`.
  period_days in the response now reflects the actual chosen span.

Frontend
- useFSMTransitions / useSleepEfficiency: optional trailing
  startDate/endDate params, included in the URL when present. Query
  keys extended so React Query refetches on window change.
- StateMachineDebuggerPage / SleepEfficiencyPage: pass start/end from
  useRangeState directly; legacy hours/days derivation kept only as
  fallback for older API builds.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…Notifications side-nav

Both /alerts and /notifications previously hosted three internal tabs that
duplicated bell logic, history queries, and preference panels. Settings
also embedded Browser Notifications, Webhook channels, and Quiet Hours
sub-panels. This change collapses everything into a single "Notifications"
sidebar group whose items are real top-level routes β€” no more internal
TabNav switching, no more duplicated /alerts vs /notifications bell logic,
and the Settings notifications block becomes pure link cards.

New routes:
  /notifications/inbox        Promoted Inbox (was /notifications?tab=inbox)
  /notifications/archived     Promoted Archived (was /notifications?tab=archived)
  /notifications/alerts       Promoted Alerts cards+stats (was /alerts?tab=alerts)
  /notifications/channels     Promoted Channels (was /notifications?tab=channels)
  /notifications/webhooks     Promoted Webhook channels (was /settings#webhooks)
  /notifications/browser      Promoted Browser Notifications (was /settings#notifications)
  /notifications/quiet-hours  Promoted Quiet Hours / DND (was /settings#quiet-hours)
  /notifications/rules        Alias of /alert-rules under new group
  /notifications/studio       Alias of /alert-studio under new group

Smart query-aware backward-compat redirects map every legacy URL,
including ?tab=history, ?tab=preferences, ?tab=channels, etc., to the
correct new top-level route while forwarding remaining query params.

Component extraction:
- InboxBody.tsx β€” full extraction of NotificationsPage Inbox/Archived
  body (~770 lines), now reused by InboxPage and ArchivedPage via
  archived prop.
- AlertCard.tsx β€” promoted from inline AlertsPage component.
- AlertsListPage.tsx β€” full extraction of AlertsPage Alerts tab
  (cards + stats + gauges + charts + ack dialog + timeline modal).
  SavedViewMenu pinned to route="/alerts" so existing user-saved view
  definitions continue to apply.
- 7 thin page wrappers (InboxPage, ArchivedPage, ChannelsPage,
  WebhooksPage, BrowserNotificationsPage, QuietHoursPage,
  AlertsListPage).
- 4 redirect components (LegacyAlertsRedirect, LegacyNotificationsRedirect,
  LegacyAlertRulesRedirect, LegacyAlertStudioRedirect).

Sweeps to honor the new map:
- App.tsx route entries
- Layout.tsx sidebar group rename + 10 new items + badge condition +
  mobile bell + searchKeywords + navI18nKeys
- routePrefetch.ts, routeMeta.ts, routeRegistry.ts (regenerated)
- useKeyboardShortcuts.ts ('n' shortcut)
- NotificationBellPopover.tsx + tests (3 navigations)
- commandRegistry.ts, draftIndex.ts + test, RecentActivityFeed.tsx,
  sw.ts (push fallback), DashboardPage Link, AlertRulesPage internal
  links, alertsTour.ts, checklist.ts, DraftRestorePrompt.test.tsx
- SettingsPage.tsx β€” removed three sections, added "Notifications moved"
  link card with sub-links to the three new pages.
- searchIndex.ts β€” bulk-replaced 14 hashed hrefs.
- en.json β€” added 9 new nav.notifications* labels.
- lazyRoutes.list.ts + url-state-adoption.test.ts β€” updated.

Deleted:
- AlertsPage.tsx
- NotificationsPage.tsx
- NotificationsPage.test.tsx

Verified: 998/998 tests pass, tsc clean, audit shows no new violations
(13 raw-HTML hits in AlertRulesPage/AlertStudioPage are pre-existing),
docker rebuild OK, all 9 new + 4 legacy routes return 200 in local stack.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…coped pages

Per the rule "fleet can have N vehicles, so every vehicle-scoped UI must
expose the global vehicle filter", introduce a shared <VehicleSelect>
form component and adopt it on every page that operates on a vehicle.

New shared component
--------------------
- web/src/components/forms/VehicleSelect.tsx
  - Wraps <Select> and internally uses useSelectedVehicle() so consumer
    pages don't have to thread store wiring.
  - Renders nothing for empty fleet; renders the picker for >=1 vehicle
    (matches the established sidebar/header convention from
    checkpoint 095, "always show selector when >=1 vehicle").
  - Props: ariaLabel, className, id, withIcon, data-testid (default
    'vehicle-select').
- web/src/components/forms/__tests__/VehicleSelect.test.tsx
  6 tests: empty fleet, single vehicle, multi-vehicle list with
  persisted selection, change-event store persistence, VIN fallback
  label, custom aria-label.
- web/src/components/forms/index.ts
  Export VehicleSelect + VehicleSelectProps from the barrel.

Pages gaining a per-page Vehicle picker (14)
--------------------------------------------
charging:        ChargingListPage, CostAnalysisPage, ChargingHeatmapPage
battery:         BatteryHealthPage, BatteryDegradationPage,
                 ProjectedRangePage, BatteryCellsPage
driving:         DrivesListPage, DriveScorePage, EfficiencyPage
maps:            MapOverviewPage, NavigationRoutePage
analytics:       MileagePage
vehicle-systems: ClimateControlPage

Pages where inline {vehicles.length > 0 && <Select.../>} blocks were
replaced by the shared component (8)
----------------------------------------------------------------------
battery:         SleepEfficiencyPage, EnergyPage
driving:         SpeedProfilePage, RegenEfficiencyPage
admin:           SecurityAccessPage
vehicle-systems: TirePressurePage, SoftwareUpdatesPage, MediaPlayerPage
- Dropped now-unused Select imports, setVehicleId / vehicles destructures,
  and dead vehicleOptions consts.

Canonical actions order is [VehicleSelect][RangePicker][...other],
matching the [Vehicle][RangePicker] convention from checkpoint 095.

URL-sync pages intentionally untouched
--------------------------------------
TimelinePage, StatisticsPage, LocationsPage, CommandHistoryPage and
StateMachineDebuggerPage keep their custom inline pickers because they
need page-specific URL+store dual-sync semantics for bookmarkable
?vehicle_id deep links. AlertStudioPage uses VehicleMultiSelect
(different API). A future enhancement may add an opt-in `urlSync` mode
to <VehicleSelect> so those pages can also adopt it.

Verification
------------
- npx tsc --noEmit  -> clean
- vitest run on touched feature folders + components/forms ->
  37 files, 377/377 tests pass (incl. 6 new VehicleSelect tests)
- Audit: no NEW violations introduced (pre-existing baseline only)
- Docker rebuild + curl smoke: all 28 affected routes return 200
  (/charging, /charging/cost-analysis, /charging/heatmap, /battery,
  /battery/health, /battery/degradation, /battery/cells,
  /battery/projected-range, /battery/sleep-efficiency, /battery/energy,
  /maps/locations, /maps, /maps/navigation-route, /drives,
  /drives/score, /efficiency, /regen, /speed-profile,
  /admin/security-access, /system/fsm-debugger, /system/command-history,
  /vehicle-systems/tire-pressure, /vehicle-systems/software-updates,
  /vehicle-systems/media-player, /vehicle-systems/climate-control,
  /analytics/mileage, /analytics/timeline, /analytics/statistics)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Followup to c9da1f9. Pages with their own local-state vehicle pickers
were not honoring the global vehicle filter β€” picking a vehicle on
/efficiency did not carry over to /route-efficiency, /driving-dynamics,
etc. This swaps each page's local useState wiring for useSelectedVehicle
and replaces the inline <Select> with the shared <VehicleSelect>.

Pages converted (8)
-------------------
analytics:       TrueCostPage              (/tco)
driving:         RouteEfficiencyPage       (/route-efficiency)
driving:         DrivingDynamicsPage       (/driving-dynamics)
driving:         DrivetrainHealthPage      (/drivetrain-health)
                 β€” drops the "All Vehicles" sentinel; per the global-
                 vehicle rule each page is scoped to a single vehicle.
driving:         TripPlannerPage           (/trip-planner)
                 β€” moves the picker out of the form into PageContainer
                 actions and uses useSelectedVehicle().vehicle for the
                 currentVehicle / battery_level lookup.
maps:            TemperatureImpactPage     (/temperature-impact)
vehicle-systems: MaintenancePage           (/maintenance)
                 β€” also drops the in-component useQuery({queryKey:
                 ['vehicles']}) and the now-unused local Vehicle
                 interface.
vehicle-systems: GuardModePage             (/guard-mode)
                 β€” uses useSelectedVehicle().vehicle for activeVehicle.

Intentionally NOT touched
-------------------------
- maps/GeofencesPage: the `selectedVehicleId` there is a form-internal
  state for the modal "use vehicle position to create geofence" feature
  (not a page-level filter). Geofences themselves are account-scoped.
- trips/TripListPage: uses useUrlNumber('vehicle_id', 0) for ?vehicle_id
  deep-link semantics; same exclusion pattern as Timeline / Statistics
  / Locations / CommandHistory / StateMachineDebugger.
- admin/RedisSignalViewerPage: admin diagnostic with a multi-pane
  vehicle switcher and side-bar UX; out of scope for the user-facing
  global filter rollout.

Verification
------------
- npx tsc --noEmit -> clean
- vitest run components/forms + analytics + driving + maps +
  vehicle-systems -> 19 files, 215/215 tests pass
- audit-violations diff scan: 0 NEW violations introduced
- Docker rebuild + curl smoke: all 8 affected routes (/efficiency,
  /route-efficiency, /driving-dynamics, /drivetrain-health,
  /maintenance, /guard-mode, /temperature-impact, /trip-planner, /tco)
  + sanity routes /charging /battery /drives -> all 200

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
atulmgupta and others added 22 commits May 11, 2026 22:58
… hero+chips

Three small layout fixes that close the wasted-space gap on /system-status
on wide screens:

1. Title + body share the same column
   PageContainer now receives className="max-w-5xl mx-auto" so its
   built-in title row is wrapped in the same column as the body. The
   inner max-w-3xl wrapper is dropped so we don't double-constrain.
   Removes the ~400px upper-left dead zone where the title floated at
   the content edge while the body cards were centered.

2. Bumped column from max-w-3xl (768px) to max-w-5xl (1024px)
   Phase 1 spec said "max-width 800px on desktop, centered". 768px
   left ~450px gutters on each side at 1920px-wide. 1024px keeps the
   single-column operator dashboard intent but uses the available
   space; mobile/tablet are unaffected because md: breakpoints don't
   change.

3. Tightened StatusHero density
   - icon container 24x24 -> 14x14 (-40px height)
   - icon glyph 12x12 -> 7x7
   - panel padding p-6 md:p-8 -> p-4 md:p-5
   - heading text-2xl md:text-3xl -> text-xl md:text-2xl
   - inter-element gap reduced (gap-5/gap-8 -> gap-4/gap-6)
   Hero shrinks from ~210px tall to ~110px tall without losing legibility.

4. Tightened StickyChipBar pills
   - row padding py-2 -> py-1.5
   - inter-pill gap gap-2 -> gap-1.5
   - pill min-h 36px -> 32px, py-1.5 -> py-1
   Chip bar shrinks from ~70px to ~50px tall.

No behavioural changes; all 102 status + system tests pass.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…rors

/admin was ~90 percent duplicated by /system-status and existing dedicated pages (api-keys, db-health, backup, tesla-account, etc.). Removed the page entirely and relocated its two unique surfaces:

- Frontend Errors panel -> FrontendErrorsCard mounted inside the Recent errors accordion on /system-status (uses useWebErrorsSummary).

- Audit Log -> dedicated /notifications/audit page with search + filter chips + paginated DataTable (uses useAuditLogs).

App-wide cleanup: removed Admin lazy import + route (now redirects to /system-status), Admin sidebar entry, m keyboard shortcut, routePrefetch + routeRegistry + lazyRoutes.list entries; updated search keyword aliases. Repointed BackgroundWorkersCard footer (Open Admin link removed) and SystemStatusPage maintenance ActionItem CTA to in-page anchor. Updated BackgroundWorkersCard test.

Deleted: web/src/features/admin/pages/AdminPage.tsx, web/src/features/admin/components/MaintenanceModePanel.tsx (only used by AdminPage; ScheduledMaintenanceCard on /system-status replaces its functionality).

Verification: tsc clean; vitest system+notifications+status+lazyRoutes pass except the pre-existing lazyRoutes.smoke parity check (110 vs 124 β€” pre-existed; net delta of these changes is 0). docker compose up -d --build web -> /system-status, /notifications/audit return 200; /admin redirects via SPA Navigate. AuditLogPage chunk built; AdminPage chunk no longer built.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The 48px progress ring on each ChargingSessionCard had an empty interior with the end-SOC % rendered as a small label below the ring β€” wasted space and the ring read like an abstract decoration.

Bumped the ring to 56px and moved the end SOC into the ring center as the primary label. Below the ring, when start and end differ, render a tiny 'start to end' transition (e.g. 70 to 93) β€” that's information that previously only existed implicitly in the +X% gain badge.

Extended ProgressRing with optional centerLabel + centerSubLabel props (overlay positioned absolutely inside the SVG, sized proportionally to the ring). Backward-compatible: existing callers that only pass label keep their below-ring layout untouched.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ing/charging

The status badge in the Security & Status panel had a hand-rolled ternary that only recognized the literal strings 'online' and 'asleep' from /vehicles/{id}/state. Every other state (driving, charging, asleep variants, anything stale) silently fell through to 'offline'.

Most visible failure mode: a vehicle actively driving and charging β€” clearly live, with security + charging telemetry streaming in β€” was tagged 'Offline'.

Replaced the inline ternary with a useMemo that:

1. Trusts twinState.isCharging / isDriving (already merged from all three live sources by buildTwinState) before falling back.

2. Uses the canonical deriveVehicleStatus() helper from @/api/types so the FSM state list is the single source of truth.

3. Treats the vehicle as 'online' (not 'offline') when /vehicles/{id}/state is empty/stale but security or charging streams are flowing β€” that's strictly more accurate, and matches the live? flag returned by useVehicleState.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Remove the in-app Changelog page and references, and switch changelog UI to link to the project's GitHub release notes. Update ChangelogModal and UpdateAvailableCallout to open external GitHub releases (new tab, noopener/noreferrer). Adjust tests to assert the external link.

Large sidebar/layout refactor: rewrite nav sections, labels and ordering, introduce new SECTION_ICON_STYLES (icons, gradients, accents), update navSearchKeywords, improve active/expanded styling and accessibility, and change default expanded section to Home. Many nav item names and destinations were renamed/reorganized.

List pages (Drives, Charging) updated to use batched URL updates (setRangeBatch) for filters/sorting/pagination, remove some direct URL setters, and streamline search/sort handlers. Minor docs/comment update in ReleaseNotes. Delete web/src/features/system/pages/ChangelogPage.tsx.
Phase-42 SI canonicalisation (migration 000185) renamed signal_log
columns: `signal` -> `field`, `created_at` -> `ts`, and split
`value_num` into typed `float_value` / `int_value`. Five backend
handlers were still issuing queries against the legacy column names,
producing recurring `column "value_num" does not exist` errors in
postgres logs every few minutes.

Migration rules applied:
- value_num         -> COALESCE(float_value, int_value::float8)
- value_num IS NOT NULL -> (float_value IS NOT NULL OR int_value IS NOT NULL)
- value_str         -> str_value
- value_bool        -> bool_value
- signal (column)   -> field
- created_at (sl)   -> ts

Files migrated:
- anomaly_handler.go             (4 SQL blocks: z-score CTE+outer,
                                  signals-checked count, range-violation,
                                  3-subquery trend)
- charging_optimizer_handler.go  (3 LATERAL joins for lat/lon/temp;
                                  COALESCE aliased as `value` for
                                  outer-SELECT readability)
- battery_degradation_handler.go (temp-exposure AVG/COUNT)
- drive_handler_listing.go       (VehicleSpeed history SELECT)

Also fixed a pre-existing GROUP BY bug in range_projection_handler.go:
the original `SELECT AVG(...) FROM drives WHERE ... ORDER BY started_at
DESC LIMIT 30` mixed an aggregate with a non-grouped ORDER BY of a base
column. Wrapped the row-selection in a `WITH recent AS (... ORDER BY
started_at DESC LIMIT 30) SELECT AVG(...) FROM recent` CTE so the
aggregate is computed over the most-recent 30 drives as intended.
Updated the covenant comment from "fix is out of scope" to describe the
fix.

Verification:
- go build ./...   clean
- go vet ./...     clean
- All 4 endpoints return HTTP 200 against the live DB:
    /api/v1/analytics/anomalies?vehicle_id=1
    /api/v1/analytics/charging-optimizer?vehicle_id=1
    /api/v1/analytics/battery-degradation?vehicle_id=1
    /api/v1/analytics/range-projection?vehicle_id=1
- Postgres logs since teslasync-api restart: 0 `value_num` errors,
  0 `signal does not exist` errors, 0 GROUP BY errors.

Note: `value_num` remains a valid column on `settings`, `alert_rules`,
`automation_steps` and `vehicle_settings`; queries against those
tables are unchanged.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…primitives

End-to-end redesign of the Drive History page with a sticky page header,
KPI overview, time-series chart, collections, density-aware list rows,
and date-grouped sections. The new building blocks are pulled out as
shared primitives in components/ui, components/data-display,
components/forms, components/feedback, components/layout and
components/charts so the same patterns can be reused across history /
list pages (drives, charging sessions, alerts).

New reusable components
- data-display: HistoryListRow, KpiOverviewCard, ComparisonHeader,
  DateGroupedList, ScoreBadge, RouteDisplay, BatteryDelta
- forms:       PillFilterBar, SortControl, DensityToggle, ListExportMenu
- feedback:    InlineCallout, EmptyStateThreshold
- layout:      PageHeaderSticky (collapses page header to a single line
               with vehicle + range + collection + result count + jump-to-top
               on scroll)
- charts:      MetricSwitcherChart (single chart, multiple metric series
               toggled via segmented control)
- ui:          Checkbox

New shared lib
- drivesAggregation:   period bucketing + KPI rollups + period-over-period
                       comparison (with explicit prior-period support and
                       graceful "no baseline" rendering)
- chargingAggregation: same shape applied to charging sessions
- bucketing:           shared date-bucket helpers (day / week / month
                       adaptive granularity)
- scoreScale:          A-F drive score scale + colour mapping
- searchQuery:         tiny query-syntax parser (e.g. score:D, in:Apr,
                       free-text address) used by the new list search box
- dateFormat:          relative-day helper ("3 days ago"), friendly date
                       headers, day-bucket grouping that respects local TZ
- numberFormat:        shared compact number / Wh-mi / cost helpers used
                       by the new KPI tiles

Page-level rewrites
- DrivesListPage: full redesign β€” sticky page header, KPI overview with
  period-over-period deltas, time-series chart, collections row, list
  controls (filter pills + sort + density + export), date-grouped rows
  with score badge / route / battery delta / efficiency / cost, inline
  anomaly badges, bulk-action bar on selection, mobile-friendly layout.
  Also: timezone-stable day grouping, search-syntax support, sort
  direction indicator, and removal of the redundant date-header / row
  duplication.
- ChargingListPage / ChargingSessionCard: adopt the new list primitives
  (date-grouped rows, KPI overview, sticky header) for visual + interaction
  parity with Drives.
- AlertsListPage: adopt KpiOverviewCard + InlineCallout for consistency.

Sidebar regrouping (Layout.tsx)
- Climate Control and Media Player moved out of "Controls" into a new
  "Cabin" section (with Icons.cabin / Armchair).
- Drives entry deduplicated β€” the canonical "DRIVING > Drives" item is
  the highlighted one when on /drives; the "Recently Used" panel no
  longer mirrors the active item.

i18n (en.json)
- Updated drives search placeholder to advertise the new query syntax.
- Added priorPeriod / noPriorData strings for the comparison header.

Misc
- .gitignore: ignore REDESIGN-NOTES.md (working notes for this redesign).

All new components and lib modules ship with co-located unit tests.
TypeScript clean (npx tsc --noEmit).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The dashboard widgets are placed inside a CSS-grid that lets users size
each widget to 1-3 columns; on narrow viewports they collapse to one
column. Until now widget content was sized off viewport breakpoints,
which meant a 1-column widget on a wide screen got the same desktop
layout as a 3-column widget and looked broken (overflow, label clipping,
two-row stat grids).

This pass moves the widget primitives to container queries so each
widget chooses its own dense / comfortable layout based on its rendered
width inside the dashboard grid - independent of the viewport.

Build infra
- web/package.json + lockfile: add @tailwindcss/container-queries
- web/tailwind.config.js: register the plugin

Widget primitives (web/src/features/dashboard/widgets/shared)
- WidgetChartSummary, WidgetComparisonCard, WidgetDetailCard,
  WidgetGaugeHero, WidgetStatGrid, WidgetStatusGrid: switch from
  viewport breakpoints (sm:, md:) to container queries (@sm:, @md:)
  and add a wrapping @container so each instance queries its own
  width. Compact / dense fallbacks for sub-300px widgets so tiles,
  gauges and stat grids stay readable on phones and on 1-col placement.

Widgets (web/src/features/dashboard/widgets)
- ChargingOptimizerWidget, CommandQuickActionsWidget,
  CostBreakdownWidget, SafetyHistoryWidget, VehicleHeroCardWidget:
  consume the new container-query primitives; tighten internal padding
  and stat layout for narrow widths.
- WidgetShell: add the @container wrapper so children can use @-prefixed
  variants without each widget repeating the boilerplate.
- DashboardGrid: minor layout adjustments so the grid itself keeps
  working as a container-query parent.

TypeScript clean (npx tsc --noEmit).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ches

Adds typed prefix filtering so the command palette can be scoped to a
single category from the input. Typing the prefix at the start of the
query restricts results to commands of that kind:

  >  - commands only
  /  - pages / navigation only
  @  - vehicles only (and excludes the active vehicle)
  ?  - help / shortcuts only
  #  - themes only

Without a prefix the palette behaves exactly as before (mixed results).

New
- web/src/lib/palettePrefix.ts: tiny pure parser that splits a raw
  query into { prefix, kindFilter, query } so callers can both filter
  the result list and re-render the input chip.
- web/src/lib/__tests__/palettePrefix.test.ts: parser tests
  (each prefix, no-prefix, prefix-only, mid-string '>' is not a prefix,
  whitespace handling, escaping).

Updated
- web/src/components/ui/CommandPalette.tsx: parses the input via
  palettePrefix, narrows the scored item list to the matching kind,
  shows a small prefix-chip in the search row, and updates the empty
  state to reflect the active scope.
- web/src/components/ui/__tests__/CommandPalette.test.tsx: covers
  the new prefix-filtered behaviour for each kind.
- web/src/components/ui/index.ts: re-export adjustment.

TypeScript clean (npx tsc --noEmit).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Only 6 of the 16 infra dashboards had `links` defined, so 10 of them
(auth-deep-dive, critical-flows, db-tesla-breakdown, fleet-telemetry,
mqtt-pipeline-traces, observability-self-health, service-graph,
service-red, slo-burn, trace-explorer) had no Prev / Next nav at all.
The 6 that were linked formed a small ring among themselves, skipping
the other 10 entirely.

Re-thread all 16 into one alphabetical ring (matching the existing
ring's structure: β—€ Prev / ⚑ TeslaSync dropdown / Next β–Ά) so every
dashboard has reciprocal Prev and Next links, and the ⚑ TeslaSync
dropdown still lists every teslasync-tagged dashboard.

Done with a surgical script (scripts/fix_grafana_infra_links.py) that
either replaces the existing `links` block or inserts a new one as
the final top-level key β€” without reformatting any of the surrounding
JSON, so the diff is just the link block per file.

Verification:
- All 16 files parse as valid JSON.
- Each file has exactly one `links` field.
- prev/next is reciprocal across the ring (A.next = B iff B.prev = A).
- Grafana picked up the changes via file provisioning; spot-checked
  via the HTTP API that previously-unlinked dashboards (auth-deep-dive,
  slo-burn) now expose the new links.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Phase-42 SI canonicalisation (migration 000185) made signal_log store
canonical SI units: pressure in Pa, distance in m, speed in m/s, but the
legacy convert_pressure / convert_distance / convert_speed functions
(migrations 000018, 000046, 000151) still assumed user-friendly inputs
(PSI, miles, mph). Camp B Grafana panels passing raw signal_log values
through these functions produced wildly wrong outputs (e.g. tire pressure
312500 `PSI`).

This migration adds three new helpers that take SI inputs and return the
user's preferred unit:

- convert_pressure_pa(val_pa float8, target text default null) -> float8
- convert_distance_m(val_m float8, target text default null)   -> float8
- convert_speed_mps(val_mps float8, target text default null)  -> float8

When `target` is NULL (the typical Grafana caller) each function reads
`settings.unit_of_pressure` / `unit_of_length` / `unit_of_speed`
respectively and converts. When `target` is supplied (e.g. backend
calls that need a fixed unit) it overrides the setting.

This pattern keeps the math in one place β€” single source of truth β€” and
lets every Grafana dashboard simply wrap its raw signal_log values in
`convert_*_<si>()` to get user-preferred display units automatically.

Conversion factors:
  Pa  -> PSI:  / 6894.757
  Pa  -> kPa:  / 1000
  Pa  -> bar:  / 100000
  m   -> mi:   / 1609.344
  m   -> km:   / 1000
  m/s -> mph:  * 2.2369362920544
  m/s -> kph:  * 3.6

The `down` migration is intentionally a no-op since these are
additive helpers and dropping them mid-flight would break dashboards.

Verification:
- Applied to live DB (schema_migrations.version = 199).
- `SELECT convert_pressure_pa(312500.0)` returns 45.32 with
  setting=psi, 312.5 with setting=kpa, 3.125 with setting=bar.
- `SELECT convert_distance_m(291764.15)` returns 181.29 with mi,
  291.76 with km.
- `SELECT convert_speed_mps(33.528)` returns 75 with mph, 120.7 with kph.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The Helm chart's `grafana-dashboards-configmap` only ships the 8 SLO
dashboards (`helm/teslasync/files/grafana/dashboards/*.json`); the 60+
system+infra dashboards under `grafana/dashboards/{system,infra}/` live
in the production grafana-data PVC and were originally uploaded
out-of-band, drifting from the repo over time.

This script provides a one-command idempotent push from the repo's
dashboard JSON to a remote Grafana instance via the HTTP API:

  python scripts/sync_grafana_dashboards.py
    --url https://grafana.cyphers.app
    --token glsa_xxxxx          # service-account or admin API token
    --dry-run                   # preview without writing
    --only battery-health,charging-curve   # filter by basename

Behaviour:
- Walks `grafana/dashboards/{system,infra}/*.json` and POSTs each to
  `/api/dashboards/db` with `overwrite=true`, `folderUid='teslasync'`
  (auto-creates the folder if missing).
- Strips `id` so each dashboard is upserted by `uid` (idempotent).
- Supports both `Authorization: Bearer <token>` (service accounts /
  API tokens) and HTTP Basic for the legacy admin login.
- Reports created / updated / unchanged / failed counts per file.

This unblocks the user being able to push the recent batch of dashboard
fixes (Phase-42 SI canonicalisation, ADR-002 panel revivals, hardcoded
unit labels, temperature filter) to grafana.cyphers.app without manual
file uploads or Helm changes.

Note: The Helm chart still does not ship system+infra dashboards. A
proper structural fix would split the ConfigMap (the combined size
~1MB approaches the K8s ConfigMap limit) and mount it via the grafana
deployment. Deferred for a separate change; this script is the
operational stop-gap.

Verification:
- Tested locally against `http://localhost:3001` with `allowUiUpdates: true`
  temporarily enabled in the file-provisioning config.
- `--dry-run` lists dashboards and target operations without writing.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…single-source-of-truth

Comprehensive sweep across 35 system dashboards to fix three classes of
display drift introduced by Phase-42 SI canonicalisation (signal_log
columns/units changed) and ADR-002 (typed snapshot tables retired
without rewiring downstream panels).

Changes are layered: every change is idempotent, every script
re-runnable, and every fix keeps the math in the DB function and the
unit preference in the `settings` table β€” so changing a user's
preferred unit in one place reactively updates every panel.

## Layer 1 β€” SI canonical convert sweep (28 dashboards)

After 000185 store signal_log values are: pressure in Pa, distance in
m, speed in m/s. Camp B panels (those passing raw signal_log values to
`convert_pressure` / `convert_distance` / `convert_speed`) produced
wildly wrong outputs because the legacy convert functions assumed
user-friendly inputs (PSI, miles, mph). Symptom: Tire Pressure panel
reading 312500 PSI (the raw Pa value, mistaken for PSI by the converter).

scripts/fix_grafana_unit_conversions.py renames the SI-input variants
across all detected Camp B sites:
  convert_pressure(<raw>, ...) -> convert_pressure_pa(<raw>, ...)
  convert_distance(<raw>, ...) -> convert_distance_m(<raw>, ...)
  convert_speed(<raw>, ...)    -> convert_speed_mps(<raw>, ...)

Camp B detected by: `\bfloat_value\b`, `\bint_value::float8\b`, or a
known signal name (TirePressure*, EstBatteryRange, VehicleSpeed, etc.).
Camp A panels (those that pre-convert in SQL like
`convert_distance(distance_m / 1609.344)`) are left alone β€” they were
correct before and remain correct.

The sweep also strips dead user-facing template variables (Distance,
Temperature, Pressure dropdowns) that were never wired to the panel
queries β€” replaced by hidden vars in Layer 3 below.

## Layer 2 β€” ADR-002 panel revivals (2 dashboards)

ADR-002 retired typed snapshot tables but a number of panels were
left as `SELECT NOW() AS time, NULL AS value` stubs with explanatory
comments and never rewired to the live signal_log stream.

scripts/revive_vehicle_intelligence_panels.py β€” repoints 5 panels in
vehicle-intelligence.json to signal_log (Vehicle Config, Software
Updates, Safety Settings, Navigation, User Preferences) and repurposes
the Location at Home/Work pair into a Top Destinations panel using
`drives.end_place`.

scripts/revive_battery_cells_panels.py β€” repurposes 3 `(Unavailable)`
panels in battery-cells.json to use the available pack/brick/module
signals: Cell Voltage Distribution -> Brick Voltage Spread Over Time,
Cell Temperature Heatmap -> Module Temperature Range Over Time, Cell
Readings -> Pack & Brick Snapshot. Switches the bar-chart that was
crashing with TypeError to a timeseries.

## Layer 3 β€” Unit-of-measure single source of truth (10 dashboards)

The user can pick units in /settings (UI) or by direct SQL on the
`settings` table (Grafana-only users). Several panels still hardcoded
`(km)` / `(mi)` axis labels, `lengthkm` / `lengthmi` field-config
units, `Β°C` SQL aliases, and `unit:celsius` / `unit:watt` field
overrides β€” so the user's choice did not propagate to the display.

Pattern adopted: a hidden Grafana template variable per unit
  unit_length:   SELECT COALESCE((SELECT value_text FROM settings
                  WHERE key='unit_of_length'), 'mi')
  unit_temp:     same for unit_of_temp
(hide=2, refresh=on dashboard load) so Grafana resolves the user's
current preference into `` / `` for SQL
interpolation.

scripts/fix_grafana_hardcoded_units.py (6 dashboards) β€” adds the
unit_length variable, replaces `(km)` / `(mi)` axis labels with
`()` and `unit:lengthkm` / `unit:lengthmi` field
configs with `unit:none`.

scripts/fix_grafana_temperature_units.py (4 dashboards) β€” same pattern
for temperature: adds unit_temp, wraps raw temperature reads with
`convert_temp(...)` (so the math respects the user's preferred unit),
replaces hardcoded `Β°C` SQL aliases with `()`, replaces
`unit:celsius` / `unit:fahrenheit` field configs with `unit:none`,
fixes dead `byName` transformation overrides that referenced `(Β°C)`
suffixes the SQL aliases no longer carried. Pack & Brick Snapshot in
battery-cells got special treatment via a CASE statement so only
ModuleTemp* signals get convert_temp while voltage/current/resistance
keep their native units, plus a synthetic Unit column.

## Layer 4 β€” Power unit mismatch (1 dashboard)

charging-curve.json :: Charge Rate vs SoC Curve had a byName field
override on `Power (kW)` set to `unit:watt`. Tesla Fleet API streams
ACChargingPower / DCChargingPower in kW directly (DB max 11.5 = exact
Mobile Connector kW max), so Grafana was rendering 11.5 (kW) as
`11.5 W` on a 0-100 W axis β€” making real charging data look like a
single dot at the bottom (read by users as `missing data`).

Fixed by changing `unit:watt` -> `unit:kwatt` so Grafana renders
`11.5 kW` on a properly-scaled axis. Audited all 35 dashboards for
similar mismatches (other `(kW)` columns either pre-convert
`peak_power_w / 1000.0` or use kW signals with no wrong override).

## Verification

For each layer:
- Camp B SI sweep: `SELECT convert_pressure_pa(312500.0)` returns
  45.32 (PSI) with setting=psi. Panel re-rendered cleanly.
- Vehicle Intelligence: all 5 revived panels now return real rows via
  `/api/ds/query`; Top Destinations correctly groups by drives.end_place.
- Battery Cells: bar-chart crash gone (timeseries no longer triggers
  the TypeError); 3 panels render real pack/brick/module data.
- Hardcoded unit labels: `UPDATE settings SET value_text='km'`
  flips display from `Distance (mi)` to `Distance (km)` reactively.
- Temperature: setting=F -> Max Module Temp = 30.5; setting=C -> -0.83.
  setting=mi -> Est. Battery Range = 181.29 mi; setting=km -> 291.76 km.
  Tested via Grafana `/api/ds/query` against live local DB.
- Power: charging-curve dashboard provisioned; override now `unit=kwatt`;
  local session 2 ACChargingPower returns 1.2 kW as expected.

All five sweep scripts are idempotent (re-running on already-fixed
dashboards reports 0 changes). All 35 modified dashboards parse as
valid JSON. Local Grafana provisioned cleanly after each restart.

## Production note

grafana.cyphers.app is a K8s deployment that has never received any
of these dashboards via Helm β€” it was uploaded out-of-band and has
drifted. To propagate the fixes:

  GRAFANA_URL=https://grafana.cyphers.app \
  GRAFANA_TOKEN=glsa_xxxxx \
    python scripts/sync_grafana_dashboards.py

(sync script committed separately).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The settings API can return `locale: ''` when the column has never
been written. Several call sites used `settings.locale ?? 'en-US'`,
but `??` does NOT catch empty strings, so the empty string flowed
through to `new Intl.NumberFormat('')` / `Intl.DateTimeFormat('')`
which throw `RangeError: Invalid language tag: `.

The most user-visible failure: SmartChargePage renders `<UnitInput
unit=""energy"" .../>` on mount. `UnitInput` calls `formatForUnit`
(`lib/unitInput.ts`), which built `new Intl.NumberFormat(locale, ...)`
with the empty string and crashed the page via the error boundary
with `Something went wrong / Invalid language tag:`. Other pages
that mount `<DateTime in=""..."">` were on the same crash path.

Single source of truth:

- New `lib/locale.ts` exports `resolveLocale(locale)` β€” returns
  `en-US` for null / undefined / empty / whitespace, otherwise the
  caller's tag verbatim. Pure, dependency-free, easy to unit test.

Defense in depth applied at every Intl-bound site that previously
relied on `??`:

- `lib/unitInput.ts` β€” `formatForUnit` and `parseForUnit` now
  resolve the locale before passing it to `Intl.NumberFormat`.
- `lib/dateFormat.ts` β€” `intlLocale` returns `undefined` for
  empty/whitespace so all date helpers (`formatDate`, `formatTime`,
  `formatDateTime` etc.) degrade to the host locale instead of
  throwing. `getFormatter` applies the same coercion to its own
  `Intl.DateTimeFormat` cache key + constructor.
- `hooks/useSettings.ts` β€” normalises `s.locale` once, before
  the value is exposed to downstream consumers via `settings`,
  so even callers that read the raw object never see `''`.
- `components/FormatterPrefsBridge.tsx` β€” the global formatter
  bridge uses `resolveLocale` when computing what to push into
  `setGlobalLocale`.
- `components/data-display/format/DateTime.tsx` β€”
  `DateTimeWithTz` resolves the locale before forwarding it as
  a `FormatOptions` override.

`hooks/useUnits.ts` already had `deriveLocale` (empty-safe), so
`unitPrefs.locale` consumers like `VehicleHeroCard` were already
safe and are left untouched. `lib/currencyFormat.ts` already had its
own `normaliseLocale` and is also untouched.

Tests:

- `lib/__tests__/locale.test.ts` (new) β€” 6 tests covering the helper
  across valid tags, empty string, whitespace, null, undefined, and a
  guard verifying `Intl.{Number,DateTime}Format` accept the result.
- `lib/__tests__/unitInput.test.ts` β€” 2 regression tests asserting
  `formatForUnit(75, 'energy', s({ locale: '' }))` no longer throws
  and returns `'75'`; same for `parseForUnit`.

Verified end-to-end against the running stack: `tsc --noEmit` clean,
`vite build` succeeds, full `vitest` lib + hooks suite passes
(1497/1497), and a Playwright probe of `/smart-charge` and
`/api-logs` against the deployed bundle reports zero pageerrors and
no error boundary text in the DOM.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The `submitExportJob` endpoint description in
`docs/public/openapi.yaml` was an unquoted plain scalar containing
`: ` (colon-space) β€” specifically `...SI canonical filenames and
columns: teslasync-drives-v2.csv, ...`. In YAML 1.x, `: ` inside
a plain scalar terminates the scalar and tries to start a new mapping
key, so `js-yaml` rejected the file with
`bad indentation of a mapping entry (4263:141)`.

Effect: the React API Playground page (`/api-playground`), which
fetches the spec via `GET /api/v1/system/openapi` and parses it
client-side with `js-yaml.load()`, rendered an inline error banner
instead of the endpoint catalog. The Go backend reads the same file
once at startup via `loadOpenAPISpec` and was holding the broken
text in memory.

Wrapped the offending description in double quotes β€” the single
narrowest fix that lets the colons through as content. The string
contains no embedded double quotes, so no escaping was needed.

Verified:

- `js-yaml.load` against the modified file succeeds, recovering
  all 143 paths with the description preserved (333 chars).
- API container rebuilt + restarted; `/api/v1/system/openapi`
  now serves YAML that parses cleanly.
- Playwright probe of `/api-playground` reports 0 pageerrors and
  no `bad indentation` text in the DOM.

Proactive sweep: grepped the spec for any other unquoted
`description|summary|title:` values containing a stray `: ` β€”
only the one occurrence was found.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The FSM Debugger / State Debugger page on production was missing
today's transitions for any user not on UTC: the FE sent the
RangePicker calendar dates as `start=YYYY-MM-DD&end=YYYY-MM-DD` and
`parseDateRange` parsed both as UTC midnight / UTC end-of-day, so a
PST user's `Last 7 days` filter ran from
`2026-05-06T00:00:00Z` .. `2026-05-12T23:59:59Z` and silently
dropped today's evening drives that landed at next-day UTC
(`2026-05-13T01:01–02:54Z`). Verified directly against the prod
fsm_transitions table: 25 rows in the buggy UTC window vs 35 rows in
the correct PDT window β€” the 10 missing rows were today's drives.

Fix follows the principal-architect recommendation: centralise the
calendar-day β†’ instant conversion rather than patching each of the
~25 hooks that currently send YYYY-MM-DD.

  * web/src/lib/dateRange.ts (new): canonical helper
    `calendarRangeToInstants({ startDate, endDate, timezone })`
    returns the half-open `[startInstant, endInstantExclusive)`
    window. Uses an Intl.DateTimeFormat round-trip so spring-forward
    and fall-back days resolve cleanly. 11 unit tests cover PST/PDT,
    UTC, IST (+5:30), DST boundaries, month rollovers, and the exact
    PST-evening regression.

  * web/src/hooks/useRangeState.ts: now also exposes `startInstant`,
    `endInstantExclusive` and the resolved `timezone` alongside
    the existing calendar-day strings. Pages must pass an explicit
    `timezone` (vehicle tz for vehicle-centric pages, browser tz
    otherwise) β€” defaulting silently was what hid the bug. Existing
    18 useRangeState tests still pass.

  * web/src/api/hooks/useFSM.ts: `useFSMTransitions` now accepts the
    instants directly and URL-encodes them. Query key includes them
    so a tz change invalidates the cache.

  * web/src/features/system/pages/StateMachineDebuggerPage.tsx:
    feeds `useTimezone('vehicle')` into `useRangeState` and
    forwards the new instants to `useFSMTransitions`.

  * internal/api/helpers.go `parseDateRange`: now accepts either an
    RFC 3339 instant (preferred β€” used verbatim, with a 1Β΅s subtract
    on the end so callers' existing `ts BETWEEN \ AND \` SQL
    treats the boundary as exclusive matching the FE half-open
    contract) or the legacy `YYYY-MM-DD` form (UTC midnight /
    end-of-day, unchanged for backward compat). 4 new tests cover
    the RFC 3339 path + the PST-evening regression.

The remaining ~24 handlers/hooks that still send YYYY-MM-DD are
correctly working today for their UTC-leaning callers (audit, fixed
reports, drives/charging list pages where the user-visible window is
day-granular). They will be migrated incrementally as the same
symptom surfaces or as part of a follow-up sweep β€” the BE accepts
both formats so the migration is non-breaking.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The Phase-42 typed /api/v1/signals/{vid}/{name}/history endpoint
returns {ts, kind, value} rows, but the three workspace pages
(SignalsWorkspacePage, SignalLogViewerPage, SignalExplorerPage)
were still mapping legacy {created_at, value_num/str/bool}
columns from the response, producing:
  - "Invalid Date" timestamps in the chart axis & history table
  - dash-only values in every row
  - bogus "string" type badge

Add a single boundary adapter alongside SignalLogEntry in
SignalQueryControls.tsx (οΏ½daptSignalHistoryPoint /
οΏ½daptSignalHistoryResp) that projects the typed
{ts, kind, value} envelope into the legacy shape consumed by
SignalHistoryTable, SignalChartPanel, and SignalStatsPanel.

Also corrects the stale SignalHistoryResp interface in
οΏ½pi/types.ts to match the BE handler in
internal/api/signal_handler.go (vehicle_id / signal /
expected_kind / from / to / count / data: SignalHistoryPoint[]).

ValueKind handling:
  - Bool   -> value_bool (false preserved, not coerced to null)
  - String -> value_str
  - Enum   -> value_str (Tesla streams enum label as string)
  - Time   -> value_str (ISO instant)
  - Int*/Float/Double -> value_num (NaN/Infinity guarded -> null)
  - null   -> all-nulls

Tests: 14 new adapter tests + 17 telemetry/component tests pass;
tsc + vite build clean.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
On the xs breakpoint each widget is a single full-width column anyway,
so RGL's fixed h x ROW_HEIGHT row sizing is the wrong tool: it pinned
each widget to its desktop-sized height (e.g. vehicle-hero h=9 -> 720
px), leaving hundreds of pixels of empty space below the actual widget
content. Phones rendered an elongated blank-space page.

Render the same widget JSX inside a vanilla flex column so each
widget's intrinsic content height drives the row height, with a floor
(min-h-[12rem]) for chart and map widgets whose ResponsiveContainer /
map canvases need a definite parent height. The wrapper is a flex
column so descendants relying on h-full resolve via flex stretch.

Also seed useContainerWidth with window.innerWidth so the very first
render already picks the correct breakpoint on mobile devices,
avoiding the lg -> xs remount that flickered every chart on load.

Mobile reorder is preserved by reading the saved xs layout y/x when
present and falling back to insertion order otherwise.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…014)

Restores per-rule custom notification message templates and adds an
`include_title` toggle so users can write body-only notifications for
transports that render a separate title (Discord, Slack, Telegram,
ntfy, generic webhooks). Email Subject, WebPush, and Pushover always
keep their canonical title regardless of the toggle.

Backend
- migrations/000200: adds alert_rules.msg_template (text NULL) and
  alert_rules.include_title (bool NOT NULL default true).
- internal/alertmsg: new leaf package owning template formatting,
  placeholder catalog, embedded preset library (presets.json), and
  the BuildContext / RenderBody / RenderTitle pipeline. Substitution
  regex matches {{ key }} with optional whitespace; canonical title
  is always persisted, include_title is transport-only.
- internal/alertmsg.Placeholders is now op-aware and the single source
  of truth for "what {{key}} substitutes for this rule's shape":
  Threshold for single-comparand ops, Min/Max for between/outside
  only, SignalName for signal kind, MetricID/Window/Threshold for
  computed_metric.
- New endpoints: GET /alerts/message-presets, GET
  /alerts/message-placeholders, POST /alerts/message-preview.
- Alert handler DTOs / repo / serializer / rule engine / telemetry
  evaluator / notification sender / notification-worker all thread
  msg_template + include_title end-to-end. Stored template length is
  capped at 1024 runes (rejects oversize writes at the API layer).

Frontend
- AlertMessageEditor (forwardRef): include-title checkbox, multi-line
  textarea with {{-trigger autocomplete popover (keyboard nav,
  group-by, click-to-insert), "Pick a preset" modal with tag chips
  and a card grid, and a 150 ms-debounced live preview pane that
  POSTs to /alerts/message-preview.
- Op-validity preset filter: hides presets whose template references
  placeholders the current op doesn't populate (e.g. {{Min}}/{{Max}}
  preset is hidden for `<` rules). Tag chips re-derive from the
  filtered set; stale tag selection auto-clears. While the
  placeholders query is loading or the rule has no op yet (skeleton
  "New Rule"), all presets are shown to avoid a flash of empty state.
- New TanStack Query hooks for the three helper endpoints.
- AlertStudioPage integration: EditorState extended, freshEditor /
  ruleToEditor / templateToEditor / buildSavePayload / handleTest all
  thread the new fields, replaces the legacy single-line "Test
  Message" input with the AlertMessageEditor. useFormDraft version
  bumped 4 -> 5 to discard pre-Phase-50 drafts that lack the new
  fields (would crash on <Checkbox checked={undefined}>).
- Schemas updated; AlertRule / AlertRuleInput / AlertTestRequest
  carry msg_template + include_title.

Tests
- Backend: alertmsg formatter + presets parseable + new
  TestPlaceholdersOpConditional covers <, >=, between, outside,
  changed; computed-kind asserts the new MetricID/Window/Threshold
  keys are emitted; existing alert_handler_test still green.
- Frontend: 6 tests for AlertMessageEditor (include-title toggle,
  autocomplete insert, preset apply, debounced preview, op-validity
  hides incompatible presets, op-validity shows compatible presets);
  full notifications suite 42/42 green.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Replace the capped left-rail signal selector with a page-global
"Add signals" tree picker, drop the per-page selection cap, and
add small-multiples + auto chart-layout switching for many-signal
cases.

Workspace layout
- Remove the 12-column grid and left rail entirely; single-column
  flow with a collapsible "Add signals" Accordion at the top, then
  toolbar (Time range / Per page / Run / Live / Compare), then
  charts/stats/history.
- New page-global state: catalog search, expanded categories,
  chart mode, accordion open β€” all URL-synced.

Tree picker (reusable)
- New TreeSelect (components/forms): generic two-level tree with
  tri-state group checkboxes, search, expand/collapse, "Select
  all". Functional onChange for safe rapid toggles. Decorative
  inner checkbox + row-level click handler (no double-toggle).
- New SignalCategoryTree (telemetry): wraps TreeSelect over the
  vehicle catalog grouped by category, with sparkline previews.

Charts
- New SmallMultiplesChart (components/charts): grid of mini line
  charts, one per series, with shared cursor (syncId). Performance
  layers: per-cell data projection (sparse signals stop scanning
  the full matrix), stride downsampling (maxPointsPerCell, default
  400), and IntersectionObserver-based lazy mounting so 60+ cells
  stay responsive.
- SignalChartPanel gains chartMode prop ('overlay' | 'grid' |
  'auto') with gridAutoThreshold (default 8). Auto switches to
  grid once the threshold is exceeded so many-signal selections
  stop collapsing into an unreadable overlay.

Stats / history
- SignalStatsPanel accepts selectedSignals so empty rows render as
  'β€”' instead of being missing; "Hide empty" toggle in header.
- SignalHistoryTable no longer renders a per-panel filter β€” the
  page-global selector is the single source of truth, removing
  the duplication. Also drops the duplicate built-in DataTable
  pagination so only the server-side <Pagination> remains.

Other
- New reusable useInView hook (IntersectionObserver wrapper) for
  lazy-mounting expensive subtrees.
- New pLimit concurrency limiter for per-signal sparkline fetches.
- Accordion extended with controlled mode (open / onOpenChange)
  plus headerExtra / headerClassName / bodyClassName slots.
- signalKeys.history includes limit so cache invalidates when
  per-page changes.

Tests
- TreeSelect: 21 tests including 2 regressions for the
  click-bubbling double-toggle and stale-closure rapid-toggle bugs.
- SmallMultiplesChart: 15 tests including a 60-cell Γ— 5,000-row
  render-without-throw regression and per-cell downsampling.
- pLimit: 5 tests covering ordering, capacity, error propagation.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The General Settings preferences audit revealed no UI map code reads
settings.google_maps_api_key -- it was a dead preference being collected
from users with no consumer. Remove the form field, type field, search
index entry, and i18n strings so the UI stops misleading users that
entering a key does anything.

Backend env-var fallback (internal/config, helm, docker-compose,
.env.example) is preserved for when maps are properly implemented later
as a reusable component that actually consumes the key.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Phase A–H sweep: plumb user display preferences (units, currency,
decimal precision, gas unit, range type, locale, time/date format)
end-to-end through the entire frontend so user-facing values respect
Settings consistently. Telemetry ingestion remains unchanged β€” this
only affects how stored SI values are rendered.

Highlights:
- New/extended hooks: useFormatting (locale-aware currency + energy
  cost), useUnits (distance/speed/temperature/pressure/energy/power/
  duration), useDateFormat, usePreferredRange, usePressureFormat.
- New reusable Range format component and preferredRange helper.
- FormatterPrefsBridge syncs settings.decimal_precision into
  fmtNumber/fmtInt/fmtPercent/fmtCompact/fmtWithUnit globals.
- chargingAggregation accepts currencySymbol; messages use fmtNumber.
- Removed Google Maps API Key field from Settings UI.
- Migrated 100+ user-visible surfaces to honor prefs: charging
  cost/cards/charts/maps, dashboard widgets, year-review slides,
  weekly digest, fleet/lifetime/timeline/true-cost analytics,
  battery cells/projected range/sleep/energy, climate, maintenance,
  drive-detail/trip-planner/shared-drive, system status (uptime,
  SLO, Tesla API usage, scheduled maintenance, telemetry pipeline),
  alert/notification surfaces, signal viewer, vehicle hero/detail.
- toFixed sweep on production numeric displays β†’ fmtNumber/fmtPercent.

useFormatting.formatCurrency(amount) and formatEnergyCost(kwh) now
default to settings.decimal_precision instead of hardcoded 2. Callers
passing an explicit decimals value are unaffected. Several i18n keys
also changed to drop the hardcoded "$" prefix (currency symbol now
embedded in the substituted value via formatCurrency) β€” affected keys
live in SystemStatusPage templates and require translation re-keying
for non-English locales.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@atulmgupta
atulmgupta merged commit d2b104e into main May 14, 2026
2 of 4 checks passed
@atulmgupta
atulmgupta deleted the refactor/filters branch May 14, 2026 00:22
atulmgupta added a commit that referenced this pull request Aug 6, 2026
Five of the ten failing audits were not code defects, they were audit rot.
PR #64 (refactor/filters) renamed or deleted several pages these audits point
at, and the audits silently skipped the very files they exist to guard.

Path rot:
- audit-inline-help: the notification-channels target pointed at a deleted
  NotificationChannelsView.tsx. Repointed at components/channels/ so further
  decomposition cannot rot it again. Coverage went 22 -> 27 (min 25) from the
  repoint alone, meaning the audit had been passing vacuously.
- audit-virtualization, audit-export-adoption: repointed post-rename paths.

Logic bugs:
- audit-deferred-filter grepped the *page* file for React.memo on a row
  component defined elsewhere, so an already-memoised DriveCard read as a
  violation. Targets now carry rowComponentPath and the check reads the
  component's defining file, accepting `export const X = memo(XImpl, ...)`.
- audit-filterbar-chips did not skip co-located *.test.tsx, contradicting its
  own documented __tests__ skip intent.

Anti-rot: stale PENDING_MIGRATION paths in audit-virtualization and
audit-export-adoption now hard-fail instead of printing "(file missing)" and
exiting 0. A path that no longer exists is now a build break, not a silent
pass, so this class of decay cannot recur unnoticed.

Also adds two justified allow-list entries: RadioCard.tsx to audit-sr-only
(identical `peer sr-only` case to the already-exempt Checkbox.tsx, where
VisuallyHidden would break the peer sibling relationship) and lib/cn.test.ts
to auditMotionTokens (the test asserts on raw duration-* strings by design).

Every change here was mutation-verified: break the rule -> exit 1,
restore -> exit 0.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
atulmgupta added a commit that referenced this pull request Aug 8, 2026
* Fixed UI

* Updated

* Added

* added

* added

* Added

* feat(web): add decision intelligence pages

Adds 20 analytical pages with statistical models for driving, charging, battery, vehicle systems, telemetry, and notifications. Wires routes, navigation, i18n, analytical history hooks, and comprehensive unit tests.

* feat: add intelligence and fleet operations

Adds privacy-preserving benchmarks, fleet operations, NHTSA service intelligence, local dashcam analysis, whole-home energy orchestration, resale vault reporting, and intelligence-pack sandboxing across the API and web app.

* feat: add unified Action Center

Adds evidence-ranked recommendations with user-scoped acknowledge, snooze, dismiss, restore, and audit history APIs. Integrates the Action Center frontend route, filters, summaries, provider status, confirmation flows, localization, and tests, while improving NHTSA timeout classification.

* feat: add advanced intelligence suite

Adds evidence-bounded intelligence services, repositories, migrations, API routes, Action Center integration, frontend pages, hooks, navigation, and tests for vehicle twins, firmware canaries, survival, hazards, resilience, causal analysis, federated learning, and TCO optimization.

* feat(ai): add evidence-grounded Helix streaming

Add shared intelligence grounding, streamed tool-call assembly across providers, chatbot knowledge retrieval, vehicle discovery, and privacy-safe evidence trails with usage metadata in the web UI.

* Improve Helix AI retrieval and stream integrity

Embed and locally rank application documentation for Helix Chat, add provider finish metadata and usage handling, reject incomplete or filtered completions, redact precise locations from tool history, and improve alert and vehicle data queries.

* fix climate HVAC state handling

Use canonical boolean HVAC signals across climate widgets, analytics, and types, preserving unknown states safely. Also type Sentinel timestamp parameters explicitly in PostgreSQL queries and add regression coverage.

* Improve light-theme contrast and disabled states

Use theme-safe accent colors, semantic surfaces, readable disabled control styling, and expanded component coverage for light/dark contrast.

* feat: add ownership intelligence suite

Adds ten ownership intelligence domains with SI-canonical data models, persistence, API routes, React pages, hooks, navigation, and database migration support.

* fix(web): register ownership routes in the three route manifests

The ownership intelligence suite added 10 routes to App.tsx but never
updated the manifests that mirror it, so the pages were missing from the
explore catalogue and excluded from lazy-route coverage.

- routeRegistry.ts regenerated via scripts/generate-route-registry.mjs
  (236 routes, matches App.tsx exactly)
- lazyRoutes.list.ts gains the 10 dynamic imports
- featureCatalog.ts gains the 10 descriptions

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(web): make energy anatomy components sum exactly to the measured total

Each of the four energy components was rounded independently, so the
breakdown could disagree with the headline figure it decomposes β€” a
500 Wh drive rendered parts adding up to 501 Wh.

Replaced independent rounding with largest-remainder (Hare quota)
apportionment, which distributes the rounding residue to the components
with the largest fractional parts. The parts now always sum to totalWh
exactly, and relative ordering is preserved.

Adds a 300-case randomised sum invariant sweep and a component-stability
test; both were mutation-verified to fail against the old implementation.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* refactor(web): move panel and control chrome onto design tokens

Modernises the UI through the existing theming layer rather than a
rewrite. Adoption of the design system was already near-total (99% of
234 pages use PageContainer, 100% use useTranslation), so re-pointing
the shared primitives at new tokens propagates to every page.

Tokens (index.css)
- new SHAPE SCALE, ELEVATION RAMP, PANEL SURFACE and CONTROL SURFACE
  blocks, plus a shared focus-ring token
- light mode overrides the elevation ramp so shadows stay legible
- .glass-* component classes now read the tokens instead of hardcoding
- --panel-blur is a single dial that restores glassmorphism app-wide

Primitives re-pointed at the tokens
- GlassPanel and Card now share one byte-identical surface contract
- Button neutral variants, Badge/neutral, StatusBadge, Tabs, Toggle,
  Skeleton, Timeline, EmptyState, ChartContainer and six dialog/widget
  surfaces move off the fixed gray-* ramp, which had been bypassing all
  140 theme presets

Also fixes four latent defects surfaced by the unification:
- Card shared GlassPanel's surface but printed without a border; both
  now emit data-print-card
- ChartContainer styled <th> from the panel token but not its own <td>
- EmptyState's link CTA was documented as mirroring Button's secondary
  variant but had silently drifted; it is now derived from the exported
  BUTTON_BASE + BUTTON_VARIANTS
- tailwind-merge could not see the custom rounded/shadow scale keys, so
  caller overrides silently left both classes on the element; cn.ts now
  registers them via extendTailwindMerge

Components export their class maps (GLOW_CLASSES, BADGE_VARIANTS,
BUTTON_VARIANTS, BUTTON_BASE) as the single source of truth, and ~30
test suites now import those instead of hardcoding class literals, so
they no longer break on a re-skin.

audit-light-mode-parity gains bg-/border-gray-surface-literal rules
scoped to the extremes (50-200, 700-950) where a fixed gray really is a
surface; mid greys stay allowed as the semantic "unknown" status hue.
The rules find 33 violations against untouched HEAD source and 0 here.

designTokens.test.tsx (17 tests) locks the whole contract: token
declarations, GlassPanel-equals-Card, no gray-* in eight primitives,
the EmptyState/Button derivation, twMerge conflict resolution, and that
Tailwind's own radius scale is untouched.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* chore(web): clear unused eslint-disable directives

npm run lint runs eslint with --report-unused-disable-directives, and
142 stale directives across 57 files were failing the gate before any
audit could run. Removed via eslint --fix.

One is a genuine fix rather than churn: KioskOverlay's cursor-overlay
had its no-restricted-syntax directive above the <div>, but the rule's
selector reports on the className JSXAttribute, so the directive never
applied and the warning was live. Moved it inside the opening tag
directly above className, matching the working precedent on the dim
overlay twelve lines above.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* test(web): wait for actual BroadcastChannel delivery instead of one tick

Every broadcast bus assertion waited a single `setTimeout(0)` hop for the
channel to deliver. BroadcastChannel delivery is asynchronous with no
guaranteed tick budget, so under CPU load the message had not arrived yet
and the suite failed with "expected [] to have a length of 1" β€” it flaked
during a full run that shared the machine with a production build, while
passing 10/10 three times in isolation.

Positive assertions now poll via waitFor() until the expected state holds,
returning as soon as delivery lands (so the suite is no slower) and failing
loudly on a 2s timeout. Mutation-verified: an unsatisfiable predicate
raises "waitFor: timed out waiting for channel delivery" rather than
passing vacuously.

Assertions that a message did NOT arrive have nothing to poll for, so they
use settle() to bound how long an erroneous delivery has to appear; extra
hops make those negatives stronger, never flakier.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(web): register token-backed duration and easing scales with tailwind-merge

tailwind.config.js defines transitionDuration (fast|normal|slow) and
transitionTimingFunction (standard|accelerate|decelerate) as CSS-var-backed
custom keys. twMerge's built-in `duration` and `ease` groups only recognise
numeric/known values, so these fell through as unrecognised classes and were
never de-duplicated:

  cn('duration-fast', 'duration-normal') => "duration-fast duration-normal"
  cn('ease-standard', 'ease-linear')     => "ease-standard ease-linear"

Both classes survived and CSS source order silently decided the winner, so a
component prop could not reliably override a base motion class. Register both
scales in extendTailwindMerge so they resolve last-wins like every other group.

Adds an it.each regression block covering both scales in each direction
(13 -> 21 tests) to lock the behaviour, since the failure mode is invisible
in review and only shows up as a subtly wrong animation speed.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(web): repoint and harden rotted frontend audit targets

Five of the ten failing audits were not code defects, they were audit rot.
PR #64 (refactor/filters) renamed or deleted several pages these audits point
at, and the audits silently skipped the very files they exist to guard.

Path rot:
- audit-inline-help: the notification-channels target pointed at a deleted
  NotificationChannelsView.tsx. Repointed at components/channels/ so further
  decomposition cannot rot it again. Coverage went 22 -> 27 (min 25) from the
  repoint alone, meaning the audit had been passing vacuously.
- audit-virtualization, audit-export-adoption: repointed post-rename paths.

Logic bugs:
- audit-deferred-filter grepped the *page* file for React.memo on a row
  component defined elsewhere, so an already-memoised DriveCard read as a
  violation. Targets now carry rowComponentPath and the check reads the
  component's defining file, accepting `export const X = memo(XImpl, ...)`.
- audit-filterbar-chips did not skip co-located *.test.tsx, contradicting its
  own documented __tests__ skip intent.

Anti-rot: stale PENDING_MIGRATION paths in audit-virtualization and
audit-export-adoption now hard-fail instead of printing "(file missing)" and
exiting 0. A path that no longer exists is now a build break, not a silent
pass, so this class of decay cannot recur unnoticed.

Also adds two justified allow-list entries: RadioCard.tsx to audit-sr-only
(identical `peer sr-only` case to the already-exempt Checkbox.tsx, where
VisuallyHidden would break the peer sibling relationship) and lib/cn.test.ts
to auditMotionTokens (the test asserts on raw duration-* strings by design).

Every change here was mutation-verified: break the rule -> exit 1,
restore -> exit 0.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* feat(web): add accessible data tables to ownership analytics charts

The chart-a11y audit requires every ChartContainer to expose a screen-reader
data table alongside the visual chart, so the underlying numbers are reachable
without interpreting an SVG. Five ownership pages rendered charts with no
tabular equivalent: driver attribution, insurance telematics, model trust,
subscription ROI and tariff lab.

Each now passes `data` plus `dataColumns` with localized headers.

TariffLabPage needed a second projection rather than reusing its chart data.
ChartDataRow is intentionally Record<string, string | number | null |
undefined>, but the tariff chart rows carry isBest/isCurrent booleans that
recharts needs for per-<Cell> fills. Rather than widen the shared type for one
page, the page derives a separate chartTableRows memo that flattens those two
flags into a single localized `status` string. That reads better in a screen
reader than two Yes/No columns and keeps ChartDataRow narrow.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* refactor(web): route ad-hoc screen-reader text through VisuallyHidden

Ten sites hand-rolled `className="sr-only"` spans instead of using the shared
VisuallyHidden component, which also carries liveRegion/focusable/priority
handling. Converting them keeps screen-reader affordances in one place.

Two DBHealthPage sites used `<SectionTitle className="sr-only">`. SectionTitle
already renders an <h2>, so these became `<VisuallyHidden as="h2">`, preserving
the heading outline without the visual typography wrapper; the now-unused
SectionTitle import is dropped.

Three of the thirteen audit hits were better served by an aria-label on the
existing element than by an extra visually-hidden node (OnboardingWizard step
progress, LifetimeStats AI QA section) and are labelled rather than wrapped.

VehicleListPage is also carrying its empty-state retry CTA in this commit
because the two changes touch the same JSX.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* refactor(web): move raw transition durations onto motion tokens

Seven files still used raw Tailwind duration-150/200/500 utilities instead of
the semantic duration-fast/normal/slow tokens defined in tailwind.config.js,
which auditMotionTokens flags so motion timing stays centrally tunable and
respects the design tokens rather than hardcoded milliseconds.

This is safe to do now that cn() actually de-duplicates the custom duration
scale; before that fix a token class and a numeric class could both survive on
the same element.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* feat(web): surface active filter chips on the dashcam clip catalog

The clip catalog rendered a FilterBar with no ActiveFilterChips, so once a
filter was applied there was no inline indication of what was narrowing the
list and no per-facet way to clear it, which is the pattern every other
filtered list in the app follows.

ClipFilterBar now wraps FilterBar and ActiveFilterChips in a fragment and
derives chips via useActiveFilterChips. The clip filters use an 'all'
sentinel rather than an empty string for "no selection", so each facet passes
an isEmpty override; without it every facet would render a permanent
"all" chip that cannot be dismissed.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* feat(web): page advanced-intelligence CSV exports past the API row cap

The advanced-intelligence list endpoints bound `limit` to 100 server-side in
handler.parseListRequest, so a single-request export silently truncated to the
first 100 rows and produced a CSV that looked complete but was not.

The export now pages at the server's own maximum until it has the full set,
with a hard 10k-row stop so a bad `total` from the API cannot spin the loop
forever.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* perf(web): thread AbortSignal through the vehicle state query

fetchVehicleState ignored the AbortSignal TanStack Query hands to queryFn, so
navigating away or refetching left the previous request running to completion.
The query-signal audit exists to catch exactly this. Accept an optional signal
and forward it to request().

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* docs(web): record why the signal log viewer needs no deferred filter

The deferred-filter audit flagged this page, but it has no keystroke-driven
client filter to defer: signal selection and time range are explicit query
inputs that only apply when the user presses Run, and paging is a bounded
slice() over an already-fetched result set. useDeferredValue would defer
nothing here, so the page carries a deferred-filter:no justification rather
than a no-op hook.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* feat(web): wire a CTA or document the dead end on every empty state

The empty-state-cta audit had 185 offenders across 110 files and 27 domains:
EmptyState renders that told the user nothing was there but gave them no way
to act on it. A dead end with no next step is the single most common
frontend UX failure in the app.

Each site was resolved one of two ways, never by relaxing the audit:

- ~55 gained a real CTA (action or actionTo) that is genuinely reachable from
  that context: retry wired to an existing refetch, "Clear filters" wired to
  the actual filter reset, or navigation to the page that would populate the
  data (/vehicles, /drives, /notifications/rules, ...). Several needed a real
  plumbing change rather than a label, for example exposing refetch from an
  existing useQuery, or threading onClearFilters/onResetFilters from the page
  down to the component that owns the EmptyState.

- ~130 carry a `// no-action:` exemption naming the site-specific reason no
  CTA is possible: the recovery control already sits adjacent to the panel,
  the data is driven by a parent RangePicker, the state is transient behind a
  30s poll, the emptiness is a business rule rather than a fixable condition,
  or the query is a mirror of a sibling with no independent refetch. These
  cite the concrete mechanism at that site rather than generic boilerplate.

Also folds in the two i18n shadowed-key renames (a parent key that was both a
string and an object namespace, which makes the parent unresolvable) and the
new translation keys backing the CTA labels.

Verified on the merged result, not per-batch:
  npm run lint (29 audits)  exit 0
  npx tsc --noEmit          0 errors
  npx vitest run            1715/1715 files, 24511/24511 tests
  npm run build             exit 0

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(api): keep house number and POI name in geocoded place labels

Journey Details rendered a drive's Start and Destination as effectively the
same place. The route map was correct, so the coordinates were fine β€” the
labels were not.

GeoResult only carried DisplayName/Road/City/State/Country/PostCode, and
ShortName() emitted "Road, City". Both ends of a drive very often sit on one
road, so the two labels differed at most by city and read identically:

  "Bothell Everett Highway, Bothell" -> "Bothell Everett Highway, Mill Creek"

Every provider already returns enough detail to tell the ends apart, and all
three adapters parsed it and threw it away:

  - Google skipped street_number and every POI type. This is the production
    path and Google returns street_number for essentially every US address,
    which is what produced the reported symptom.
  - Azure skipped streetNumber and municipalitySubdivision.
  - Nominatim parsed house_number and even built a house-numbered string, but
    wrote it into DisplayName, which ShortName() only reads when no locality
    resolved. The richer label lived in NominatimResult.ShortName(), which
    nothing on the drive path called β€” two implementations, and the lossy one
    won. It now delegates, so there is a single labelling implementation.

GeoResult gains Name (POI), HouseNumber and Suburb, and ShortName() walks a
specificity ladder: POI name, then house number + road, then road, then
locality, then a truncated DisplayName. Suburb wins over City because two ends
of a cross-town drive share a city but rarely a neighbourhood.

  after: "19205 Bothell Everett Hwy, Bothell" -> "Costco Wholesale, Mill Creek"

The documented fallback where a road with no locality yields DisplayName is
preserved β€” a bare road is less informative than the provider's own formatted
address. geocode.TestReverse_DisplayNameFollowsShortName pins it.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* feat(api): re-resolve place labels written by the old geocoder

Fixing the labeller only helps drives geocoded afterwards. Every drive already
in the database keeps the road-level label it was stored with, so the reported
production drives would still show a Start and Destination that read as the
same place. This adds a bounded, idempotent startup pass that re-resolves them.

Deciding "this label is lossy" from the string alone is unreliable β€” a POI
label and a road label both just start with a letter β€” so a heuristic repair
would re-geocode the whole table on every boot. drives.place_label_version
records which labelling revision produced a row instead. Adding the column with
DEFAULT 0 lands every existing row in the backlog, and raising the default
afterwards means new drives are never queued. A partial index covers only the
backlog, so the scan costs nothing once it drains.

The pass deliberately bypasses the places cache: the cache stores labels
produced by whichever revision was live, so a cached read would hand back the
exact string being replaced. Because Upsert overwrites any entry within 50m,
the first repair at a location also refreshes the stale entry for every other
drive that would have read it.

resolveAndUpdateAddress now reports whether it resolved anything, and a drive
is marked repaired only when at least one endpoint succeeded β€” a provider
outage defers the drive to the next run instead of stranding it on the old
label forever. The backlog query is not cursor-paginated, so a batch that
marked nothing would be re-selected identically; the loop stops rather than
spinning and leaves the survivors for the next boot.

BackfillAddresses previously returned early when no drive was missing an
address, which is the normal steady state, so the repair is sequenced after it
rather than inside that path.

Verified against a live database: pre-migration rows land on version 0 and
post-migration rows on 2; a seeded drive carrying the reported duplicate labels
comes out with two distinct ones; a second pass makes zero geocoder calls; a
failing provider leaves the drive queued and terminates; a cancelled context
stops before the first lookup.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(web): stop radial gauges misrepresenting signed and offset-scale values

The driving-dynamics gauges did not mean what they appeared to mean.

1. Signed quantities were clamped away. RadialGauge floors at zero, but
   DiTorqueActualF/R go negative under regen and DiAxleSpeedF/R go negative
   in reverse -- and nothing in the pipeline takes an absolute value. Both
   states therefore rendered identically to a stationary car. A full-circle
   radial cannot express sign (centring zero puts it at 6 o'clock, which
   reads terribly), so add BipolarBar: a zero-centred bar that renders sign
   as direction and supports asymmetric ends, since drive torque far exceeds
   the regen limit.

2. Temperature was an offset-scale bug. The gauge was fed an already
   converted reading against a fixed max, so the same temperature swept a
   different arc in Fahrenheit -- a degree scale has a non-zero origin, so
   converting only the top of the range does not cancel it. RadialGauge now
   takes an optional `min` and measures (v - min) / (max - min), letting a
   caller pass both ends converted. Defaults to 0, so every existing gauge
   is bit-identical.

3. A near-zero arc rendered as a floating dot. `strokeLinecap="round"` adds
   half a stroke width at each end, so below ~1% the cap was the entire mark
   and read as a position pip rather than a magnitude. Cap butt when the arc
   is shorter than the stroke.

Establishes the split: RadialGauge for a bounded 0-to-max magnitude,
BipolarBar for anything whose sign carries meaning.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* feat(web): add an SSE-to-query bridge and let callers set poll cadence

Groundwork for per-panel live data.

useMotorHistory, useDrives and useDrivingCoach had no refetchInterval
parameter at all, so every consumer was frozen at first load with no way to
opt into polling; useSignalObservations accepted options but ignored the
interval. All four now take a cadence the caller chooses.

Polling alone still caps freshness at the interval, so add
useSignalQueryInvalidation: the backend already pushes every field-level
change on the signal_change SSE channel, so a pushed field can invalidate
exactly the queries that project it and collapse worst-case staleness to the
coalescing window.

Two properties are load-bearing. Powertrain fields arrive at many hertz, so
events are accumulated and flushed at most once per throttle window --
invalidating per event would be a worse request storm than the poll it
replaces. And refetchIntervalInBackground is false app-wide, so flushes are
suppressed while the document is hidden and caught up on visibilitychange;
without that this hook would silently reintroduce the background traffic
that default exists to prevent.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(web): give each driving-dynamics panel its own live subscription

The page reported itself as live but most of it was frozen. Seven of the
eleven panels -- SummaryStats, LiveMotorStatus, SpeedGearPanel,
MotorEfficiencyInsights, MotorHistoryCharts, DrivingTips and
DrivingCoachSection -- were fed by page-level fetches that had no
refetchInterval, so they rendered first-load data forever while the two
panels that did poll kept moving beside them.

Prop-drilling from one page-level fetch also forces a single cadence on
signals with very different volatility. Each panel now owns its own query at
a rate that matches what it shows: motor latest and gear at 5s, motor
history at 10s, the drive list at 30s, the coach summary at 5min. TanStack
dedupes by key, so the panels sharing a source still issue one request per
interval rather than one each, and the shared motor-history derivation moves
into useMotorStats so four panels compute it once.

On top of the polls the page subscribes to the signal_change stream and
invalidates the motor, drive-dynamics and cruise queries when a field they
project actually changes, so a gear or torque change lands in well under a
second instead of waiting out the interval.

LiveMotorStatus is rebuilt on the new primitives: the temperature ring gets
both ends unit-converted so the arc is correct in Fahrenheit, torque and
axle speed become BipolarBars so regen and reverse are visible at all, and
rear axle speed is surfaced rather than silently dropped. The panel also
gains the loading, error and empty branches it never had.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* chore: trigger CI

Empty commit with no file changes, pushed to kick off a pipeline run.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(web): correct temperature gauge scales app-wide

The offset-scale defect fixed on the driving-dynamics motor gauge was not
local to that panel -- the same mistake appears on eight gauges across four
more surfaces: both vehicle hero cards, the drivetrain sensor grid and the
three climate gauges.

Temperature is an interval scale, so value/max is not preserved across a unit
conversion. Every one of these fed a converted reading against a ceiling that
was fixed, hand-converted or converted-but-unpaired, while the floor stayed at
an implicit zero. The same temperature therefore swept a different arc
depending on the user's unit preference: 20C reads 40% of a 0-50C ring but
55.7% of the equivalent 0-122F ring.

Ambient gauges had a second, more visible failure. A zero floor cannot render
a sub-zero reading at all, so every below-freezing outside temperature clamped
to a completely empty ring in Celsius while the same reading showed a partial
arc in Fahrenheit. Cabin and outside gauges now start below freezing, so cold
weather is legible and two different freezing temperatures no longer look
identical.

Rather than repeat the min/max conversion at each call site, add
temperatureGaugeRange() and its ambient preset, which produce both ends from
one converter so a caller cannot convert one end and forget the other. The
driving-dynamics gauge moves onto the shared helper too. Unit invariance is
pinned by property tests over the fill fraction instead of hard-coded numbers,
so the bounds can be retuned without silently losing the guarantee.

VehicleHero's isFahrenheit prop existed only to hand-convert the two gauge
ceilings and is dead once the conversion is done correctly, so it is removed
along with the useSettings call that fed it.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(web): render motor torque sign instead of clamping it away

The Motor Performance dashboard widget fed Math.abs(torque) to a
RadialGauge while passing the signed value as the gauge's label. Two
problems followed:

  - Regen and an equal drive torque drew an identical arc, so the
    dial could not distinguish -150 Nm from +150 Nm.
  - The label slot is a caption, not a value readout, so a regen
    reading rendered '150' as the large centred number and '-150'
    beneath it -- two different numbers for one quantity.

Swap in BipolarBar, which carries sign as fill direction and shows a
single signed reading. Drive and regen scale independently (600 vs
250 Nm) because the regen limit absorbs far less than the drive limit
puts down. torqueColor is retained for both directions so the existing
severity banding is unchanged.

This is the same defect class already fixed on /driving-dynamics in
d7867bf; this was the last RadialGauge left holding a signed value.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(web): stop double-scaling the regen recovery percentage

/analytics/regen returns regen_ratio already as a percentage --
internal/api/regen/handler.go computes totalRegenWh / totalDriveWh * 100
(pinned by TestRegenRatio: 100/100 -> 100). RegenEfficiencyWidget
multiplied it by 100 a second time, so a real 25% recovery rendered as
'2500%'. Because the gauge caps at max=100 it also sat permanently full,
and regenColor's '> 30 is green' branch made every vehicle look optimal
regardless of actual recovery.

RegenEfficiencyPage, which reads the same endpoint, already treated the
value as a percentage -- so the page and the dashboard widget disagreed
about the same number.

The widget's fixtures encoded the wrong assumption (regenRatio: 0.4),
which is why the suite stayed green; they now carry real percentages.
Added an API-scale contract test that fails if the multiply comes back.

The confusion is that two endpoints expose regen_ratio on different
scales: /drives/stats returns a 0-1 fraction, /analytics/regen returns
0-100. Both interfaces in types/driving.ts now document their scale and
cross-reference each other.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* chore: trigger CI

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* feat(web): add ThresholdBar for readings with no meaningful 100%

A radial ring implies `proportion of a whole`, which only reads correctly
when 100% is a real, reachable state. Most physical readings have no such
whole: a tyre at 100% of an arbitrary 72.5 psi ceiling is destroyed, not
full. Worse, several gauges derive their ceiling from the value itself
(max={value * 1.5}), which makes the arc mathematically constant -- 66.7%
for every reading -- so it conveys nothing at all.

ThresholdBar renders the domain that actually matters, splits it into
named qualitative bands, and marks where the reading falls. It is a marker
on a banded track rather than a fill, so it never re-implies a whole.

Callers owning an asymmetric status predicate can override the inferred
band name via statusLabel, so the bar can never contradict the status the
rest of the page shows for the same reading.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(web): replace mathematically pinned gauges with real readings

Three call sites derived a radial gauge's ceiling from the reading it was
displaying, which makes the arc a constant:

  max={Math.max(value * 1.5, floor)}  -> exactly 66.7% for any value
  max={Math.max(count, 50)}           -> exactly 100% for any count > 50

On drive detail all four hero rings drew an identical arc, so a 5 km errand
and a 500 km road trip looked the same. The charging strip filled its
session, energy and cost rings completely for every non-trivial account.

Drive detail now shows each reading next to the reader's own baseline from
useDrivingStats -- per-drive distance and duration means, the max-speed
record, and average consumption -- via the existing direction-aware Delta.
The charging strip and the energy hero show unbounded totals as MetricTile
readings, and keep a scale only where a real one exists (the 250 kW
Supercharger peak, and the efficiency band ceiling).

Also fixes a live bug on the energy hero: max={toEfficiencyDisplay(300)}
fed 300 Wh/m into a converter that multiplies by 1000, producing a
300000 Wh/km scale on which a real 180 Wh/km reading filled 0.06% of the
ring. The gauge was blank for every user.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(web): make every radial gauge state the scale it is drawn against

A ring implies "proportion of a whole", but the whole lived only in
`aria-valuemax`. A sighted reader saw an arc and had no way to learn
whether a full ring meant 250 kW, 60 C or 1500 cycles, so the arc carried
no information the number did not already give. Four tyre gauges all
reading ~47 psi drew visually identical arcs; that was the trigger, but
the defect is in the primitive, not the call sites.

RadialGauge now prints its own range under the label whenever the scale
is not a percentage. Percent-ness is derived from the UNIT rather than
the numbers: a 0-100 %% ring is genuinely self-describing, a 0-100 C ring
is not and gets a caption (that distinction was the first bug this
caught). `hideScale` opts out for the two callers that already print
the ceiling themselves.

This fixes ~26 fixed-ceiling gauges at once with no call-site churn, and
it correctly reports offset scales at both ends -- the Fahrenheit floor
of a 0 C ring reads "32 - 302 F", not "0 - 302 F".

- RadialGauge: hideScale prop + scale caption; 13 new tests (51 total)
- TemperatureGauges: drop the now-duplicated "Max: n" caption line
- DriveScorePage: hideScale, the card already renders "/max" and a bar
- EnergyPage: drop an eslint-disable that no longer suppresses anything

Verified: tsc --noEmit exit 0; npm run lint exit 0 (29 audits);
npx vitest run 24,655/24,656 passing (the single failure is the
pre-existing MarkdownRenderer lazy-chunk contention flake, which passes
in isolation and is untouched by this change).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(web): stop the sandbox gauge scaling itself to its own sample peak

The pack sandbox rendered `radial-gauge` widgets with
`max = Math.max(1, ...series.map(abs))`. A pack formula has no declared
ceiling, so the ring measured the latest row against the sample's own
peak -- meaning it drew a completely full circle whenever the latest row
happened to BE the peak, no matter the magnitude. It was the last gauge
in the app whose "whole" was invented from the data it displayed.

Replaced with MetricTile: the latest reading as a number, with the range
the sample actually spanned as a sublabel. That states the context the
ring was only pretending to encode.

Verified: tsc --noEmit exit 0; npm run lint exit 0 (29 audits);
npx vitest run src/features/intelligence-packs 228/228 passing.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(web): do not print a dash placeholder as a gauge unit

Three call sites signal "no reading yet" by swapping the unit for an
em-dash (`unit={motorTempC != null ? tempUnit : 'β€”'}`). The new scale
caption appended that verbatim, so a gauge awaiting telemetry rendered
"0 - 150β€”". A dash is the absence of a unit, not a unit: it is now
stripped from the caption, and on a 0-100 ring it counts as "no unit" so
the ring stays uncaptioned like any other percentage.

Also passes hideScale on the battery health gauge, whose unit is
literally "/100" -- it already states its own scale and would otherwise
read "0 - 100/100".

Verified: tsc --noEmit exit 0; npm run lint exit 0 (29 audits);
vitest RadialGauge + driving-dynamics + battery pages 344/344 passing.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* refactor(web): replace radial gauges with linear gauges app-wide

A radial ring encodes magnitude as arc length around a circle whose end
is invisible. The reader sees three-quarters of a ring and cannot tell
whether the whole is 250 kW, 150 C or 1500 cycles, because that ceiling
lived only in aria-valuemax. The circle has no landmarks either, so two
readings 40% apart looked similar and four tyres at different pressures
drew identical arcs.

Earlier commits fixed the dishonest ceilings one at a time (pinned
maxima, invented ranges, self-derived peaks) and made the survivors
print their own scale. The remaining defect was structural rather than
per-call-site, so RadialGauge is removed entirely.

LinearGauge replaces it with a RadialGauge-compatible prop surface:
- the far edge of the track is a visible boundary;
- fills are directly comparable between stacked gauges;
- the numeric ends of the scale are printed beneath the bar;
- offset (interval) scales measure from min, so a temperature draws the
  same bar in C and F;
- non-finite / nullish values degrade to an empty bar, never width: NaN%.

Also in this change:
- size (once a pixel diameter) now maps to readout size and track weight.
- New marker/markerLabel prop draws a reference tick, replacing the
  charge-limit ring that BatteryLinearGaugeWidget floated over the gauge.
  The tick now renders at every widget size, not just the large one.
- New ariaLabel names meters whose visible label is intentionally blank;
  those were previously announced as bare digits.
- Call sites whose parents were laid out for fixed-diameter circles are
  re-laid out for bars: wrapping gauge rows become responsive grids,
  gauges beside detail lists get an explicit column, lone centred gauges
  are width-capped, and a shrink-to-fit inline-flex wrapper that would
  have collapsed the bar to min-content is removed.
- EnergyProductsPage no longer prints the backup reserve twice.
- src/test/gaugeTestUtils.ts centralises gauge DOM assertions so the 13
  suites that reached into SVG ring internals no longer can.

The persisted widget id battery-radial-gauge and the signed-manifest viz
kind radial-gauge are deliberately left untouched: renaming the former
would silently drop the widget from every saved dashboard, and the
latter is covered by fixture signatures.

Verified: tsc --noEmit exit 0; vitest run 1720/1720 files passing;
npm run lint exit 0 (29 audits, 0 violations).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* test(web): guard the layouts that a percentage-width gauge can collapse

A radial ring had an intrinsic size, so it laid out correctly wherever it
was dropped. LinearGauge is w-full and takes its width from its parent,
which silently breaks three layouts that were fine for rings:

  - a shrink-to-fit parent (inline-flex / w-fit) sizes to its content
    while the child asks for 100% of the parent, so the browser resolves
    the cycle by collapsing the bar to min-content;
  - a wrapping flex row gives each bar its own line, turning a row of six
    gauges into six stacked full-width bars;
  - a bar sharing a flex row with a flex-1 sibling competes for the same
    space, so the result falls out of flex-shrink rounding.

None of these fail tsc or vitest, because jsdom has no layout engine β€”
they are only visible in a real browser. The previous commit fixed all
14 affected call sites by hand; this audit is what stops the 15th.

Verified by reverting each of the three fixes in turn and confirming the
audit reports exactly that file and line, then restoring it. Registered
in the lint chain: npm run lint exit 0, 59 gauges across 37 files, 0
failures.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(api): derive drive place labels from the true endpoints

Journey Details showed a byte-identical Start and Destination on drives
covering many miles, while the route map drew the correct route.

The two disagree because they read different sources. The map renders the
recorded GPS track; the labels are geocoded from drives.start_lat and
drives.end_lat. When a drive's boundary moments carry no Location sample,
completion falls back to a point-in-time snapshot that can resolve the same
fix for both ends, so the row lands with end_lat/end_lng equal to
start_lat/start_lng. Every subsequent geocode then resolves one coordinate
twice and writes the identical string into start_place and end_place.

That also explains why the earlier place-label rewrite did not help: the
repair pass reads the same stored columns, so it faithfully re-derived one
label for both ends no matter how good the labelling logic became.

Endpoints are now corrected from the drive's own track before any geocode
runs, in drive completion and in both startup passes. A drive that really
did finish where it started keeps its coordinates, because the track's first
and last fix agree with what is stored, and a drive with no usable GPS
history is left untouched rather than guessed at.

Migration 000227 requeues the affected rows so existing drives are repaired
once. Only drives that cannot describe two distinct places are selected --
re-geocoding the whole table would take one provider request per endpoint at
roughly one request per second.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(web): show all drives in range instead of only the newest 50

The drive history page filtered and paginated entirely on the client
while `useDrives` sent no `limit`, so the API applied its default 50-row
page. `?size=100&from=2026-01-01&to=2026-08-06` therefore rendered at
most 50 drives, and the KPI tiles, trend chart and collection counts
were all derived from that truncated slice rather than the real range.

`useDrives` now accepts an optional server-side window (start/end/limit,
clamped to the backend-accepted 1..1000) and the list page scopes its
request with it. The window deliberately reaches back over the prior
comparison period, because the delta tiles are computed from drives that
fall *before* the selected range and would otherwise read "No drives in
prior period". Both ends are padded a day since the API filters on UTC
while the page buckets drives by the vehicle's local day.

The second parameter still accepts a bare number so the 36 polling
callers are untouched, and an empty window still produces the original
`['drives', vehicleId]` cache key. Windowed keys extend that key rather
than replacing it, so the `['drives']` prefix invalidation fired after a
bulk delete still reaches them.

When a range does fill a whole 1,000-row page the list now says so
instead of silently presenting a subset.

Verified against the live API on seeded data: the old request returned
50 drives, the new one returns all 120.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(api): close out monthly trips once their month ends

The Trips page listed every recent month as "(In Progress)" β€” May, Jun,
Jul and Aug 2026 all at once β€” when only the running month can be. Their
durations were wrong too (Jul 2026 read "74h 44m" for a whole month)
because ended_at kept whatever "now" was the last time the generator
touched the row.

GenerateMonthlyTrips only ever inserted months that had no trip yet, so
a month first written while it was the current month kept its in-progress
name and its provisional ended_at forever: the month rolled over and
nothing ever went back to close it. Only the very first month a vehicle
ever drove β€” created retroactively, already in the past β€” came out right,
which is why the symptom looked like "all recent months".

A finalize pass now closes any month that has ended but still carries
in-progress state, setting the completed name and an exact month-boundary
ended_at. Scope is deliberately narrow: it only touches rows the
generator owns (month-boundary start, no owning user, and either the
auto_generated flag or one of the two names this file writes), so a
user-owned trip starting on the 1st is never rewritten. Existing installs
match by name and repair themselves on the next tick, so no backfill
migration is needed.

Generated rows now also set auto_generated, which the table has always
had but nothing ever populated, and the current-month boundary is
computed once so the finalize pass and the in-progress upsert can never
disagree and flip a row back and forth.

Verified against a live database seeded into the exact reported state:
all four months read "(In Progress)" before, and after one generator run
May/Jun/Jul read "Summary" with month-boundary end dates while only Aug
stayed in progress.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* feat(web): expand Drive Calendar with rhythm insights

Decompose DriveCalendarPage into independent bento sections (drive-calendar/ components): heatmap, monthly activity chart, weekday pattern chart, rhythm insights, and top driving days panel. Extend buildDriveCalendar with weeks/months/weekdays aggregation, top-days ranking, favorite weekday, busiest month, and rhythm metrics (activity rate, weekend share, averages). Each section independently handles loading/error/empty states. Adds i18n strings and test coverage for the new calendar logic and page composition.

* feat(web): expand vehicle analytics workspaces

Add evidence-driven analytics components, richer models, bounded-history handling, and page tests across driving, battery, vehicle, and milestone views.

* refactor(web): rebuild Regen Efficiency page on drive evidence

Replace the client-derived Regen Efficiency page and dashboard widget with a data-driven model that separates the complete `/analytics/regen` aggregate from a capped, energy-weighted `/drives` sample.

- Add `regenEfficiency.ts` pure model builder (coverage accounting, ratio statistics, monthly buckets, temperature/SoC context, ranked drives) plus tests.
- Split the page into dedicated `regen-efficiency` components (KPI band, overview, monthly trend, distribution, temperature/SoC context, ranked evidence, methodology) each with explicit loading/error/empty states and detail-cap notices.
- Fix `RegenEfficiencyWidget` to stop mislabeling average absolute drive power as "Monthly Avg" regen power; show "Drive Energy" instead.
- Extend `RegenEfficiencyData` types with `batteryCapacityWh`, `capacitySource`, `monthlySummary`, and embedded `drives`, documenting legacy/misnamed fields.
- Update i18n strings and tests accordingly.

* feat(web): expand Drive DNA analytics

Adds sampled telemetry evidence, coverage metrics, distributions, charts, methodology panels, safe SVG export, resilient loading/error states, SI-aware formatting, and comprehensive model/page tests for Drive DNA.

* feat(web): expand vehicle analytics evidence

Adds evidence-focused analytical workspaces across battery, driving, and vehicle systems with detailed accounting, timezone-aware SI displays, persistent loading/error/empty states, canonical API hooks, and comprehensive tests.

* feat(analytics): add evidence-led workspaces

Expand carbon, true-cost, drive-archetype, and share-card analytics with validated evidence models, persistent query states, accounting checks, exports, and SI-boundary display formatting. Correct gasoline-unit handling in TCO calculations and strengthen preconditioning evidence qualification.

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant