mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 04:31:13 -05:00
Implement client-side resilience UI
Create NetworkErrorBanner, SubmissionQueueIndicator, and enhanced retry progress UI components. Integrate them into the application using a ResilienceProvider to manage network status and submission queue states. Update App.tsx to include the ResilienceProvider.
This commit is contained in:
@@ -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,6 +148,7 @@ function AppContent(): React.JSX.Element {
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<ResilienceProvider>
|
||||
<APIStatusBanner />
|
||||
<div className={cn(showBanner && "pt-20")}>
|
||||
<NavigationTracker />
|
||||
@@ -401,6 +403,7 @@ function AppContent(): React.JSX.Element {
|
||||
<Footer />
|
||||
</div>
|
||||
</div>
|
||||
</ResilienceProvider>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
139
src/components/error/NetworkErrorBanner.tsx
Normal file
139
src/components/error/NetworkErrorBanner.tsx
Normal file
@@ -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<void>;
|
||||
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<number | null>(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 (
|
||||
<div
|
||||
className={cn(
|
||||
"fixed top-0 left-0 right-0 z-50 transition-transform duration-300",
|
||||
isVisible ? "translate-y-0" : "-translate-y-full"
|
||||
)}
|
||||
>
|
||||
<div className="bg-destructive/90 backdrop-blur-sm text-destructive-foreground shadow-lg">
|
||||
<div className="container mx-auto px-4 py-3">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3 flex-1">
|
||||
<WifiOff className="h-5 w-5 flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-semibold text-sm">
|
||||
{isOffline ? 'You are offline' : 'Network Issue Detected'}
|
||||
</p>
|
||||
<p className="text-xs opacity-90 truncate">
|
||||
{pendingCount > 0 ? (
|
||||
<>
|
||||
{pendingCount} submission{pendingCount !== 1 ? 's' : ''} pending
|
||||
{countdown !== null && countdown > 0 && (
|
||||
<span className="ml-2">
|
||||
· Retrying in {countdown}s
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
'Changes will sync when connection is restored'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{pendingCount > 0 && onViewQueue && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={onViewQueue}
|
||||
className="h-8 text-xs bg-background/20 hover:bg-background/30"
|
||||
>
|
||||
<Eye className="h-3.5 w-3.5 mr-1.5" />
|
||||
View Queue ({pendingCount})
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{onRetryNow && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={handleRetryNow}
|
||||
disabled={isRetrying}
|
||||
className="h-8 text-xs bg-background/20 hover:bg-background/30"
|
||||
>
|
||||
<RefreshCw className={cn(
|
||||
"h-3.5 w-3.5 mr-1.5",
|
||||
isRetrying && "animate-spin"
|
||||
)} />
|
||||
{isRetrying ? 'Retrying...' : 'Retry Now'}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setIsVisible(false)}
|
||||
className="h-8 w-8 p-0 hover:bg-background/20"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Dismiss</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
61
src/components/layout/ResilienceProvider.tsx
Normal file
61
src/components/layout/ResilienceProvider.tsx
Normal file
@@ -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 */}
|
||||
<NetworkErrorBanner
|
||||
isOffline={!isOnline}
|
||||
pendingCount={queuedItems.length}
|
||||
onRetryNow={retryAll}
|
||||
estimatedRetryTime={nextRetryTime || undefined}
|
||||
/>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="min-h-screen">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{/* Floating Queue Indicator - Shows in bottom right */}
|
||||
{queuedItems.length > 0 && (
|
||||
<div className="fixed bottom-6 right-6 z-40">
|
||||
<SubmissionQueueIndicator
|
||||
queuedItems={queuedItems}
|
||||
lastSyncTime={lastSyncTime || undefined}
|
||||
onRetryItem={retryItem}
|
||||
onRetryAll={retryAll}
|
||||
onRemoveItem={removeItem}
|
||||
onClearQueue={clearQueue}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
228
src/components/submission/SubmissionQueueIndicator.tsx
Normal file
228
src/components/submission/SubmissionQueueIndicator.tsx
Normal file
@@ -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<void>;
|
||||
onRetryAll?: () => Promise<void>;
|
||||
onClearQueue?: () => Promise<void>;
|
||||
onRemoveItem?: (id: string) => void;
|
||||
}
|
||||
|
||||
export function SubmissionQueueIndicator({
|
||||
queuedItems,
|
||||
lastSyncTime,
|
||||
onRetryItem,
|
||||
onRetryAll,
|
||||
onClearQueue,
|
||||
onRemoveItem,
|
||||
}: SubmissionQueueIndicatorProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [retryingIds, setRetryingIds] = useState<Set<string>>(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 <Clock className="h-3.5 w-3.5 text-muted-foreground" />;
|
||||
case 'retrying':
|
||||
return <RefreshCw className="h-3.5 w-3.5 text-primary animate-spin" />;
|
||||
case 'failed':
|
||||
return <XCircle className="h-3.5 w-3.5 text-destructive" />;
|
||||
}
|
||||
};
|
||||
|
||||
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 (
|
||||
<Popover open={isOpen} onOpenChange={setIsOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="relative gap-2 h-9"
|
||||
>
|
||||
<Clock className="h-4 w-4" />
|
||||
<span className="text-sm font-medium">
|
||||
Queue
|
||||
</span>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="h-5 min-w-[20px] px-1.5 bg-primary text-primary-foreground"
|
||||
>
|
||||
{queuedItems.length}
|
||||
</Badge>
|
||||
<ChevronDown className={cn(
|
||||
"h-3.5 w-3.5 transition-transform",
|
||||
isOpen && "rotate-180"
|
||||
)} />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-96 p-0"
|
||||
align="end"
|
||||
sideOffset={8}
|
||||
>
|
||||
<div className="flex items-center justify-between p-4 border-b">
|
||||
<div>
|
||||
<h3 className="font-semibold text-sm">Submission Queue</h3>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{queuedItems.length} pending submission{queuedItems.length !== 1 ? 's' : ''}
|
||||
</p>
|
||||
{lastSyncTime && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5 flex items-center gap-1">
|
||||
<CheckCircle2 className="h-3 w-3" />
|
||||
Last sync {formatDistanceToNow(lastSyncTime, { addSuffix: true })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
{onRetryAll && queuedItems.length > 0 && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onRetryAll}
|
||||
className="h-8"
|
||||
>
|
||||
<RefreshCw className="h-3.5 w-3.5 mr-1.5" />
|
||||
Retry All
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="max-h-[400px]">
|
||||
<div className="p-2 space-y-1">
|
||||
{queuedItems.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className={cn(
|
||||
"group rounded-md p-3 border transition-colors hover:bg-accent/50",
|
||||
getStatusColor(item.status)
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
{getStatusIcon(item.status)}
|
||||
<span className="text-sm font-medium truncate">
|
||||
{item.entityName}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span className="capitalize">{item.type}</span>
|
||||
<span>•</span>
|
||||
<span>{formatDistanceToNow(item.timestamp, { addSuffix: true })}</span>
|
||||
{item.retryCount && item.retryCount > 0 && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span>{item.retryCount} {item.retryCount === 1 ? 'retry' : 'retries'}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{item.error && (
|
||||
<p className="text-xs text-destructive mt-1.5 truncate">
|
||||
{item.error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{onRetryItem && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleRetryItem(item.id)}
|
||||
disabled={retryingIds.has(item.id)}
|
||||
className="h-7 w-7 p-0"
|
||||
>
|
||||
<RefreshCw className={cn(
|
||||
"h-3.5 w-3.5",
|
||||
retryingIds.has(item.id) && "animate-spin"
|
||||
)} />
|
||||
<span className="sr-only">Retry</span>
|
||||
</Button>
|
||||
)}
|
||||
{onRemoveItem && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => onRemoveItem(item.id)}
|
||||
className="h-7 w-7 p-0 hover:bg-destructive/10 hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
<span className="sr-only">Remove</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{onClearQueue && queuedItems.length > 0 && (
|
||||
<div className="p-3 border-t">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onClearQueue}
|
||||
className="w-full h-8 text-destructive hover:bg-destructive/10"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5 mr-1.5" />
|
||||
Clear Queue
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
28
src/hooks/useNetworkStatus.ts
Normal file
28
src/hooks/useNetworkStatus.ts
Normal file
@@ -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 };
|
||||
}
|
||||
125
src/hooks/useRetryProgress.ts
Normal file
125
src/hooks/useRetryProgress.ts
Normal file
@@ -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<AbortController | null>(null);
|
||||
|
||||
const retryWithProgress = useCallback(
|
||||
async <T,>(
|
||||
operation: () => Promise<T>,
|
||||
options: RetryOptions = {}
|
||||
): Promise<T> => {
|
||||
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,
|
||||
};
|
||||
}
|
||||
119
src/hooks/useSubmissionQueue.ts
Normal file
119
src/hooks/useSubmissionQueue.ts
Normal file
@@ -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<QueuedSubmission[]>([]);
|
||||
const [lastSyncTime, setLastSyncTime] = useState<Date | null>(null);
|
||||
const [nextRetryTime, setNextRetryTime] = useState<Date | null>(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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user