Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions internal/api/journey/nudge.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions internal/api/journey/nudge_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand Down
24 changes: 24 additions & 0 deletions web/src/api/hooks/useJourney.ts
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,11 @@ export function useJourney(id: number | null | undefined, options?: { enabled?:
request<JourneyDetail>(`/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),
}),
});
}

Expand Down Expand Up @@ -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),
}),
});
}

Expand All @@ -350,6 +360,7 @@ export function useChecklist(id: number | null | undefined, options?: { enabled?
request<ChecklistRun>(`/journey/sessions/${id}/checklist`, { signal }),
enabled: (options?.enabled ?? true) && id != null && id > 0,
...queryPolicy('operational'),
select: (data) => ({ ...data, items: safeArray(data?.items) }),
});
}

Expand Down Expand Up @@ -384,6 +395,11 @@ export function useJourneyLive(id: number | null | undefined, options?: { enable
request<JourneyLiveView>(`/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),
}),
});
}

Expand Down Expand Up @@ -426,6 +442,7 @@ export function useReplanAssessment(id: number | null | undefined, options?: { e
request<JourneyReplanAssessment>(`/journey/sessions/${id}/replan`, { signal }),
enabled: (options?.enabled ?? true) && id != null && id > 0,
...queryPolicy('operational'),
select: (data) => ({ ...data, evidence: safeArray(data?.evidence) }),
});
}

Expand Down Expand Up @@ -460,6 +477,7 @@ export function useArrival(id: number | null | undefined, options?: { enabled?:
request<JourneyArrival>(`/journey/sessions/${id}/arrival`, { signal }),
enabled: (options?.enabled ?? true) && id != null && id > 0,
...queryPolicy('live'),
select: (data) => ({ ...data, evidence: safeArray(data?.evidence) }),
});
}

Expand All @@ -471,6 +489,7 @@ export function useReport(id: number | null | undefined, options?: { enabled?: b
request<JourneyReport>(`/journey/sessions/${id}/report`, { signal }),
enabled: (options?.enabled ?? true) && id != null && id > 0,
...queryPolicy('operational'),
select: (data) => ({ ...data, evidence: safeArray(data?.evidence) }),
});
}

Expand All @@ -482,6 +501,11 @@ export function useNudge(id: number | null | undefined, options?: { enabled?: bo
request<JourneyNudge>(`/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),
}),
});
}

Expand Down
20 changes: 17 additions & 3 deletions web/src/api/hooks/useSystemDiagnostic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<DiagnosticReport>(diagnosticKeys.last);
const { data } = useQuery<DiagnosticReport>({
queryKey: diagnosticKeys.last,
queryFn: ({ signal }) => {
void signal;
throw new Error('diagnosticKeys.last is cache-only');
},
enabled: false,
staleTime: Infinity,
retry: false,
});
return data;
}

/**
Expand Down
16 changes: 15 additions & 1 deletion web/src/features/settings/components/SettingsSearch.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export function SettingsSearch({ className }: SettingsSearchProps) {
const [open, setOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState(0);
const wrapperRef = useRef<HTMLDivElement | null>(null);
const scrollTimerRef = useRef<ReturnType<typeof window.setTimeout> | null>(null);
const listboxId = useId();

const index = useMemo(() => getSettingsIndex(t), [t]);
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
});
});
6 changes: 4 additions & 2 deletions web/src/features/trips/components/ArrivalPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<ChecklistStatus, string> = {
ok: 'journey.arrival.verdict.ok',
Expand Down Expand Up @@ -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 (
<div className="space-y-4">
Expand Down Expand Up @@ -107,9 +109,9 @@ export function ArrivalPanel({ session }: { session: JourneySession }) {
) : null}
</div>

{arrival.evidence.length > 0 ? (
{arrivalEvidence.length > 0 ? (
<ul className="space-y-1">
{arrival.evidence.map((line) => (
{arrivalEvidence.map((line) => (
<Text as="li" key={line} size="xs" color="muted">
· {line}
</Text>
Expand Down
4 changes: 3 additions & 1 deletion web/src/features/trips/components/ChecklistPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -130,7 +132,7 @@ export function ChecklistPanel({ session }: { session: JourneySession }) {
})}
</Text>
<ul className="space-y-2">
{run.items.map((item) => (
{items.map((item) => (
<li
key={item.key}
className="flex items-start justify-between gap-3 rounded-lg border border-white/[0.07] bg-white/[0.02] px-3 py-2"
Expand Down
8 changes: 5 additions & 3 deletions web/src/features/trips/components/DeparturePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { Badge, Button, 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 HORIZONS = [12, 24, 48] as const;

Expand Down Expand Up @@ -42,6 +43,7 @@ export function DeparturePanel({ session }: { session: JourneySession }) {
});
const adviceState = useDataState(adviceQuery);
const advice = adviceQuery.data ?? null;
const slots = safeArray(advice?.slots);

if (!hasOrigin) {
return (
Expand Down Expand Up @@ -79,7 +81,7 @@ export function DeparturePanel({ session }: { session: JourneySession }) {
<ListSkeleton label={t('journey.departure.loading', 'Scoring departure hours…')} />
) : adviceState.fatalError ? (
<QueryError error={adviceState.fatalError} onRetry={() => adviceState.retry?.()} />
) : advice == null || advice.slots.length === 0 ? (
) : advice == null || slots.length === 0 ? (
<Text as="p" size="sm" color="secondary">
{t('journey.departure.uncovered', 'The forecast covers none of this window.')}
</Text>
Expand All @@ -106,7 +108,7 @@ export function DeparturePanel({ session }: { session: JourneySession }) {
</Badge>
)}
<div className="flex flex-wrap gap-1.5" role="list" aria-label={t('journey.departure.slots', 'Departure hours')}>
{advice.slots.map((slot) => (
{slots.map((slot) => (
<span
key={slot.depart_at}
role="listitem"
Expand All @@ -122,7 +124,7 @@ export function DeparturePanel({ session }: { session: JourneySession }) {
))}
</div>
<ul className="space-y-1">
{advice.evidence.map((line) => (
{safeArray(advice.evidence).map((line) => (
<Text as="li" key={line} size="xs" color="muted">
· {line}
</Text>
Expand Down
40 changes: 40 additions & 0 deletions web/src/features/trips/components/JourneyPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
9 changes: 5 additions & 4 deletions web/src/features/trips/components/JourneyPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -321,7 +322,7 @@ export function JourneyPanel({ vehicleId }: { vehicleId: number | null }) {
<Badge variant={statusVariant(detail.session.status)}>
{statusLabel(detail.session.status)}
</Badge>
{detail.next_statuses.map((next) => {
{safeArray(detail.next_statuses).map((next) => {
const action = transitionAction(next, detail.session.status);
return (
<Button
Expand All @@ -342,13 +343,13 @@ export function JourneyPanel({ vehicleId }: { vehicleId: number | null }) {
<Icons.package className="h-4 w-4" aria-hidden="true" />
{t('journey.plans.title', 'Plan versions')}
</Text>
{detail.plans.length === 0 ? (
{safeArray(detail.plans).length === 0 ? (
<Text as="p" size="sm" color="secondary">
{t('journey.plans.empty', 'No plans saved yet — the stop optimizer lands here.')}
</Text>
) : (
<ul className="space-y-2">
{detail.plans.map((plan) => (
{safeArray(detail.plans).map((plan) => (
<li
key={plan.id}
className="flex items-center justify-between gap-3 rounded-lg border border-white/[0.07] bg-white/[0.02] px-3 py-2"
Expand Down
Loading
Loading