@burdenoff/microfe-movethewheels 2026.1101.1 → 2026.1101.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,343 @@
1
+ /**
2
+ * ePOD capture form (BOFF-4399), extracted from the driver deliveries page so
3
+ * the BOFF-3991 driver day view can open it from a stop's detail screen.
4
+ *
5
+ * Captures recipient name, signature (drawn or typed), an optional delivery
6
+ * photo (uploaded via the platform files primitive), device GPS if available,
7
+ * and notes, after the driver reviews the linked shipment status and attests
8
+ * completion. `captureEpod` owner-verifies the calling driver server-side.
9
+ */
10
+ import { useEffect, useRef, useState } from 'react';
11
+ import {
12
+ CheckCircle2,
13
+ Loader2,
14
+ MapPin,
15
+ PenLine,
16
+ Truck,
17
+ Type as TypeIcon,
18
+ Upload,
19
+ } from 'lucide-react';
20
+ import { Button, Card, CardContent, CardHeader, CardTitle, Input, Label, Textarea } from './ui';
21
+ import { SignaturePad, type SignaturePadHandle } from './SignaturePad';
22
+ import { FormError } from './FormError';
23
+ import { useCaptureEpod, useEpodPhotoUpload, type PendingEpodDeliveryJob } from '../hooks/useEpod';
24
+ import { formatAddress } from '../utils/formatters';
25
+ import type { SignatureType } from '../types';
26
+ import { useShipmentStatusQuery } from '../hooks/useShipmentStatusAuthority';
27
+ import { StatusAuthorityOwnedDeliveryDocument } from '../../generated/shipment-status-authority-operations';
28
+ import type { StatusAuthorityOwnedDeliveryQuery } from '../../generated/shipment-status-authority-operations';
29
+
30
+ /** Resolves the device's current position, if the browser grants it. Never blocks submission. */
31
+ function getCurrentPosition(): Promise<GeolocationPosition | null> {
32
+ return new Promise((resolve) => {
33
+ if (!navigator.geolocation) {
34
+ resolve(null);
35
+ return;
36
+ }
37
+ navigator.geolocation.getCurrentPosition(
38
+ (position) => resolve(position),
39
+ () => resolve(null),
40
+ { enableHighAccuracy: true, timeout: 8000, maximumAge: 30000 }
41
+ );
42
+ });
43
+ }
44
+
45
+ export function EpodCaptureForm({
46
+ job,
47
+ onCaptured,
48
+ onCancel,
49
+ offline = false,
50
+ }: {
51
+ job: PendingEpodDeliveryJob;
52
+ onCaptured: (deliveryJobId: string) => void;
53
+ onCancel: () => void;
54
+ /**
55
+ * True while the device has no network. Capture needs the live linked
56
+ * shipment status and an acknowledged server write, so it is blocked
57
+ * rather than queued while offline.
58
+ */
59
+ offline?: boolean;
60
+ }) {
61
+ const { captureEpod, isSubmitting, error: captureError, captureFeedback } = useCaptureEpod();
62
+ const [statusRefresh, setStatusRefresh] = useState(0);
63
+ const ownedStatus = useShipmentStatusQuery<StatusAuthorityOwnedDeliveryQuery>(
64
+ StatusAuthorityOwnedDeliveryDocument,
65
+ { deliveryJobId: job.id },
66
+ statusRefresh
67
+ );
68
+ const [completionAttestation, setCompletionAttestation] = useState(false);
69
+ const uploadedPhoto = useRef<{ file: File; id: string } | null>(null);
70
+ const submissionLock = useRef(false);
71
+ const active = useRef(true);
72
+ useEffect(() => {
73
+ active.current = true;
74
+ return () => {
75
+ active.current = false;
76
+ };
77
+ }, []);
78
+ const [preparing, setPreparing] = useState(false);
79
+ const [requiredStatusRefresh, setRequiredStatusRefresh] = useState(0);
80
+ const { upload: uploadPhoto, isUploading, error: uploadError } = useEpodPhotoUpload();
81
+ const signaturePadRef = useRef<SignaturePadHandle>(null);
82
+
83
+ const [recipientName, setRecipientName] = useState('');
84
+ const [signatureType, setSignatureType] = useState<SignatureType>('drawn');
85
+ const [typedSignature, setTypedSignature] = useState('');
86
+ const [photoFile, setPhotoFile] = useState<File | null>(null);
87
+ const [notes, setNotes] = useState('');
88
+ const [validationError, setValidationError] = useState<string | null>(null);
89
+
90
+ const handleSubmit = async () => {
91
+ if (submissionLock.current) return;
92
+ setValidationError(null);
93
+ if (offline) {
94
+ setValidationError('You are offline. Reconnect to confirm this delivery.');
95
+ return;
96
+ }
97
+ if (
98
+ ownedStatus.loading ||
99
+ ownedStatus.error ||
100
+ !ownedStatus.data ||
101
+ statusRefresh < requiredStatusRefresh ||
102
+ !completionAttestation ||
103
+ captureFeedback?.kind === 'UNKNOWN'
104
+ ) {
105
+ setValidationError(
106
+ 'Review the current linked shipment status and confirm completion before capture. Do not resend an uncertain capture.'
107
+ );
108
+ return;
109
+ }
110
+
111
+ const trimmedRecipient = recipientName.trim();
112
+ if (!trimmedRecipient) {
113
+ setValidationError('Recipient name is required.');
114
+ return;
115
+ }
116
+
117
+ const signatureData =
118
+ signatureType === 'typed'
119
+ ? typedSignature.trim()
120
+ : (signaturePadRef.current?.toDataUrl() ?? null);
121
+ if (!signatureData) {
122
+ setValidationError(
123
+ signatureType === 'typed'
124
+ ? 'Type the recipient full name as their signature.'
125
+ : 'A signature is required.'
126
+ );
127
+ return;
128
+ }
129
+
130
+ submissionLock.current = true;
131
+ setPreparing(true);
132
+ try {
133
+ let photoFileIds: string[] | undefined;
134
+ if (photoFile) {
135
+ if (uploadedPhoto.current?.file === photoFile) photoFileIds = [uploadedPhoto.current.id];
136
+ else {
137
+ const uploaded = await uploadPhoto(photoFile);
138
+ uploadedPhoto.current = { file: photoFile, id: uploaded.fileId };
139
+ photoFileIds = [uploaded.fileId];
140
+ }
141
+ }
142
+
143
+ if (!active.current) return;
144
+ const position = await getCurrentPosition();
145
+ if (!active.current) return;
146
+
147
+ const proof = await captureEpod({
148
+ expectedShipmentId: ownedStatus.data.myEpodShipmentStatus?.id ?? null,
149
+ expectedShipmentStatusRevision: ownedStatus.data.myEpodShipmentStatus?.statusRevision,
150
+ completionAttestation,
151
+ deliveryJobId: job.id,
152
+ recipientName: trimmedRecipient,
153
+ signatureType,
154
+ signatureData,
155
+ photoFileIds,
156
+ latitude: position?.coords.latitude,
157
+ longitude: position?.coords.longitude,
158
+ locationAccuracy: position?.coords.accuracy,
159
+ notes: notes.trim() || undefined,
160
+ });
161
+
162
+ if (!active.current) return;
163
+ onCaptured(proof.deliveryJobId);
164
+ } catch {
165
+ setCompletionAttestation(false);
166
+ setRequiredStatusRefresh(statusRefresh + 1);
167
+ // captureError / uploadError already carry the message for display below.
168
+ } finally {
169
+ submissionLock.current = false;
170
+ setPreparing(false);
171
+ }
172
+ };
173
+
174
+ const submitting = isSubmitting || isUploading || preparing;
175
+
176
+ return (
177
+ <Card>
178
+ <CardHeader>
179
+ <CardTitle className="flex items-center gap-2">
180
+ <Truck size={18} /> Capture delivery for {job.deliveryJobNumber}
181
+ </CardTitle>
182
+ </CardHeader>
183
+ <CardContent className="space-y-4">
184
+ <div className="text-sm text-text-secondary space-y-1">
185
+ {job.customerName && <p>Recipient on file: {job.customerName}</p>}
186
+ {job.deliveryAddress && <p>{formatAddress(job.deliveryAddress)}</p>}
187
+ </div>
188
+ <section aria-label="Linked shipment status" className="space-y-2">
189
+ <p>
190
+ Recorded shipment:{' '}
191
+ {ownedStatus.data?.myEpodShipmentStatus
192
+ ? `${ownedStatus.data.myEpodShipmentStatus.status.toLowerCase().replaceAll('_', ' ')} · revision ${ownedStatus.data.myEpodShipmentStatus.statusRevision}`
193
+ : ownedStatus.data
194
+ ? 'No linked shipment'
195
+ : 'Loading current record'}
196
+ </p>
197
+ {ownedStatus.error && <p role="alert">{ownedStatus.error}</p>}
198
+ <Button
199
+ variant="outline"
200
+ disabled={ownedStatus.loading || submitting}
201
+ onClick={() => {
202
+ setCompletionAttestation(false);
203
+ setStatusRefresh((value) => value + 1);
204
+ }}
205
+ >
206
+ Refresh linked status
207
+ </Button>
208
+ <label className="flex items-start gap-2">
209
+ <input
210
+ type="checkbox"
211
+ checked={completionAttestation}
212
+ disabled={
213
+ ownedStatus.loading ||
214
+ !!ownedStatus.error ||
215
+ submitting ||
216
+ statusRefresh < requiredStatusRefresh ||
217
+ captureFeedback?.kind === 'UNKNOWN'
218
+ }
219
+ onChange={(event) => setCompletionAttestation(event.target.checked)}
220
+ />
221
+ I reviewed the recorded linked status and attest to this delivery completion.
222
+ </label>
223
+ <p>
224
+ This records an assigned driver's attestation; it does not independently verify physical
225
+ freight movement.
226
+ </p>
227
+ {captureFeedback && <p role="status">{captureFeedback.message}</p>}
228
+ </section>
229
+
230
+ <div className="space-y-1.5">
231
+ <Label htmlFor="epod-recipient-name">Recipient name</Label>
232
+ <Input
233
+ id="epod-recipient-name"
234
+ value={recipientName}
235
+ onChange={(event) => setRecipientName(event.target.value)}
236
+ placeholder="Who received the delivery?"
237
+ />
238
+ </div>
239
+
240
+ <div className="space-y-1.5">
241
+ <Label>Signature</Label>
242
+ <div className="flex gap-2">
243
+ <Button
244
+ type="button"
245
+ variant={signatureType === 'drawn' ? 'default' : 'outline'}
246
+ size="sm"
247
+ onClick={() => setSignatureType('drawn')}
248
+ >
249
+ <PenLine size={14} /> Draw
250
+ </Button>
251
+ <Button
252
+ type="button"
253
+ variant={signatureType === 'typed' ? 'default' : 'outline'}
254
+ size="sm"
255
+ onClick={() => setSignatureType('typed')}
256
+ >
257
+ <TypeIcon size={14} /> Type name instead
258
+ </Button>
259
+ </div>
260
+ {signatureType === 'drawn' ? (
261
+ <SignaturePad ref={signaturePadRef} />
262
+ ) : (
263
+ <Input
264
+ value={typedSignature}
265
+ onChange={(event) => setTypedSignature(event.target.value)}
266
+ placeholder="Type full name as signature"
267
+ />
268
+ )}
269
+ </div>
270
+
271
+ <div className="space-y-1.5">
272
+ <Label htmlFor="epod-photo">Delivery photo (optional)</Label>
273
+ <div className="flex items-center gap-2">
274
+ <input
275
+ id="epod-photo"
276
+ type="file"
277
+ accept="image/*"
278
+ capture="environment"
279
+ onChange={(event) => setPhotoFile(event.target.files?.[0] ?? null)}
280
+ className="text-sm"
281
+ />
282
+ {photoFile && <Upload size={14} className="text-text-secondary" />}
283
+ </div>
284
+ {photoFile && <p className="text-xs text-text-secondary">{photoFile.name}</p>}
285
+ </div>
286
+
287
+ <div className="space-y-1.5">
288
+ <Label htmlFor="epod-notes">Notes (optional)</Label>
289
+ <Textarea
290
+ id="epod-notes"
291
+ value={notes}
292
+ onChange={(event) => setNotes(event.target.value)}
293
+ placeholder="e.g. Left with front desk, gate code used, etc."
294
+ rows={2}
295
+ />
296
+ </div>
297
+
298
+ <p className="flex items-center gap-1.5 text-xs text-text-secondary">
299
+ <MapPin size={12} /> Device GPS is captured automatically when permitted by the browser.
300
+ </p>
301
+
302
+ {offline && (
303
+ <p role="alert" className="text-sm text-text-secondary">
304
+ You are offline. Your entries stay on this screen; reconnect to confirm the delivery.
305
+ </p>
306
+ )}
307
+ {validationError && <FormError message={validationError} />}
308
+ {captureError && <FormError message={captureError.message} />}
309
+ {uploadError && <FormError message={uploadError.message} />}
310
+
311
+ <div className="flex gap-2 justify-end">
312
+ <Button type="button" variant="outline" onClick={onCancel} disabled={submitting}>
313
+ Cancel
314
+ </Button>
315
+ <Button
316
+ type="button"
317
+ onClick={() => void handleSubmit()}
318
+ disabled={
319
+ submitting ||
320
+ offline ||
321
+ ownedStatus.loading ||
322
+ !!ownedStatus.error ||
323
+ !ownedStatus.data ||
324
+ statusRefresh < requiredStatusRefresh ||
325
+ !completionAttestation ||
326
+ captureFeedback?.kind === 'UNKNOWN'
327
+ }
328
+ >
329
+ {submitting ? (
330
+ <>
331
+ <Loader2 size={14} className="animate-spin" /> Submitting…
332
+ </>
333
+ ) : (
334
+ <>
335
+ <CheckCircle2 size={14} /> Confirm delivery
336
+ </>
337
+ )}
338
+ </Button>
339
+ </div>
340
+ </CardContent>
341
+ </Card>
342
+ );
343
+ }
@@ -80,6 +80,15 @@ export {
80
80
  type EntityDocumentType,
81
81
  } from './useEntityDocuments';
82
82
 
83
+ // Driver day view (mobile driver task list) — BOFF-3991
84
+ export {
85
+ useMyDeliveryDay,
86
+ useOnlineStatus,
87
+ type DriverDay,
88
+ type DriverStop,
89
+ type DriverStopState,
90
+ } from './useDriverDay';
91
+
83
92
  // Electronic proof of delivery (ePOD) capture — BOFF-4399
84
93
  export {
85
94
  useEpod,
@@ -0,0 +1,289 @@
1
+ /**
2
+ * Driver day view data (BOFF-3991): the calling driver's stops for one local
3
+ * calendar day, in driving sequence, from the `myDeliveryDay` Type C
4
+ * self-list in `wspace-movethewheels-svc` (src/modules/epod/schema/epod.gql).
5
+ * The driver is resolved from the authenticated actor server-side; this hook
6
+ * never sends a driver or user id.
7
+ */
8
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
9
+ import { useMoveTheWheels } from '../providers/MoveTheWheelsProvider';
10
+ import type { Order } from '../types';
11
+ import { executeGraphQL } from '../utils/graphqlClient';
12
+ import { deviceTimeZone, timezoneOffsetFor } from '../utils/driverNavigation';
13
+
14
+ const STOP_FIELDS = `
15
+ sequence
16
+ state
17
+ awaitingCapture
18
+ overdue
19
+ deliveredAt
20
+ routeId
21
+ routeName
22
+ deliveryJob {
23
+ id
24
+ deliveryJobNumber
25
+ status
26
+ priority
27
+ deliveryAddress
28
+ scheduledDelivery
29
+ specialInstructions
30
+ customer {
31
+ name
32
+ phone
33
+ }
34
+ }
35
+ `;
36
+
37
+ const MY_DELIVERY_DAY_QUERY = `
38
+ query MyDeliveryDay($date: String!, $timezoneOffsetMinutes: Int, $timeZone: String) {
39
+ myDeliveryDay(date: $date, timezoneOffsetMinutes: $timezoneOffsetMinutes, timeZone: $timeZone) {
40
+ date
41
+ driverId
42
+ driverName
43
+ totalStops
44
+ completedStops
45
+ remainingStops
46
+ stops {
47
+ ${STOP_FIELDS}
48
+ }
49
+ }
50
+ }
51
+ `;
52
+
53
+ export type DriverStopState = 'COMPLETED' | 'EN_ROUTE' | 'UPCOMING' | 'FAILED';
54
+
55
+ export interface DriverStop {
56
+ sequence: number;
57
+ state: DriverStopState;
58
+ awaitingCapture: boolean;
59
+ overdue: boolean;
60
+ deliveredAt?: Date;
61
+ routeId?: string;
62
+ routeName?: string;
63
+ jobId: string;
64
+ deliveryJobNumber: string;
65
+ status: string;
66
+ priority: string;
67
+ deliveryAddress: Order['deliveryAddress'] | null;
68
+ scheduledDelivery?: Date;
69
+ specialInstructions?: string;
70
+ customerName?: string;
71
+ customerPhone?: string;
72
+ }
73
+
74
+ export interface DriverDay {
75
+ date: string;
76
+ driverId: string | null;
77
+ driverName: string | null;
78
+ stops: DriverStop[];
79
+ totalStops: number;
80
+ completedStops: number;
81
+ remainingStops: number;
82
+ nextStop: DriverStop | null;
83
+ }
84
+
85
+ interface ApiStop {
86
+ sequence: number;
87
+ state: DriverStopState;
88
+ awaitingCapture: boolean;
89
+ overdue: boolean;
90
+ deliveredAt?: string | null;
91
+ routeId?: string | null;
92
+ routeName?: string | null;
93
+ deliveryJob: {
94
+ id: string;
95
+ deliveryJobNumber: string;
96
+ status: string;
97
+ priority: string;
98
+ deliveryAddress: Order['deliveryAddress'] | null;
99
+ scheduledDelivery?: string | null;
100
+ specialInstructions?: string | null;
101
+ customer?: { name?: string | null; phone?: string | null } | null;
102
+ };
103
+ }
104
+
105
+ interface ApiDriverDay {
106
+ date: string;
107
+ driverId: string | null;
108
+ driverName: string | null;
109
+ totalStops: number;
110
+ completedStops: number;
111
+ remainingStops: number;
112
+ stops: ApiStop[];
113
+ }
114
+
115
+ function mapStop(stop: ApiStop): DriverStop {
116
+ const job = stop.deliveryJob;
117
+ return {
118
+ sequence: stop.sequence,
119
+ state: stop.state,
120
+ awaitingCapture: stop.awaitingCapture,
121
+ overdue: stop.overdue,
122
+ deliveredAt: stop.deliveredAt ? new Date(stop.deliveredAt) : undefined,
123
+ routeId: stop.routeId ?? undefined,
124
+ routeName: stop.routeName ?? undefined,
125
+ jobId: job.id,
126
+ deliveryJobNumber: job.deliveryJobNumber,
127
+ status: job.status,
128
+ priority: job.priority,
129
+ deliveryAddress: job.deliveryAddress,
130
+ scheduledDelivery: job.scheduledDelivery ? new Date(job.scheduledDelivery) : undefined,
131
+ specialInstructions: job.specialInstructions ?? undefined,
132
+ customerName: job.customer?.name ?? undefined,
133
+ customerPhone: job.customer?.phone ?? undefined,
134
+ };
135
+ }
136
+
137
+ /**
138
+ * Applies acknowledged captures on top of a server snapshot: a stop whose
139
+ * capture the server already acknowledged is shown completed even if a
140
+ * delayed read still reports it en route. This is an in-memory UI fact for
141
+ * the current actor/scope, never a fabricated server update.
142
+ */
143
+ export function applyAcknowledgedCaptures(
144
+ stops: DriverStop[],
145
+ acknowledged: ReadonlySet<string>
146
+ ): DriverDay['stops'] {
147
+ return stops.map((stop) =>
148
+ acknowledged.has(stop.jobId) && stop.state !== 'COMPLETED'
149
+ ? { ...stop, state: 'COMPLETED', awaitingCapture: false, status: 'DELIVERED' }
150
+ : stop
151
+ );
152
+ }
153
+
154
+ /** Derived counters and the next stop, recomputed after acknowledgement overrides. */
155
+ export function summariseStops(stops: DriverStop[]) {
156
+ const completedStops = stops.filter(
157
+ (stop) => stop.state === 'COMPLETED' || stop.state === 'FAILED'
158
+ ).length;
159
+ return {
160
+ totalStops: stops.length,
161
+ completedStops,
162
+ remainingStops: stops.length - completedStops,
163
+ nextStop:
164
+ stops.find((stop) => stop.awaitingCapture) ??
165
+ stops.find((stop) => stop.state === 'UPCOMING') ??
166
+ null,
167
+ };
168
+ }
169
+
170
+ export function useMyDeliveryDay(date: string) {
171
+ const { apiGatewayUrl, authToken, workspaceToken, workspaceId, tenantId, currentUser } =
172
+ useMoveTheWheels();
173
+ const graphQLOptions = useMemo(
174
+ () => ({
175
+ apiGatewayUrl,
176
+ authToken,
177
+ workspaceToken,
178
+ workspaceId,
179
+ tenantId,
180
+ actorId: currentUser?.id,
181
+ }),
182
+ [apiGatewayUrl, authToken, workspaceToken, workspaceId, tenantId, currentUser?.id]
183
+ );
184
+ const scopeKey = JSON.stringify([apiGatewayUrl, workspaceId, tenantId, currentUser?.id]);
185
+ const readKey = JSON.stringify([scopeKey, date]);
186
+ const [acknowledgedJobs, setAcknowledgedJobs] = useState<{
187
+ scopeKey: string;
188
+ ids: ReadonlySet<string>;
189
+ }>({ scopeKey, ids: new Set<string>() });
190
+ const requestId = useRef(0);
191
+ const [snapshot, setSnapshot] = useState<{
192
+ readKey: string;
193
+ day: ApiDriverDay | null;
194
+ isLoading: boolean;
195
+ error: Error | null;
196
+ }>({ readKey, day: null, isLoading: true, error: null });
197
+
198
+ const load = useCallback(async () => {
199
+ const request = ++requestId.current;
200
+ setSnapshot((previous) => ({
201
+ readKey,
202
+ day: previous.readKey === readKey ? previous.day : null,
203
+ isLoading: true,
204
+ error: null,
205
+ }));
206
+ try {
207
+ const data = await executeGraphQL<{ myDeliveryDay: ApiDriverDay }>(
208
+ graphQLOptions,
209
+ MY_DELIVERY_DAY_QUERY,
210
+ { date, timezoneOffsetMinutes: timezoneOffsetFor(date), timeZone: deviceTimeZone() }
211
+ );
212
+ if (request !== requestId.current) return;
213
+ setSnapshot({ readKey, day: data.myDeliveryDay, isLoading: false, error: null });
214
+ } catch (fetchError) {
215
+ if (request !== requestId.current) return;
216
+ setSnapshot((previous) => ({
217
+ readKey,
218
+ // Keep the last good list on screen (e.g. a dropped connection on the road).
219
+ day: previous.readKey === readKey ? previous.day : null,
220
+ isLoading: false,
221
+ error:
222
+ fetchError instanceof Error ? fetchError : new Error('Failed to load your deliveries'),
223
+ }));
224
+ }
225
+ }, [graphQLOptions, date, readKey]);
226
+
227
+ const cancelPendingRead = useCallback(() => {
228
+ requestId.current++;
229
+ }, []);
230
+
231
+ useEffect(() => {
232
+ void load();
233
+ return cancelPendingRead;
234
+ }, [load, cancelPendingRead]);
235
+
236
+ const markCaptured = useCallback(
237
+ (deliveryJobId: string) => {
238
+ setAcknowledgedJobs((previous) => {
239
+ // An actor/workspace switch starts a fresh acknowledgement set.
240
+ const ids = new Set(previous.scopeKey === scopeKey ? previous.ids : []);
241
+ ids.add(deliveryJobId);
242
+ return { scopeKey, ids };
243
+ });
244
+ },
245
+ [scopeKey]
246
+ );
247
+
248
+ const current = snapshot.readKey === readKey ? snapshot : null;
249
+ const currentDay = current?.day ?? null;
250
+ const day = useMemo<DriverDay | null>(() => {
251
+ if (!currentDay) return null;
252
+ const acknowledged =
253
+ acknowledgedJobs.scopeKey === scopeKey ? acknowledgedJobs.ids : new Set<string>();
254
+ const stops = applyAcknowledgedCaptures(currentDay.stops.map(mapStop), acknowledged);
255
+ return {
256
+ date: currentDay.date,
257
+ driverId: currentDay.driverId,
258
+ driverName: currentDay.driverName,
259
+ stops,
260
+ ...summariseStops(stops),
261
+ };
262
+ }, [currentDay, scopeKey, acknowledgedJobs]);
263
+
264
+ return {
265
+ day,
266
+ isLoading: !current || current.isLoading,
267
+ error: current?.error ?? null,
268
+ refetch: load,
269
+ markCaptured,
270
+ };
271
+ }
272
+
273
+ /** Tracks `navigator.onLine` so the driver sees when capture is unavailable. */
274
+ export function useOnlineStatus(): boolean {
275
+ const [online, setOnline] = useState(() =>
276
+ typeof navigator === 'undefined' ? true : navigator.onLine !== false
277
+ );
278
+ useEffect(() => {
279
+ if (typeof window === 'undefined') return;
280
+ const update = () => setOnline(navigator.onLine !== false);
281
+ window.addEventListener('online', update);
282
+ window.addEventListener('offline', update);
283
+ return () => {
284
+ window.removeEventListener('online', update);
285
+ window.removeEventListener('offline', update);
286
+ };
287
+ }, []);
288
+ return online;
289
+ }