Skip to content

Commit d2b104e

Browse files
atulmguptaCopilot
andauthored
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

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,3 +88,6 @@ ENGINEERING_GUIDELINES.md
8888
scripts/check-loc.js
8989
/.github/prompts
9090
/.github/prompts/db-refactor/logs
91+
92+
# Redesign working notes (Phase 50+ /charging redesign)
93+
REDESIGN-NOTES.md

CHANGELOG.md

Lines changed: 542 additions & 465 deletions
Large diffs are not rendered by default.

cmd/notification-worker/main.go

Lines changed: 43 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"github.com/rs/zerolog"
1313
"github.com/rs/zerolog/log"
1414

15+
"github.com/ev-dev-labs/teslasync/internal/alertmsg"
1516
"github.com/ev-dev-labs/teslasync/internal/apilog"
1617
"github.com/ev-dev-labs/teslasync/internal/config"
1718
"github.com/ev-dev-labs/teslasync/internal/database"
@@ -345,7 +346,21 @@ continue
345346
if !result.Triggered {
346347
continue
347348
}
348-
dispatchComputedMetricNotification(rule, vid, result, channels, mqttClient)
349+
// Resolve a friendly vehicle name for the message template,
350+
// falling back silently when the vehicle is missing — the
351+
// renderer is tolerant of an empty VehicleName.
352+
vehicleName := ""
353+
for _, v := range allVehicles {
354+
if v != nil && v.ID == vid {
355+
if v.DisplayName != "" {
356+
vehicleName = v.DisplayName
357+
} else {
358+
vehicleName = v.VIN
359+
}
360+
break
361+
}
362+
}
363+
dispatchComputedMetricNotification(rule, vid, vehicleName, result, channels, mqttClient)
349364
}
350365
}
351366
}
@@ -376,22 +391,43 @@ func vehiclesForRule(rule *models.AlertRule, all []*models.Vehicle) []int64 {
376391
func dispatchComputedMetricNotification(
377392
rule *models.AlertRule,
378393
vehicleID int64,
394+
vehicleName string,
379395
result computed.Result,
380396
channels []*models.NotificationChannel,
381397
mqttClient pahomqtt.Client,
382398
) {
399+
// Phase-50 / ADR-005: route computed-metric dispatch through the
400+
// shared alertmsg package so the message is rendered identically to
401+
// the telemetry path. Without this, a custom msg_template on a
402+
// computed-metric rule would be ignored and the IncludeTitle toggle
403+
// would never reach the transports.
404+
msgCtx := alertmsg.BuildContext(rule, vehicleName, nil, map[string]any{
405+
"Severity": rule.Severity,
406+
"MetricValue": result.Value,
407+
"MetricPrevValue": result.PreviousValue,
408+
"MetricChangePct": result.PercentChange,
409+
})
410+
title := alertmsg.RenderTitle(rule, msgCtx)
411+
body := alertmsg.RenderBody(rule, msgCtx)
412+
if !rule.IncludeTitle && body == "" {
413+
body = rule.Name
414+
}
415+
suppressTransportTitle := !rule.IncludeTitle
416+
383417
dispatched := 0
384418
for _, ch := range channels {
385419
if ch == nil || !ch.Enabled {
386420
continue
387421
}
388422
req := &notification.Request{
389-
ChannelType: ch.Type,
390-
Config: ch.Config,
391-
Title: rule.Name,
392-
Message: result.Message,
393-
ChannelID: ch.ID,
394-
AlertID: rule.ID,
423+
ChannelType: ch.Type,
424+
Config: ch.Config,
425+
Title: title,
426+
Message: body,
427+
ChannelID: ch.ID,
428+
AlertID: rule.ID,
429+
Severity: rule.Severity,
430+
SuppressTransportTitle: suppressTransportTitle,
395431
}
396432
if pubErr := notification.Publish(mqttClient, req); pubErr != nil {
397433
log.Error().

docs/public/openapi.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4260,7 +4260,7 @@ paths:
42604260
post:
42614261
operationId: submitExportJob
42624262
summary: Submit async export job
4263-
description: Submits an asynchronous export job for large datasets. CSV exports are v2-only and use SI canonical filenames and columns: teslasync-drives-v2.csv, teslasync-charging-v2.csv, teslasync-trips-v2.csv. Drive headers include distance_m, duration_s, max_speed_mps; trip headers include total_distance_m, total_energy_wh, total_duration_s.
4263+
description: "Submits an asynchronous export job for large datasets. CSV exports are v2-only and use SI canonical filenames and columns: teslasync-drives-v2.csv, teslasync-charging-v2.csv, teslasync-trips-v2.csv. Drive headers include distance_m, duration_s, max_speed_mps; trip headers include total_distance_m, total_energy_wh, total_duration_s."
42644264
tags: [Export]
42654265
requestBody:
42664266
required: true

grafana/dashboards/infra/api-performance.json

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -835,9 +835,9 @@
835835
"tags": [],
836836
"targetBlank": false,
837837
"title": "◀ Prev",
838-
"tooltip": "TeslaSync - Telemetry Pipeline",
838+
"tooltip": "TeslaSync - Trace Explorer (Tempo TraceQL)",
839839
"type": "link",
840-
"url": "/d/teslasync-infra-telemetry"
840+
"url": "/d/teslasync-infra-trace-explorer"
841841
},
842842
{
843843
"asDropdown": true,
@@ -859,9 +859,9 @@
859859
"tags": [],
860860
"targetBlank": false,
861861
"title": "Next ▶",
862-
"tooltip": "TeslaSync - Rules Engine",
862+
"tooltip": "TeslaSync - Auth Login Deep-Dive (always-sampled)",
863863
"type": "link",
864-
"url": "/d/teslasync-infra-cep"
864+
"url": "/d/teslasync-infra-auth-deep-dive"
865865
}
866866
]
867867
}

grafana/dashboards/infra/auth-deep-dive.json

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,5 +166,43 @@
166166
"targets": [{ "datasource": { "type": "tempo", "uid": "DS_TESLASYNC_TEMPO" }, "queryType": "traceql", "query": "{ span.http.route = \"/api/v1/auth/login\" && duration > 1s }", "limit": 30, "refId": "A" }],
167167
"datasource": { "type": "tempo", "uid": "DS_TESLASYNC_TEMPO" }
168168
}
169+
],
170+
"links": [
171+
{
172+
"asDropdown": false,
173+
"icon": "bolt",
174+
"includeVars": true,
175+
"keepTime": true,
176+
"tags": [],
177+
"targetBlank": false,
178+
"title": "◀ Prev",
179+
"tooltip": "TeslaSync - API Performance",
180+
"type": "link",
181+
"url": "/d/teslasync-infra-api-perf"
182+
},
183+
{
184+
"asDropdown": true,
185+
"icon": "external link",
186+
"includeVars": true,
187+
"keepTime": true,
188+
"tags": [
189+
"teslasync"
190+
],
191+
"targetBlank": false,
192+
"title": "⚡ TeslaSync",
193+
"type": "dashboards"
194+
},
195+
{
196+
"asDropdown": false,
197+
"icon": "bolt",
198+
"includeVars": true,
199+
"keepTime": true,
200+
"tags": [],
201+
"targetBlank": false,
202+
"title": "Next ▶",
203+
"tooltip": "TeslaSync - Rules Engine",
204+
"type": "link",
205+
"url": "/d/teslasync-infra-cep"
206+
}
169207
]
170208
}

grafana/dashboards/infra/cep-rule-engine.json

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -464,9 +464,9 @@
464464
"tags": [],
465465
"targetBlank": false,
466466
"title": "◀ Prev",
467-
"tooltip": "TeslaSync - API Performance",
467+
"tooltip": "TeslaSync - Auth Login Deep-Dive (always-sampled)",
468468
"type": "link",
469-
"url": "/d/teslasync-infra-api-perf"
469+
"url": "/d/teslasync-infra-auth-deep-dive"
470470
},
471471
{
472472
"asDropdown": true,
@@ -488,9 +488,9 @@
488488
"tags": [],
489489
"targetBlank": false,
490490
"title": "Next ▶",
491-
"tooltip": "TeslaSync - Infrastructure Health",
491+
"tooltip": "TeslaSync - Critical Flow Latency (Phase-44 instrumented)",
492492
"type": "link",
493-
"url": "/d/teslasync-infra-health"
493+
"url": "/d/teslasync-infra-critical-flows"
494494
}
495495
]
496496
}

grafana/dashboards/infra/critical-flows.json

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,5 +184,43 @@
184184
"targets": [{ "datasource": { "type": "tempo", "uid": "DS_TESLASYNC_TEMPO" }, "queryType": "traceql", "query": "{ name =~ \"mqtt.*|normalize.*\" && duration > 500ms }", "limit": 30, "refId": "A" }],
185185
"datasource": { "type": "tempo", "uid": "DS_TESLASYNC_TEMPO" }
186186
}
187+
],
188+
"links": [
189+
{
190+
"asDropdown": false,
191+
"icon": "bolt",
192+
"includeVars": true,
193+
"keepTime": true,
194+
"tags": [],
195+
"targetBlank": false,
196+
"title": "◀ Prev",
197+
"tooltip": "TeslaSync - Rules Engine",
198+
"type": "link",
199+
"url": "/d/teslasync-infra-cep"
200+
},
201+
{
202+
"asDropdown": true,
203+
"icon": "external link",
204+
"includeVars": true,
205+
"keepTime": true,
206+
"tags": [
207+
"teslasync"
208+
],
209+
"targetBlank": false,
210+
"title": "⚡ TeslaSync",
211+
"type": "dashboards"
212+
},
213+
{
214+
"asDropdown": false,
215+
"icon": "bolt",
216+
"includeVars": true,
217+
"keepTime": true,
218+
"tags": [],
219+
"targetBlank": false,
220+
"title": "Next ▶",
221+
"tooltip": "TeslaSync - DB & Tesla API Span Breakdown (Tempo)",
222+
"type": "link",
223+
"url": "/d/teslasync-infra-db-tesla-breakdown"
224+
}
187225
]
188226
}

grafana/dashboards/infra/db-tesla-breakdown.json

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,5 +182,43 @@
182182
"targets": [{ "datasource": { "type": "tempo", "uid": "DS_TESLASYNC_TEMPO" }, "queryType": "traceql", "query": "{ span.kind = \"client\" && span.http.url =~ \".*tesla.*\" && span.duration > 2s }", "limit": 30, "refId": "A" }],
183183
"datasource": { "type": "tempo", "uid": "DS_TESLASYNC_TEMPO" }
184184
}
185+
],
186+
"links": [
187+
{
188+
"asDropdown": false,
189+
"icon": "bolt",
190+
"includeVars": true,
191+
"keepTime": true,
192+
"tags": [],
193+
"targetBlank": false,
194+
"title": "◀ Prev",
195+
"tooltip": "TeslaSync - Critical Flow Latency (Phase-44 instrumented)",
196+
"type": "link",
197+
"url": "/d/teslasync-infra-critical-flows"
198+
},
199+
{
200+
"asDropdown": true,
201+
"icon": "external link",
202+
"includeVars": true,
203+
"keepTime": true,
204+
"tags": [
205+
"teslasync"
206+
],
207+
"targetBlank": false,
208+
"title": "⚡ TeslaSync",
209+
"type": "dashboards"
210+
},
211+
{
212+
"asDropdown": false,
213+
"icon": "bolt",
214+
"includeVars": true,
215+
"keepTime": true,
216+
"tags": [],
217+
"targetBlank": false,
218+
"title": "Next ▶",
219+
"tooltip": "TeslaSync - Fleet Telemetry Server",
220+
"type": "link",
221+
"url": "/d/teslasync-infra-fleet-telemetry"
222+
}
185223
]
186224
}

grafana/dashboards/infra/fleet-telemetry.json

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -572,5 +572,43 @@
572572
"current": { "text": "Prometheus", "value": "prometheus" }
573573
}
574574
]
575-
}
575+
},
576+
"links": [
577+
{
578+
"asDropdown": false,
579+
"icon": "bolt",
580+
"includeVars": true,
581+
"keepTime": true,
582+
"tags": [],
583+
"targetBlank": false,
584+
"title": "◀ Prev",
585+
"tooltip": "TeslaSync - DB & Tesla API Span Breakdown (Tempo)",
586+
"type": "link",
587+
"url": "/d/teslasync-infra-db-tesla-breakdown"
588+
},
589+
{
590+
"asDropdown": true,
591+
"icon": "external link",
592+
"includeVars": true,
593+
"keepTime": true,
594+
"tags": [
595+
"teslasync"
596+
],
597+
"targetBlank": false,
598+
"title": "⚡ TeslaSync",
599+
"type": "dashboards"
600+
},
601+
{
602+
"asDropdown": false,
603+
"icon": "bolt",
604+
"includeVars": true,
605+
"keepTime": true,
606+
"tags": [],
607+
"targetBlank": false,
608+
"title": "Next ▶",
609+
"tooltip": "TeslaSync - Infrastructure Health",
610+
"type": "link",
611+
"url": "/d/teslasync-infra-health"
612+
}
613+
]
576614
}

0 commit comments

Comments
 (0)