540 lines
16 KiB
JavaScript
540 lines
16 KiB
JavaScript
import {
|
|
CheckCircleOutline,
|
|
ClearAll,
|
|
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',
|
|
update_chore_history: 'Edit history',
|
|
complete_chore: 'Complete chore',
|
|
skip_chore: 'Skip chore',
|
|
start_chore: 'Start chore',
|
|
pause_chore: 'Pause chore',
|
|
delete_chore: 'Delete chore',
|
|
delete_chore_history: 'Delete history',
|
|
reschedule_chore: 'Reschedule chore',
|
|
archive_chore: 'Archive chore',
|
|
unarchive_chore: 'Restore chore',
|
|
}
|
|
|
|
const formatCommandLabel = commandType => {
|
|
return (
|
|
COMMAND_LABELS[commandType] ||
|
|
commandType
|
|
?.replace(/_/g, ' ')
|
|
?.replace(/\b\w/g, letter => letter.toUpperCase()) ||
|
|
'Pending action'
|
|
)
|
|
}
|
|
|
|
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 [offlineReason, setOfflineReason] = useState(networkManager.offlineReason)
|
|
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)
|
|
setOfflineReason(networkManager.offlineReason)
|
|
if (!online) setOfflineSince(networkManager.offlineSince)
|
|
})
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
// Run countdown both when online (pending commands) and when server-unreachable (probe interval)
|
|
if (syncState.syncing) return
|
|
if (isOnline && pendingCommands.length === 0) return
|
|
if (!isOnline && offlineReason === 'device') return
|
|
const interval = setInterval(() => {
|
|
setRetryIn(prev => (prev <= 1 ? RETRY_INTERVAL : prev - 1))
|
|
}, 1000)
|
|
return () => clearInterval(interval)
|
|
}, [isOnline, offlineReason, syncState.syncing, syncState.lastSync, pendingCommands.length])
|
|
|
|
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 handleCancelAll = async () => {
|
|
const [pending, failed] = await Promise.all([
|
|
commandQueue.getPending(),
|
|
commandQueue.getFailed(),
|
|
])
|
|
const allCommands = [...pending, ...failed]
|
|
await Promise.all(allCommands.map(cmd => commandQueue.cancel(cmd.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
|
|
aria-label='Open sync and network status'
|
|
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'>
|
|
{formatCommandLabel(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,
|
|
}}
|
|
>
|
|
{formatCommandLabel(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 && offlineReason === 'server' && (
|
|
<Box sx={{ px: 1, pb: 0.5 }}>
|
|
<Typography
|
|
level='body-xs'
|
|
sx={{ color: 'var(--joy-palette-text-tertiary)' }}
|
|
>
|
|
{syncState.syncing
|
|
? 'Checking server...'
|
|
: `Retrying in ${retryIn}s`}
|
|
</Typography>
|
|
</Box>
|
|
)}
|
|
{!isOnline && offlineReason !== 'server' && (
|
|
<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 }} />
|
|
|
|
<MenuItem
|
|
disabled={syncState.syncing || totalBadge === 0}
|
|
onClick={handleCancelAll}
|
|
sx={{
|
|
borderRadius: 'var(--joy-radius-sm)',
|
|
'&:hover': {
|
|
backgroundColor: 'var(--joy-palette-neutral-softHoverBg)',
|
|
},
|
|
}}
|
|
>
|
|
<ListItemDecorator>
|
|
<ClearAll sx={{ fontSize: 18 }} />
|
|
</ListItemDecorator>
|
|
<Typography level='body-sm' sx={{ fontWeight: 500 }}>
|
|
Cancel All
|
|
</Typography>
|
|
</MenuItem>
|
|
|
|
{/* 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
|