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() )} {/* Header */} {isOnline ? 'Online' : 'Offline'} {syncState.syncing && ( Syncing... )} Last sync: {formatTime(syncState.lastSync)} {!isOnline && offlineSince && ( Offline since {formatOfflineDuration(offlineSince)} )} {syncState.error && ( Error: {syncState.error} )} {/* Pending actions */} {groupedPending.length > 0 && ( <> Pending ({pendingCount}) {groupedPending.map(([type, count]) => ( {formatCommandLabel(type)} {count} ))} )} {/* Failed actions */} {failedCommands.length > 0 && ( <> Failed ({failedCount}) {failedCommands.map(cmd => ( {formatCommandLabel(cmd.commandType)} {cmd.error && ( {cmd.error} )} ))} )} {/* All clear */} {pendingCount === 0 && failedCount === 0 && ( All changes synced )} {/* Next retry / offline hint */} {isOnline && !syncState.syncing && pendingCount > 0 && ( Next auto-sync in {retryIn}s )} {!isOnline && offlineReason === 'server' && ( {syncState.syncing ? 'Checking server...' : `Retrying in ${retryIn}s`} )} {!isOnline && offlineReason !== 'server' && ( Will sync when back online )} Cancel All {/* Sync Now — must be a MenuItem so Menu doesn't swallow the click */} {syncState.syncing ? ( ) : ( )} {syncState.syncing ? 'Syncing...' : 'Sync Now'} ) } export default SyncStatusIndicator