From 425c36253f7c5ef614ab233322f8502a6f8966c2 Mon Sep 17 00:00:00 2001 From: Atul Gupta Date: Sat, 8 Aug 2026 15:46:57 -0700 Subject: [PATCH 01/15] refactor(web): rewrite VehicleCommandCenter around shared range/command hooks Redesigns the vehicle command center into modular components (hero, readiness strip, workspace, safety panel, activity feed) under features/system/components/command-center/, replacing the monolithic VehicleCommandCenter with domain-grouped command browsing and a dedicated /vehicles/{id}/command endpoint contract. Introduces useRangeState with shared cross-page localStorage persistence (SHARED_RANGE_STORAGE_KEY) so date-range selections propagate between pages, and migrates all range-picker consumers (StatisticsPage, EnergyPage, ChargingListPage, CostAnalysisPage, DriveScorePage, DrivesListPage, DrivetrainHealthPage, EfficiencyPage, RouteEfficiencyPage, TripListPage, MyActivityPage) off ad-hoc useUrlString/useUrlBatch date defaults. Adds a refetchInterval to useCommandLatest, normalizes command-query invalidation keys via a shared commandKeys helper, fixes MediaPlayerPage volume formatting to respect global precision, and removes an unstable Tesla charging-history sort parameter. Also relaxes several Go/TS command types to optional/nullable fields and adds CollapsibleCommandGroup vehicleKey prop rename. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/tesla/client_energy_charging.go | 4 +- internal/tesla/client_energy_charging_test.go | 73 +++ web/src/api/hooks/useCommands.ts | 3 +- web/src/api/hooks/useVehicleCommand.test.tsx | 12 +- web/src/api/hooks/useVehicleCommand.ts | 11 +- .../forms/__tests__/RangePicker.test.tsx | 1 + .../analytics/pages/StatisticsPage.tsx | 17 +- .../battery/pages/EnergyPage.test.tsx | 8 + web/src/features/battery/pages/EnergyPage.tsx | 19 +- .../charging/pages/ChargingListPage.tsx | 18 +- .../charging/pages/CostAnalysisPage.tsx | 26 +- .../driving/pages/DriveScorePage.test.tsx | 28 +- .../features/driving/pages/DriveScorePage.tsx | 24 +- .../features/driving/pages/DrivesListPage.tsx | 18 +- .../pages/DrivetrainHealthPage.test.tsx | 37 +- .../driving/pages/DrivetrainHealthPage.tsx | 17 +- .../features/driving/pages/EfficiencyPage.tsx | 16 +- .../driving/pages/RouteEfficiencyPage.tsx | 17 +- web/src/features/system/commands.ts | 49 +- .../CollapsibleCommandGroup.test.tsx | 14 +- .../components/CollapsibleCommandGroup.tsx | 12 +- .../system/components/CommandSelectDialog.tsx | 9 +- .../system/components/CommandTile.tsx | 12 +- .../system/components/InputCommandTile.tsx | 10 +- .../system/components/ToggleCommandTile.tsx | 12 +- .../components/VehicleCommandCenter.test.tsx | 597 ++++++++++-------- .../components/VehicleCommandCenter.tsx | 519 +++++---------- .../command-center/CommandCenterFallback.tsx | 135 ++++ .../command-center/CommandCenterHero.tsx | 165 +++++ .../command-center/CommandDialogs.tsx | 99 +++ .../command-center/CommandDomainBrowser.tsx | 117 ++++ .../command-center/CommandReadinessStrip.tsx | 154 +++++ .../command-center/CommandSafetyPanel.tsx | 82 +++ .../command-center/CommandWorkspace.tsx | 192 ++++++ .../command-center/RecentCommandActivity.tsx | 126 ++++ .../VehicleFreshnessWarning.tsx | 38 ++ .../command-center/commandDomains.test.ts | 24 + .../command-center/commandDomains.ts | 66 ++ .../command-center/commandLabels.ts | 47 ++ .../system/components/command-center/index.ts | 6 + .../system/components/command-center/types.ts | 5 + .../command-center/useCommandFavorites.ts | 43 ++ .../system/pages/CommandsPage.test.tsx | 457 +++++--------- .../features/system/pages/CommandsPage.tsx | 307 ++++----- .../system/pages/MyActivityPage.test.tsx | 1 + .../features/system/pages/MyActivityPage.tsx | 29 +- web/src/features/trips/pages/TripListPage.tsx | 25 +- .../pages/MediaPlayerPage.test.tsx | 28 + .../vehicle-systems/pages/MediaPlayerPage.tsx | 6 +- .../hooks/__tests__/useRangeState.test.tsx | 141 ++++- web/src/hooks/useRangeState.ts | 205 ++++-- web/src/i18n/en.json | 338 +++++++++- 52 files changed, 3013 insertions(+), 1406 deletions(-) create mode 100644 internal/tesla/client_energy_charging_test.go create mode 100644 web/src/features/system/components/command-center/CommandCenterFallback.tsx create mode 100644 web/src/features/system/components/command-center/CommandCenterHero.tsx create mode 100644 web/src/features/system/components/command-center/CommandDialogs.tsx create mode 100644 web/src/features/system/components/command-center/CommandDomainBrowser.tsx create mode 100644 web/src/features/system/components/command-center/CommandReadinessStrip.tsx create mode 100644 web/src/features/system/components/command-center/CommandSafetyPanel.tsx create mode 100644 web/src/features/system/components/command-center/CommandWorkspace.tsx create mode 100644 web/src/features/system/components/command-center/RecentCommandActivity.tsx create mode 100644 web/src/features/system/components/command-center/VehicleFreshnessWarning.tsx create mode 100644 web/src/features/system/components/command-center/commandDomains.test.ts create mode 100644 web/src/features/system/components/command-center/commandDomains.ts create mode 100644 web/src/features/system/components/command-center/commandLabels.ts create mode 100644 web/src/features/system/components/command-center/index.ts create mode 100644 web/src/features/system/components/command-center/types.ts create mode 100644 web/src/features/system/components/command-center/useCommandFavorites.ts diff --git a/internal/tesla/client_energy_charging.go b/internal/tesla/client_energy_charging.go index 0cfd084887..62d66a0918 100644 --- a/internal/tesla/client_energy_charging.go +++ b/internal/tesla/client_energy_charging.go @@ -33,8 +33,8 @@ func (c *Client) GetChargingHistory(ctx context.Context, vin string, startTime, } params.Set("pageNo", strconv.Itoa(pageNo)) params.Set("pageSize", strconv.Itoa(pageSize)) - params.Set("sortBy", "chargeStartDateTime") - params.Set("sortOrder", "DESC") + // Tesla's sort-field allowlist is not stable; omit optional server-side + // sorting and let the repository/page order the persisted results. path := "/api/1/dx/charging/history?" + params.Encode() return c.doRequest(ctx, http.MethodGet, path, nil) diff --git a/internal/tesla/client_energy_charging_test.go b/internal/tesla/client_energy_charging_test.go new file mode 100644 index 0000000000..3847e2caf5 --- /dev/null +++ b/internal/tesla/client_energy_charging_test.go @@ -0,0 +1,73 @@ +package tesla + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" +) + +func TestGetChargingHistoryOmitsUnsupportedSortParameters(t *testing.T) { + t.Parallel() + + const ( + vin = "5YJ3E1EA1KF000001" + startTime = "2026-05-08T20:39:45Z" + endTime = "2026-08-08T20:39:45Z" + ) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("method = %s, want GET", r.Method) + } + if r.URL.Path != "/api/1/dx/charging/history" { + t.Errorf("path = %q, want /api/1/dx/charging/history", r.URL.Path) + } + + query := r.URL.Query() + if got := query.Get("vin"); got != vin { + t.Errorf("vin = %q, want %q", got, vin) + } + if got := query.Get("startTime"); got != startTime { + t.Errorf("startTime = %q, want %q", got, startTime) + } + if got := query.Get("endTime"); got != endTime { + t.Errorf("endTime = %q, want %q", got, endTime) + } + if got := query.Get("pageNo"); got != "1" { + t.Errorf("pageNo = %q, want 1", got) + } + if got := query.Get("pageSize"); got != "50" { + t.Errorf("pageSize = %q, want 50", got) + } + if query.Has("sortBy") { + t.Errorf("unsupported sortBy parameter sent: %q", query.Get("sortBy")) + } + if query.Has("sortOrder") { + t.Errorf("sortOrder sent without a supported sortBy: %q", query.Get("sortOrder")) + } + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"response":{"data":[],"hasMoreData":false}}`)) + })) + defer server.Close() + + client := newTestClient(server) + body, status, err := client.GetChargingHistory( + context.Background(), + vin, + startTime, + endTime, + 1, + 50, + ) + if err != nil { + t.Fatalf("GetChargingHistory() error = %v", err) + } + if status != http.StatusOK { + t.Fatalf("status = %d, want %d", status, http.StatusOK) + } + if len(body) == 0 { + t.Fatal("response body is empty") + } +} diff --git a/web/src/api/hooks/useCommands.ts b/web/src/api/hooks/useCommands.ts index f4182df634..255f6ca1e8 100644 --- a/web/src/api/hooks/useCommands.ts +++ b/web/src/api/hooks/useCommands.ts @@ -1,6 +1,6 @@ import { useQuery } from '@tanstack/react-query'; import { request } from '../client'; -import { STALE_TIMES } from '@/lib/constants'; +import { INTERVALS, STALE_TIMES } from '@/lib/constants'; export interface CommandLogEntry { id: number; @@ -64,5 +64,6 @@ export function useCommandLatest(vehicleId: string | number | undefined) { }, enabled: !!vehicleId, staleTime: STALE_TIMES.MODERATE, + refetchInterval: INTERVALS.STANDARD, }); } diff --git a/web/src/api/hooks/useVehicleCommand.test.tsx b/web/src/api/hooks/useVehicleCommand.test.tsx index 13107e3ec1..1b8e91a27a 100644 --- a/web/src/api/hooks/useVehicleCommand.test.tsx +++ b/web/src/api/hooks/useVehicleCommand.test.tsx @@ -47,9 +47,9 @@ vi.mock('../client', async () => { return { ...actual, request: vi.fn() }; }); -vi.mock('@/components/feedback/Toast', async () => { - const actual = await vi.importActual( - '@/components/feedback/Toast', +vi.mock('@/components/feedback', async () => { + const actual = await vi.importActual( + '@/components/feedback', ); return { ...actual, @@ -178,9 +178,9 @@ describe('useVehicleCommand — success', () => { const keys = invalidatedKeys(invalidateSpy); expect(invalidateSpy).toHaveBeenCalledTimes(4); expect(keys).toContainEqual(['vehicle-state', 7]); - expect(keys).toContainEqual(['command-latest', 7]); - // The command-history key is stringified so a numeric id invalidation still - // hits a query primed with the string route param. + // Both command query keys are normalized so numeric ids invalidate + // consumers initialized from string route params (and vice versa). + expect(keys).toContainEqual(['command-latest', '7']); expect(keys).toContainEqual(['command-history', '7']); expect(keys).toContainEqual(['vehicles']); }); diff --git a/web/src/api/hooks/useVehicleCommand.ts b/web/src/api/hooks/useVehicleCommand.ts index 96089bf9c8..55d80d1827 100644 --- a/web/src/api/hooks/useVehicleCommand.ts +++ b/web/src/api/hooks/useVehicleCommand.ts @@ -1,8 +1,9 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { request } from '../client'; -import { useToast } from '@/components/feedback/Toast'; +import { useToast } from '@/components/feedback'; import { vehicleKeys } from './useVehicles'; +import { commandKeys } from './useCommands'; import { isTeslaAuthExpiredError } from '@/lib/resilience'; import { queueTeslaMutation } from '@/lib/teslaAuthRecovery'; @@ -19,7 +20,7 @@ import { queueTeslaMutation } from '@/lib/teslaAuthRecovery'; * forward-compatibility with a future handler revision that returns a * friendlier human string; today the failure reason lives in `error`. */ -interface CommandResult { +export interface CommandResult { success?: boolean; /** Present on the success path (currently the literal `"success"`). */ result?: string; @@ -29,7 +30,7 @@ interface CommandResult { message?: string; } -interface SendCommandParams { +export interface SendCommandParams { vehicleId: number; command: string; params?: Record; @@ -60,8 +61,8 @@ export function useVehicleCommand() { }), onSuccess: (data, { vehicleId }) => { queryClient.invalidateQueries({ queryKey: vehicleKeys.state(vehicleId) }); - queryClient.invalidateQueries({ queryKey: ['command-latest', vehicleId] }); - queryClient.invalidateQueries({ queryKey: ['command-history', String(vehicleId)] }); + queryClient.invalidateQueries({ queryKey: commandKeys.latest(vehicleId) }); + queryClient.invalidateQueries({ queryKey: commandKeys.history(vehicleId) }); queryClient.invalidateQueries({ queryKey: vehicleKeys.all }); if (data?.success) { toast.success(data.message || t('commands.toast.success', 'Command sent successfully')); diff --git a/web/src/components/forms/__tests__/RangePicker.test.tsx b/web/src/components/forms/__tests__/RangePicker.test.tsx index 5c9e712907..acad7bb2c0 100644 --- a/web/src/components/forms/__tests__/RangePicker.test.tsx +++ b/web/src/components/forms/__tests__/RangePicker.test.tsx @@ -12,6 +12,7 @@ describe(' trigger', () => { render(); const trigger = screen.getByRole('button', { name: /date range/i }); expect(trigger).toBeInTheDocument(); + expect(trigger).toHaveTextContent('Custom range'); // The readout always renders the resolved dates. expect(trigger).toHaveTextContent(/jan/i); }); diff --git a/web/src/features/analytics/pages/StatisticsPage.tsx b/web/src/features/analytics/pages/StatisticsPage.tsx index 256fd1b1ae..939e9da0fe 100644 --- a/web/src/features/analytics/pages/StatisticsPage.tsx +++ b/web/src/features/analytics/pages/StatisticsPage.tsx @@ -27,7 +27,8 @@ import { useUnits } from '@/hooks/useUnits'; import { useFormatting } from '@/hooks/useFormatting'; import { useChartPalette } from '@/hooks/useChartPalette'; import { useSavedViewUrl } from '@/hooks/useSavedViewUrl'; -import { useUrlBatch, useUrlString } from '@/hooks/useUrlState'; +import { useUrlString } from '@/hooks/useUrlState'; +import { useRangeState } from '@/hooks/useRangeState'; import { useHiddenSeries } from '@/hooks/useHiddenSeries'; import { convertDistanceFromSI } from '@/lib/unitConversion'; import { fmtNumber, fmtInt } from '@/lib/numberFormat'; @@ -93,14 +94,10 @@ export default function StatisticsPage() { } }; - const defaultStart = useMemo(() => { - const d = new Date(); d.setFullYear(d.getFullYear() - 1); - return d.toISOString().slice(0, 10); - }, []); - const defaultEnd = useMemo(() => new Date().toISOString().slice(0, 10), []); - const [startDate] = useUrlString('from', defaultStart); - const [endDate] = useUrlString('to', defaultEnd); - const setRangeBatch = useUrlBatch(); + const { start: startDate, end: endDate, setRange } = useRangeState({ + persistKey: 'statistics.range', + defaultPresetId: '1y', + }); // Reactive chart palette: color-blind safe or neon per user preference. const palette = useChartPalette(); @@ -177,7 +174,7 @@ export default function StatisticsPage() { )} setRangeBatch({ from: r.start, to: r.end })} + onChange={setRange} align="end" triggerTestId="statistics-range" /> diff --git a/web/src/features/battery/pages/EnergyPage.test.tsx b/web/src/features/battery/pages/EnergyPage.test.tsx index 5097778cec..5e04fb6fa8 100644 --- a/web/src/features/battery/pages/EnergyPage.test.tsx +++ b/web/src/features/battery/pages/EnergyPage.test.tsx @@ -76,6 +76,14 @@ vi.mock('react-i18next', async () => { } }) +vi.mock('@/hooks/useRangeState', () => ({ + useRangeState: () => ({ + start: '2025-06-01', + end: '2025-06-30', + setRange: vi.fn(), + }), +})) + import { request } from '@/api/client' import { ToastProvider } from '@/components/feedback/Toast' import { SelectedVehicleProvider } from '@/store/selectedVehicle' diff --git a/web/src/features/battery/pages/EnergyPage.tsx b/web/src/features/battery/pages/EnergyPage.tsx index cbfe24d498..c88618e4d9 100644 --- a/web/src/features/battery/pages/EnergyPage.tsx +++ b/web/src/features/battery/pages/EnergyPage.tsx @@ -32,7 +32,7 @@ import { useUnits } from '@/hooks/useUnits'; import { useFormatting } from '@/hooks/useFormatting'; import { usePageTitle } from '@/hooks/usePageTitle'; import { useSavedViewUrl } from '@/hooks/useSavedViewUrl'; -import { useUrlBatch, useUrlString } from '@/hooks/useUrlState'; +import { useRangeState } from '@/hooks/useRangeState'; import { useHiddenSeries } from '@/hooks/useHiddenSeries'; import { formatDateShort } from '@/lib/dateFormat'; import { fmtNumber, fmtInt, fmtPercent } from '@/lib/numberFormat'; @@ -194,15 +194,10 @@ export default function EnergyPage() { const { vehicleId } = useSelectedVehicle(); /* ── Date range ───────────────────────────────────────────────── */ - const defaultStartDate = useMemo(() => { - const d = new Date(); - d.setDate(d.getDate() - 30); - return d.toISOString().split('T')[0]; - }, []); - const defaultEndDate = useMemo(() => new Date().toISOString().split('T')[0], []); - const [startDate] = useUrlString('from', defaultStartDate); - const [endDate] = useUrlString('to', defaultEndDate); - const setRangeBatch = useUrlBatch(); + const { start: startDate, end: endDate, setRange } = useRangeState({ + persistKey: 'energy.range', + defaultPresetId: '30d', + }); /* URL-persisted hidden-series state for the two-series energy/efficiency composed chart. */ @@ -264,7 +259,7 @@ export default function EnergyPage() { const startMs = new Date(startDate).getTime(); const endMs = new Date(endDate).getTime(); const periodDays = Number.isFinite(startMs) && Number.isFinite(endMs) - ? Math.max(1, Math.ceil((endMs - startMs) / 86400000)) + ? Math.max(1, Math.floor((endMs - startMs) / 86400000) + 1) : 30; const costPerKm = totalDistance > 0 ? totalCost / totalDistance : 0; const costPerKwh = totalEnergy > 0 ? totalCost / (totalEnergy / 1000) : 0; @@ -481,7 +476,7 @@ export default function EnergyPage() { setRangeBatch({ from: r.start, to: r.end })} + onChange={setRange} align="end" triggerTestId="energy-range" /> diff --git a/web/src/features/charging/pages/ChargingListPage.tsx b/web/src/features/charging/pages/ChargingListPage.tsx index e423f131d2..b2d69e0601 100644 --- a/web/src/features/charging/pages/ChargingListPage.tsx +++ b/web/src/features/charging/pages/ChargingListPage.tsx @@ -30,6 +30,7 @@ import { import { MetricSwitcherChart, type MetricSwitcherMetric } from '@/components/charts'; import { useSavedViewUrl } from '@/hooks/useSavedViewUrl'; import { useUrlBatch, useUrlBoolean, useUrlEnum, useUrlNumber, useUrlString } from '@/hooks/useUrlState'; +import { useRangeState } from '@/hooks/useRangeState'; import { parseSearchQuery, matchesTokens, compareNumeric, parseDurationToken, matchesYmdPrefix } from '@/lib/searchQuery'; import { useChargingSessionsPaginated, useChargingOptimizer, useBulkDeleteCharging } from '@/api/hooks/useCharging'; import { useUnits } from '@/hooks/useUnits'; @@ -102,13 +103,14 @@ export default function ChargingListPage() { const { formatCurrency, currencySymbol } = useFormatting(); /* ── URL state ───────────────────────────────────────────────── */ - const defaultStartDate = useMemo(() => { - const d = new Date(); d.setDate(d.getDate() - 30); - return d.toISOString().split('T')[0]; - }, []); - const defaultEndDate = useMemo(() => new Date().toISOString().split('T')[0], []); - const [startDate] = useUrlString('from', defaultStartDate); - const [endDate] = useUrlString('to', defaultEndDate); + const { + start: startDate, + end: endDate, + setRangeWithUrlUpdates, + } = useRangeState({ + persistKey: 'charging.list.range', + defaultPresetId: '30d', + }); const [search] = useUrlString('q', ''); const [collection] = useUrlEnum('coll', COLLECTIONS, 'all'); const [trendMetric, setTrendMetric] = useUrlEnum('trend', TREND_METRICS, 'sessions'); @@ -570,7 +572,7 @@ export default function ChargingListPage() { { - setUrlBatch({ from: r.start, to: r.end, page: null }); + setRangeWithUrlUpdates(r, { page: null }); }} align="end" triggerTestId="charging-list-range" diff --git a/web/src/features/charging/pages/CostAnalysisPage.tsx b/web/src/features/charging/pages/CostAnalysisPage.tsx index a2b298960e..ef13ce04b9 100644 --- a/web/src/features/charging/pages/CostAnalysisPage.tsx +++ b/web/src/features/charging/pages/CostAnalysisPage.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useState } from 'react'; +import { useCallback, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { PageContainer } from '@/components/layout'; import { FadeIn } from '@/components/motion'; @@ -12,7 +12,7 @@ import { useUnits } from '@/hooks/useUnits'; import { usePageTitle } from '@/hooks/usePageTitle'; import { useSelectedVehicle } from '@/hooks/useSelectedVehicle'; import { useSavedViewUrl } from '@/hooks/useSavedViewUrl'; -import { useUrlBatch, useUrlString } from '@/hooks/useUrlState'; +import { useRangeState } from '@/hooks/useRangeState'; import { convertDistanceFromSI } from '@/lib/unitConversion'; import { DEFAULT_GAS_PRICE, DEFAULT_MPG, DEFAULT_ELECTRICITY_RATE } from '../components/cost-analysis/constants'; import { useCostAnalysisData } from '../components/cost-analysis/useCostAnalysisData'; @@ -48,15 +48,15 @@ export default function CostAnalysisPage() { const { vehicleId } = useSelectedVehicle(); // ── Filters ────────────────────────────────────────────────────────── - const defaultStartDate = useMemo(() => { - const d = new Date(); - d.setFullYear(d.getFullYear() - 1); - return d.toISOString().split('T')[0]; - }, []); - const defaultEndDate = useMemo(() => new Date().toISOString().split('T')[0], []); - const [startDate] = useUrlString('from', defaultStartDate); - const [endDate] = useUrlString('to', defaultEndDate); - const setRangeBatch = useUrlBatch(); + const { + start: startDate, + end: endDate, + setRange, + reset: resetRange, + } = useRangeState({ + persistKey: 'cost-analysis.range', + defaultPresetId: '1y', + }); // ── Gas calculator inputs ──────────────────────────────────────────── const [gasPrice, setGasPrice] = useState(DEFAULT_GAS_PRICE); @@ -89,7 +89,7 @@ export default function CostAnalysisPage() { setRangeBatch({ from: r.start, to: r.end })} + onChange={setRange} align="end" triggerTestId="cost-analysis-range" /> @@ -120,7 +120,7 @@ export default function CostAnalysisPage() { isLoading={sessionsLoading} error={sessionsError} onRetry={retrySessions} - onResetRange={() => setRangeBatch({ from: null, to: null })} + onResetRange={resetRange} /> diff --git a/web/src/features/driving/pages/DriveScorePage.test.tsx b/web/src/features/driving/pages/DriveScorePage.test.tsx index 22ada48e1c..56ab35bd6b 100644 --- a/web/src/features/driving/pages/DriveScorePage.test.tsx +++ b/web/src/features/driving/pages/DriveScorePage.test.tsx @@ -3,8 +3,7 @@ * * DriveScorePage default-exports the page plus a set of pure helpers that are * unit-tested directly (the scoring algorithm, grade mapping, tip/achievement - * builders, the default date window, and the newly-extracted - * `computePeriodStats`). The page's file-local sub-components + * builders, and `computePeriodStats`). The page's file-local sub-components * (CategoryGaugeCard, the nine render bands) are exercised transitively through * the full page render. * @@ -23,8 +22,8 @@ * (the date-filter + state wiring). * 7. REFRESH — the icon-only refresh control is labelled and calls refetch. * 8. HELPERS — scoreDrive branches (typical / null-fallbacks / floors), - * gradeFromScore boundaries, gradeVariant, gradeColor, the default date - * window, buildTips, buildAchievements checks, and computePeriodStats + * gradeFromScore boundaries, gradeVariant, gradeColor, buildTips, + * buildAchievements checks, and computePeriodStats * including the cross-month "Best Week" collision fix. * * Network is never hit: the data hooks, vehicle picker, form controls, and the @@ -134,8 +133,6 @@ import DriveScorePage, { gradeFromScore, gradeVariant, gradeColor, - getDefaultStartDate, - getDefaultEndDate, buildTips, buildAchievements, computePeriodStats, @@ -541,25 +538,6 @@ describe('gradeVariant / gradeColor', () => { }); }); -describe('default date window', () => { - it('returns a today→30-days-ago ISO window', () => { - vi.useFakeTimers(); - vi.setSystemTime(new Date('2024-06-15T12:00:00Z')); - try { - const end = getDefaultEndDate(); - const start = getDefaultStartDate(); - // getDefaultEndDate is a pure UTC slice → deterministic across timezones. - expect(end).toBe('2024-06-15'); - expect(start).toMatch(/^\d{4}-\d{2}-\d{2}$/); - const spanDays = - (Date.parse(`${end}T00:00:00Z`) - Date.parse(`${start}T00:00:00Z`)) / DAY_MS; - expect(spanDays).toBe(30); - } finally { - vi.useRealTimers(); - } - }); -}); - describe('buildTips', () => { const tips = buildTips((_k, fallback) => fallback); diff --git a/web/src/features/driving/pages/DriveScorePage.tsx b/web/src/features/driving/pages/DriveScorePage.tsx index 58ff8e2caf..506e384fff 100644 --- a/web/src/features/driving/pages/DriveScorePage.tsx +++ b/web/src/features/driving/pages/DriveScorePage.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState, type ReactNode } from 'react'; +import { useMemo, type ReactNode } from 'react'; import { useTranslation } from 'react-i18next'; import { PageContainer } from '@/components/layout'; @@ -49,6 +49,7 @@ import { useDriveScore, useDrives } from '@/api/hooks/useDriving'; import { useUnits } from '@/hooks/useUnits'; import { usePageTitle } from '@/hooks/usePageTitle'; import { useSelectedVehicle } from '@/hooks/useSelectedVehicle'; +import { useRangeState } from '@/hooks/useRangeState'; import { formatDateShort, formatDurationMinutes } from '@/lib/dateFormat'; import { fmtNumber, fmtInt } from '@/lib/numberFormat'; import { cn } from '@/lib/cn'; @@ -212,16 +213,6 @@ function scoreTextClass(score: number | null): string { return 'text-rose-300'; } -export function getDefaultStartDate(): string { - const d = new Date(); - d.setDate(d.getDate() - 30); - return d.toISOString().slice(0, 10); -} - -export function getDefaultEndDate(): string { - return new Date().toISOString().slice(0, 10); -} - /* ------------------------------------------------------------------ */ /* Tips data */ /* ------------------------------------------------------------------ */ @@ -603,8 +594,10 @@ export default function DriveScorePage() { `${fmtInt(efficiencyDisplay(whPerKm))} ${efficiencyUnit}`; /* ---- date filter ---- */ - const [startDate, setStartDate] = useState(getDefaultStartDate); - const [endDate, setEndDate] = useState(getDefaultEndDate); + const { start: startDate, end: endDate, setRange } = useRangeState({ + persistKey: 'drive-score.range', + defaultPresetId: '30d', + }); /* ---- table sort (shared DataTable controlled sort) ---- */ const { sortKey, sortDir, onSort, sortFn } = useSortToggle('date', 'desc'); @@ -1001,10 +994,7 @@ export default function DriveScorePage() { { - setStartDate(r.start); - setEndDate(r.end); - }} + onChange={setRange} align="end" triggerTestId="drive-score-range" /> diff --git a/web/src/features/driving/pages/DrivesListPage.tsx b/web/src/features/driving/pages/DrivesListPage.tsx index 3fc9dc80c6..5771030eb6 100644 --- a/web/src/features/driving/pages/DrivesListPage.tsx +++ b/web/src/features/driving/pages/DrivesListPage.tsx @@ -26,6 +26,7 @@ import { SearchInput } from '@/components/forms/SearchInput'; import { FilterBar } from '@/components/forms/FilterBar'; import { ActiveFilterChips, type FilterChipDescriptor } from '@/components/forms/ActiveFilterChips'; import { useUrlBatch, useUrlEnum, useUrlString, useUrlNumber } from '@/hooks/useUrlState'; +import { useRangeState } from '@/hooks/useRangeState'; import { parseSearchQuery, matchesTokens, compareNumeric } from '@/lib/searchQuery'; import { FadeIn } from '@/components/motion/FadeIn'; import { StaggerContainer } from '@/components/motion/StaggerContainer'; @@ -79,13 +80,14 @@ export default function DrivesListPage() { * the API applies a 50-row default page, so filtering client-side alone * capped this page at the 50 newest drives regardless of the chosen range * or page size. */ - const defaultStart = useMemo(() => { - const d = new Date(); d.setDate(d.getDate() - 30); - return d.toISOString().split('T')[0]; - }, []); - const defaultEnd = useMemo(() => new Date().toISOString().split('T')[0], []); - const [startDate] = useUrlString('from', defaultStart); - const [endDate] = useUrlString('to', defaultEnd); + const { + start: startDate, + end: endDate, + setRangeWithUrlUpdates, + } = useRangeState({ + persistKey: 'drives.list.range', + defaultPresetId: '30d', + }); const priorRange = useMemo(() => priorPeriod(startDate, endDate), [startDate, endDate]); /* Fetch window. It has to reach back over the prior period as well, because @@ -532,7 +534,7 @@ export default function DrivesListPage() { { - setUrlBatch({ from: r.start, to: r.end, page: null }); + setRangeWithUrlUpdates(r, { page: null }); }} align="end" triggerTestId="drives-range-picker" diff --git a/web/src/features/driving/pages/DrivetrainHealthPage.test.tsx b/web/src/features/driving/pages/DrivetrainHealthPage.test.tsx index 0be9ed3c38..653bf43a3d 100644 --- a/web/src/features/driving/pages/DrivetrainHealthPage.test.tsx +++ b/web/src/features/driving/pages/DrivetrainHealthPage.test.tsx @@ -20,11 +20,11 @@ * converters are `useCallback`-memoised, so equal inputs must yield the * SAME array reference (a regression guard for the memo-defeat bug the * inline closures used to cause). - * 9. Live band wiring + range-picker → URL-batch write. + * 9. Live band wiring + range-picker → shared range write. * * Strategy (mirrors web/src/features/admin/pages/VehicleCostPage.test.tsx): * - Every data hook + the vehicle selector + useUnits / useDateFormat / - * url-state are mocked with hoisted vi.fn()s so the network is never + * range-state are mocked with hoisted vi.fn()s so the network is never * touched and each render is deterministic. The REAL `HEALTH_SCORE` * constant + REAL `convertDistanceFromSI` / `convertTempFromSI` run, so * the conversions are genuinely exercised. @@ -74,9 +74,9 @@ const { dateFormatMock, selectedVehicleMock, vehicleLiveMock, - urlStringMock, + rangeStateMock, refetchMock, - setRangeBatchMock, + setRangeMock, formatDateShort, formatTime, UNIT_PREFS_KM, @@ -91,9 +91,9 @@ const { dateFormatMock: vi.fn(), selectedVehicleMock: vi.fn(), vehicleLiveMock: vi.fn(), - urlStringMock: vi.fn(), + rangeStateMock: vi.fn(), refetchMock: vi.fn(), - setRangeBatchMock: vi.fn(), + setRangeMock: vi.fn(), formatDateShort: (v: unknown) => `D:${String(v)}`, formatTime: (v: unknown) => `T:${String(v)}`, UNIT_PREFS_KM: { @@ -160,10 +160,7 @@ vi.mock('@/hooks/useUnits', () => ({ useUnits: () => unitsMock() })); vi.mock('@/hooks/useDateFormat', () => ({ useDateFormat: () => dateFormatMock() })); vi.mock('@/hooks/useSelectedVehicle', () => ({ useSelectedVehicle: () => selectedVehicleMock() })); vi.mock('@/hooks/useVehicleLive', () => ({ useVehicleLive: (...args: unknown[]) => vehicleLiveMock(...args) })); -vi.mock('@/hooks/useUrlState', () => ({ - useUrlString: (key: string) => urlStringMock(key), - useUrlBatch: () => setRangeBatchMock, -})); +vi.mock('@/hooks/useRangeState', () => ({ useRangeState: () => rangeStateMock() })); // Stub the 12 sections so we can capture the exact props the page computed. vi.mock('../components/drivetrain-health', async () => { @@ -190,7 +187,7 @@ vi.mock('../components/drivetrain-health', async () => { }); // Stub the two toolbar controls; RangePicker forwards a fixed range on click so -// the URL-batch wiring can be asserted. +// the shared range wiring can be asserted. vi.mock('@/components/forms', async () => { const actual = await vi.importActual('@/components/forms'); const React = await vi.importActual('react'); @@ -344,11 +341,6 @@ const MOTOR_LATEST = makeMotor({ ts: 'latest', motor_temp_c_front: 45 }); const LIVE_STATE = { isolationResistance: 987 }; -// Stable tuples so `[startDate]`/`[endDate]` reads keep identical primitives -// across re-renders (memo-stability spec). -const FROM_TUPLE: [string, () => void] = ['2024-01-01', () => {}]; -const TO_TUPLE: [string, () => void] = ['2024-01-31', () => {}]; - function renderPage() { const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); return render( @@ -373,7 +365,11 @@ beforeEach(() => { dateFormatMock.mockReturnValue({ formatTime, formatDateShort }); selectedVehicleMock.mockReturnValue({ vehicleId: 42, vehicle: null, vehicles: [], setVehicleId: vi.fn() }); vehicleLiveMock.mockReturnValue({ state: LIVE_STATE, connected: true }); - urlStringMock.mockImplementation((key: string) => (key === 'from' ? FROM_TUPLE : TO_TUPLE)); + rangeStateMock.mockReturnValue({ + start: '2024-01-01', + end: '2024-01-31', + setRange: setRangeMock, + }); }); /* ── Specs ────────────────────────────────────────────────────────── */ @@ -566,10 +562,13 @@ describe('DrivetrainHealthPage', () => { expect(captured.live.motorLatest).toBe(MOTOR_LATEST); }); - it('pushes range-picker changes into the URL batch as from/to', () => { + it('commits range-picker changes through shared range state', () => { renderPage(); fireEvent.click(screen.getByTestId('range-picker')); - expect(setRangeBatchMock).toHaveBeenCalledWith({ from: '2024-03-01', to: '2024-03-31' }); + expect(setRangeMock).toHaveBeenCalledWith({ + start: '2024-03-01', + end: '2024-03-31', + }); }); }); diff --git a/web/src/features/driving/pages/DrivetrainHealthPage.tsx b/web/src/features/driving/pages/DrivetrainHealthPage.tsx index 2351db816f..b8c969cd71 100644 --- a/web/src/features/driving/pages/DrivetrainHealthPage.tsx +++ b/web/src/features/driving/pages/DrivetrainHealthPage.tsx @@ -12,7 +12,7 @@ import { useVehicleLive } from '@/hooks/useVehicleLive'; import { useUnits } from '@/hooks/useUnits'; import { usePageTitle } from '@/hooks/usePageTitle'; import { useDateFormat } from '@/hooks/useDateFormat'; -import { useUrlString, useUrlBatch } from '@/hooks/useUrlState'; +import { useRangeState } from '@/hooks/useRangeState'; import { convertDistanceFromSI, convertTempFromSI } from '@/lib/unitConversion'; import { @@ -44,15 +44,10 @@ export default function DrivetrainHealthPage() { const { vehicleId } = useSelectedVehicle(); const vehicleIdStr = vehicleId != null ? String(vehicleId) : undefined; - const defaultStartDate = useMemo(() => { - const d = new Date(); - d.setDate(d.getDate() - 30); - return d.toISOString().split('T')[0]; - }, []); - const defaultEndDate = useMemo(() => new Date().toISOString().split('T')[0], []); - const [startDate] = useUrlString('from', defaultStartDate); - const [endDate] = useUrlString('to', defaultEndDate); - const setRangeBatch = useUrlBatch(); + const { start: startDate, end: endDate, setRange } = useRangeState({ + persistKey: 'drivetrain-health.range', + defaultPresetId: '30d', + }); const healthQuery = useDrivetrainHealth(vehicleIdStr); const { @@ -161,7 +156,7 @@ export default function DrivetrainHealthPage() { setRangeBatch({ from: r.start, to: r.end })} + onChange={setRange} align="end" triggerTestId="drivetrain-health-range-picker" /> diff --git a/web/src/features/driving/pages/EfficiencyPage.tsx b/web/src/features/driving/pages/EfficiencyPage.tsx index 079e39f9e7..b8d6bd4ddb 100644 --- a/web/src/features/driving/pages/EfficiencyPage.tsx +++ b/web/src/features/driving/pages/EfficiencyPage.tsx @@ -24,7 +24,7 @@ import { useUnits } from '@/hooks/useUnits'; import { usePageTitle } from '@/hooks/usePageTitle'; import { useSelectedVehicle } from '@/hooks/useSelectedVehicle'; import { useSavedViewUrl } from '@/hooks/useSavedViewUrl'; -import { useUrlBatch, useUrlString } from '@/hooks/useUrlState'; +import { useRangeState } from '@/hooks/useRangeState'; import { formatDateShort } from '@/lib/dateFormat'; import { fmtNumber, fmtInt } from '@/lib/numberFormat'; import { getErrorMessage } from '@/lib/errorMessage'; @@ -123,14 +123,10 @@ export default function EfficiencyPage() { [unitPrefs.speed], ); - const defaultStartDate = useMemo(() => { - const d = new Date(); d.setDate(d.getDate() - 30); - return d.toISOString().split('T')[0]; - }, []); - const defaultEndDate = useMemo(() => new Date().toISOString().split('T')[0], []); - const [startDate] = useUrlString('from', defaultStartDate); - const [endDate] = useUrlString('to', defaultEndDate); - const setRangeBatch = useUrlBatch(); + const { start: startDate, end: endDate, setRange } = useRangeState({ + persistKey: 'efficiency.range', + defaultPresetId: '30d', + }); /* ---- Filtered drives ---- */ const filteredDrives = useMemo(() => { @@ -282,7 +278,7 @@ export default function EfficiencyPage() { setRangeBatch({ from: r.start, to: r.end })} + onChange={setRange} align="end" triggerTestId="efficiency-range" /> diff --git a/web/src/features/driving/pages/RouteEfficiencyPage.tsx b/web/src/features/driving/pages/RouteEfficiencyPage.tsx index 4bd56665c4..459e373621 100644 --- a/web/src/features/driving/pages/RouteEfficiencyPage.tsx +++ b/web/src/features/driving/pages/RouteEfficiencyPage.tsx @@ -16,7 +16,7 @@ import { import { AIRouteEfficiencySuggestions } from '@/components/ai/AIRouteEfficiencySuggestions'; import { useRouteEfficiency } from '@/api/hooks/useDriving'; import { useSelectedVehicle } from '@/hooks/useSelectedVehicle'; -import { useUrlString, useUrlBatch } from '@/hooks/useUrlState'; +import { useRangeState } from '@/hooks/useRangeState'; import { useUnits } from '@/hooks/useUnits'; import { usePageTitle } from '@/hooks/usePageTitle'; import { fmtInt } from '@/lib/numberFormat'; @@ -31,15 +31,10 @@ export default function RouteEfficiencyPage() { const { vehicleId } = useSelectedVehicle(); const vehicleIdStr = vehicleId != null ? String(vehicleId) : undefined; - const defaultStartDate = useMemo(() => { - const d = new Date(); - d.setDate(d.getDate() - 30); - return d.toISOString().split('T')[0]; - }, []); - const defaultEndDate = useMemo(() => new Date().toISOString().split('T')[0], []); - const [startDate] = useUrlString('from', defaultStartDate); - const [endDate] = useUrlString('to', defaultEndDate); - const setRangeBatch = useUrlBatch(); + const { start: startDate, end: endDate, setRange } = useRangeState({ + persistKey: 'route-efficiency.range', + defaultPresetId: '30d', + }); const routeQuery = useRouteEfficiency(vehicleIdStr, startDate, endDate); const { data, isLoading, error, refetch } = routeQuery; @@ -97,7 +92,7 @@ export default function RouteEfficiencyPage() { setRangeBatch({ from: r.start, to: r.end })} + onChange={setRange} align="end" triggerTestId="route-efficiency-range-picker" /> diff --git a/web/src/features/system/commands.ts b/web/src/features/system/commands.ts index 7a8ae4da6d..5b09e3633e 100644 --- a/web/src/features/system/commands.ts +++ b/web/src/features/system/commands.ts @@ -49,6 +49,9 @@ export interface SelectOption { value: string; labelKey: string; labelFallback: string; + descriptionKey?: string; + descriptionFallback?: string; + /** @deprecated Prefer descriptionKey + descriptionFallback for visible copy. */ description?: string; } @@ -88,20 +91,22 @@ export interface Vehicle { display_name: string; model: string; state: string; - battery_level: number; - battery_range: number; + battery_level?: number | null; + battery_range?: number | null; updated_at: string; } export interface VehicleState { - battery_level: number; - rated_range: number; - is_locked: boolean; - is_charging: boolean; - is_climate_on: boolean; - sentry_mode: boolean; - inside_temp: number; - speed: number; + vehicle_id?: number; + state?: string; + battery_level?: number | null; + rated_range?: number | null; + is_locked?: boolean | null; + is_charging?: boolean | null; + is_climate_on?: boolean | null; + sentry_mode?: boolean | null; + inside_temp?: number | null; + speed?: number | null; } export interface CommandLogEntry { @@ -357,9 +362,27 @@ export const COMMANDS: CommandDef[] = [ selectConfig: { paramName: 'cop_temp', options: [ - { value: '0', labelKey: 'commands.climate.copLow', labelFallback: 'Low', description: '90°F / 30°C' }, - { value: '1', labelKey: 'commands.climate.copMedium', labelFallback: 'Medium', description: '95°F / 35°C' }, - { value: '2', labelKey: 'commands.climate.copHigh', labelFallback: 'High', description: '100°F / 40°C' }, + { + value: '0', + labelKey: 'commands.climate.copLow', + labelFallback: 'Low', + descriptionKey: 'commands.climate.copLowTemperature', + descriptionFallback: '90°F / 30°C', + }, + { + value: '1', + labelKey: 'commands.climate.copMedium', + labelFallback: 'Medium', + descriptionKey: 'commands.climate.copMediumTemperature', + descriptionFallback: '95°F / 35°C', + }, + { + value: '2', + labelKey: 'commands.climate.copHigh', + labelFallback: 'High', + descriptionKey: 'commands.climate.copHighTemperature', + descriptionFallback: '100°F / 40°C', + }, ], }, }, diff --git a/web/src/features/system/components/CollapsibleCommandGroup.test.tsx b/web/src/features/system/components/CollapsibleCommandGroup.test.tsx index 6bf1f22a64..bbe1411263 100644 --- a/web/src/features/system/components/CollapsibleCommandGroup.test.tsx +++ b/web/src/features/system/components/CollapsibleCommandGroup.test.tsx @@ -20,7 +20,7 @@ type GroupProps = ComponentProps; function renderGroup(overrides: Partial = {}) { const props: GroupProps = { category: 'security', - vehicleId: 1, + vehicleKey: 1, count: 3, children: CHILD TILE, ...overrides, @@ -48,7 +48,7 @@ describe('CollapsibleCommandGroup', () => { }); it('expands on click: children mount, aria-expanded flips true, aria-controls points at the panel, state persisted', () => { - renderGroup({ vehicleId: 1, category: 'security' }); + renderGroup({ vehicleKey: 1, category: 'security' }); const button = screen.getByRole('button'); fireEvent.click(button); @@ -69,7 +69,7 @@ describe('CollapsibleCommandGroup', () => { }); it('honours defaultOpen and collapses (persisting "false") on click', () => { - renderGroup({ vehicleId: 5, category: 'charging', defaultOpen: true }); + renderGroup({ vehicleKey: 5, category: 'charging', defaultOpen: true }); expect(screen.getByTestId('cmd-child')).toBeInTheDocument(); const button = screen.getByRole('button'); expect(button).toHaveAttribute('aria-expanded', 'true'); @@ -84,14 +84,14 @@ describe('CollapsibleCommandGroup', () => { it('restores a persisted OPEN state from sessionStorage, overriding defaultOpen=false', () => { sessionStorage.setItem('teslasync-cat-7-charging', 'true'); // defaultOpen omitted → false, but the stored value wins. - renderGroup({ vehicleId: 7, category: 'charging' }); + renderGroup({ vehicleKey: 7, category: 'charging' }); expect(screen.getByTestId('cmd-child')).toBeInTheDocument(); expect(screen.getByRole('button')).toHaveAttribute('aria-expanded', 'true'); }); it('restores a persisted CLOSED state from sessionStorage, overriding defaultOpen=true', () => { sessionStorage.setItem('teslasync-cat-2-climate', 'false'); - renderGroup({ vehicleId: 2, category: 'climate', defaultOpen: true }); + renderGroup({ vehicleKey: 2, category: 'climate', defaultOpen: true }); expect(screen.queryByTestId('cmd-child')).toBeNull(); expect(screen.getByRole('button')).toHaveAttribute('aria-expanded', 'false'); }); @@ -122,7 +122,7 @@ describe('CollapsibleCommandGroup', () => { vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { throw new Error('storage blocked'); }); - renderGroup({ vehicleId: 9, category: 'drive', defaultOpen: true }); + renderGroup({ vehicleKey: 9, category: 'drive', defaultOpen: true }); expect(screen.getByTestId('cmd-child')).toBeInTheDocument(); expect(screen.getByRole('button')).toHaveAttribute('aria-expanded', 'true'); }); @@ -131,7 +131,7 @@ describe('CollapsibleCommandGroup', () => { vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { throw new Error('QuotaExceededError'); }); - renderGroup({ vehicleId: 3, category: 'doors' }); + renderGroup({ vehicleKey: 3, category: 'doors' }); const button = screen.getByRole('button'); expect(() => fireEvent.click(button)).not.toThrow(); diff --git a/web/src/features/system/components/CollapsibleCommandGroup.tsx b/web/src/features/system/components/CollapsibleCommandGroup.tsx index aed937bfbd..d1d96a1b72 100644 --- a/web/src/features/system/components/CollapsibleCommandGroup.tsx +++ b/web/src/features/system/components/CollapsibleCommandGroup.tsx @@ -8,7 +8,7 @@ import { CATEGORY_META, type CommandCategory } from '../commands'; interface CollapsibleCommandGroupProps { category: CommandCategory; - vehicleId: number; + vehicleKey: number; children: ReactNode; count: number; defaultOpen?: boolean; @@ -16,14 +16,14 @@ interface CollapsibleCommandGroupProps { export function CollapsibleCommandGroup({ category, - vehicleId, + vehicleKey, children, count, defaultOpen = false, }: CollapsibleCommandGroupProps) { const { t } = useTranslation(); - const storageKey = `teslasync-cat-${vehicleId}-${category}`; - const panelId = `teslasync-cmdgroup-${vehicleId}-${category}`; + const storageKey = `teslasync-cat-${vehicleKey}-${category}`; + const panelId = `teslasync-cmdgroup-${vehicleKey}-${category}`; const [open, setOpen] = useState(() => { try { @@ -57,7 +57,7 @@ export function CollapsibleCommandGroup({ onClick={toggle} aria-expanded={open} aria-controls={open ? panelId : undefined} - className="group h-auto w-full justify-start py-2 text-left font-normal hover:bg-transparent" + className="group h-auto min-h-11 w-full justify-start py-2 text-left font-normal hover:bg-transparent" >