From c3e555348f6da6dcac1b2d29d315202c5644335a Mon Sep 17 00:00:00 2001 From: QueenColly Date: Mon, 29 Jun 2026 08:59:02 +0100 Subject: [PATCH] enhance(logistics): add offline caching to qr code handoff generator --- components/logistics/QrGenerator.tsx | 233 ++++++++++++++++++ .../logistics/__tests__/QrGenerator.test.tsx | 222 +++++++++++++++++ hooks/__tests__/useOfflineQr.test.ts | 223 +++++++++++++++++ hooks/useOfflineQr.ts | 183 ++++++++++++++ jest.config.ts | 1 + services/qrCacheService.ts | 162 ++++++++++++ 6 files changed, 1024 insertions(+) create mode 100644 components/logistics/QrGenerator.tsx create mode 100644 components/logistics/__tests__/QrGenerator.test.tsx create mode 100644 hooks/__tests__/useOfflineQr.test.ts create mode 100644 hooks/useOfflineQr.ts create mode 100644 services/qrCacheService.ts diff --git a/components/logistics/QrGenerator.tsx b/components/logistics/QrGenerator.tsx new file mode 100644 index 0000000..e423609 --- /dev/null +++ b/components/logistics/QrGenerator.tsx @@ -0,0 +1,233 @@ +'use client'; + +/** + * QrGenerator — Component layer for offline-ready QR code generation. + * + * Renders the handoff QR code using `qrcode.react`. + * Shows a "Cached" badge when the payload originates from local IndexedDB + * rather than a live API response, so the driver knows the data may be stale. + * + * Component → Hook → Service pattern: + * QrGenerator → useOfflineQr → { shipmentHandoffService, qrCacheService } + */ + +import React from 'react'; +import { QRCodeCanvas } from 'qrcode.react'; +import { useOfflineQr } from '@/hooks/useOfflineQr'; +import { ScanConfirmation } from '@/services/qrCacheService'; + +// ─── Props ──────────────────────────────────────────────────────────────────── + +export interface QrGeneratorProps { + deliveryId: string; + /** Pixel dimensions of the rendered QR code (default 256). */ + size?: number; + /** Show delivery ID / expiry metadata below the QR (default true). */ + includeLabel?: boolean; + /** Called after the user confirms a successful scan. */ + onScanConfirmed?: (confirmation: ScanConfirmation) => void; + className?: string; +} + +// ─── Sub-components ─────────────────────────────────────────────────────────── + +function CachedBadge() { + return ( + + + ); +} + +function LiveBadge() { + return ( + + + ); +} + +function SyncBadge({ pending }: { pending: number }) { + return ( + + + ); +} + +// ─── Main component ─────────────────────────────────────────────────────────── + +export function QrGenerator({ + deliveryId, + size = 256, + includeLabel = true, + onScanConfirmed, + className = '', +}: QrGeneratorProps) { + const { + payload, + source, + isLoading, + error, + isSyncing, + pendingCount, + queueScan, + refresh, + } = useOfflineQr(deliveryId); + + // ── Loading ────────────────────────────────────────────────────────────── + + if (isLoading) { + return ( +
+
+

Loading QR code…

+
+ ); + } + + // ── Error (no live + no cache) ─────────────────────────────────────────── + + if (error) { + return ( +
+ +

{error}

+ +
+ ); + } + + // ── No payload (shouldn't normally reach here) ─────────────────────────── + + if (!payload) return null; + + // ── Success ────────────────────────────────────────────────────────────── + + const handleConfirmScan = async () => { + const confirmation: ScanConfirmation = { + deliveryId: payload.deliveryId, + token: payload.token, + scannedAt: new Date().toISOString(), + }; + await queueScan(confirmation); + onScanConfirmed?.(confirmation); + }; + + const msLeft = new Date(payload.expiresAt).getTime() - Date.now(); + const minutesLeft = Math.max(0, Math.floor(msLeft / 60_000)); + + return ( +
+ {/* ── Source + sync badges ── */} +
+ {source === 'cached' ? : } + {isSyncing && } +
+ + {/* ── QR code ── */} +
+ +
+ + {/* ── Label ── */} + {includeLabel && ( +
+

Package Handoff QR

+

Delivery: {deliveryId}

+

+ {msLeft <= 0 ? 'Expired' : `Expires in ${minutesLeft}m`} +

+ {source === 'cached' && ( +

+ Offline — showing cached QR +

+ )} +
+ )} + + {/* ── Actions ── */} +
+ + +
+
+ ); +} + +export default QrGenerator; diff --git a/components/logistics/__tests__/QrGenerator.test.tsx b/components/logistics/__tests__/QrGenerator.test.tsx new file mode 100644 index 0000000..67cab2d --- /dev/null +++ b/components/logistics/__tests__/QrGenerator.test.tsx @@ -0,0 +1,222 @@ +/** + * QrGenerator — unit tests + * + * Key scenarios: + * - Loading skeleton while hook initialises + * - Renders QR canvas + "Live" badge on live payload + * - Renders QR canvas + "Cached" badge on cached payload + * - Shows "Offline — showing cached QR" note when source is cached + * - Renders error state with retry button when no data available + * - Confirm Scan button calls queueScan and onScanConfirmed callback + * - Refresh button calls refresh() + * - Syncing badge rendered when pendingCount > 0 + */ + +import React from 'react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { QrGenerator } from '../QrGenerator'; +import * as useOfflineQrModule from '@/hooks/useOfflineQr'; + +// ─── Mocks ──────────────────────────────────────────────────────────────────── + +jest.mock('@/hooks/useOfflineQr'); +jest.mock('qrcode.react', () => ({ + QRCodeCanvas: ({ value }: { value: string }) => ( + + ), +})); + +const mockUseOfflineQr = useOfflineQrModule.useOfflineQr as jest.MockedFunction< + typeof useOfflineQrModule.useOfflineQr +>; + +// ─── Fixture ────────────────────────────────────────────────────────────────── + +const DELIVERY_ID = 'del-001'; + +const basePayload = { + qrData: 'https://example.com/qr/tok', + expiresAt: new Date(Date.now() + 10 * 60 * 1000).toISOString(), + deliveryId: DELIVERY_ID, + token: 'tok-abc', +}; + +function makeHookResult( + overrides: Partial = {}, +): useOfflineQrModule.UseOfflineQrResult { + return { + payload: basePayload, + source: 'live', + isLoading: false, + error: null, + isSyncing: false, + pendingCount: 0, + queueScan: jest.fn().mockResolvedValue(undefined), + refresh: jest.fn().mockResolvedValue(undefined), + ...overrides, + }; +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +beforeEach(() => jest.clearAllMocks()); + +describe('QrGenerator — loading state', () => { + it('renders loading skeleton while isLoading is true', () => { + mockUseOfflineQr.mockReturnValue(makeHookResult({ isLoading: true, payload: null, source: null })); + render(); + expect(screen.getByTestId('qr-loading')).toBeInTheDocument(); + expect(screen.queryByTestId('qr-canvas')).not.toBeInTheDocument(); + }); +}); + +describe('QrGenerator — live payload', () => { + it('renders QR canvas when payload is available', () => { + mockUseOfflineQr.mockReturnValue(makeHookResult({ source: 'live' })); + render(); + expect(screen.getByTestId('qr-canvas')).toBeInTheDocument(); + }); + + it('encodes the correct qrData value into the canvas', () => { + mockUseOfflineQr.mockReturnValue(makeHookResult({ source: 'live' })); + render(); + expect(screen.getByTestId('qr-canvas')).toHaveAttribute('data-value', basePayload.qrData); + }); + + it('shows "Live" badge when source is live', () => { + mockUseOfflineQr.mockReturnValue(makeHookResult({ source: 'live' })); + render(); + expect(screen.getByLabelText('QR code loaded from live API')).toBeInTheDocument(); + }); + + it('does NOT show "Cached" badge when source is live', () => { + mockUseOfflineQr.mockReturnValue(makeHookResult({ source: 'live' })); + render(); + expect(screen.queryByLabelText('QR code loaded from local cache')).not.toBeInTheDocument(); + }); +}); + +describe('QrGenerator — cached payload (offline fallback)', () => { + it('shows "Cached" badge when source is cached', () => { + mockUseOfflineQr.mockReturnValue(makeHookResult({ source: 'cached' })); + render(); + expect(screen.getByLabelText('QR code loaded from local cache')).toBeInTheDocument(); + }); + + it('shows offline notice text when source is cached', () => { + mockUseOfflineQr.mockReturnValue(makeHookResult({ source: 'cached' })); + render(); + expect(screen.getByText(/Offline — showing cached QR/i)).toBeInTheDocument(); + }); + + it('still renders the QR canvas from cached data', () => { + mockUseOfflineQr.mockReturnValue(makeHookResult({ source: 'cached' })); + render(); + expect(screen.getByTestId('qr-canvas')).toBeInTheDocument(); + }); + + it('does NOT show "Live" badge when source is cached', () => { + mockUseOfflineQr.mockReturnValue(makeHookResult({ source: 'cached' })); + render(); + expect(screen.queryByLabelText('QR code loaded from live API')).not.toBeInTheDocument(); + }); +}); + +describe('QrGenerator — error state', () => { + it('renders error container when error is set', () => { + mockUseOfflineQr.mockReturnValue( + makeHookResult({ payload: null, source: null, error: 'Unable to load QR code. No cached data available.' }), + ); + render(); + expect(screen.getByTestId('qr-error')).toBeInTheDocument(); + expect(screen.getByText(/Unable to load QR/i)).toBeInTheDocument(); + }); + + it('does not render QR canvas in error state', () => { + mockUseOfflineQr.mockReturnValue( + makeHookResult({ payload: null, source: null, error: 'No data' }), + ); + render(); + expect(screen.queryByTestId('qr-canvas')).not.toBeInTheDocument(); + }); + + it('calls refresh() when Retry button is clicked', async () => { + const refresh = jest.fn().mockResolvedValue(undefined); + mockUseOfflineQr.mockReturnValue( + makeHookResult({ payload: null, source: null, error: 'No data', refresh }), + ); + render(); + fireEvent.click(screen.getByText('Retry')); + await waitFor(() => expect(refresh).toHaveBeenCalledTimes(1)); + }); +}); + +describe('QrGenerator — syncing badge', () => { + it('shows syncing badge when isSyncing is true', () => { + mockUseOfflineQr.mockReturnValue( + makeHookResult({ isSyncing: true, pendingCount: 2 }), + ); + render(); + expect( + screen.getByLabelText('2 scan confirmations queued for sync'), + ).toBeInTheDocument(); + }); + + it('does not show syncing badge when isSyncing is false', () => { + mockUseOfflineQr.mockReturnValue(makeHookResult({ isSyncing: false, pendingCount: 0 })); + render(); + expect(screen.queryByLabelText(/queued for sync/i)).not.toBeInTheDocument(); + }); +}); + +describe('QrGenerator — actions', () => { + it('calls refresh() when Refresh button is clicked', async () => { + const refresh = jest.fn().mockResolvedValue(undefined); + mockUseOfflineQr.mockReturnValue(makeHookResult({ refresh })); + render(); + fireEvent.click(screen.getByTestId('refresh-button')); + await waitFor(() => expect(refresh).toHaveBeenCalledTimes(1)); + }); + + it('calls queueScan() when Confirm Scan button is clicked', async () => { + const queueScan = jest.fn().mockResolvedValue(undefined); + mockUseOfflineQr.mockReturnValue(makeHookResult({ queueScan })); + render(); + fireEvent.click(screen.getByTestId('confirm-scan-button')); + await waitFor(() => expect(queueScan).toHaveBeenCalledTimes(1)); + expect(queueScan).toHaveBeenCalledWith( + expect.objectContaining({ deliveryId: DELIVERY_ID, token: 'tok-abc' }), + ); + }); + + it('fires onScanConfirmed callback after Confirm Scan', async () => { + const queueScan = jest.fn().mockResolvedValue(undefined); + const onScanConfirmed = jest.fn(); + mockUseOfflineQr.mockReturnValue(makeHookResult({ queueScan })); + render(); + fireEvent.click(screen.getByTestId('confirm-scan-button')); + await waitFor(() => expect(onScanConfirmed).toHaveBeenCalledTimes(1)); + }); +}); + +describe('QrGenerator — label', () => { + it('shows delivery ID and expiry when includeLabel is true', () => { + mockUseOfflineQr.mockReturnValue(makeHookResult()); + render(); + expect(screen.getByText(/Delivery: del-001/)).toBeInTheDocument(); + expect(screen.getByText('Package Handoff QR')).toBeInTheDocument(); + }); + + it('hides label when includeLabel is false', () => { + mockUseOfflineQr.mockReturnValue(makeHookResult()); + render(); + expect(screen.queryByText('Package Handoff QR')).not.toBeInTheDocument(); + }); + + it('uses custom size prop on QR canvas', () => { + mockUseOfflineQr.mockReturnValue(makeHookResult()); + // The mock doesn't use size, but the wrapper is rendered + render(); + expect(screen.getByTestId('qr-canvas-wrapper')).toBeInTheDocument(); + }); +}); diff --git a/hooks/__tests__/useOfflineQr.test.ts b/hooks/__tests__/useOfflineQr.test.ts new file mode 100644 index 0000000..2ab385b --- /dev/null +++ b/hooks/__tests__/useOfflineQr.test.ts @@ -0,0 +1,223 @@ +/** + * useOfflineQr — unit tests + * + * Key scenarios: + * - Returns live payload when API succeeds + * - Falls back to cache on network timeout + * - Falls back to cache when navigator.onLine is false + * - Reports error when both API and cache are unavailable + * - Queues a scan confirmation and flushes it when online + */ + +import { renderHook, act, waitFor } from '@testing-library/react'; +import { useOfflineQr } from '../useOfflineQr'; +import { shipmentHandoffService } from '@/services/shipmentHandoffService'; +import { qrCacheService } from '@/services/qrCacheService'; + +// ─── Mocks ──────────────────────────────────────────────────────────────────── + +jest.mock('@/services/shipmentHandoffService'); +jest.mock('@/services/qrCacheService'); + +const mockService = shipmentHandoffService as jest.Mocked; +const mockCache = qrCacheService as jest.Mocked; + +const DELIVERY_ID = 'del-001'; + +const mockPayload = { + qrData: 'https://example.com/qr/abc', + expiresAt: new Date(Date.now() + 10 * 60 * 1000).toISOString(), + deliveryId: DELIVERY_ID, + token: 'tok-abc', +}; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function setupCacheDefaults() { + mockCache.savePayload.mockResolvedValue(undefined); + mockCache.getPayload.mockResolvedValue(null); + mockCache.getPendingConfirmations.mockResolvedValue([]); + mockCache.pendingCount.mockResolvedValue(0); + mockCache.queueScanConfirmation.mockResolvedValue(undefined); + mockCache.removeConfirmation.mockResolvedValue(undefined); +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +beforeEach(() => { + jest.clearAllMocks(); + setupCacheDefaults(); +}); + +describe('useOfflineQr — live fetch success', () => { + it('returns live payload and source="live" when API succeeds', async () => { + mockService.getHandoffQR.mockResolvedValue(mockPayload); + + const { result } = renderHook(() => useOfflineQr(DELIVERY_ID)); + + expect(result.current.isLoading).toBe(true); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(result.current.payload).toEqual(mockPayload); + expect(result.current.source).toBe('live'); + expect(result.current.error).toBeNull(); + }); + + it('persists payload to cache after a successful live fetch', async () => { + mockService.getHandoffQR.mockResolvedValue(mockPayload); + + renderHook(() => useOfflineQr(DELIVERY_ID)); + + await waitFor(() => { + expect(mockCache.savePayload).toHaveBeenCalledWith(mockPayload); + }); + }); +}); + +describe('useOfflineQr — offline / timeout fallback', () => { + it('returns cached payload and source="cached" when API times out', async () => { + // API never resolves within the 5 s window — fake with a long delay + mockService.getHandoffQR.mockImplementation( + () => new Promise((resolve) => setTimeout(() => resolve(mockPayload), 10_000)), + ); + mockCache.getPayload.mockResolvedValue(mockPayload); + + // Fake timers so the 5 s timeout fires immediately + jest.useFakeTimers(); + + const { result } = renderHook(() => useOfflineQr(DELIVERY_ID)); + + act(() => { jest.advanceTimersByTime(6_000); }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(result.current.source).toBe('cached'); + expect(result.current.payload).toEqual(mockPayload); + expect(result.current.error).toBeNull(); + + jest.useRealTimers(); + }); + + it('returns error when API fails and cache is empty', async () => { + mockService.getHandoffQR.mockRejectedValue(new Error('Network error')); + mockCache.getPayload.mockResolvedValue(null); + + const { result } = renderHook(() => useOfflineQr(DELIVERY_ID)); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(result.current.payload).toBeNull(); + expect(result.current.error).toMatch(/Unable to load QR/); + }); + + it('falls back to cache when API rejects', async () => { + mockService.getHandoffQR.mockRejectedValue(new Error('503')); + mockCache.getPayload.mockResolvedValue(mockPayload); + + const { result } = renderHook(() => useOfflineQr(DELIVERY_ID)); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(result.current.source).toBe('cached'); + expect(result.current.payload).toEqual(mockPayload); + }); +}); + +describe('useOfflineQr — scan confirmation queue', () => { + it('queues a scan confirmation via queueScan()', async () => { + mockService.getHandoffQR.mockResolvedValue(mockPayload); + + const { result } = renderHook(() => useOfflineQr(DELIVERY_ID)); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + const confirmation = { + deliveryId: DELIVERY_ID, + token: 'tok-abc', + scannedAt: new Date().toISOString(), + }; + + await act(async () => { + await result.current.queueScan(confirmation); + }); + + expect(mockCache.queueScanConfirmation).toHaveBeenCalledWith(confirmation); + }); + + it('flushes queue immediately when online after queueScan()', async () => { + mockService.getHandoffQR.mockResolvedValue(mockPayload); + mockService.verifyHandoffQR.mockResolvedValue({ + id: 'v1', + deliveryId: DELIVERY_ID, + token: 'tok-abc', + expiresAt: mockPayload.expiresAt, + createdAt: new Date().toISOString(), + }); + // Return one pending confirmation on the first call, then 0 afterwards + mockCache.getPendingConfirmations + .mockResolvedValueOnce([{ key: 1, value: { deliveryId: DELIVERY_ID, token: 'tok-abc', scannedAt: '' } }]) + .mockResolvedValue([]); + mockCache.pendingCount.mockResolvedValue(0); + + // Simulate being online + Object.defineProperty(navigator, 'onLine', { value: true, configurable: true }); + + const { result } = renderHook(() => useOfflineQr(DELIVERY_ID)); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + await act(async () => { + await result.current.queueScan({ deliveryId: DELIVERY_ID, token: 'tok-abc', scannedAt: '' }); + }); + + expect(mockService.verifyHandoffQR).toHaveBeenCalledWith(DELIVERY_ID, 'tok-abc'); + expect(mockCache.removeConfirmation).toHaveBeenCalledWith(1); + }); +}); + +describe('useOfflineQr — online event sync', () => { + it('flushes pending queue when browser comes back online', async () => { + mockService.getHandoffQR.mockResolvedValue(mockPayload); + mockService.verifyHandoffQR.mockResolvedValue({ + id: 'v2', + deliveryId: DELIVERY_ID, + token: 'tok-xyz', + expiresAt: mockPayload.expiresAt, + createdAt: new Date().toISOString(), + }); + mockCache.getPendingConfirmations.mockResolvedValue([ + { key: 42, value: { deliveryId: DELIVERY_ID, token: 'tok-xyz', scannedAt: '' } }, + ]); + mockCache.pendingCount.mockResolvedValue(0); + + renderHook(() => useOfflineQr(DELIVERY_ID)); + + // Simulate coming back online + await act(async () => { + window.dispatchEvent(new Event('online')); + }); + + await waitFor(() => { + expect(mockService.verifyHandoffQR).toHaveBeenCalledWith(DELIVERY_ID, 'tok-xyz'); + }); + }); +}); + +describe('useOfflineQr — refresh', () => { + it('re-fetches and updates source to "live" on manual refresh', async () => { + mockService.getHandoffQR + .mockRejectedValueOnce(new Error('offline')) + .mockResolvedValueOnce(mockPayload); + mockCache.getPayload.mockResolvedValue(mockPayload); + + const { result } = renderHook(() => useOfflineQr(DELIVERY_ID)); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(result.current.source).toBe('cached'); + + await act(async () => { + await result.current.refresh(); + }); + + expect(result.current.source).toBe('live'); + }); +}); diff --git a/hooks/useOfflineQr.ts b/hooks/useOfflineQr.ts new file mode 100644 index 0000000..0ca06d8 --- /dev/null +++ b/hooks/useOfflineQr.ts @@ -0,0 +1,183 @@ +'use client'; + +/** + * useOfflineQr — Hook layer for offline-ready QR code handoff. + * + * Strategy: + * 1. Attempt to fetch the latest payload from the backend API (5 s timeout). + * 2. On success — persist to IndexedDB and return the live payload. + * 3. On timeout / network failure — return the cached payload from IndexedDB. + * 4. Listen for the browser `online` event to flush the pending scan + * confirmation queue and refresh the live payload. + * + * Component → Hook → Service pattern: + * QrGenerator (component) → useOfflineQr (hook) + * → shipmentHandoffService (API) + * → qrCacheService (IndexedDB) + */ + +import { useState, useEffect, useCallback, useRef } from 'react'; +import { shipmentHandoffService } from '@/services/shipmentHandoffService'; +import { qrCacheService, ScanConfirmation } from '@/services/qrCacheService'; +import { HandoffQRData } from '@/types/shipment'; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +export type QrSource = 'live' | 'cached'; + +export interface UseOfflineQrResult { + /** The QR payload to render — either live or from cache. */ + payload: HandoffQRData | null; + /** Where the current payload came from. */ + source: QrSource | null; + /** True while the initial fetch / cache lookup is in progress. */ + isLoading: boolean; + /** Error message when both live fetch and cache are unavailable. */ + error: string | null; + /** True when queued scan confirmations are waiting to be synced. */ + isSyncing: boolean; + /** Number of confirmations still in the queue. */ + pendingCount: number; + /** Queue a successful scan for background sync. */ + queueScan: (confirmation: ScanConfirmation) => Promise; + /** Manually trigger a live refresh. */ + refresh: () => Promise; +} + +// ─── Constants ──────────────────────────────────────────────────────────────── + +const FETCH_TIMEOUT_MS = 5_000; + +// ─── Helper ─────────────────────────────────────────────────────────────────── + +function withTimeout(promise: Promise, ms: number): Promise { + return new Promise((resolve, reject) => { + const id = setTimeout(() => reject(new Error('Request timed out')), ms); + promise.then( + (val) => { clearTimeout(id); resolve(val); }, + (err) => { clearTimeout(id); reject(err); }, + ); + }); +} + +// ─── Hook ───────────────────────────────────────────────────────────────────── + +export function useOfflineQr(deliveryId: string): UseOfflineQrResult { + const [payload, setPayload] = useState(null); + const [source, setSource] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [isSyncing, setIsSyncing] = useState(false); + const [pendingCount, setPendingCount] = useState(0); + const mountedRef = useRef(true); + + // ── Core fetch logic ───────────────────────────────────────────────────── + + const fetchAndCache = useCallback(async () => { + if (!deliveryId) return; + + try { + const live = await withTimeout( + shipmentHandoffService.getHandoffQR(deliveryId), + FETCH_TIMEOUT_MS, + ); + // Persist immediately — driver may go offline at any time + await qrCacheService.savePayload(live); + if (mountedRef.current) { + setPayload(live); + setSource('live'); + setError(null); + } + } catch { + // Live fetch failed — fall back to cache + const cached = await qrCacheService.getPayload(deliveryId); + if (!mountedRef.current) return; + if (cached) { + setPayload(cached); + setSource('cached'); + setError(null); + } else { + setPayload(null); + setSource(null); + setError('Unable to load QR code. No cached data available.'); + } + } + }, [deliveryId]); + + // ── Sync queue flush ───────────────────────────────────────────────────── + + const flushQueue = useCallback(async () => { + const pending = await qrCacheService.getPendingConfirmations(); + if (pending.length === 0) return; + + setIsSyncing(true); + for (const { key, value } of pending) { + try { + await shipmentHandoffService.verifyHandoffQR(value.deliveryId, value.token); + await qrCacheService.removeConfirmation(key); + } catch { + // Leave failed entries in the queue for the next online event + } + } + const remaining = await qrCacheService.pendingCount(); + if (mountedRef.current) { + setPendingCount(remaining); + setIsSyncing(remaining > 0); + } + }, []); + + const syncPendingCount = useCallback(async () => { + const count = await qrCacheService.pendingCount(); + if (mountedRef.current) { + setPendingCount(count); + setIsSyncing(count > 0); + } + }, []); + + // ── Initial load ───────────────────────────────────────────────────────── + + useEffect(() => { + mountedRef.current = true; + + const init = async () => { + setIsLoading(true); + await fetchAndCache(); + await syncPendingCount(); + if (mountedRef.current) setIsLoading(false); + }; + + void init(); + + return () => { mountedRef.current = false; }; + }, [deliveryId, fetchAndCache, syncPendingCount]); + + // ── Online event → flush queue + refresh ──────────────────────────────── + + useEffect(() => { + const handleOnline = () => { + void flushQueue(); + void fetchAndCache(); + }; + window.addEventListener('online', handleOnline); + return () => window.removeEventListener('online', handleOnline); + }, [flushQueue, fetchAndCache]); + + // ── Public API ─────────────────────────────────────────────────────────── + + const queueScan = useCallback(async (confirmation: ScanConfirmation) => { + await qrCacheService.queueScanConfirmation(confirmation); + await syncPendingCount(); + // Attempt immediate sync if online + if (typeof navigator !== 'undefined' && navigator.onLine) { + await flushQueue(); + } + }, [flushQueue, syncPendingCount]); + + const refresh = useCallback(async () => { + setIsLoading(true); + await fetchAndCache(); + if (mountedRef.current) setIsLoading(false); + }, [fetchAndCache]); + + return { payload, source, isLoading, error, isSyncing, pendingCount, queueScan, refresh }; +} diff --git a/jest.config.ts b/jest.config.ts index 1e34004..d061a79 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -13,6 +13,7 @@ const config: Config = { tsconfig: { jsx: 'react-jsx', moduleResolution: 'node', + ignoreDeprecations: '5.0', }, }, ], diff --git a/services/qrCacheService.ts b/services/qrCacheService.ts new file mode 100644 index 0000000..a36b04e --- /dev/null +++ b/services/qrCacheService.ts @@ -0,0 +1,162 @@ +/** + * qrCacheService — Service layer for offline QR payload persistence. + * + * Responsibilities: + * - Store / retrieve HandoffQRData in IndexedDB so drivers can generate + * QR codes even when fully offline. + * - Maintain a queue of successful scan confirmations that must be synced + * to the backend once connectivity is restored. + * + * Follows the singleton pattern used elsewhere in this service layer. + */ + +import { HandoffQRData } from '@/types/shipment'; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +export interface ScanConfirmation { + deliveryId: string; + token: string; + scannedAt: string; // ISO timestamp +} + +// ─── Constants ──────────────────────────────────────────────────────────────── + +const DB_NAME = 'swiftchain_qr_cache'; +const DB_VERSION = 1; +const STORE_PAYLOAD = 'qr_payloads'; +const STORE_QUEUE = 'scan_queue'; + +// ─── IndexedDB bootstrap ────────────────────────────────────────────────────── + +function openDB(): Promise { + return new Promise((resolve, reject) => { + const request = indexedDB.open(DB_NAME, DB_VERSION); + + request.onupgradeneeded = (event) => { + const db = (event.target as IDBOpenDBRequest).result; + if (!db.objectStoreNames.contains(STORE_PAYLOAD)) { + db.createObjectStore(STORE_PAYLOAD, { keyPath: 'deliveryId' }); + } + if (!db.objectStoreNames.contains(STORE_QUEUE)) { + // Auto-increment key — one entry per queued confirmation + db.createObjectStore(STORE_QUEUE, { autoIncrement: true }); + } + }; + + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); +} + +// ─── Service class ──────────────────────────────────────────────────────────── + +class QrCacheService { + // ── Payload cache ────────────────────────────────────────────────────────── + + /** + * Persist the latest live payload for a delivery. + * Call this immediately after a successful API fetch. + */ + async savePayload(payload: HandoffQRData): Promise { + const db = await openDB(); + return new Promise((resolve, reject) => { + const tx = db.transaction(STORE_PAYLOAD, 'readwrite'); + tx.objectStore(STORE_PAYLOAD).put(payload); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); + } + + /** + * Retrieve the cached payload for a delivery. + * Returns `null` if no cache entry exists. + */ + async getPayload(deliveryId: string): Promise { + const db = await openDB(); + return new Promise((resolve, reject) => { + const tx = db.transaction(STORE_PAYLOAD, 'readonly'); + const request = tx.objectStore(STORE_PAYLOAD).get(deliveryId); + request.onsuccess = () => resolve((request.result as HandoffQRData) ?? null); + request.onerror = () => reject(request.error); + }); + } + + /** + * Remove the cached payload for a delivery (e.g. after a confirmed handoff). + */ + async clearPayload(deliveryId: string): Promise { + const db = await openDB(); + return new Promise((resolve, reject) => { + const tx = db.transaction(STORE_PAYLOAD, 'readwrite'); + tx.objectStore(STORE_PAYLOAD).delete(deliveryId); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); + } + + // ── Scan confirmation queue ──────────────────────────────────────────────── + + /** + * Enqueue a scan confirmation to be synced once connectivity returns. + */ + async queueScanConfirmation(confirmation: ScanConfirmation): Promise { + const db = await openDB(); + return new Promise((resolve, reject) => { + const tx = db.transaction(STORE_QUEUE, 'readwrite'); + tx.objectStore(STORE_QUEUE).add(confirmation); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); + } + + /** + * Return all queued confirmations (in insertion order). + */ + async getPendingConfirmations(): Promise> { + const db = await openDB(); + return new Promise((resolve, reject) => { + const results: Array<{ key: IDBValidKey; value: ScanConfirmation }> = []; + const tx = db.transaction(STORE_QUEUE, 'readonly'); + const request = tx.objectStore(STORE_QUEUE).openCursor(); + request.onsuccess = (event) => { + const cursor = (event.target as IDBRequest).result; + if (cursor) { + results.push({ key: cursor.key, value: cursor.value as ScanConfirmation }); + cursor.continue(); + } else { + resolve(results); + } + }; + request.onerror = () => reject(request.error); + }); + } + + /** + * Remove a synced confirmation from the queue by its IDB key. + */ + async removeConfirmation(key: IDBValidKey): Promise { + const db = await openDB(); + return new Promise((resolve, reject) => { + const tx = db.transaction(STORE_QUEUE, 'readwrite'); + tx.objectStore(STORE_QUEUE).delete(key); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); + } + + /** + * Return the number of confirmations waiting to be synced. + */ + async pendingCount(): Promise { + const db = await openDB(); + return new Promise((resolve, reject) => { + const tx = db.transaction(STORE_QUEUE, 'readonly'); + const request = tx.objectStore(STORE_QUEUE).count(); + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); + } +} + +export const qrCacheService = new QrCacheService();