From 1236bf093e30e9dfef1a3cda357a0e1410c1d0d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Falc=C3=B3n?= Date: Sat, 6 Dec 2025 21:54:11 +0100 Subject: [PATCH] Add pending operations dialog and improve sync status UI (#16) - Add PendingOperationsDialog component to display pending sync operations - Replace sync status tooltip with dropdown menu showing pending count badge - Add pending operations tracking to sync context with refresh capability - Improve sync manager error handling for 404 responses on updates/deletes - Only remove successfully synced changes from pending queue ## Images image image --- .../components/pending-operations-dialog.tsx | 113 +++++++++++++++ .../js/components/sync-status-button.tsx | 137 +++++++++++++----- resources/js/contexts/sync-context.tsx | 25 +++- resources/js/lib/sync-manager.ts | 62 ++++++-- 4 files changed, 282 insertions(+), 55 deletions(-) create mode 100644 resources/js/components/pending-operations-dialog.tsx diff --git a/resources/js/components/pending-operations-dialog.tsx b/resources/js/components/pending-operations-dialog.tsx new file mode 100644 index 00000000..c791265e --- /dev/null +++ b/resources/js/components/pending-operations-dialog.tsx @@ -0,0 +1,113 @@ +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import type { PendingChange } from '@/lib/dexie-db'; +import { formatDistanceToNow } from 'date-fns'; + +interface PendingOperationsDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + operations: PendingChange[]; +} + +function getOperationBadgeClass(operation: PendingChange['operation']): string { + switch (operation) { + case 'create': + return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'; + case 'update': + return 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200'; + case 'delete': + return 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'; + default: + return 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200'; + } +} + +function formatDataPreview(data: Record): string { + if (data.id) { + const preview = `ID: ${String(data.id).slice(0, 8)}...`; + if (data.name) { + return `${preview}, Name: ${data.name}`; + } + if (data.description) { + return `${preview}, ${String(data.description).slice(0, 20)}...`; + } + return preview; + } + return JSON.stringify(data).slice(0, 50) + '...'; +} + +export function PendingOperationsDialog({ + open, + onOpenChange, + operations, +}: PendingOperationsDialogProps) { + return ( + + + + Pending Operations + + These operations are waiting to be synced with the + server. + + +
+ {operations.length === 0 ? ( +
+ No pending operations +
+ ) : ( + + + + Store + Operation + Data + Time + + + + {operations.map((op) => ( + + + {op.store.replace('_', ' ')} + + + + {op.operation} + + + + {formatDataPreview(op.data)} + + + {formatDistanceToNow( + new Date(op.timestamp), + { addSuffix: true }, + )} + + + ))} + +
+ )} +
+
+
+ ); +} diff --git a/resources/js/components/sync-status-button.tsx b/resources/js/components/sync-status-button.tsx index 2b000973..2a98bded 100644 --- a/resources/js/components/sync-status-button.tsx +++ b/resources/js/components/sync-status-button.tsx @@ -1,17 +1,36 @@ import { Button } from '@/components/ui/button'; import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from '@/components/ui/tooltip'; + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; import { useSyncContext } from '@/contexts/sync-context'; import { formatDistanceToNow } from 'date-fns'; -import { CloudAlert, CloudCheck, CloudOff, RefreshCw } from 'lucide-react'; +import { + CloudAlert, + CloudCheck, + CloudOff, + List, + RefreshCw, +} from 'lucide-react'; +import { useState } from 'react'; +import { PendingOperationsDialog } from './pending-operations-dialog'; export function SyncStatusButton() { - const { syncStatus, lastSyncTime, isOnline, sync, error } = - useSyncContext(); + const { + syncStatus, + lastSyncTime, + isOnline, + sync, + error, + pendingOperationsCount, + pendingOperations, + } = useSyncContext(); + const [showPendingDialog, setShowPendingDialog] = useState(false); + const [isMenuOpen, setIsMenuOpen] = useState(false); const getIcon = () => { if (syncStatus === 'syncing') { @@ -29,48 +48,90 @@ export function SyncStatusButton() { return ; }; - const getTooltipText = () => { - if (syncStatus === 'syncing') { - return 'Syncing...'; - } - - if (!isOnline) { - return 'Offline - Click to retry when online'; - } - - if (syncStatus === 'error') { - return error || 'Sync failed - Click to retry'; - } - + const getLastSyncText = () => { if (lastSyncTime) { return `Synced ${formatDistanceToNow(lastSyncTime, { addSuffix: true })}`; } - - return 'Not synced yet - Click to sync'; + return 'Not synced yet'; }; - const getVariant = () => { - return 'ghost' as const; + const getStatusText = () => { + if (syncStatus === 'syncing') { + return 'Syncing...'; + } + if (!isOnline) { + return 'Offline'; + } + if (syncStatus === 'error') { + return error || 'Sync failed'; + } + return getLastSyncText(); + }; + + const handleSyncNow = () => { + sync(); }; return ( - - - + <> + + - - -

{getTooltipText()}

-
-
-
+ + + +
+

+ {getStatusText()} +

+

+ {pendingOperationsCount} pending{' '} + {pendingOperationsCount === 1 + ? 'operation' + : 'operations'} +

+
+
+ + { + e.preventDefault(); + handleSyncNow(); + }} + disabled={syncStatus === 'syncing' || !isOnline} + > + + Sync now + + setShowPendingDialog(true)} + > + + View pending operations + +
+ + + + ); } diff --git a/resources/js/contexts/sync-context.tsx b/resources/js/contexts/sync-context.tsx index 4a8d907d..161e5cdd 100644 --- a/resources/js/contexts/sync-context.tsx +++ b/resources/js/contexts/sync-context.tsx @@ -1,5 +1,6 @@ import { useOnlineStatus } from '@/hooks/use-online-status'; import { checkDatabaseVersion } from '@/lib/db-migration-helper'; +import { db, type PendingChange } from '@/lib/dexie-db'; import { accountBalanceSyncService } from '@/services/account-balance-sync'; import { accountSyncService } from '@/services/account-sync'; import { automationRuleSyncService } from '@/services/automation-rule-sync'; @@ -27,6 +28,9 @@ interface SyncContextType { isAuthenticated: boolean; sync: () => Promise; error: string | null; + pendingOperationsCount: number; + pendingOperations: PendingChange[]; + refreshPendingOperations: () => Promise; } const SyncContext = createContext(undefined); @@ -75,8 +79,22 @@ export function SyncProvider({ const [lastSyncTime, setLastSyncTime] = useState(null); const [error, setError] = useState(null); const [wasOffline, setWasOffline] = useState(!isOnline); + const [pendingOperationsCount, setPendingOperationsCount] = useState(0); + const [pendingOperations, setPendingOperations] = useState( + [], + ); const syncInProgressRef = useRef(false); + const refreshPendingOperations = useCallback(async () => { + try { + const operations = await db.pending_changes.toArray(); + setPendingOperations(operations); + setPendingOperationsCount(operations.length); + } catch (err) { + console.error('Failed to fetch pending operations:', err); + } + }, []); + useEffect(() => { const unsubscribe = router.on('navigate', (event) => { const page = event.detail.page as Page<{ @@ -173,8 +191,9 @@ export function SyncProvider({ }, 5000); } finally { syncInProgressRef.current = false; + await refreshPendingOperations(); } - }, [isAuthenticated, isOnline]); + }, [isAuthenticated, isOnline, refreshPendingOperations]); useEffect(() => { if (isAuthenticated && isOnline && wasOffline) { @@ -210,6 +229,7 @@ export function SyncProvider({ } }); + refreshPendingOperations(); sync(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [isAuthenticated]); @@ -223,6 +243,9 @@ export function SyncProvider({ isAuthenticated, sync, error, + pendingOperationsCount, + pendingOperations, + refreshPendingOperations, }} > {children} diff --git a/resources/js/lib/sync-manager.ts b/resources/js/lib/sync-manager.ts index 3e62e909..609e8676 100644 --- a/resources/js/lib/sync-manager.ts +++ b/resources/js/lib/sync-manager.ts @@ -1,5 +1,5 @@ import type { UUID } from '@/types/uuid'; -import axios from 'axios'; +import axios, { AxiosError } from 'axios'; import { uuidv7 } from 'uuidv7'; import { db } from './dexie-db'; @@ -80,12 +80,11 @@ export class SyncManager { try { await this.syncFromServer(result); - await this.syncToServer(result); + const successfulChangeIds = await this.syncToServer(result); - await db.pending_changes - .where('store') - .equals(this.options.storeName) - .delete(); + if (successfulChangeIds.length > 0) { + await db.pending_changes.bulkDelete(successfulChangeIds); + } await this.setLastSyncTime(new Date().toISOString()); } catch (error) { @@ -158,12 +157,14 @@ export class SyncManager { } } - private async syncToServer(result: SyncResult): Promise { + private async syncToServer(result: SyncResult): Promise { const pendingChanges = await db.pending_changes .where('store') .equals(this.options.storeName) .toArray(); + const successfulChangeIds: number[] = []; + for (const change of pendingChanges) { try { const data = this.options.transformToServer @@ -176,25 +177,54 @@ export class SyncManager { result.inserted++; break; case 'update': - await axios.patch( - `${this.options.endpoint}/${change.data.id}`, - data, - ); - result.updated++; + try { + await axios.patch( + `${this.options.endpoint}/${change.data.id}`, + data, + ); + result.updated++; + } catch (updateError) { + if ( + updateError instanceof AxiosError && + updateError.response?.status === 404 + ) { + await axios.post(this.options.endpoint, data); + result.inserted++; + } else { + throw updateError; + } + } break; case 'delete': - await axios.delete( - `${this.options.endpoint}/${change.data.id}`, - ); - result.deleted++; + try { + await axios.delete( + `${this.options.endpoint}/${change.data.id}`, + ); + result.deleted++; + } catch (deleteError) { + if ( + deleteError instanceof AxiosError && + deleteError.response?.status === 404 + ) { + result.deleted++; + } else { + throw deleteError; + } + } break; } + + if (change.id !== undefined) { + successfulChangeIds.push(change.id); + } } catch (error) { result.errors.push( `Failed to sync ${change.operation} for ${this.options.storeName}: ${error}`, ); } } + + return successfulChangeIds; } async createLocal(