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:
parent
760824f0c8
commit
1236bf093e
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>(
|
||||
|
|
|
|||
Loading…
Reference in New Issue