diff --git a/internal/api/journey/nudge.go b/internal/api/journey/nudge.go index 642d60eaac..cbae57bf56 100644 --- a/internal/api/journey/nudge.go +++ b/internal/api/journey/nudge.go @@ -54,14 +54,15 @@ func NudgeVerdict(now time.Time, recommended *time.Time, blockers []Item) string return NudgeLeaveNow } -// Blockers filters a run to action-level items. Nil run yields nil — -// no run is not a blocker, it is flagged separately in evidence so a +// Blockers filters a run to action-level items. Nil run yields an +// empty slice (JSON []), not nil — the UI reads blockers.length. +// No run is not a blocker; it is flagged separately in evidence so a // fresh trip does not read as broken. Pure. func Blockers(run *Run) []Item { + out := []Item{} if run == nil { - return nil + return out } - out := []Item{} for _, item := range run.Items { if item.Status == ItemAction { out = append(out, item) diff --git a/internal/api/journey/nudge_test.go b/internal/api/journey/nudge_test.go index 905494934f..a886e5145b 100644 --- a/internal/api/journey/nudge_test.go +++ b/internal/api/journey/nudge_test.go @@ -40,8 +40,8 @@ func TestNudgeVerdict(t *testing.T) { } func TestBlockers(t *testing.T) { - if b := Blockers(nil); b != nil { - t.Fatalf("nil = %+v, want nil", b) + if b := Blockers(nil); b == nil || len(b) != 0 { + t.Fatalf("nil run = %+v, want empty slice", b) } run := &Run{Items: []Item{ {Key: "a", Status: ItemOK}, diff --git a/web/src/api/hooks/useJourney.ts b/web/src/api/hooks/useJourney.ts index 6d842163ae..78db72dd46 100644 --- a/web/src/api/hooks/useJourney.ts +++ b/web/src/api/hooks/useJourney.ts @@ -276,6 +276,11 @@ export function useJourney(id: number | null | undefined, options?: { enabled?: request(`/journey/sessions/${id}`, { signal }), enabled: (options?.enabled ?? true) && id != null && id > 0, ...queryPolicy('operational'), + select: (data) => ({ + ...data, + plans: safeArray(data?.plans), + next_statuses: safeArray(data?.next_statuses), + }), }); } @@ -339,6 +344,11 @@ export function useDeparture( ), enabled: (options?.enabled ?? true) && id != null && id > 0, ...queryPolicy('operational'), + select: (data) => ({ + ...data, + slots: safeArray(data?.slots), + evidence: safeArray(data?.evidence), + }), }); } @@ -350,6 +360,7 @@ export function useChecklist(id: number | null | undefined, options?: { enabled? request(`/journey/sessions/${id}/checklist`, { signal }), enabled: (options?.enabled ?? true) && id != null && id > 0, ...queryPolicy('operational'), + select: (data) => ({ ...data, items: safeArray(data?.items) }), }); } @@ -384,6 +395,11 @@ export function useJourneyLive(id: number | null | undefined, options?: { enable request(`/journey/sessions/${id}/live`, { signal }), enabled: (options?.enabled ?? true) && id != null && id > 0, ...queryPolicy('live'), + select: (data) => ({ + ...data, + trail: safeArray(data?.trail), + evidence: safeArray(data?.evidence), + }), }); } @@ -426,6 +442,7 @@ export function useReplanAssessment(id: number | null | undefined, options?: { e request(`/journey/sessions/${id}/replan`, { signal }), enabled: (options?.enabled ?? true) && id != null && id > 0, ...queryPolicy('operational'), + select: (data) => ({ ...data, evidence: safeArray(data?.evidence) }), }); } @@ -460,6 +477,7 @@ export function useArrival(id: number | null | undefined, options?: { enabled?: request(`/journey/sessions/${id}/arrival`, { signal }), enabled: (options?.enabled ?? true) && id != null && id > 0, ...queryPolicy('live'), + select: (data) => ({ ...data, evidence: safeArray(data?.evidence) }), }); } @@ -471,6 +489,7 @@ export function useReport(id: number | null | undefined, options?: { enabled?: b request(`/journey/sessions/${id}/report`, { signal }), enabled: (options?.enabled ?? true) && id != null && id > 0, ...queryPolicy('operational'), + select: (data) => ({ ...data, evidence: safeArray(data?.evidence) }), }); } @@ -482,6 +501,11 @@ export function useNudge(id: number | null | undefined, options?: { enabled?: bo request(`/journey/sessions/${id}/nudge`, { signal }), enabled: (options?.enabled ?? true) && id != null && id > 0, ...queryPolicy('operational'), + select: (data) => ({ + ...data, + blockers: safeArray(data?.blockers), + evidence: safeArray(data?.evidence), + }), }); } diff --git a/web/src/api/hooks/useSystemDiagnostic.ts b/web/src/api/hooks/useSystemDiagnostic.ts index 82b4e5a007..b90dce75c5 100644 --- a/web/src/api/hooks/useSystemDiagnostic.ts +++ b/web/src/api/hooks/useSystemDiagnostic.ts @@ -10,7 +10,7 @@ // losing the most recent run. On error we emit a toast so the failure // is visible even if the page is unmounted before the catch resolves. -import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { request } from '../client'; import { useMutationToast } from './_toastHelpers'; import type { DiagnosticReport } from '../types'; @@ -53,10 +53,24 @@ export function useRunDiagnostic(options: UseRunDiagnosticOptions = {}) { * Convenience hook to read the most recent report without re-running * the diagnostic. Returns `undefined` until the user fires at least * one successful run in this session. + * + * Must subscribe via useQuery — getQueryData() is a one-shot read and + * does not notify React when onSuccess writes diagnosticKeys.last. + * Tests also use gcTime: 0, so an unobserved cache entry can be GC'd + * before the next render (CI flake: last stays undefined). */ export function useLastDiagnostic(): DiagnosticReport | undefined { - const qc = useQueryClient(); - return qc.getQueryData(diagnosticKeys.last); + const { data } = useQuery({ + queryKey: diagnosticKeys.last, + queryFn: ({ signal }) => { + void signal; + throw new Error('diagnosticKeys.last is cache-only'); + }, + enabled: false, + staleTime: Infinity, + retry: false, + }); + return data; } /** diff --git a/web/src/features/settings/components/SettingsSearch.tsx b/web/src/features/settings/components/SettingsSearch.tsx index 78b16d07bd..c81ccc372d 100644 --- a/web/src/features/settings/components/SettingsSearch.tsx +++ b/web/src/features/settings/components/SettingsSearch.tsx @@ -33,6 +33,7 @@ export function SettingsSearch({ className }: SettingsSearchProps) { const [open, setOpen] = useState(false); const [activeIndex, setActiveIndex] = useState(0); const wrapperRef = useRef(null); + const scrollTimerRef = useRef | null>(null); const listboxId = useId(); const index = useMemo(() => getSettingsIndex(t), [t]); @@ -61,6 +62,14 @@ export function SettingsSearch({ className }: SettingsSearchProps) { return () => document.removeEventListener('mousedown', handlePointerDown); }, [open]); + useEffect(() => { + return () => { + if (scrollTimerRef.current !== null) { + window.clearTimeout(scrollTimerRef.current); + } + }; + }, []); + function commit(entry: SettingsEntry) { setQuery(''); setOpen(false); @@ -72,7 +81,12 @@ export function SettingsSearch({ className }: SettingsSearchProps) { // resolver and our smooth-scroll behave consistently. const id = entry.href.split('#')[1]; if (!id) return; - window.setTimeout(() => { + if (scrollTimerRef.current !== null) { + window.clearTimeout(scrollTimerRef.current); + } + scrollTimerRef.current = window.setTimeout(() => { + scrollTimerRef.current = null; + if (typeof document === 'undefined') return; const el = document.getElementById(id); if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' }); }, SCROLL_FALLBACK_DELAY_MS); diff --git a/web/src/features/settings/components/__tests__/SettingsSearch.test.tsx b/web/src/features/settings/components/__tests__/SettingsSearch.test.tsx index 182bdb70a3..ba5414bd9e 100644 --- a/web/src/features/settings/components/__tests__/SettingsSearch.test.tsx +++ b/web/src/features/settings/components/__tests__/SettingsSearch.test.tsx @@ -192,4 +192,27 @@ describe('SettingsSearch', () => { // the user-visible promise we care about. expect(screen.getByTestId('location').textContent).toBe('/settings#appearance'); }); + + it('clears the hash-scroll fallback timer on unmount', () => { + vi.useFakeTimers(); + try { + const { unmount } = renderSearch(); + const input = screen.getByPlaceholderText('Search settings…'); + + act(() => { + fireEvent.focus(input); + fireEvent.change(input, { target: { value: 'theme' } }); + fireEvent.keyDown(input, { key: 'Enter' }); + }); + + unmount(); + expect(() => { + act(() => { + vi.runAllTimers(); + }); + }).not.toThrow(); + } finally { + vi.useRealTimers(); + } + }); }); diff --git a/web/src/features/trips/components/ArrivalPanel.tsx b/web/src/features/trips/components/ArrivalPanel.tsx index 49764c0d7c..0c10ff0dcd 100644 --- a/web/src/features/trips/components/ArrivalPanel.tsx +++ b/web/src/features/trips/components/ArrivalPanel.tsx @@ -7,6 +7,7 @@ import { Badge, Text } from '@/components/ui'; import { ListSkeleton, QueryError } from '@/components/feedback'; import { formatTime } from '@/lib/dateFormat'; import { fmtNumber } from '@/lib/numberFormat'; +import { safeArray } from '@/lib/safeArray'; const VERDICT_LABEL_KEYS: Record = { ok: 'journey.arrival.verdict.ok', @@ -47,6 +48,7 @@ export function ArrivalPanel({ session }: { session: JourneySession }) { const arrivalQuery = useArrival(session.id); const arrivalState = useDataState(arrivalQuery); const arrival = arrivalQuery.data ?? null; + const arrivalEvidence = safeArray(arrival?.evidence); return (
@@ -107,9 +109,9 @@ export function ArrivalPanel({ session }: { session: JourneySession }) { ) : null}
- {arrival.evidence.length > 0 ? ( + {arrivalEvidence.length > 0 ? (
    - {arrival.evidence.map((line) => ( + {arrivalEvidence.map((line) => ( · {line} diff --git a/web/src/features/trips/components/ChecklistPanel.tsx b/web/src/features/trips/components/ChecklistPanel.tsx index f756671bce..250a8f1078 100644 --- a/web/src/features/trips/components/ChecklistPanel.tsx +++ b/web/src/features/trips/components/ChecklistPanel.tsx @@ -11,6 +11,7 @@ import { Badge, Button, Text } from '@/components/ui'; import { EmptyState, ListSkeleton, QueryError } from '@/components/feedback'; import { isApiError } from '@/lib/resilience'; import { formatDateTime } from '@/lib/dateFormat'; +import { safeArray } from '@/lib/safeArray'; const ITEM_LABEL_KEYS = { charge_level: 'journey.checklist.item.charge_level', @@ -67,6 +68,7 @@ export function ChecklistPanel({ session }: { session: JourneySession }) { const runQuery = useChecklist(session.id); const runState = useDataState(runQuery); const run = runQuery.data ?? null; + const items = safeArray(run?.items); const refresh = useRefreshChecklist(); @@ -130,7 +132,7 @@ export function ChecklistPanel({ session }: { session: JourneySession }) { })}
      - {run.items.map((item) => ( + {items.map((item) => (
    • ) : adviceState.fatalError ? ( adviceState.retry?.()} /> - ) : advice == null || advice.slots.length === 0 ? ( + ) : advice == null || slots.length === 0 ? ( {t('journey.departure.uncovered', 'The forecast covers none of this window.')} @@ -106,7 +108,7 @@ export function DeparturePanel({ session }: { session: JourneySession }) { )}
      - {advice.slots.map((slot) => ( + {slots.map((slot) => (
        - {advice.evidence.map((line) => ( + {safeArray(advice.evidence).map((line) => ( · {line} diff --git a/web/src/features/trips/components/JourneyPanel.test.tsx b/web/src/features/trips/components/JourneyPanel.test.tsx index c0df7c858b..41380a090b 100644 --- a/web/src/features/trips/components/JourneyPanel.test.tsx +++ b/web/src/features/trips/components/JourneyPanel.test.tsx @@ -233,4 +233,44 @@ describe('JourneyPanel', () => { fireEvent.click(screen.getByText('Retry')); expect(refetch).toHaveBeenCalled(); }); + + it('renders when Go sends null slices on a planned journey', () => { + mockDetail.mockReturnValue( + idle({ + data: { session: sessions[0], plans: null, next_statuses: null }, + }), + ); + mockNudge.mockReturnValue( + idle({ + data: { + session_id: 1, + verdict: 'unknown', + slot_at: null, + blockers: null, + evidence: null, + }, + }), + ); + mockChecklist.mockReturnValue( + idle({ + data: { + id: 1, + session_id: 1, + run_at: '2026-09-10T10:00:00Z', + items: null, + }, + }), + ); + mockList.mockReturnValue(idle({ data: null })); + const { unmount } = renderPanel(); + expect(screen.getByText(/No journeys yet/)).toBeInTheDocument(); + unmount(); + + mockList.mockReturnValue(idle({ data: sessions })); + renderPanel(); + fireEvent.click(screen.getAllByText('Open')[0]); + expect(screen.getAllByText('Tahoe ski trip').length).toBeGreaterThan(0); + expect(screen.getByText(/No plans saved yet/)).toBeInTheDocument(); + expect(screen.getByText('Leave now?')).toBeInTheDocument(); + }); }); diff --git a/web/src/features/trips/components/JourneyPanel.tsx b/web/src/features/trips/components/JourneyPanel.tsx index e96c282a1e..5f0af50927 100644 --- a/web/src/features/trips/components/JourneyPanel.tsx +++ b/web/src/features/trips/components/JourneyPanel.tsx @@ -15,6 +15,7 @@ import { Badge, Button, DataTable, GlassPanel, Input, PanelTitle, Select, Text } import type { Column } from '@/components/ui'; import { EmptyState, ListSkeleton, QueryError } from '@/components/feedback'; import { formatDateTime } from '@/lib/dateFormat'; +import { safeArray } from '@/lib/safeArray'; import { StopScorePanel } from './StopScorePanel'; import { DeparturePanel } from './DeparturePanel'; import { ChecklistPanel } from './ChecklistPanel'; @@ -109,7 +110,7 @@ export function JourneyPanel({ vehicleId }: { vehicleId: number | null }) { const listQuery = useJourneys(vehicleId, statusFilter); const listState = useDataState(listQuery); - const sessions = listQuery.data ?? []; + const sessions = safeArray(listQuery.data); const detailQuery = useJourney(selectedId); const detailState = useDataState(detailQuery); @@ -321,7 +322,7 @@ export function JourneyPanel({ vehicleId }: { vehicleId: number | null }) { {statusLabel(detail.session.status)} - {detail.next_statuses.map((next) => { + {safeArray(detail.next_statuses).map((next) => { const action = transitionAction(next, detail.session.status); return (
      - {assessment.evidence.length > 0 ? ( + {replanEvidence.length > 0 ? (
        - {assessment.evidence.map((line) => ( + {replanEvidence.map((line) => ( · {line} @@ -123,7 +125,7 @@ export function ReplanPanel({ session }: { session: JourneySession }) { {t('journey.planVersion', 'v{{version}}', { version: result.plan_version })} - + ) : null} diff --git a/web/src/features/trips/components/ReportPanel.tsx b/web/src/features/trips/components/ReportPanel.tsx index dd65387109..9df4e282bf 100644 --- a/web/src/features/trips/components/ReportPanel.tsx +++ b/web/src/features/trips/components/ReportPanel.tsx @@ -6,6 +6,7 @@ import { useUnits } from '@/hooks/useUnits'; import { Text } from '@/components/ui'; import { ListSkeleton, QueryError } from '@/components/feedback'; import { fmtNumber } from '@/lib/numberFormat'; +import { safeArray } from '@/lib/safeArray'; /** * Trip report card: distance, duration, detour factor, plan/replan @@ -19,6 +20,7 @@ export function ReportPanel({ session }: { session: JourneySession }) { const reportQuery = useReport(session.id); const reportState = useDataState(reportQuery); const report = reportQuery.data ?? null; + const reportEvidence = safeArray(report?.evidence); return (
        @@ -111,9 +113,9 @@ export function ReportPanel({ session }: { session: JourneySession }) {
        - {report.evidence.length > 0 ? ( + {reportEvidence.length > 0 ? (
          - {report.evidence.map((line) => ( + {reportEvidence.map((line) => ( · {line} diff --git a/web/src/features/trips/components/StopScorePanel.tsx b/web/src/features/trips/components/StopScorePanel.tsx index c39e1b85de..9cbcffb939 100644 --- a/web/src/features/trips/components/StopScorePanel.tsx +++ b/web/src/features/trips/components/StopScorePanel.tsx @@ -8,6 +8,7 @@ import { Badge, Button, Checkbox, Input, Text } from '@/components/ui'; import { UnitInput } from '@/components/forms'; import { EmptyState, ListSkeleton, QueryError } from '@/components/feedback'; import { StopScoreTable } from './StopScoreTable'; +import { safeArray } from '@/lib/safeArray'; const MAX_CANDIDATES = 10; @@ -36,7 +37,7 @@ export function StopScorePanel({ session }: { session: JourneySession }) { const sitesQuery = useWaitOracleSites(); const sitesState = useDataState(sitesQuery); - const sites = useMemo(() => sitesQuery.data ?? [], [sitesQuery.data]); + const sites = useMemo(() => safeArray(sitesQuery.data), [sitesQuery.data]); const score = useScoreStops(); const result = score.data ?? null; @@ -157,7 +158,7 @@ export function StopScorePanel({ session }: { session: JourneySession }) { {t('journey.planVersion', 'v{{version}}', { version: result.plan_version })} - + ) : null} diff --git a/web/src/features/trips/components/StopScoreTable.tsx b/web/src/features/trips/components/StopScoreTable.tsx index cab01c37d0..f96d695247 100644 --- a/web/src/features/trips/components/StopScoreTable.tsx +++ b/web/src/features/trips/components/StopScoreTable.tsx @@ -5,6 +5,7 @@ import { DataTable, Text } from '@/components/ui'; import type { Column } from '@/components/ui'; import { fmtNumber } from '@/lib/numberFormat'; import type { ScoredStop } from '@/api/hooks/useJourney'; +import { safeArray } from '@/lib/safeArray'; /** * Ranked stop table shared by initial scoring and replans: identical @@ -25,7 +26,7 @@ export function StopScoreTable({ stops, tableId }: { stops: ScoredStop[]; tableI {row.site} - {row.evidence.map((line) => ( + {safeArray(row.evidence).map((line) => ( {line} @@ -93,7 +94,7 @@ export function StopScoreTable({ stops, tableId }: { stops: ScoredStop[]; tableI return ( row.site} tableId={tableId} />