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
if (!isOnline) return
if (failedCount > 0)
return
if (pendingCount > 0)
return
return
}
if (!offlineFeatureEnabled) return null
return (
{syncState.syncing && (
)}
{totalBadge > 0 ? (
0 ? 'danger' : 'warning'}
sx={{
'& .MuiBadge-badge': {
fontSize: 9,
minWidth: 16,
height: 16,
},
}}
>
{getStatusIcon()}
) : (
getStatusIcon()
)}
)
}
export default SyncStatusIndicator