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
<img width="100%" height="auto" alt="image"
src="https://github.com/user-attachments/assets/3855a239-aa72-4579-a19e-9cad8027b006"
/>
<img width="100%" height="auto" alt="image"
src="https://github.com/user-attachments/assets/cf14b3e2-fb0e-41fb-ba5e-01013b7a5ce1"
/>
This commit is contained in:
Víctor Falcón 2025-12-06 21:54:11 +01:00 committed by GitHub
parent 760824f0c8
commit 1236bf093e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 282 additions and 55 deletions

View File

@ -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, unknown>): 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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Pending Operations</DialogTitle>
<DialogDescription>
These operations are waiting to be synced with the
server.
</DialogDescription>
</DialogHeader>
<div className="max-h-96 overflow-y-auto">
{operations.length === 0 ? (
<div className="flex h-32 items-center justify-center text-muted-foreground">
No pending operations
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Store</TableHead>
<TableHead>Operation</TableHead>
<TableHead>Data</TableHead>
<TableHead>Time</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{operations.map((op) => (
<TableRow key={op.id}>
<TableCell className="font-medium capitalize">
{op.store.replace('_', ' ')}
</TableCell>
<TableCell>
<span
className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${getOperationBadgeClass(op.operation)}`}
>
{op.operation}
</span>
</TableCell>
<TableCell className="max-w-48 truncate text-xs text-muted-foreground">
{formatDataPreview(op.data)}
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{formatDistanceToNow(
new Date(op.timestamp),
{ addSuffix: true },
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
</DialogContent>
</Dialog>
);
}

View File

@ -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 <CloudCheck className="h-4 w-4" />;
};
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 (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<>
<DropdownMenu open={isMenuOpen} onOpenChange={setIsMenuOpen}>
<DropdownMenuTrigger asChild>
<Button
variant={getVariant()}
variant="ghost"
size="icon"
onClick={sync}
disabled={syncStatus === 'syncing' || !isOnline}
className={`${syncStatus === 'error' || !isOnline ? 'bg-red-100 dark:bg-red-900' : ''}`}
className={`relative ${isMenuOpen ? 'bg-accent' : ''} ${syncStatus === 'error' || !isOnline ? 'bg-red-100 dark:bg-red-900' : ''}`}
>
{getIcon()}
{pendingOperationsCount > 0 && (
<span className="absolute -top-1 -right-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-medium text-primary-foreground">
{pendingOperationsCount > 99
? '99+'
: pendingOperationsCount}
</span>
)}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{getTooltipText()}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel className="font-normal">
<div className="flex flex-col gap-1">
<p className="text-xs font-medium">
{getStatusText()}
</p>
<p className="text-xs text-muted-foreground">
{pendingOperationsCount} pending{' '}
{pendingOperationsCount === 1
? 'operation'
: 'operations'}
</p>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={(e) => {
e.preventDefault();
handleSyncNow();
}}
disabled={syncStatus === 'syncing' || !isOnline}
>
<RefreshCw
className={`mr-2 h-4 w-4 ${syncStatus === 'syncing' ? 'animate-spin' : ''}`}
/>
Sync now
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setShowPendingDialog(true)}
>
<List className="mr-2 h-4 w-4" />
View pending operations
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<PendingOperationsDialog
open={showPendingDialog}
onOpenChange={setShowPendingDialog}
operations={pendingOperations}
/>
</>
);
}

View File

@ -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<void>;
error: string | null;
pendingOperationsCount: number;
pendingOperations: PendingChange[];
refreshPendingOperations: () => Promise<void>;
}
const SyncContext = createContext<SyncContextType | undefined>(undefined);
@ -75,8 +79,22 @@ export function SyncProvider({
const [lastSyncTime, setLastSyncTime] = useState<Date | null>(null);
const [error, setError] = useState<string | null>(null);
const [wasOffline, setWasOffline] = useState(!isOnline);
const [pendingOperationsCount, setPendingOperationsCount] = useState(0);
const [pendingOperations, setPendingOperations] = useState<PendingChange[]>(
[],
);
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}

View File

@ -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<void> {
private async syncToServer(result: SyncResult): Promise<number[]> {
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<T extends IndexedDBRecord>(