Skip to content

Commit a22e294

Browse files
committed
feat(journey): offline check-in outbox with reconnect replay
Slice 10: taps while offline (or sends failing without a status) queue in localStorage and replay in order with original instants on reconnect; the server dedupes idempotently. useCheckIn takes an optional recorded_at and stays silent on network failures by contract; LiveTripPanel shows the queued count.
1 parent e5d5337 commit a22e294

9 files changed

Lines changed: 473 additions & 30 deletions

File tree

web/src/api/hooks/useJourney.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { scopedPath } from '../scope';
55
import { safeArray } from '@/lib/safeArray';
66
import { useMutationToast } from './_toastHelpers';
77
import { invalidateAndBroadcast } from '@/lib/queryBroadcast';
8+
import { isApiError } from '@/lib/resilience';
89

910
/**
1011
* Journey Autopilot: trip sessions + versioned plans. Reads the backend
@@ -386,15 +387,22 @@ export function useJourneyLive(id: number | null | undefined, options?: { enable
386387
});
387388
}
388389

389-
/** Snapshots one trail point; the server backfills missing fields from live telemetry. */
390+
/**
391+
* Snapshots one trail point; the server backfills missing fields from
392+
* live telemetry. `recorded_at` replays an outbox entry under its
393+
* original instant (the server dedupes idempotently).
394+
*
395+
* Network failures stay silent here BY CONTRACT: the check-in outbox
396+
* hook queues them instead of toasting, so only ApiErrors toast.
397+
*/
390398
export function useCheckIn() {
391399
const qc = useQueryClient();
392400
const { success, error } = useMutationToast();
393401
return useMutation({
394-
mutationFn: (id: number) =>
402+
mutationFn: ({ id, recorded_at }: { id: number; recorded_at?: string }) =>
395403
request<JourneyCheckpoint>(`/journey/sessions/${id}/checkpoints`, {
396404
method: 'POST',
397-
body: JSON.stringify({}),
405+
body: JSON.stringify(recorded_at == null ? {} : { recorded_at }),
398406
}),
399407
onSuccess: (checkpoint) => {
400408
invalidateAndBroadcast(qc, { queryKey: journeyKeys.live(checkpoint.session_id) });
@@ -403,7 +411,10 @@ export function useCheckIn() {
403411
invalidateAndBroadcast(qc, { queryKey: journeyKeys.report(checkpoint.session_id) });
404412
success('toast.journey.checkin.success', 'Checked in');
405413
},
406-
onError: (err) => error(err, 'toast.journey.checkin.error', 'Check-in failed'),
414+
onError: (err) => {
415+
if (!isApiError(err)) return;
416+
error(err, 'toast.journey.checkin.error', 'Check-in failed');
417+
},
407418
});
408419
}
409420

web/src/features/trips/components/LiveTripPanel.test.tsx

Lines changed: 27 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
/**
22
* LiveTripPanel — behaviour coverage.
33
*
4-
* Data hooks (`useJourneyLive` / `useCheckIn`) and `useUnits` are mocked
5-
* and driven per test; shared UI (Badge, Button, EmptyState,
4+
* Data hooks (`useJourneyLive` / `useQueuedCheckIn`) and `useUnits` are
5+
* mocked and driven per test; shared UI (Badge, Button, EmptyState,
66
* ListSkeleton, QueryError) is REAL so the render-boundary wiring is
77
* genuinely exercised.
88
*/
@@ -41,7 +41,10 @@ vi.mock('react-i18next', () => {
4141
// ── data hooks, driven per test ──
4242
vi.mock('@/api/hooks/useJourney', () => ({
4343
useJourneyLive: vi.fn(),
44-
useCheckIn: vi.fn(),
44+
}));
45+
46+
vi.mock('../hooks/useQueuedCheckIn', () => ({
47+
useQueuedCheckIn: vi.fn(),
4548
}));
4649

4750
vi.mock('@/hooks/useUnits', () => ({
@@ -52,11 +55,12 @@ vi.mock('@/hooks/useUnits', () => ({
5255
}),
5356
}));
5457

55-
import { useJourneyLive, useCheckIn, type JourneySession } from '@/api/hooks/useJourney';
58+
import { useJourneyLive, type JourneySession } from '@/api/hooks/useJourney';
59+
import { useQueuedCheckIn } from '../hooks/useQueuedCheckIn';
5660
import { LiveTripPanel } from './LiveTripPanel';
5761

5862
const mockLive = useJourneyLive as unknown as ReturnType<typeof vi.fn>;
59-
const mockCheckIn = useCheckIn as unknown as ReturnType<typeof vi.fn>;
63+
const mockQueuedCheckIn = useQueuedCheckIn as unknown as ReturnType<typeof vi.fn>;
6064

6165
const session: JourneySession = {
6266
id: 1, vehicle_id: 7, name: 'Denver run',
@@ -111,7 +115,7 @@ function renderPanel() {
111115
beforeEach(() => {
112116
vi.clearAllMocks();
113117
mockLive.mockReturnValue(idle({ data: view }));
114-
mockCheckIn.mockReturnValue({ mutate: vi.fn(), isPending: false });
118+
mockQueuedCheckIn.mockReturnValue({ checkIn: vi.fn(), queued: 0, flushing: false, isPending: false });
115119
});
116120

117121
describe('LiveTripPanel', () => {
@@ -125,21 +129,32 @@ describe('LiveTripPanel', () => {
125129
});
126130

127131
it('checks in for the session', () => {
128-
const mutate = vi.fn();
129-
mockCheckIn.mockReturnValue({ mutate, isPending: false });
132+
const checkIn = vi.fn();
133+
mockQueuedCheckIn.mockReturnValue({ checkIn, queued: 0, flushing: false, isPending: false });
130134
renderPanel();
131135
fireEvent.click(screen.getByText('Check in'));
132-
expect(mutate).toHaveBeenCalledWith(1);
136+
expect(checkIn).toHaveBeenCalledTimes(1);
137+
});
138+
139+
it('shows the queued count while offline entries wait', () => {
140+
mockQueuedCheckIn.mockReturnValue({
141+
checkIn: vi.fn(),
142+
queued: 2,
143+
flushing: false,
144+
isPending: false,
145+
});
146+
renderPanel();
147+
expect(screen.getByText('2 queued')).toBeInTheDocument();
133148
});
134149

135150
it('treats no-fixes as an empty state with a check-in action', () => {
136151
mockLive.mockReturnValue(idle({ data: { ...view, latest: null, trail: [] } }));
137-
const mutate = vi.fn();
138-
mockCheckIn.mockReturnValue({ mutate, isPending: false });
152+
const checkIn = vi.fn();
153+
mockQueuedCheckIn.mockReturnValue({ checkIn, queued: 0, flushing: false, isPending: false });
139154
renderPanel();
140155
expect(screen.getByText(/No fixes yet/)).toBeInTheDocument();
141156
fireEvent.click(screen.getByText('Check in'));
142-
expect(mutate).toHaveBeenCalledWith(1);
157+
expect(checkIn).toHaveBeenCalledTimes(1);
143158
});
144159

145160
it('omits progress and range numbers when the snapshot degrades', () => {

web/src/features/trips/components/LiveTripPanel.tsx

Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
import { useTranslation } from 'react-i18next';
22
import { Icons } from '@/lib/icons';
33
import {
4-
useCheckIn,
54
useJourneyLive,
65
type JourneyRangeVerdict,
76
type JourneySession,
87
} from '@/api/hooks/useJourney';
8+
import { useQueuedCheckIn } from '../hooks/useQueuedCheckIn';
99
import { useDataState } from '@/hooks/useDataState';
1010
import { useUnits } from '@/hooks/useUnits';
1111
import { Badge, Button, Text } from '@/components/ui';
@@ -42,7 +42,8 @@ function verdictVariant(verdict: JourneyRangeVerdict) {
4242
/**
4343
* Live trip session: glanceable progress, range verdict, next stop,
4444
* and the trail behind. The backend polls vehicle state on a 15 s
45-
* ambient tick; "Check in" drops an explicit trail point on demand.
45+
* ambient tick; "Check in" drops an explicit trail point on demand —
46+
* queued offline and replayed on reconnect.
4647
*/
4748
export function LiveTripPanel({ session }: { session: JourneySession }) {
4849
const { t } = useTranslation();
@@ -52,8 +53,8 @@ export function LiveTripPanel({ session }: { session: JourneySession }) {
5253
const liveState = useDataState(liveQuery);
5354
const view = liveQuery.data ?? null;
5455

55-
const checkIn = useCheckIn();
56-
const busy = liveQuery.isLoading || checkIn.isPending;
56+
const { checkIn, queued, isPending: checkInPending } = useQueuedCheckIn(session.id);
57+
const busy = liveQuery.isLoading || checkInPending;
5758

5859
const pct =
5960
view?.progress != null && view.progress.total_m > 0
@@ -67,14 +68,21 @@ export function LiveTripPanel({ session }: { session: JourneySession }) {
6768
<Icons.satellite className="h-4 w-4 text-[var(--text-muted)]" aria-hidden="true" />
6869
{t('journey.live.title', 'Live trip')}
6970
</Text>
70-
<Button
71-
variant="secondary"
72-
size="sm"
73-
loading={checkIn.isPending}
74-
onClick={() => checkIn.mutate(session.id)}
75-
>
76-
{t('journey.live.checkIn', 'Check in')}
77-
</Button>
71+
<div className="flex items-center gap-2">
72+
{queued > 0 ? (
73+
<Text as="p" variant="caption" className="tabular-nums">
74+
{t('journey.live.queued', '{{count}} queued', { count: queued })}
75+
</Text>
76+
) : null}
77+
<Button
78+
variant="secondary"
79+
size="sm"
80+
loading={checkInPending}
81+
onClick={checkIn}
82+
>
83+
{t('journey.live.checkIn', 'Check in')}
84+
</Button>
85+
</div>
7886
</div>
7987

8088
{busy ? (
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
/**
2+
* useQueuedCheckIn — behaviour coverage via a harness component.
3+
*
4+
* `useCheckIn` and the resilience connection API are mocked and driven
5+
* per test; the real localStorage-backed outbox runs underneath so the
6+
* queue/flush round-trip is genuinely exercised.
7+
*/
8+
import { describe, it, expect, vi, beforeEach } from 'vitest';
9+
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
10+
import type { ReactNode } from 'react';
11+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
12+
13+
// ── i18n stub (useMutationToast reads useTranslation) ──
14+
vi.mock('react-i18next', () => {
15+
const t = (key: string, vars?: Record<string, unknown>): string => {
16+
if (vars && typeof vars.defaultValue === 'string') {
17+
let s: string = vars.defaultValue;
18+
for (const [k, v] of Object.entries(vars)) {
19+
if (k === 'defaultValue') continue;
20+
s = s.replace(new RegExp(`{{\\s*${k}\\s*}}`, 'g'), String(v));
21+
}
22+
return s;
23+
}
24+
return key;
25+
};
26+
return {
27+
useTranslation: () => ({ t, i18n: { language: 'en', changeLanguage: vi.fn() } }),
28+
Trans: ({ children }: { children?: ReactNode }) => <>{children}</>,
29+
initReactI18next: { type: '3rdParty', init: () => undefined },
30+
};
31+
});
32+
33+
vi.mock('@/api/hooks/useJourney', () => ({
34+
useCheckIn: vi.fn(),
35+
}));
36+
37+
vi.mock('@/api/hooks/_toastHelpers', () => ({
38+
useMutationToast: () => ({ success: vi.fn(), warning: vi.fn(), error: vi.fn() }),
39+
}));
40+
41+
const statusState = {
42+
status: 'online' as 'online' | 'offline',
43+
listeners: new Set<(s: 'online' | 'offline') => void>(),
44+
};
45+
46+
vi.mock('@/lib/resilience', async (importOriginal) => {
47+
const original = await importOriginal<typeof import('@/lib/resilience')>();
48+
return {
49+
...original,
50+
getConnectionStatus: () => statusState.status,
51+
onStatusChange: (fn: (s: 'online' | 'offline') => void) => {
52+
statusState.listeners.add(fn);
53+
return () => {
54+
statusState.listeners.delete(fn);
55+
};
56+
},
57+
};
58+
});
59+
60+
function setStatus(s: 'online' | 'offline') {
61+
statusState.status = s;
62+
statusState.listeners.forEach((fn) => fn(s));
63+
}
64+
65+
import { useCheckIn } from '@/api/hooks/useJourney';
66+
import { useQueuedCheckIn } from './useQueuedCheckIn';
67+
import { enqueueCheckIn, loadOutbox } from '../lib/checkInOutbox';
68+
69+
const mockCheckIn = useCheckIn as unknown as ReturnType<typeof vi.fn>;
70+
71+
function Harness({ sessionId = 1 }: { sessionId?: number }) {
72+
const { checkIn, queued, isPending } = useQueuedCheckIn(sessionId);
73+
return (
74+
<div>
75+
<button type="button" onClick={checkIn} disabled={isPending}>
76+
tap
77+
</button>
78+
<span data-testid="queued">{queued}</span>
79+
</div>
80+
);
81+
}
82+
83+
function renderHarness(sessionId?: number) {
84+
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
85+
return render(
86+
<QueryClientProvider client={client}>
87+
<Harness sessionId={sessionId} />
88+
</QueryClientProvider>,
89+
);
90+
}
91+
92+
beforeEach(() => {
93+
vi.clearAllMocks();
94+
localStorage.clear();
95+
statusState.status = 'online';
96+
statusState.listeners.clear();
97+
mockCheckIn.mockReturnValue({ mutate: vi.fn(), mutateAsync: vi.fn(), isPending: false });
98+
});
99+
100+
describe('useQueuedCheckIn', () => {
101+
it('sends immediately when online', () => {
102+
const mutate = vi.fn();
103+
mockCheckIn.mockReturnValue({ mutate, mutateAsync: vi.fn(), isPending: false });
104+
renderHarness();
105+
fireEvent.click(screen.getByText('tap'));
106+
expect(mutate).toHaveBeenCalledWith({ id: 1 }, expect.anything());
107+
expect(loadOutbox()).toHaveLength(0);
108+
});
109+
110+
it('queues the tap while offline', () => {
111+
statusState.status = 'offline';
112+
const mutate = vi.fn();
113+
mockCheckIn.mockReturnValue({ mutate, mutateAsync: vi.fn(), isPending: false });
114+
renderHarness();
115+
fireEvent.click(screen.getByText('tap'));
116+
expect(mutate).not.toHaveBeenCalled();
117+
expect(screen.getByTestId('queued')).toHaveTextContent('1');
118+
expect(loadOutbox()).toHaveLength(1);
119+
expect(loadOutbox()[0].session_id).toBe(1);
120+
});
121+
122+
it('queues when the send fails without a status', () => {
123+
const mutate = vi.fn((_vars: unknown, opts?: { onError?: (e: unknown) => void }) => {
124+
opts?.onError?.(new TypeError('fetch failed'));
125+
});
126+
mockCheckIn.mockReturnValue({ mutate, mutateAsync: vi.fn(), isPending: false });
127+
renderHarness();
128+
fireEvent.click(screen.getByText('tap'));
129+
expect(screen.getByTestId('queued')).toHaveTextContent('1');
130+
expect(loadOutbox()).toHaveLength(1);
131+
});
132+
133+
it('does not queue HTTP errors', () => {
134+
const apiError = Object.assign(new Error('HTTP 409'), { name: 'ApiError', status: 409 });
135+
const mutate = vi.fn((_vars: unknown, opts?: { onError?: (e: unknown) => void }) => {
136+
opts?.onError?.(apiError);
137+
});
138+
mockCheckIn.mockReturnValue({ mutate, mutateAsync: vi.fn(), isPending: false });
139+
renderHarness();
140+
fireEvent.click(screen.getByText('tap'));
141+
expect(screen.getByTestId('queued')).toHaveTextContent('0');
142+
expect(loadOutbox()).toHaveLength(0);
143+
});
144+
145+
it('flushes the queue with original instants on reconnect', async () => {
146+
statusState.status = 'offline';
147+
const mutateAsync = vi.fn().mockResolvedValue({ id: 9, session_id: 1 });
148+
mockCheckIn.mockReturnValue({ mutate: vi.fn(), mutateAsync, isPending: false });
149+
renderHarness();
150+
fireEvent.click(screen.getByText('tap'));
151+
// A second tap in the same millisecond dedupes honestly, so seed
152+
// the second entry directly with a distinct instant.
153+
enqueueCheckIn({ session_id: 1, recorded_at: '2026-09-14T10:01:00.000Z' });
154+
const stamped = loadOutbox().map((e) => e.recorded_at);
155+
expect(loadOutbox()).toHaveLength(2);
156+
await act(async () => {
157+
setStatus('online');
158+
});
159+
await waitFor(() => expect(mutateAsync).toHaveBeenCalledTimes(2));
160+
expect(mutateAsync).toHaveBeenNthCalledWith(1, { id: 1, recorded_at: stamped[0] });
161+
expect(mutateAsync).toHaveBeenNthCalledWith(2, { id: 1, recorded_at: stamped[1] });
162+
await waitFor(() => expect(loadOutbox()).toHaveLength(0));
163+
expect(screen.getByTestId('queued')).toHaveTextContent('0');
164+
});
165+
166+
it('stops the flush at the first failure', async () => {
167+
statusState.status = 'offline';
168+
const mutateAsync = vi
169+
.fn()
170+
.mockResolvedValueOnce({ id: 9, session_id: 1 })
171+
.mockRejectedValueOnce(new TypeError('fetch failed'));
172+
mockCheckIn.mockReturnValue({ mutate: vi.fn(), mutateAsync, isPending: false });
173+
renderHarness();
174+
fireEvent.click(screen.getByText('tap'));
175+
enqueueCheckIn({ session_id: 1, recorded_at: '2026-09-14T10:01:00.000Z' });
176+
await act(async () => {
177+
setStatus('online');
178+
});
179+
await waitFor(() => expect(mutateAsync).toHaveBeenCalledTimes(2));
180+
expect(loadOutbox()).toHaveLength(1);
181+
expect(screen.getByTestId('queued')).toHaveTextContent('1');
182+
});
183+
});

0 commit comments

Comments
 (0)