'use client' import { useState, useEffect, useCallback, useRef } from 'react' /** * Represents a queued mutation to be synced when online */ export interface QueuedMutation { id: string type: 'create' | 'update' | 'delete' entity: 'list' | 'item' data: any timestamp: number retries: number } /** * Hook for managing offline synchronization * Detects connectivity, queues mutations, and syncs when online * Validates: Requirements 4.5 */ export function useOfflineSync() { const [isOnline, setIsOnline] = useState(true) const [isSyncing, setIsSyncing] = useState(false) const [syncError, setSyncError] = useState(null) const [queueLength, setQueueLength] = useState(0) const syncInProgressRef = useRef(false) // Initialize online status useEffect(() => { if (typeof window !== 'undefined') { setIsOnline(navigator.onLine) } }, []) // Load queue length from localStorage const loadQueueLength = useCallback(() => { if (typeof window === 'undefined') return 0 try { const queue = localStorage.getItem('mutation-queue') if (queue) { const parsed = JSON.parse(queue) as QueuedMutation[] return parsed.length } } catch (error) { console.error('Failed to load queue length:', error) } return 0 }, []) // Update queue length on mount and when syncing useEffect(() => { setQueueLength(loadQueueLength()) // Listen for mutation-queued events const handleMutationQueued = () => { setQueueLength(loadQueueLength()) } window.addEventListener('mutation-queued', handleMutationQueued) return () => { window.removeEventListener('mutation-queued', handleMutationQueued) } }, [loadQueueLength, isSyncing]) /** * Add a mutation to the queue */ const queueMutation = useCallback((mutation: Omit) => { if (typeof window === 'undefined') return try { const queue = localStorage.getItem('mutation-queue') const mutations: QueuedMutation[] = queue ? JSON.parse(queue) : [] const newMutation: QueuedMutation = { ...mutation, id: `${Date.now()}-${Math.random().toString(36).substring(2, 11)}`, timestamp: Date.now(), retries: 0, } mutations.push(newMutation) localStorage.setItem('mutation-queue', JSON.stringify(mutations)) setQueueLength(mutations.length) console.log('Mutation queued:', newMutation) } catch (error) { console.error('Failed to queue mutation:', error) } }, []) /** * Process the mutation queue and sync with server */ const syncQueue = useCallback(async () => { if (typeof window === 'undefined') return if (!isOnline) return if (syncInProgressRef.current) return syncInProgressRef.current = true setIsSyncing(true) setSyncError(null) try { const queue = localStorage.getItem('mutation-queue') if (!queue) { setIsSyncing(false) syncInProgressRef.current = false return } const mutations: QueuedMutation[] = JSON.parse(queue) if (mutations.length === 0) { setIsSyncing(false) syncInProgressRef.current = false return } console.log(`Syncing ${mutations.length} mutations...`) const failedMutations: QueuedMutation[] = [] // Process mutations sequentially to maintain order for (const mutation of mutations) { try { await processMutation(mutation) console.log('Mutation synced:', mutation.id) } catch (error) { console.error('Failed to sync mutation:', mutation.id, error) // Retry logic: keep in queue if retries < 3 if (mutation.retries < 3) { failedMutations.push({ ...mutation, retries: mutation.retries + 1, }) } else { console.error('Mutation failed after 3 retries, discarding:', mutation.id) } } } // Update queue with failed mutations if (failedMutations.length > 0) { localStorage.setItem('mutation-queue', JSON.stringify(failedMutations)) setQueueLength(failedMutations.length) setSyncError(new Error(`${failedMutations.length} mutations failed to sync`)) } else { localStorage.removeItem('mutation-queue') setQueueLength(0) } console.log('Sync complete') } catch (error) { console.error('Sync failed:', error) setSyncError(error instanceof Error ? error : new Error('Sync failed')) } finally { setIsSyncing(false) syncInProgressRef.current = false } }, [isOnline]) /** * Process a single mutation by calling the appropriate server action * Handles conflicts gracefully by catching errors */ const processMutation = async (mutation: QueuedMutation): Promise => { const { type, entity, data } = mutation // Import actions dynamically to avoid circular dependencies const actions = await import('@/lib/actions') try { if (entity === 'list') { if (type === 'create') { await actions.createList(data.title, data.color) } else if (type === 'update') { await actions.updateList(data.id, data) } else if (type === 'delete') { await actions.deleteList(data.id) } } else if (entity === 'item') { if (type === 'create') { await actions.createItem( data.listId, data.title, data.description, data.metadata ) } else if (type === 'update') { await actions.updateItem(data.id, data) } else if (type === 'delete') { await actions.deleteItem(data.id) } } } catch (error) { // Handle conflicts gracefully // If the resource doesn't exist (404), we can skip this mutation if (error instanceof Error && error.message.includes('not found')) { console.warn('Resource not found, skipping mutation:', mutation.id) return // Don't throw, just skip this mutation } // For other errors, re-throw to trigger retry logic throw error } } /** * Manually trigger sync */ const triggerSync = useCallback(() => { if (isOnline) { syncQueue() } }, [isOnline, syncQueue]) /** * Auto-sync when coming back online */ useEffect(() => { if (isOnline && queueLength > 0) { // Small delay to ensure connection is stable const timer = setTimeout(() => { syncQueue() }, 1000) return () => clearTimeout(timer) } }, [isOnline, queueLength, syncQueue]) /** * Listen for online/offline events and service worker messages */ useEffect(() => { const handleOnline = () => { setIsOnline(true) setSyncError(null) // Register background sync with service worker if ('serviceWorker' in navigator && navigator.serviceWorker.controller) { navigator.serviceWorker.controller.postMessage({ type: 'REGISTER_SYNC', }) } } const handleOffline = () => { setIsOnline(false) } window.addEventListener('online', handleOnline) window.addEventListener('offline', handleOffline) // Listen for sync messages from service worker const handleMessage = (event: MessageEvent) => { if (event.data && event.data.type === 'SYNC_MUTATIONS') { syncQueue() } } if ('serviceWorker' in navigator) { navigator.serviceWorker.addEventListener('message', handleMessage) } return () => { window.removeEventListener('online', handleOnline) window.removeEventListener('offline', handleOffline) if ('serviceWorker' in navigator) { navigator.serviceWorker.removeEventListener('message', handleMessage) } } }, [syncQueue]) /** * Clear the queue (for testing or manual intervention) */ const clearQueue = useCallback(() => { if (typeof window === 'undefined') return localStorage.removeItem('mutation-queue') setQueueLength(0) setSyncError(null) }, []) return { isOnline, isSyncing, syncError, queueLength, queueMutation, triggerSync, clearQueue, } }