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:
gpt-engineer-app[bot]
2025-11-07 14:54:06 +00:00
parent e52e699ca4
commit 095278dafd
7 changed files with 715 additions and 12 deletions

View File

@@ -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 (
<TooltipProvider>
<APIStatusBanner />
<div className={cn(showBanner && "pt-20")}>
<NavigationTracker />
<LocationAutoDetectProvider />
<RetryStatusIndicator />
<Toaster />
<Sonner />
<div className="min-h-screen flex flex-col">
<div className="flex-1">
<Suspense fallback={<PageLoader />}>
<RouteErrorBoundary>
<Routes>
<ResilienceProvider>
<APIStatusBanner />
<div className={cn(showBanner && "pt-20")}>
<NavigationTracker />
<LocationAutoDetectProvider />
<RetryStatusIndicator />
<Toaster />
<Sonner />
<div className="min-h-screen flex flex-col">
<div className="flex-1">
<Suspense fallback={<PageLoader />}>
<RouteErrorBoundary>
<Routes>
{/* Core routes - eager loaded */}
<Route path="/" element={<Index />} />
<Route path="/parks" element={<Parks />} />
@@ -401,6 +403,7 @@ function AppContent(): React.JSX.Element {
<Footer />
</div>
</div>
</ResilienceProvider>
</TooltipProvider>
);
}

View 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>
);
}

View 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>
)}
</>
);
}

View 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>
);
}

View 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 };
}

View 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,
};
}

View 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,
};
}