diff --git a/src/App.tsx b/src/App.tsx index 79fa2f25..536c38f3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -20,6 +20,7 @@ import { breadcrumb } from "@/lib/errorBreadcrumbs"; import { handleError } from "@/lib/errorHandler"; import { RetryStatusIndicator } from "@/components/ui/retry-status-indicator"; import { APIStatusBanner } from "@/components/ui/api-status-banner"; +import { ResilienceProvider } from "@/components/layout/ResilienceProvider"; import { useAdminRoutePreload } from "@/hooks/useAdminRoutePreload"; import { useVersionCheck } from "@/hooks/useVersionCheck"; import { cn } from "@/lib/utils"; @@ -147,18 +148,19 @@ function AppContent(): React.JSX.Element { return ( - -
- - - - - -
-
- }> - - + + +
+ + + + + +
+
+ }> + + {/* Core routes - eager loaded */} } /> } /> @@ -401,6 +403,7 @@ function AppContent(): React.JSX.Element {
+ ); } diff --git a/src/components/error/NetworkErrorBanner.tsx b/src/components/error/NetworkErrorBanner.tsx new file mode 100644 index 00000000..6d4cdd5c --- /dev/null +++ b/src/components/error/NetworkErrorBanner.tsx @@ -0,0 +1,139 @@ +import { useState, useEffect } from 'react'; +import { WifiOff, RefreshCw, X, Eye } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; + +interface NetworkErrorBannerProps { + isOffline: boolean; + pendingCount?: number; + onRetryNow?: () => Promise; + onViewQueue?: () => void; + estimatedRetryTime?: Date; +} + +export function NetworkErrorBanner({ + isOffline, + pendingCount = 0, + onRetryNow, + onViewQueue, + estimatedRetryTime, +}: NetworkErrorBannerProps) { + const [isVisible, setIsVisible] = useState(false); + const [isRetrying, setIsRetrying] = useState(false); + const [countdown, setCountdown] = useState(null); + + useEffect(() => { + setIsVisible(isOffline || pendingCount > 0); + }, [isOffline, pendingCount]); + + useEffect(() => { + if (!estimatedRetryTime) { + setCountdown(null); + return; + } + + const interval = setInterval(() => { + const now = Date.now(); + const remaining = Math.max(0, estimatedRetryTime.getTime() - now); + setCountdown(Math.ceil(remaining / 1000)); + + if (remaining <= 0) { + clearInterval(interval); + setCountdown(null); + } + }, 1000); + + return () => clearInterval(interval); + }, [estimatedRetryTime]); + + const handleRetryNow = async () => { + if (!onRetryNow) return; + + setIsRetrying(true); + try { + await onRetryNow(); + } finally { + setIsRetrying(false); + } + }; + + if (!isVisible) return null; + + return ( +
+
+
+
+
+ +
+

+ {isOffline ? 'You are offline' : 'Network Issue Detected'} +

+

+ {pendingCount > 0 ? ( + <> + {pendingCount} submission{pendingCount !== 1 ? 's' : ''} pending + {countdown !== null && countdown > 0 && ( + + · Retrying in {countdown}s + + )} + + ) : ( + 'Changes will sync when connection is restored' + )} +

+
+
+ +
+ {pendingCount > 0 && onViewQueue && ( + + )} + + {onRetryNow && ( + + )} + + +
+
+
+
+
+ ); +} diff --git a/src/components/layout/ResilienceProvider.tsx b/src/components/layout/ResilienceProvider.tsx new file mode 100644 index 00000000..f60561a8 --- /dev/null +++ b/src/components/layout/ResilienceProvider.tsx @@ -0,0 +1,61 @@ +import { ReactNode } from 'react'; +import { NetworkErrorBanner } from '@/components/error/NetworkErrorBanner'; +import { SubmissionQueueIndicator } from '@/components/submission/SubmissionQueueIndicator'; +import { useNetworkStatus } from '@/hooks/useNetworkStatus'; +import { useSubmissionQueue } from '@/hooks/useSubmissionQueue'; + +interface ResilienceProviderProps { + children: ReactNode; +} + +/** + * ResilienceProvider wraps the app with network error handling + * and submission queue management UI + */ +export function ResilienceProvider({ children }: ResilienceProviderProps) { + const { isOnline } = useNetworkStatus(); + const { + queuedItems, + lastSyncTime, + nextRetryTime, + retryItem, + retryAll, + removeItem, + clearQueue, + } = useSubmissionQueue({ + autoRetry: true, + retryDelayMs: 5000, + maxRetries: 3, + }); + + return ( + <> + {/* Network Error Banner - Shows at top when offline or errors present */} + + + {/* Main Content */} +
+ {children} +
+ + {/* Floating Queue Indicator - Shows in bottom right */} + {queuedItems.length > 0 && ( +
+ +
+ )} + + ); +} diff --git a/src/components/submission/SubmissionQueueIndicator.tsx b/src/components/submission/SubmissionQueueIndicator.tsx new file mode 100644 index 00000000..dded490b --- /dev/null +++ b/src/components/submission/SubmissionQueueIndicator.tsx @@ -0,0 +1,228 @@ +import { useState } from 'react'; +import { Clock, RefreshCw, Trash2, CheckCircle2, XCircle, ChevronDown } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/ui/popover'; +import { Badge } from '@/components/ui/badge'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { cn } from '@/lib/utils'; +import { formatDistanceToNow } from 'date-fns'; + +export interface QueuedSubmission { + id: string; + type: string; + entityName: string; + timestamp: Date; + status: 'pending' | 'retrying' | 'failed'; + retryCount?: number; + error?: string; +} + +interface SubmissionQueueIndicatorProps { + queuedItems: QueuedSubmission[]; + lastSyncTime?: Date; + onRetryItem?: (id: string) => Promise; + onRetryAll?: () => Promise; + onClearQueue?: () => Promise; + onRemoveItem?: (id: string) => void; +} + +export function SubmissionQueueIndicator({ + queuedItems, + lastSyncTime, + onRetryItem, + onRetryAll, + onClearQueue, + onRemoveItem, +}: SubmissionQueueIndicatorProps) { + const [isOpen, setIsOpen] = useState(false); + const [retryingIds, setRetryingIds] = useState>(new Set()); + + const handleRetryItem = async (id: string) => { + if (!onRetryItem) return; + + setRetryingIds(prev => new Set(prev).add(id)); + try { + await onRetryItem(id); + } finally { + setRetryingIds(prev => { + const next = new Set(prev); + next.delete(id); + return next; + }); + } + }; + + const getStatusIcon = (status: QueuedSubmission['status']) => { + switch (status) { + case 'pending': + return ; + case 'retrying': + return ; + case 'failed': + return ; + } + }; + + const getStatusColor = (status: QueuedSubmission['status']) => { + switch (status) { + case 'pending': + return 'bg-secondary text-secondary-foreground'; + case 'retrying': + return 'bg-primary/10 text-primary'; + case 'failed': + return 'bg-destructive/10 text-destructive'; + } + }; + + if (queuedItems.length === 0) { + return null; + } + + return ( + + + + + +
+
+

Submission Queue

+

+ {queuedItems.length} pending submission{queuedItems.length !== 1 ? 's' : ''} +

+ {lastSyncTime && ( +

+ + Last sync {formatDistanceToNow(lastSyncTime, { addSuffix: true })} +

+ )} +
+
+ {onRetryAll && queuedItems.length > 0 && ( + + )} +
+
+ + +
+ {queuedItems.map((item) => ( +
+
+
+
+ {getStatusIcon(item.status)} + + {item.entityName} + +
+
+ {item.type} + + {formatDistanceToNow(item.timestamp, { addSuffix: true })} + {item.retryCount && item.retryCount > 0 && ( + <> + + {item.retryCount} {item.retryCount === 1 ? 'retry' : 'retries'} + + )} +
+ {item.error && ( +

+ {item.error} +

+ )} +
+ +
+ {onRetryItem && ( + + )} + {onRemoveItem && ( + + )} +
+
+
+ ))} +
+
+ + {onClearQueue && queuedItems.length > 0 && ( +
+ +
+ )} +
+
+ ); +} diff --git a/src/hooks/useNetworkStatus.ts b/src/hooks/useNetworkStatus.ts new file mode 100644 index 00000000..8d78b7e6 --- /dev/null +++ b/src/hooks/useNetworkStatus.ts @@ -0,0 +1,28 @@ +import { useState, useEffect } from 'react'; + +export function useNetworkStatus() { + const [isOnline, setIsOnline] = useState(navigator.onLine); + const [wasOffline, setWasOffline] = useState(false); + + useEffect(() => { + const handleOnline = () => { + setIsOnline(true); + setWasOffline(false); + }; + + const handleOffline = () => { + setIsOnline(false); + setWasOffline(true); + }; + + window.addEventListener('online', handleOnline); + window.addEventListener('offline', handleOffline); + + return () => { + window.removeEventListener('online', handleOnline); + window.removeEventListener('offline', handleOffline); + }; + }, []); + + return { isOnline, wasOffline }; +} diff --git a/src/hooks/useRetryProgress.ts b/src/hooks/useRetryProgress.ts new file mode 100644 index 00000000..ced0f42f --- /dev/null +++ b/src/hooks/useRetryProgress.ts @@ -0,0 +1,125 @@ +import { useState, useCallback } from 'react'; +import { toast } from '@/hooks/use-toast'; + +interface RetryOptions { + maxAttempts?: number; + delayMs?: number; + exponentialBackoff?: boolean; + onProgress?: (attempt: number, maxAttempts: number) => void; +} + +export function useRetryProgress() { + const [isRetrying, setIsRetrying] = useState(false); + const [currentAttempt, setCurrentAttempt] = useState(0); + const [abortController, setAbortController] = useState(null); + + const retryWithProgress = useCallback( + async ( + operation: () => Promise, + options: RetryOptions = {} + ): Promise => { + const { + maxAttempts = 3, + delayMs = 1000, + exponentialBackoff = true, + onProgress, + } = options; + + setIsRetrying(true); + const controller = new AbortController(); + setAbortController(controller); + + let lastError: Error | null = null; + let toastId: string | undefined; + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + if (controller.signal.aborted) { + throw new Error('Operation cancelled'); + } + + setCurrentAttempt(attempt); + onProgress?.(attempt, maxAttempts); + + // Show progress toast + if (attempt > 1) { + const delay = exponentialBackoff ? delayMs * Math.pow(2, attempt - 2) : delayMs; + const countdown = Math.ceil(delay / 1000); + + toast({ + title: `Retrying (${attempt}/${maxAttempts})`, + description: `Waiting ${countdown}s before retry...`, + duration: delay, + }); + + await new Promise(resolve => setTimeout(resolve, delay)); + } + + try { + const result = await operation(); + + setIsRetrying(false); + setCurrentAttempt(0); + setAbortController(null); + + // Show success toast + toast({ + title: "Success", + description: attempt > 1 + ? `Operation succeeded on attempt ${attempt}` + : 'Operation completed successfully', + duration: 3000, + }); + + return result; + } catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)); + + if (attempt < maxAttempts) { + toast({ + title: `Attempt ${attempt} Failed`, + description: `${lastError.message}. Retrying...`, + duration: 2000, + }); + } + } + } + + // All attempts failed + setIsRetrying(false); + setCurrentAttempt(0); + setAbortController(null); + + toast({ + variant: 'destructive', + title: "All Retries Failed", + description: `Failed after ${maxAttempts} attempts: ${lastError?.message}`, + duration: 5000, + }); + + throw lastError; + }, + [] + ); + + const cancel = useCallback(() => { + if (abortController) { + abortController.abort(); + setAbortController(null); + setIsRetrying(false); + setCurrentAttempt(0); + + toast({ + title: 'Cancelled', + description: 'Retry operation cancelled', + duration: 2000, + }); + } + }, [abortController]); + + return { + retryWithProgress, + isRetrying, + currentAttempt, + cancel, + }; +} diff --git a/src/hooks/useSubmissionQueue.ts b/src/hooks/useSubmissionQueue.ts new file mode 100644 index 00000000..7d618a57 --- /dev/null +++ b/src/hooks/useSubmissionQueue.ts @@ -0,0 +1,119 @@ +import { useState, useEffect, useCallback } from 'react'; +import { QueuedSubmission } from '@/components/submission/SubmissionQueueIndicator'; +import { useNetworkStatus } from './useNetworkStatus'; + +// This is a placeholder implementation +// In a real app, this would interact with IndexedDB and the actual submission system + +interface UseSubmissionQueueOptions { + autoRetry?: boolean; + retryDelayMs?: number; + maxRetries?: number; +} + +export function useSubmissionQueue(options: UseSubmissionQueueOptions = {}) { + const { + autoRetry = true, + retryDelayMs = 5000, + maxRetries = 3, + } = options; + + const [queuedItems, setQueuedItems] = useState([]); + const [lastSyncTime, setLastSyncTime] = useState(null); + const [nextRetryTime, setNextRetryTime] = useState(null); + const { isOnline } = useNetworkStatus(); + + // Load queued items from IndexedDB on mount + useEffect(() => { + loadQueueFromStorage(); + }, []); + + // Auto-retry when back online + useEffect(() => { + if (isOnline && autoRetry && queuedItems.length > 0) { + const timer = setTimeout(() => { + retryAll(); + }, retryDelayMs); + + setNextRetryTime(new Date(Date.now() + retryDelayMs)); + + return () => clearTimeout(timer); + } + }, [isOnline, autoRetry, queuedItems.length, retryDelayMs]); + + const loadQueueFromStorage = useCallback(async () => { + // Placeholder: Load from IndexedDB + // In real implementation, this would query the offline queue + try { + // const items = await getQueuedSubmissions(); + // setQueuedItems(items); + } catch (error) { + console.error('Failed to load queue:', error); + } + }, []); + + const retryItem = useCallback(async (id: string) => { + setQueuedItems(prev => + prev.map(item => + item.id === id + ? { ...item, status: 'retrying' as const } + : item + ) + ); + + try { + // Placeholder: Retry the submission + // await retrySubmission(id); + + // Remove from queue on success + setQueuedItems(prev => prev.filter(item => item.id !== id)); + setLastSyncTime(new Date()); + } catch (error) { + // Mark as failed + setQueuedItems(prev => + prev.map(item => + item.id === id + ? { + ...item, + status: 'failed' as const, + retryCount: (item.retryCount || 0) + 1, + error: error instanceof Error ? error.message : 'Unknown error', + } + : item + ) + ); + } + }, []); + + const retryAll = useCallback(async () => { + const pendingItems = queuedItems.filter( + item => item.status === 'pending' || item.status === 'failed' + ); + + for (const item of pendingItems) { + if ((item.retryCount || 0) < maxRetries) { + await retryItem(item.id); + } + } + }, [queuedItems, maxRetries, retryItem]); + + const removeItem = useCallback((id: string) => { + setQueuedItems(prev => prev.filter(item => item.id !== id)); + }, []); + + const clearQueue = useCallback(async () => { + // Placeholder: Clear from IndexedDB + setQueuedItems([]); + }, []); + + return { + queuedItems, + lastSyncTime, + nextRetryTime, + retryItem, + retryAll, + removeItem, + clearQueue, + refresh: loadQueueFromStorage, + }; +}