feat: implement offline support for labels and projects, enhance advanced settings with offline feature toggle, and add sync status indicator
- Added offline support for fetching and caching labels and projects using `offlineDB`. - Enhanced `AdvancedSettings` to include an offline feature toggle with confirmation modal for disabling offline support. - Introduced `SyncStatusIndicator` component to display sync status, pending commands, and failed commands. - Created `PendingBadge` component to manage and display pending actions for synchronization. - Removed deprecated offline mode toggle from `StorageSettings`. - Improved error handling and user notifications for offline actions and sync processes.
This commit is contained in:
478
src/views/components/SyncStatusIndicator.jsx
Normal file
478
src/views/components/SyncStatusIndicator.jsx
Normal file
@@ -0,0 +1,478 @@
|
||||
import {
|
||||
CheckCircleOutline,
|
||||
CloudDone,
|
||||
CloudQueue,
|
||||
CloudSync,
|
||||
Refresh,
|
||||
WifiOff,
|
||||
} from '@mui/icons-material'
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
Divider,
|
||||
Dropdown,
|
||||
ListItemDecorator,
|
||||
Menu,
|
||||
MenuButton,
|
||||
MenuItem,
|
||||
Sheet,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { networkManager } from '../../hooks/NetworkManager'
|
||||
import { commandQueue } from '../../utils/CommandQueue'
|
||||
import {
|
||||
isOfflineFeatureEnabled,
|
||||
subscribeToOfflineFeature,
|
||||
} from '../../utils/OfflineFeatureToggle'
|
||||
import { syncEngine } from '../../utils/SyncEngine'
|
||||
|
||||
const COMMAND_LABELS = {
|
||||
create_chore: 'Create chore',
|
||||
update_chore: 'Update chore',
|
||||
complete_chore: 'Complete chore',
|
||||
skip_chore: 'Skip chore',
|
||||
delete_chore: 'Delete chore',
|
||||
reschedule_chore: 'Reschedule chore',
|
||||
archive_chore: 'Archive chore',
|
||||
unarchive_chore: 'Restore chore',
|
||||
}
|
||||
|
||||
const RETRY_INTERVAL = 30
|
||||
|
||||
function SyncStatusIndicator() {
|
||||
const queryClient = useQueryClient()
|
||||
const [pendingCommands, setPendingCommands] = useState([])
|
||||
const [failedCommands, setFailedCommands] = useState([])
|
||||
const [syncState, setSyncState] = useState({
|
||||
syncing: false,
|
||||
lastSync: null,
|
||||
error: null,
|
||||
})
|
||||
const [isOnline, setIsOnline] = useState(networkManager.isOnline)
|
||||
const [offlineSince, setOfflineSince] = useState(networkManager.offlineSince)
|
||||
const [retryIn, setRetryIn] = useState(RETRY_INTERVAL)
|
||||
const [offlineFeatureEnabled, setOfflineFeatureEnabled] = useState(
|
||||
isOfflineFeatureEnabled(),
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = subscribeToOfflineFeature(setOfflineFeatureEnabled)
|
||||
return unsubscribe
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = syncEngine.onSyncStateChange(state => {
|
||||
setSyncState(prev => ({ ...prev, ...state }))
|
||||
})
|
||||
return unsubscribe
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!syncState.syncing) {
|
||||
setRetryIn(RETRY_INTERVAL)
|
||||
}
|
||||
}, [syncState.syncing, syncState.lastSync])
|
||||
|
||||
useEffect(() => {
|
||||
networkManager.registerNetworkListener(online => {
|
||||
setIsOnline(online)
|
||||
if (!online) setOfflineSince(networkManager.offlineSince)
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOnline || syncState.syncing) return
|
||||
const interval = setInterval(() => {
|
||||
setRetryIn(prev => (prev <= 1 ? RETRY_INTERVAL : prev - 1))
|
||||
}, 1000)
|
||||
return () => clearInterval(interval)
|
||||
}, [isOnline, syncState.syncing, syncState.lastSync])
|
||||
|
||||
useEffect(() => {
|
||||
const update = async () => {
|
||||
try {
|
||||
const [pending, failed] = await Promise.all([
|
||||
commandQueue.getPending(),
|
||||
commandQueue.getFailed(),
|
||||
])
|
||||
setPendingCommands(pending)
|
||||
setFailedCommands(failed)
|
||||
} catch {
|
||||
// OfflineDB may not be initialized yet
|
||||
}
|
||||
}
|
||||
update()
|
||||
const interval = setInterval(update, 5000)
|
||||
return () => clearInterval(interval)
|
||||
}, [syncState])
|
||||
|
||||
const refreshCommands = async () => {
|
||||
const [pending, failed] = await Promise.all([
|
||||
commandQueue.getPending(),
|
||||
commandQueue.getFailed(),
|
||||
])
|
||||
setPendingCommands(pending)
|
||||
setFailedCommands(failed)
|
||||
}
|
||||
|
||||
const handleForceSync = async () => {
|
||||
const didSync = await syncEngine.sync()
|
||||
if (didSync) queryClient.invalidateQueries()
|
||||
await refreshCommands()
|
||||
}
|
||||
|
||||
const handleDismissFailed = async id => {
|
||||
await commandQueue.cancel(id)
|
||||
await refreshCommands()
|
||||
}
|
||||
|
||||
const formatTime = timestamp => {
|
||||
if (!timestamp) return 'Never'
|
||||
const seconds = Math.floor((Date.now() - timestamp) / 1000)
|
||||
if (seconds < 10) return 'Just now'
|
||||
if (seconds < 60) return `${seconds}s ago`
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
if (minutes < 60) return `${minutes}m ago`
|
||||
return `${Math.floor(minutes / 60)}h ago`
|
||||
}
|
||||
|
||||
const formatOfflineDuration = timestamp => {
|
||||
if (!timestamp) return ''
|
||||
const minutes = Math.floor((Date.now() - timestamp) / 60000)
|
||||
if (minutes < 1) return 'just now'
|
||||
if (minutes < 60) return `${minutes}m ago`
|
||||
return `${Math.floor(minutes / 60)}h ago`
|
||||
}
|
||||
|
||||
const groupedPending = Object.entries(
|
||||
pendingCommands.reduce((acc, cmd) => {
|
||||
acc[cmd.commandType] = (acc[cmd.commandType] || 0) + 1
|
||||
return acc
|
||||
}, {}),
|
||||
)
|
||||
|
||||
const pendingCount = pendingCommands.length
|
||||
const failedCount = failedCommands.length
|
||||
const totalBadge = pendingCount + failedCount
|
||||
|
||||
const getStatusIcon = () => {
|
||||
if (syncState.syncing)
|
||||
return <CloudSync sx={{ fontSize: 20, color: 'primary.500' }} />
|
||||
if (!isOnline) return <WifiOff sx={{ fontSize: 20, color: 'danger.500' }} />
|
||||
if (failedCount > 0)
|
||||
return <CloudQueue sx={{ fontSize: 20, color: 'danger.400' }} />
|
||||
if (pendingCount > 0)
|
||||
return <CloudQueue sx={{ fontSize: 20, color: 'warning.500' }} />
|
||||
return <CloudDone sx={{ fontSize: 20, color: 'success.500' }} />
|
||||
}
|
||||
|
||||
if (!offlineFeatureEnabled) return null
|
||||
|
||||
return (
|
||||
<Dropdown>
|
||||
<MenuButton
|
||||
variant='plain'
|
||||
sx={{
|
||||
p: 0.5,
|
||||
border: 'none',
|
||||
backgroundColor: 'transparent',
|
||||
borderRadius: 'var(--joy-radius-sm)',
|
||||
'&:hover': {
|
||||
backgroundColor: 'var(--joy-palette-neutral-softHoverBg)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{ display: 'flex', alignItems: 'center', position: 'relative' }}
|
||||
>
|
||||
{syncState.syncing && (
|
||||
<CircularProgress
|
||||
size='sm'
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
'--CircularProgress-size': '28px',
|
||||
'--CircularProgress-trackThickness': '2px',
|
||||
'--CircularProgress-progressThickness': '2px',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{totalBadge > 0 ? (
|
||||
<Badge
|
||||
badgeContent={totalBadge}
|
||||
size='sm'
|
||||
color={failedCount > 0 ? 'danger' : 'warning'}
|
||||
sx={{
|
||||
'& .MuiBadge-badge': {
|
||||
fontSize: 9,
|
||||
minWidth: 16,
|
||||
height: 16,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{getStatusIcon()}
|
||||
</Badge>
|
||||
) : (
|
||||
getStatusIcon()
|
||||
)}
|
||||
</Box>
|
||||
</MenuButton>
|
||||
|
||||
<Menu
|
||||
placement='bottom-end'
|
||||
sx={{
|
||||
minWidth: 280,
|
||||
p: 1,
|
||||
'--List-gap': '4px',
|
||||
boxShadow: 'var(--joy-shadow-lg)',
|
||||
border: '1px solid var(--joy-palette-divider)',
|
||||
borderRadius: 'var(--joy-radius-md)',
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<Sheet sx={{ p: 1.5, borderRadius: 'var(--joy-radius-sm)', mb: 1 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
mb: 0.5,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: isOnline
|
||||
? 'var(--joy-palette-success-500)'
|
||||
: 'var(--joy-palette-danger-500)',
|
||||
}}
|
||||
/>
|
||||
<Typography level='title-sm' sx={{ fontWeight: 600 }}>
|
||||
{isOnline ? 'Online' : 'Offline'}
|
||||
</Typography>
|
||||
</Box>
|
||||
{syncState.syncing && (
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{ color: 'var(--joy-palette-primary-500)' }}
|
||||
>
|
||||
Syncing...
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{ color: 'var(--joy-palette-text-tertiary)' }}
|
||||
>
|
||||
Last sync: {formatTime(syncState.lastSync)}
|
||||
</Typography>
|
||||
|
||||
{!isOnline && offlineSince && (
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{ color: 'var(--joy-palette-danger-400)', mt: 0.25 }}
|
||||
>
|
||||
Offline since {formatOfflineDuration(offlineSince)}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{syncState.error && (
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{ color: 'var(--joy-palette-danger-500)', mt: 0.25 }}
|
||||
>
|
||||
Error: {syncState.error}
|
||||
</Typography>
|
||||
)}
|
||||
</Sheet>
|
||||
|
||||
{/* Pending actions */}
|
||||
{groupedPending.length > 0 && (
|
||||
<>
|
||||
<Box sx={{ px: 1, py: 0.5 }}>
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
color: 'var(--joy-palette-text-secondary)',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
}}
|
||||
>
|
||||
Pending ({pendingCount})
|
||||
</Typography>
|
||||
</Box>
|
||||
{groupedPending.map(([type, count]) => (
|
||||
<Box
|
||||
key={type}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
px: 1,
|
||||
py: 0.5,
|
||||
borderRadius: 'var(--joy-radius-sm)',
|
||||
}}
|
||||
>
|
||||
<Typography level='body-sm'>
|
||||
{COMMAND_LABELS[type] || type}
|
||||
</Typography>
|
||||
<Chip size='sm' color='warning' variant='soft'>
|
||||
{count}
|
||||
</Chip>
|
||||
</Box>
|
||||
))}
|
||||
<Divider sx={{ my: 0.5 }} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Failed actions */}
|
||||
{failedCommands.length > 0 && (
|
||||
<>
|
||||
<Box sx={{ px: 1, py: 0.5 }}>
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
color: 'var(--joy-palette-danger-500)',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
}}
|
||||
>
|
||||
Failed ({failedCount})
|
||||
</Typography>
|
||||
</Box>
|
||||
{failedCommands.map(cmd => (
|
||||
<Box
|
||||
key={cmd.id}
|
||||
sx={{
|
||||
px: 1,
|
||||
py: 0.75,
|
||||
mb: 0.5,
|
||||
borderRadius: 'var(--joy-radius-sm)',
|
||||
backgroundColor: 'var(--joy-palette-danger-softBg)',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
level='body-sm'
|
||||
sx={{
|
||||
color: 'var(--joy-palette-danger-700)',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{COMMAND_LABELS[cmd.commandType] || cmd.commandType}
|
||||
</Typography>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='plain'
|
||||
color='danger'
|
||||
sx={{ fontSize: 11, py: 0, minHeight: 'unset', px: 0.5 }}
|
||||
onClick={() => handleDismissFailed(cmd.id)}
|
||||
>
|
||||
Dismiss
|
||||
</Button>
|
||||
</Box>
|
||||
{cmd.error && (
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{ color: 'var(--joy-palette-danger-500)', mt: 0.25 }}
|
||||
>
|
||||
{cmd.error}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
<Divider sx={{ my: 0.5 }} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* All clear */}
|
||||
{pendingCount === 0 && failedCount === 0 && (
|
||||
<Box
|
||||
sx={{
|
||||
px: 1,
|
||||
py: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<CheckCircleOutline
|
||||
sx={{ fontSize: 16, color: 'var(--joy-palette-success-500)' }}
|
||||
/>
|
||||
<Typography
|
||||
level='body-sm'
|
||||
sx={{ color: 'var(--joy-palette-text-secondary)' }}
|
||||
>
|
||||
All changes synced
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Next retry / offline hint */}
|
||||
{isOnline && !syncState.syncing && pendingCount > 0 && (
|
||||
<Box sx={{ px: 1, pb: 0.5 }}>
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{ color: 'var(--joy-palette-text-tertiary)' }}
|
||||
>
|
||||
Next auto-sync in {retryIn}s
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
{!isOnline && (
|
||||
<Box sx={{ px: 1, pb: 0.5 }}>
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{ color: 'var(--joy-palette-text-tertiary)' }}
|
||||
>
|
||||
Will sync when back online
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Divider sx={{ my: 0.5 }} />
|
||||
|
||||
{/* Sync Now — must be a MenuItem so Menu doesn't swallow the click */}
|
||||
<MenuItem
|
||||
disabled={syncState.syncing}
|
||||
onClick={handleForceSync}
|
||||
sx={{
|
||||
borderRadius: 'var(--joy-radius-sm)',
|
||||
'&:hover': {
|
||||
backgroundColor: 'var(--joy-palette-neutral-softHoverBg)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ListItemDecorator>
|
||||
{syncState.syncing ? (
|
||||
<CircularProgress size='sm' />
|
||||
) : (
|
||||
<Refresh sx={{ fontSize: 18 }} />
|
||||
)}
|
||||
</ListItemDecorator>
|
||||
<Typography level='body-sm' sx={{ fontWeight: 500 }}>
|
||||
{syncState.syncing ? 'Syncing...' : 'Sync Now'}
|
||||
</Typography>
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</Dropdown>
|
||||
)
|
||||
}
|
||||
|
||||
export default SyncStatusIndicator
|
||||
Reference in New Issue
Block a user