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

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