Commit d2b104e
feat!: Refactor/filters (#64)
* feat(web): add RangePicker, Popover primitive, and useRangeState hook
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>
* feat(web): migrate 15 pages to <RangePicker> + add presetsOnly mode
Replaces 11 inline <DateRangeFilter> usages and 4 page-local
TIME_RANGES/RANGE_OPTIONS arrays with the new <RangePicker> shipped in
5065dfc62. 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>
* feat(web): right-align RangePicker into PageContainer actions slot on 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>
* feat(web/timeline): add RangePicker to /timeline (presetsOnly, header 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>
* feat(web/locations): add RangePicker to /locations (header right)
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>
* feat(speed-profile): add RangePicker with start/end bounds (no hardcoded 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>
* feat(regen-efficiency): add RangePicker with start/end bounds (no hardcoded 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>
* feat(web/charging-curve): add RangePicker to /charging-curve (header 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>
* feat(web/charging-heatmap): add RangePicker to /charging-heatmap (header 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>
* feat(web): add RangePicker to CommandHistory + AlertsPage
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>
* feat(software-updates): add RangePicker with start/end bounds
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>
* feat(web/tesla-charging): add RangePicker to history + sessions pages
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>
* feat(web): single-line RangePicker trigger + SecurityAccess range filter
- 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>
* feat(web/api-logs): unify date filter to RangePicker; drop hardcoded 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>
* refactor(web): vehicle-first ordering on PageContainer.actions across 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>
* refactor(web): unify vehicle selector via useSelectedVehicle store
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>
* feat(analytics): migrate Fleet Analytics to RangePicker; drop hardcoded 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>
* refactor(command-history): vehicle-first actions + useSelectedVehicle 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>
* feat(telemetry): unify Signal Log/Explorer range filter to canonical 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>
* refactor(signal-explorer): unify control row layout with Signal Log Viewer
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>
* refactor(tesla-charging-history): unify filter row into PageContainer.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>
* fix(ui): make Slider track visible everywhere; align with Input/Select 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>
* refactor(sleep-efficiency): canonical [Vehicle][RangePicker] actions
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>
* refactor(tire-pressure): lift filters to PageContainer.actions
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>
* fix(layout): wrap PageHeader actions on narrow viewports
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>
* refactor(fsm-debugger): use global Vehicle + RangePicker filters
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>
* fix(filters): honor exact RangePicker windows in FSM Debugger + Sleep 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>
* refactor(notifications): consolidate /alerts and /notifications into 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>
* refactor(filters): roll out global VehicleSelect across all vehicle-scoped 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>
* refactor(filters): wire 8 more pages to global Vehicle store
Followup to c9da1f988. 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>
* feat(filters): add global RangePicker to /route-efficiency and /drivetrain-health
Both pages already adopted the global VehicleSelect in commit 1bd18f36.
This closes the parity gap with /efficiency by also exposing the global
date range (RangePicker) at the page header, so fleet-managers can scope
vehicle data by both vehicle AND time window from a single, predictable
place.
Frontend (canonical pattern from EfficiencyPage):
- Imports RangePicker from @/components/forms
- Wires from/to URL params via useUrlString + useUrlBatch
- 30-day default window, RangePicker rendered align=end
- Actions wrapped in flex-wrap div: [VehicleSelect][RangePicker]
Backend / data flow:
- useRouteEfficiency(vehicleId, start?, end?) now passes the date
range as start/end query params (mirrors useRegenEfficiency).
- route_efficiency_handler.go List() reads start/end via the shared
parseDateRange helper and adds the canonical SQL guard:
AND (\::timestamptz IS NULL OR started_at BETWEEN \ AND \)
Omitting the params preserves legacy 'lifetime' behavior.
- useDrivetrainHealth left untouched (it returns CURRENT signal
snapshot — date range not meaningful). The page filters its
useDrives() chart input client-side by start/end before slicing
the last 30 points.
Verification:
- tsc --noEmit clean, go build internal/api clean
- vitest driving features pass (2 pre-existing useSignals failures
untouched and unrelated to this change)
- audit: 0 new violations introduced
- docker rebuild teslasync-api + web, both healthy
- smoke: /route-efficiency, /drivetrain-health -> 200
- smoke: /api/v1/analytics/route-efficiency?vehicle_id=1 -> 2 routes
same with wide range -> 2 routes; narrow ancient range -> 0 routes
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* ui(drives): lift RangePicker into actions row alongside VehicleSelect
On /drives the date filter (RangePicker) lived in the FilterBar row
below the page header, while VehicleSelect sat in the page actions —
visually splitting the two global filters across two rows. The user
asked: 'why date filter is not at same level as vehicle' — both are
global, page-scoping filters and belong on the same row.
Move RangePicker into the actions <div> immediately after VehicleSelect,
so the canonical action order is now [Vehicle][RangePicker][Freshness]
[SavedViews] — matching how /efficiency, /route-efficiency, and the
rest of the driving pages already arrange these controls. The FilterBar
below now contains only the address SearchInput, which is page-local.
Behavior preserved: page reset to 1 on range change, align=end to keep
the popover from clipping at the right edge, triggerTestId added for
future e2e coverage.
Verified: tsc clean, vitest src/features/driving 43/43 pass, docker
rebuild healthy, /drives -> 200.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* feat(filters): wave 4 -- VehicleSelect global rollout to 10 more pages
Convert remaining vehicle-scoped pages from local useState pickers to
the canonical global VehicleSelect + useSelectedVehicle() store so
vehicle context sticks across navigation across the entire app.
Pages converted (Phase A -- user's explicit list):
/charging-curve ChargingCurvePage drops conditional Select
/digital-twin DigitalTwinPage drops selectedIdx state
/trips TripListPage drops useUrlNumber('vehicle_id')
/lifetime-stats LifetimeStatsPage drops 'All Vehicles' option
/vampire-drain VampireDrainPage
/energy-flow EnergyFlowPage
Pages converted (Phase B -- audit-discovered):
/powershare PowersharePage
/smart-charge SmartChargePage vehicleId now comes from store
/safety SafetySettingsPage drops local Vehicle iface + useQuery
/anomalies AnomalyDashboardPage
All pages now expose a single VehicleSelect in the page actions row
alongside any existing RangePicker. Header parity with /efficiency.
Verification:
- npx tsc --noEmit clean
- vitest 479/479 forms+hooks pass; 15/15 affected feature tests pass
- audit script: 0 NEW violations (3 baseline pre-existing)
- docker rebuild: all 10 routes return 200
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* fix(chatbot): make /chatbot mobile-friendly
On phones the page was unusable: the 288px session sidebar consumed
the entire viewport, the chat panel was pushed to ~72px, the height
calc used vh (broken when the address bar / soft keyboard collapses
the visual viewport), and message-action affordances depended on
hover (invisible on touch).
Changes:
- useIsMobile() gates sidebar render. On mobile the history panel
is hidden by default and opens as a fixed-overlay drawer with a
backdrop, full height and 85vw width capped at max-w-sm.
Tapping a session or 'New chat' auto-closes the drawer.
- Container height switched from calc(100vh - 14rem) to
calc(100dvh - 12rem) so the layout breathes correctly when the
mobile URL bar / on-screen keyboard collapses the viewport.
- Chat panel: added min-w-0 so the Textarea inside cannot push
the panel wider than its flex parent.
- Composer: gap reduced (gap-2 sm:gap-3), Stop label hidden below
sm: (icon-only), Send button already icon-only -- both shrink-0.
- ChatMessageItem actions row: added [@media(pointer:coarse)]
:opacity-100 so Copy / Regenerate / Edit are always visible on
touch devices (group-hover never fires without a real cursor).
Verification:
- tsc clean
- vitest 37/37 system tests pass
- docker rebuild: /chatbot returns 200
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* feat(filters): add VehicleSelect to /live-monitor with SSE filter
LiveSignalMonitorPage previously consumed the multiplexed SSE stream and showed signals from every vehicle in the fleet — there was no way to scope the live tail to one car.
Wire it through the global useSelectedVehicle() store like the rest of the Wave 4 rollout:
- Add <VehicleSelect /> to the page actions row alongside the connection badge.
- In handleVehicleUpdate, drop events whose payload.vehicle_id does not match the currently selected vehicle. Events without a vehicle_id (system-level) still pass through so we don't accidentally hide diagnostic frames.
- Track the active vehicle in a ref so the once-registered SSE callback always sees the latest selection without having to re-subscribe.
- Clear the entry buffer + rate samples when the user switches vehicles so we don't intermix stale signals from the previous car.
Note: /live (MapOverviewPage) already exposes VehicleSelect, so no change needed there. The actual gap was /live-monitor.
Verification: tsc clean, vitest 3127/3137 (10 pre-existing failures unrelated), docker rebuild ok, /live + /live-monitor both 200.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* feat(nav): consolidate location pages under Maps section + move API Logs to Diagnostics
Two related sidebar reorganisations:
1. New 'Maps' sidebar section consolidating every map/location page that was previously scattered across Overview, Fleet, Driving, and Notifications:
- /live (was Overview)
- /locations (was Fleet)
- /navigation (was Fleet)
- /trip-planner (was Driving)
- /geofences (was Notifications — clearly out of place; geofences are a location primitive that happens to trigger notifications, not a notification surface)
/live stays in DEFAULT_PINNED_NAV_PATHS, so the Live Map is still pinned at the top of the sidebar — moving its category does not bury it.
2. /api-logs moved from Integrations to Diagnostics. API Logs is a debugging/observability surface, not an integration setup screen — it sits naturally next to MQTT Inspector, Redis Signals, and Telemetry Coverage.
Implementation notes:
- Added a 'Maps' entry to SECTION_ICON_STYLES (green-400 accent — distinct from Charging emerald and Costs green).
- Section titles render directly from section.title (no i18n key required), matching how other sections like 'Charging', 'Battery', etc. work today.
- CommandPalette consumes navSections, so the new section + reordering surface there automatically.
- navSearchKeywords entries for the moved paths are unchanged (keyed by path, not section).
Verification: tsc clean, layout vitest 49/49 pass, docker rebuild ok, all six moved routes return 200.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* feat(integrations): promote Tesla account sub-sections from /settings to dedicated pages
Three Tesla integration surfaces previously rendered as inline panels at the top of /settings now have their own first-class pages under the Integrations sidebar group:
/tesla-features Feature Flags (Tesla account feature configuration)
/tesla-region Region & API (Tesla account region + Fleet API endpoint)
/tesla-orders Active Orders (Vehicle orders + delivery tracking)
Mirrors the earlier promotion of Browser Notifications, Webhooks, and Quiet Hours into their own pages — same pattern, same rationale: deep-linkable, sidebar-discoverable, no scrolling through a 20-section settings page to find a single Tesla integration knob.
Implementation:
- New thin PageContainer wrappers in features/admin/pages/ that mount the existing components from features/settings/components/ — no UI duplication, just a new surface.
- Routes registered in App.tsx alongside /tesla-account and /fleet-api.
- Sidebar Integrations entries added with flag/globe/shoppingCart icons (added Flag + ShoppingCart to lib/icons.ts).
- navSearchKeywords entries added so the command palette finds them.
- /settings: removed the four inline <section>s (TeslaAccountSection, FeatureToggles, RegionSettings, ActiveOrdersSection) and replaced them with a single 'Tesla integration moved' link card matching the existing Notifications-moved pattern. Old hash anchors (#tesla-account, #features, #region, #orders) no longer scroll-target anything inside /settings — the searchIndex was updated to point at the new page URLs instead.
Verification: tsc clean, vitest 115/115 in settings + layout, docker rebuild ok, all six routes (/settings, /tesla-account, /tesla-features, /tesla-region, /tesla-orders, /fleet-api) return 200.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* feat(integrations): promote Gas Price Auto-Poll to its own page
The EIA gas-price auto-poll panel previously rendered as an inline section on /settings#gas-price now has its own first-class page at /gas-price under the Integrations sidebar group, mirroring the recent promotion of the Tesla integration sub-sections (Feature Flags, Region & API, Active Orders).
Implementation:
- New thin PageContainer wrapper at features/admin/pages/GasPriceAutoPollPage.tsx that mounts the existing <GasPriceSettings /> component — no UI duplication.
- Route registered in App.tsx alongside /tesla-orders.
- Sidebar Integrations entry added with the fuel icon (Fuel added to lib/icons.ts).
- navSearchKeywords entry added so the command palette + nav search find it.
- /settings: removed the inline <section id='gas-price'> and folded the link into the existing 'Integrations moved' card (renamed from 'Tesla integration moved' since it now spans both Tesla and EIA integrations). Card grid widened from 4 to 5 columns at the lg breakpoint.
- searchIndex.ts: gas-price entry's href flipped from /settings#gas-price to /gas-price.
Verification: tsc clean, vitest 115/115 settings+layout, docker rebuild ok, /gas-price + /settings + the four prior tesla pages all return 200.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* Move sidebar help row to footer StatusBar (HelpSegment)
Sidebar previously dedicated 50px at the bottom to a passive
"Press ? for shortcuts ┬╖ Take a tour ┬╖ Report bug" row. Wasted
vertical real estate on every page even though the footer
StatusBar already lived right below.
Folded the same three actions into a new HelpSegment component
slotted between BackgroundWork/ActiveVehicle and VersionSegment
in the right group of the StatusBar:
• Press ? → window event 'toggle-keyboard-shortcuts'
(Layout already listens; opens the cheat sheet
modal, same as pressing ? key)
• Take a tour → dispatchTourLauncherOpen() from tourRegistry
• Report bug → window event 'open-feedback-modal'
(matches the existing CmdK 'feedback.open'
command; Layout listener unchanged)
All three actions stay decoupled from the React tree so the Cmd+K
palette, keyboard shortcuts, and any other dispatcher continue to
work without modification. Visual treatment matches the other
status-bar segments: 11px label + 12px icon, hover bg-white/4,
collapses to icon-only on narrow viewports / compact prefs.
Removed:
- The 50px <div> at the bottom of the sidebar in Layout.tsx
- Now-unused dispatchTourLauncherOpen import in Layout.tsx
Renamed the data-testid on the Report-bug button from
'sidebar-feedback-trigger' to 'status-bar-feedback-trigger'
to match its new location (no callers referenced the old id).
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* Remove "Notifications moved" advisory panel from /settings
The Notifications section in /settings was a one-time forwarding
panel (purple Bell icon → Browser / Webhooks / Quiet hours) we
left behind when those screens moved to their own pages and the
sidebar got a dedicated Notifications category. Now that the
sidebar shows them directly, the advisory adds clutter without
any new info, so it gets removed.
Changes:
- SettingsPage.tsx ΓÇö delete the entire <section id="notifications">
block and the now-unused Bell/WebhookIcon/Moon lucide imports.
Link/IconBox stay (used elsewhere on the page).
- checklist.ts ΓÇö redirect the "Enable browser notifications"
onboarding task CTA from '/settings#notifications' (deleted
anchor) to '/notifications/browser' (the dedicated page where
the permission prompt actually lives).
Settings search-index entries already point at /notifications/*
so search results are unaffected.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* Split 2FA + Active Sessions into /account pages under new Account sidebar category
Account-security primitives (TOTP enrollment, active session management) deserve their own dedicated pages distinct from the catch-all /settings page and from the vehicle-Security sidebar group.
- New routes: /account/2fa and /account/sessions
- New pages: TwoFactorAuthPage and ActiveSessionsPage are thin PageContainer wrappers around the existing TOTPEnrollmentSection and ActiveSessionsSection components, so behavior is unchanged
- New 'Account' sidebar section between Integrations and Settings & Admin (zinc accent)
- Cmd-K search index updated: 5 entries now point at /account/2fa or /account/sessions instead of the deleted #security and #sessions hash anchors
- SettingsPage no longer renders the security/sessions sections
- Added Icons.activity alias used by the new section's nav entry
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* Split Privacy into /account/privacy under Account sidebar category
Browser-local privacy controls (recently viewed pages, cookies / GDPR
analytics consent) are user-scoped account preferences, not deployment-wide
settings, so they belong with 2FA and Active Sessions under the Account
side-nav category - not buried in the dense Settings page.
- New route: /account/privacy
- New page: PrivacyPage is a thin PageContainer wrapper around the existing
PrivacySection component (recent-pages clear + ConfirmDialog, cookie-consent
re-grant/withdraw/reset), so behavior is unchanged
- New 'Privacy' nav item added to the existing Account sidebar section
(emerald shield icon)
- Cmd-K palette: navSearchKeywords entry added; SettingsSearch index gains
2 new entries (recent-pages clear + consent management) under the new
'privacy' tag pointing at /account/privacy
- SettingsPage no longer renders the privacy section and the PrivacySection
import was converted to a comment matching the existing TOTPEnrollmentSection
/ ActiveSessionsSection convention
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* Move settings JSON export/import into /backup, drop duplicate from Settings
The Settings page rendered a "Backup & Restore" GlassPanel
(SettingsExportImport) that visually duplicated the dedicated /backup
page already living under the DATA sidebar category. Two surfaces with
the same name and overlapping intent were confusing.
Consolidation:
- BackupRestorePage now mounts SettingsExportImport as a third panel
beneath the existing backup configurations and runs history. The
comment block clarifies the distinction: scheduled provider-backed
database snapshots above, portable JSON config bundle below.
- SettingsPage no longer renders <section id="backup"> and the
SettingsExportImport import was converted to a comment matching the
TOTPEnrollmentSection / ActiveSessionsSection / PrivacySection
convention used for previously-promoted sections.
- Cmd-K search index entries (backup.export, backup.import) repointed
from /settings#backup to /backup so palette navigation lands on the
canonical page.
The cross-feature import (admin -> features/settings/components) follows
the established pattern already used by GasPriceAutoPollPage,
TeslaFeatureFlagsPage, TeslaOrdersPage, and TeslaRegionPage.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* Rename 'Controls' sidebar group to 'Vehicle Commands'
The single-word 'Controls' label was ambiguous (UI controls? form controls? feature toggles?). 'Vehicle Commands' makes the group's purpose explicit — it houses the /commands launcher and /command-history audit log.
Mechanical rename in two places: the navSections title and the matching SECTION_ICON_STYLES key (kept fuchsia accent). Items unchanged.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* Add 'Smart Actions' sidebar group; move Studio + Automations under it
The 'Automation' group conflated three different concerns: alert rule
listing, the visual rule builder, and the workflow automations engine.
Splitting it makes the menu read cleanly:
- Notifications (now includes Rules) — passive surfaces and triggers
for alerts. Rules belong here because they configure when alerts fire.
- Smart Actions (new) — composable / authored automation surfaces:
Studio (visual alert-rule builder) and Automations (workflow engine).
Changes:
- New 'Smart Actions' navSection inserted directly after Notifications
with Studio and Automations items, keeping the amber accent inherited
from the old Automation group.
- Rules item moved into the Notifications group between Alerts and
Channels (logical grouping with the other notification surfaces).
- Old 'Automation' navSection removed.
- SECTION_ICON_STYLES key 'Automation' renamed to 'Smart Actions'
(same color tokens).
No route changes — all three pages (/notifications/rules,
/notifications/studio, /automations) keep their existing paths and
are still reachable from the same URLs.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* Move Anomaly Detection from Infrastructure to Analytics sidebar group
Anomaly detection is an analytical surface (find outliers in vehicle/charging metrics) not an infra observability surface, so it belongs alongside /analytics and /statistics rather than /system-status and /db-health.
- /anomaly-detection nav item moved into the Analytics group, appended after Statistics (route unchanged)
- navSearchKeywords entry refreshed: removed 'diagnostics' (no longer accurate), added 'analytics' and 'detection' for palette discoverability
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* Split 'Assistant' sidebar group into 'AI' (Chatbot) and 'Media' (Media Player)
Chatbot is an LLM-backed conversational surface; Media Player is a
remote-control UI for in-car audio playback. Bundling them under a
generic 'Assistant' label undersold both — promoting the AI category
makes the LLM surface easy to find and primes the group for future
AI-powered tools (recommendations, natural-language drive search, etc).
Changes:
- New 'AI' navSection contains Chatbot (purple accent — distinct from
the rest of the palette and signals the LLM/AI nature of the group)
- New 'Media' navSection inherits the pink accent the old group used
and houses Media Player (kept on the same route)
- Old 'Assistant' navSection removed
- SECTION_ICON_STYLES updated: 'Assistant' replaced with 'AI' (purple)
and 'Media' (pink, same tokens as before)
No route changes — /chatbot and /me…1 parent d2d6ef7 commit d2b104e
491 files changed
Lines changed: 34809 additions & 9369 deletions
File tree
- cmd/notification-worker
- docs/public
- grafana/dashboards
- infra
- system
- internal
- alertmsg
- api
- database
- models
- notification
- migrations
- scripts
- web
- src
- __tests__
- api
- hooks
- components
- __tests__
- charts
- __tests__
- data-display
- __tests__
- format
- feedback
- __tests__
- forms
- __tests__
- layout
- __tests__
- status-bar
- status
- __tests__
- ui
- __tests__
- vehicles
- features
- admin
- components
- pages
- analytics
- components
- analytics
- review
- weekly-digest
- pages
- automations/pages
- battery/pages
- charging
- components
- charging-curve
- charging-list
- cost-analysis
- pages
- __tests__
- dashboard
- components
- __tests__
- pages
- widgets
- diagnostics/pages
- driving
- components
- drive-detail
- driving-dynamics
- pages
- maps/pages
- notifications
- components
- pages
- schemas
- onboarding
- tours
- settings
- components
- pages
- sharing/pages
- system
- components
- chatbot
- state-machine
- status
- __tests__
- hooks
- pages
- __tests__
- telemetry
- components
- hooks
- pages
- __tests__
- trips/pages
- vehicle-systems/pages
- vehicles
- components
- telemetry-panels
- vehicle-detail
- pages
- hooks
- __tests__
- i18n
- lib
- __tests__
- sw
- types
Some content is hidden
Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
88 | 88 | | |
89 | 89 | | |
90 | 90 | | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
12 | 12 | | |
13 | 13 | | |
14 | 14 | | |
| 15 | + | |
15 | 16 | | |
16 | 17 | | |
17 | 18 | | |
| |||
345 | 346 | | |
346 | 347 | | |
347 | 348 | | |
348 | | - | |
| 349 | + | |
| 350 | + | |
| 351 | + | |
| 352 | + | |
| 353 | + | |
| 354 | + | |
| 355 | + | |
| 356 | + | |
| 357 | + | |
| 358 | + | |
| 359 | + | |
| 360 | + | |
| 361 | + | |
| 362 | + | |
| 363 | + | |
349 | 364 | | |
350 | 365 | | |
351 | 366 | | |
| |||
376 | 391 | | |
377 | 392 | | |
378 | 393 | | |
| 394 | + | |
379 | 395 | | |
380 | 396 | | |
381 | 397 | | |
382 | 398 | | |
| 399 | + | |
| 400 | + | |
| 401 | + | |
| 402 | + | |
| 403 | + | |
| 404 | + | |
| 405 | + | |
| 406 | + | |
| 407 | + | |
| 408 | + | |
| 409 | + | |
| 410 | + | |
| 411 | + | |
| 412 | + | |
| 413 | + | |
| 414 | + | |
| 415 | + | |
| 416 | + | |
383 | 417 | | |
384 | 418 | | |
385 | 419 | | |
386 | 420 | | |
387 | 421 | | |
388 | 422 | | |
389 | | - | |
390 | | - | |
391 | | - | |
392 | | - | |
393 | | - | |
394 | | - | |
| 423 | + | |
| 424 | + | |
| 425 | + | |
| 426 | + | |
| 427 | + | |
| 428 | + | |
| 429 | + | |
| 430 | + | |
395 | 431 | | |
396 | 432 | | |
397 | 433 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
4260 | 4260 | | |
4261 | 4261 | | |
4262 | 4262 | | |
4263 | | - | |
| 4263 | + | |
4264 | 4264 | | |
4265 | 4265 | | |
4266 | 4266 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
835 | 835 | | |
836 | 836 | | |
837 | 837 | | |
838 | | - | |
| 838 | + | |
839 | 839 | | |
840 | | - | |
| 840 | + | |
841 | 841 | | |
842 | 842 | | |
843 | 843 | | |
| |||
859 | 859 | | |
860 | 860 | | |
861 | 861 | | |
862 | | - | |
| 862 | + | |
863 | 863 | | |
864 | | - | |
| 864 | + | |
865 | 865 | | |
866 | 866 | | |
867 | 867 | | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
166 | 166 | | |
167 | 167 | | |
168 | 168 | | |
| 169 | + | |
| 170 | + | |
| 171 | + | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
| 181 | + | |
| 182 | + | |
| 183 | + | |
| 184 | + | |
| 185 | + | |
| 186 | + | |
| 187 | + | |
| 188 | + | |
| 189 | + | |
| 190 | + | |
| 191 | + | |
| 192 | + | |
| 193 | + | |
| 194 | + | |
| 195 | + | |
| 196 | + | |
| 197 | + | |
| 198 | + | |
| 199 | + | |
| 200 | + | |
| 201 | + | |
| 202 | + | |
| 203 | + | |
| 204 | + | |
| 205 | + | |
| 206 | + | |
169 | 207 | | |
170 | 208 | | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
464 | 464 | | |
465 | 465 | | |
466 | 466 | | |
467 | | - | |
| 467 | + | |
468 | 468 | | |
469 | | - | |
| 469 | + | |
470 | 470 | | |
471 | 471 | | |
472 | 472 | | |
| |||
488 | 488 | | |
489 | 489 | | |
490 | 490 | | |
491 | | - | |
| 491 | + | |
492 | 492 | | |
493 | | - | |
| 493 | + | |
494 | 494 | | |
495 | 495 | | |
496 | 496 | | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
184 | 184 | | |
185 | 185 | | |
186 | 186 | | |
| 187 | + | |
| 188 | + | |
| 189 | + | |
| 190 | + | |
| 191 | + | |
| 192 | + | |
| 193 | + | |
| 194 | + | |
| 195 | + | |
| 196 | + | |
| 197 | + | |
| 198 | + | |
| 199 | + | |
| 200 | + | |
| 201 | + | |
| 202 | + | |
| 203 | + | |
| 204 | + | |
| 205 | + | |
| 206 | + | |
| 207 | + | |
| 208 | + | |
| 209 | + | |
| 210 | + | |
| 211 | + | |
| 212 | + | |
| 213 | + | |
| 214 | + | |
| 215 | + | |
| 216 | + | |
| 217 | + | |
| 218 | + | |
| 219 | + | |
| 220 | + | |
| 221 | + | |
| 222 | + | |
| 223 | + | |
| 224 | + | |
187 | 225 | | |
188 | 226 | | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
182 | 182 | | |
183 | 183 | | |
184 | 184 | | |
| 185 | + | |
| 186 | + | |
| 187 | + | |
| 188 | + | |
| 189 | + | |
| 190 | + | |
| 191 | + | |
| 192 | + | |
| 193 | + | |
| 194 | + | |
| 195 | + | |
| 196 | + | |
| 197 | + | |
| 198 | + | |
| 199 | + | |
| 200 | + | |
| 201 | + | |
| 202 | + | |
| 203 | + | |
| 204 | + | |
| 205 | + | |
| 206 | + | |
| 207 | + | |
| 208 | + | |
| 209 | + | |
| 210 | + | |
| 211 | + | |
| 212 | + | |
| 213 | + | |
| 214 | + | |
| 215 | + | |
| 216 | + | |
| 217 | + | |
| 218 | + | |
| 219 | + | |
| 220 | + | |
| 221 | + | |
| 222 | + | |
185 | 223 | | |
186 | 224 | | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
572 | 572 | | |
573 | 573 | | |
574 | 574 | | |
575 | | - | |
| 575 | + | |
| 576 | + | |
| 577 | + | |
| 578 | + | |
| 579 | + | |
| 580 | + | |
| 581 | + | |
| 582 | + | |
| 583 | + | |
| 584 | + | |
| 585 | + | |
| 586 | + | |
| 587 | + | |
| 588 | + | |
| 589 | + | |
| 590 | + | |
| 591 | + | |
| 592 | + | |
| 593 | + | |
| 594 | + | |
| 595 | + | |
| 596 | + | |
| 597 | + | |
| 598 | + | |
| 599 | + | |
| 600 | + | |
| 601 | + | |
| 602 | + | |
| 603 | + | |
| 604 | + | |
| 605 | + | |
| 606 | + | |
| 607 | + | |
| 608 | + | |
| 609 | + | |
| 610 | + | |
| 611 | + | |
| 612 | + | |
| 613 | + | |
576 | 614 | | |
0 commit comments