Skip to content

Commit cadfa94

Browse files
committed
feat(web): add data synchronization status indicator (task-33)
- Create sync status types with 5 states: idle, syncing, success, error, offline - Add SyncStatusContext for real-time operation tracking - Create SyncStatusIndicator component with visual badges and tooltips - Track online/offline status via browser connectivity events - Auto-remove successful operations after 5 seconds - Keep last 50 completed operations in history - Display active operation count and error messages in tooltip - Integrate into MainLayout header next to other controls
1 parent 0b20ffe commit cadfa94

File tree

5 files changed

+337
-12
lines changed

5 files changed

+337
-12
lines changed

apps/web/src/components/layout/MainLayout.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ import { HistorySidebar } from "./HistorySidebar";
4848
import { LeftRibbon } from "./LeftRibbon";
4949
import { NotificationCenter } from "@/components/notifications/NotificationCenter";
5050
import { UndoRedoControls } from "@/components/undo-redo/UndoRedoControls";
51+
import { SyncStatusIndicator } from "@/components/sync/SyncStatusIndicator";
5152
import { RightRibbon } from "./RightRibbon";
5253
import { RightSidebarContent } from "./RightSidebarContent";
5354
// import { ThemeDropdown } from "./ThemeDropdown";
@@ -310,6 +311,7 @@ export const MainLayout: React.FC<MainLayoutProps> = ({ children }) => {
310311

311312
{/* Sidebar controls - simplified on mobile */}
312313
<Group gap="xs" visibleFrom="sm">
314+
<SyncStatusIndicator />
313315
<UndoRedoControls />
314316
<NotificationCenter />
315317
<ActionIcon
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
/**
2+
* Sync Status Indicator Component
3+
*
4+
* Displays real-time synchronization status with visual indicators.
5+
* Shows current state (idle, syncing, success, error, offline) with tooltips.
6+
*/
7+
8+
import { useSyncStatus } from '@/contexts/SyncStatusContext';
9+
import { ICON_SIZE } from '@/config/style-constants';
10+
import { Badge, Box, Group, Stack, Text, Tooltip, UnstyledButton } from '@mantine/core';
11+
import {
12+
IconAlertTriangle,
13+
IconCheck,
14+
IconCloudOff,
15+
IconLoader,
16+
IconX,
17+
} from '@tabler/icons-react';
18+
import { memo, useMemo } from 'react';
19+
20+
const STATUS_CONFIG = {
21+
idle: {
22+
icon: IconCheck,
23+
color: 'gray',
24+
label: 'Synced',
25+
animate: false,
26+
},
27+
syncing: {
28+
icon: IconLoader,
29+
color: 'blue',
30+
label: 'Syncing...',
31+
animate: true,
32+
},
33+
success: {
34+
icon: IconCheck,
35+
color: 'teal',
36+
label: 'Synced',
37+
animate: false,
38+
},
39+
error: {
40+
icon: IconX,
41+
color: 'red',
42+
label: 'Sync error',
43+
animate: false,
44+
},
45+
offline: {
46+
icon: IconCloudOff,
47+
color: 'orange',
48+
label: 'Offline',
49+
animate: false,
50+
},
51+
} as const;
52+
53+
export const SyncStatusIndicator = memo(() => {
54+
const { syncStatus, clearCompleted } = useSyncStatus();
55+
const { overall, operations } = syncStatus;
56+
57+
const config = STATUS_CONFIG[overall];
58+
const Icon = config.icon;
59+
const hasErrors = operations.some((op) => op.status === 'error');
60+
61+
// Get active operations count
62+
const activeCount = useMemo(() => {
63+
return operations.filter((op) => op.status === 'syncing').length;
64+
}, [operations]);
65+
66+
// Get error messages
67+
const errorMessages = useMemo(() => {
68+
return operations
69+
.filter((op) => op.status === 'error')
70+
.map((op) => `${op.name}: ${op.error?.message || 'Unknown error'}`)
71+
.slice(0, 3);
72+
}, [operations]);
73+
74+
const label = activeCount > 0
75+
? `Syncing ${activeCount} operation${activeCount > 1 ? 's' : ''}...`
76+
: config.label;
77+
78+
return (
79+
<Tooltip
80+
withinPortal
81+
label={
82+
<Stack gap={4} p="xs">
83+
<Group gap="xs">
84+
<Icon size={ICON_SIZE.SM} />
85+
<Text size="sm" fw={500}>
86+
{label}
87+
</Text>
88+
</Group>
89+
90+
{hasErrors && (
91+
<Stack gap={2}>
92+
<Text size="xs" c="red">
93+
Errors:
94+
</Text>
95+
{errorMessages.map((msg, i) => (
96+
<Text key={i} size="xs" c="red">
97+
{msg}
98+
</Text>
99+
))}
100+
</Stack>
101+
)}
102+
103+
{operations.length > 0 && (
104+
<Text size="xs" c="dimmed">
105+
{operations.length} operation{operations.length > 1 ? 's' : ''} in history
106+
</Text>
107+
)}
108+
109+
{hasErrors && (
110+
<Text size="xs" c="dimmed" style={{ fontStyle: 'italic' }}>
111+
Click to clear completed operations
112+
</Text>
113+
)}
114+
</Stack>
115+
}
116+
multiline
117+
>
118+
<UnstyledButton
119+
onClick={clearCompleted}
120+
aria-label={`Sync status: ${label}`}
121+
>
122+
<Badge
123+
leftSection={
124+
<Box
125+
style={{
126+
display: 'flex',
127+
alignItems: 'center',
128+
animation: config.animate ? 'spin 1s linear infinite' : undefined,
129+
}}
130+
>
131+
<Icon size={ICON_SIZE.SM} />
132+
</Box>
133+
}
134+
color={config.color}
135+
variant="light"
136+
size="lg"
137+
>
138+
{label}
139+
</Badge>
140+
</UnstyledButton>
141+
</Tooltip>
142+
);
143+
});
144+
145+
SyncStatusIndicator.displayName = 'SyncStatusIndicator';
146+
147+
export default SyncStatusIndicator;
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
/**
2+
* Sync Status Context
3+
*
4+
* Provides real-time synchronization status for IndexedDB operations.
5+
* Tracks active operations and shows visual indicators for sync state.
6+
*/
7+
8+
import React, { createContext, useCallback, useContext, useEffect, useState } from 'react';
9+
import type { SyncOperation, SyncStatus, SyncStatusType } from '@/types/sync';
10+
11+
interface SyncStatusContextValue {
12+
syncStatus: SyncStatus;
13+
startOperation: (name: string) => string;
14+
updateOperation: (id: string, updates: Partial<SyncOperation>) => void;
15+
completeOperation: (id: string, success: boolean, error?: Error) => void;
16+
clearCompleted: () => void;
17+
}
18+
19+
const MAX_HISTORY = 50; // Keep last 50 completed operations
20+
21+
const SyncStatusContext = createContext<SyncStatusContextValue | null>(null);
22+
23+
export const SyncStatusProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
24+
const [operations, setOperations] = useState<SyncOperation[]>([]);
25+
const [isOnline, setIsOnline] = useState(true);
26+
27+
// Track online/offline status
28+
useEffect(() => {
29+
const handleOnline = () => setIsOnline(true);
30+
const handleOffline = () => setIsOnline(false);
31+
32+
window.addEventListener('online', handleOnline);
33+
window.addEventListener('offline', handleOffline);
34+
35+
// Initial check
36+
setIsOnline(navigator.onLine);
37+
38+
return () => {
39+
window.removeEventListener('online', handleOnline);
40+
window.removeEventListener('offline', handleOffline);
41+
};
42+
}, []);
43+
44+
// Calculate overall sync status
45+
const overall: SyncStatusType = !isOnline
46+
? 'offline'
47+
: operations.some((op) => op.status === 'error')
48+
? 'error'
49+
: operations.some((op) => op.status === 'syncing')
50+
? 'syncing'
51+
: operations.some((op) => op.status === 'success')
52+
? 'success'
53+
: 'idle';
54+
55+
const startOperation = useCallback((name: string): string => {
56+
const id = crypto.randomUUID();
57+
const operation: SyncOperation = {
58+
id,
59+
name,
60+
status: 'syncing',
61+
startTime: new Date(),
62+
};
63+
64+
setOperations((prev) => [operation, ...prev]);
65+
return id;
66+
}, []);
67+
68+
const updateOperation = useCallback((id: string, updates: Partial<SyncOperation>) => {
69+
setOperations((prev) =>
70+
prev.map((op) => (op.id === id ? { ...op, ...updates } : op))
71+
);
72+
}, []);
73+
74+
const completeOperation = useCallback((id: string, success: boolean, error?: Error) => {
75+
setOperations((prev) =>
76+
prev.map((op) =>
77+
op.id === id
78+
? {
79+
...op,
80+
status: success ? 'success' : 'error',
81+
endTime: new Date(),
82+
error,
83+
progress: 100,
84+
}
85+
: op
86+
)
87+
);
88+
}, []);
89+
90+
const clearCompleted = useCallback(() => {
91+
setOperations((prev) => prev.filter((op) => op.status === 'syncing'));
92+
}, []);
93+
94+
// Auto-remove successful operations after 5 seconds
95+
useEffect(() => {
96+
const interval = setInterval(() => {
97+
const now = new Date();
98+
setOperations((prev) => {
99+
const active = prev.filter((op) => op.status === 'syncing');
100+
const completed = prev.filter((op) => op.status !== 'syncing');
101+
102+
// Remove successful ops older than 5 seconds
103+
const recentCompleted = completed.filter((op) => {
104+
if (!op.endTime) return true;
105+
const age = now.getTime() - op.endTime.getTime();
106+
return age < 5000 || op.status === 'error';
107+
});
108+
109+
// Keep only last MAX_HISTORY completed operations
110+
const trimmedCompleted = recentCompleted.slice(0, MAX_HISTORY);
111+
112+
return [...active, ...trimmedCompleted];
113+
});
114+
}, 1000);
115+
116+
return () => clearInterval(interval);
117+
}, []);
118+
119+
const syncStatus: SyncStatus = {
120+
overall,
121+
operations,
122+
lastSyncTime: operations.find((op) => op.status === 'success')?.endTime,
123+
isOnline,
124+
};
125+
126+
const value: SyncStatusContextValue = {
127+
syncStatus,
128+
startOperation,
129+
updateOperation,
130+
completeOperation,
131+
clearCompleted,
132+
};
133+
134+
return (
135+
<SyncStatusContext.Provider value={value}>
136+
{children}
137+
</SyncStatusContext.Provider>
138+
);
139+
};
140+
141+
export const useSyncStatus = (): SyncStatusContextValue => {
142+
const context = useContext(SyncStatusContext);
143+
if (!context) {
144+
throw new Error('useSyncStatus must be used within SyncStatusProvider');
145+
}
146+
return context;
147+
};
148+
149+
export default SyncStatusContext;

apps/web/src/routes/__root.tsx

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { NavigationTracker } from "@/components/NavigationTracker";
77
import { UrlFixer } from "@/components/UrlFixer";
88
import { NotificationProvider } from "@/contexts/NotificationContext";
99
import { ActivityProvider } from "@/contexts/ActivityContext";
10+
import { SyncStatusProvider } from "@/contexts/SyncStatusContext";
1011
import { UndoRedoProvider } from "@/contexts/UndoRedoContext";
1112
import { GraphVisualizationProvider } from "@/contexts/GraphVisualizationContext";
1213

@@ -25,21 +26,23 @@ const RootLayout = () => {
2526
<NavigationTracker />
2627
<NotificationProvider>
2728
<ActivityProvider>
28-
<UndoRedoProvider>
29-
{/* Conditionally wrap MainLayout and Outlet with GraphVisualizationProvider on graph page */}
30-
{/* This allows the sidebar (in MainLayout) to access the context */}
31-
{isGraphPage ? (
32-
<GraphVisualizationProvider>
29+
<SyncStatusProvider>
30+
<UndoRedoProvider>
31+
{/* Conditionally wrap MainLayout and Outlet with GraphVisualizationProvider on graph page */}
32+
{/* This allows the sidebar (in MainLayout) to access the context */}
33+
{isGraphPage ? (
34+
<GraphVisualizationProvider>
35+
<MainLayout>
36+
<Outlet />
37+
</MainLayout>
38+
</GraphVisualizationProvider>
39+
) : (
3340
<MainLayout>
3441
<Outlet />
3542
</MainLayout>
36-
</GraphVisualizationProvider>
37-
) : (
38-
<MainLayout>
39-
<Outlet />
40-
</MainLayout>
41-
)}
42-
</UndoRedoProvider>
43+
)}
44+
</UndoRedoProvider>
45+
</SyncStatusProvider>
4346
</ActivityProvider>
4447
</NotificationProvider>
4548
</div>

apps/web/src/types/sync.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
/**
2+
* Sync Status Types
3+
*
4+
* Types for tracking data synchronization status across the application.
5+
*/
6+
7+
export type SyncStatusType = 'idle' | 'syncing' | 'success' | 'error' | 'offline';
8+
9+
export interface SyncOperation {
10+
id: string;
11+
name: string;
12+
status: SyncStatusType;
13+
startTime: Date;
14+
endTime?: Date;
15+
error?: Error;
16+
progress?: number; // 0-100
17+
}
18+
19+
export interface SyncStatus {
20+
overall: SyncStatusType;
21+
operations: SyncOperation[];
22+
lastSyncTime?: Date;
23+
isOnline: boolean;
24+
}

0 commit comments

Comments
 (0)