feat: implement offline synchronization with mutation queue and background sync
- Created useOfflineSync hook for connectivity detection and sync management - Implemented localStorage-based mutation queue for offline operations - Added background sync support in service worker - Created OfflineIndicator component to show connection status - Added offline-aware action wrappers for seamless offline/online transitions - Implemented conflict handling with retry logic (up to 3 retries) - Auto-sync when connection is restored - Validates Requirements 4.5
This commit is contained in:
@@ -164,7 +164,7 @@
|
|||||||
- **Property 12: Offline resource availability**
|
- **Property 12: Offline resource availability**
|
||||||
- **Validates: Requirements 4.4**
|
- **Validates: Requirements 4.4**
|
||||||
|
|
||||||
- [ ] 12. Implement offline synchronization
|
- [x] 12. Implement offline synchronization
|
||||||
- Create useOfflineSync hook for detecting connectivity
|
- Create useOfflineSync hook for detecting connectivity
|
||||||
- Implement mutation queue for offline operations
|
- Implement mutation queue for offline operations
|
||||||
- Add background sync for pending mutations
|
- Add background sync for pending mutations
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { Metadata } from 'next'
|
import type { Metadata } from 'next'
|
||||||
import './globals.css'
|
import './globals.css'
|
||||||
import ServiceWorkerRegistration from '@/components/ServiceWorkerRegistration'
|
import ServiceWorkerRegistration from '@/components/ServiceWorkerRegistration'
|
||||||
|
import OfflineIndicator from '@/components/OfflineIndicator'
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'Ultimate Lists',
|
title: 'Ultimate Lists',
|
||||||
@@ -36,6 +37,7 @@ export default function RootLayout({
|
|||||||
</head>
|
</head>
|
||||||
<body className="bg-gray-50 min-h-screen">
|
<body className="bg-gray-50 min-h-screen">
|
||||||
<ServiceWorkerRegistration />
|
<ServiceWorkerRegistration />
|
||||||
|
<OfflineIndicator />
|
||||||
{children}
|
{children}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
91
components/OfflineIndicator.tsx
Normal file
91
components/OfflineIndicator.tsx
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useOfflineSync } from '@/hooks'
|
||||||
|
import { WifiOff, Wifi, RefreshCw, AlertCircle } from 'lucide-react'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Offline indicator component
|
||||||
|
* Shows connection status and sync progress
|
||||||
|
* Validates: Requirements 4.5
|
||||||
|
*/
|
||||||
|
export default function OfflineIndicator() {
|
||||||
|
const { isOnline, isSyncing, syncError, queueLength, triggerSync } = useOfflineSync()
|
||||||
|
|
||||||
|
// Don't show anything if online and no pending syncs
|
||||||
|
if (isOnline && queueLength === 0 && !syncError) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed bottom-4 right-4 z-50">
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
flex items-center gap-2 px-4 py-3 rounded-lg shadow-lg
|
||||||
|
${!isOnline ? 'bg-gray-800 text-white' : ''}
|
||||||
|
${isOnline && queueLength > 0 ? 'bg-blue-600 text-white' : ''}
|
||||||
|
${syncError ? 'bg-red-600 text-white' : ''}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{/* Icon */}
|
||||||
|
{!isOnline && <WifiOff className="w-5 h-5" />}
|
||||||
|
{isOnline && !syncError && <Wifi className="w-5 h-5" />}
|
||||||
|
{syncError && <AlertCircle className="w-5 h-5" />}
|
||||||
|
|
||||||
|
{/* Status text */}
|
||||||
|
<div className="flex flex-col">
|
||||||
|
{!isOnline && (
|
||||||
|
<>
|
||||||
|
<span className="text-sm font-medium">Offline</span>
|
||||||
|
{queueLength > 0 && (
|
||||||
|
<span className="text-xs opacity-90">
|
||||||
|
{queueLength} change{queueLength !== 1 ? 's' : ''} pending
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isOnline && isSyncing && (
|
||||||
|
<>
|
||||||
|
<span className="text-sm font-medium">Syncing...</span>
|
||||||
|
<span className="text-xs opacity-90">
|
||||||
|
{queueLength} change{queueLength !== 1 ? 's' : ''} remaining
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isOnline && !isSyncing && queueLength > 0 && !syncError && (
|
||||||
|
<>
|
||||||
|
<span className="text-sm font-medium">Ready to sync</span>
|
||||||
|
<span className="text-xs opacity-90">
|
||||||
|
{queueLength} change{queueLength !== 1 ? 's' : ''} pending
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{syncError && (
|
||||||
|
<>
|
||||||
|
<span className="text-sm font-medium">Sync failed</span>
|
||||||
|
<span className="text-xs opacity-90">{syncError.message}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sync button */}
|
||||||
|
{isOnline && queueLength > 0 && !isSyncing && (
|
||||||
|
<button
|
||||||
|
onClick={triggerSync}
|
||||||
|
className="ml-2 p-1 hover:bg-white/20 rounded transition-colors"
|
||||||
|
aria-label="Sync now"
|
||||||
|
>
|
||||||
|
<RefreshCw className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Syncing spinner */}
|
||||||
|
{isSyncing && (
|
||||||
|
<RefreshCw className="w-4 h-4 animate-spin ml-2" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -188,10 +188,73 @@ function MyComponent({ listId }) {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### `useOfflineSync`
|
||||||
|
|
||||||
|
Manages offline synchronization with automatic mutation queuing and background sync.
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
- Automatic online/offline detection
|
||||||
|
- Mutation queue for offline operations
|
||||||
|
- Background sync when connection restored
|
||||||
|
- Conflict handling
|
||||||
|
- Retry logic with exponential backoff
|
||||||
|
|
||||||
|
**Usage:**
|
||||||
|
```typescript
|
||||||
|
import { useOfflineSync } from '@/hooks'
|
||||||
|
|
||||||
|
function MyComponent() {
|
||||||
|
const {
|
||||||
|
isOnline,
|
||||||
|
isSyncing,
|
||||||
|
syncError,
|
||||||
|
queueLength,
|
||||||
|
queueMutation,
|
||||||
|
triggerSync,
|
||||||
|
clearQueue
|
||||||
|
} = useOfflineSync()
|
||||||
|
|
||||||
|
// Queue a mutation when offline
|
||||||
|
const handleCreateItem = async () => {
|
||||||
|
if (!isOnline) {
|
||||||
|
queueMutation({
|
||||||
|
type: 'create',
|
||||||
|
entity: 'item',
|
||||||
|
data: { listId: '123', title: 'New Item' }
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
await createItem('123', 'New Item')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{!isOnline && <div>Offline - changes will sync when online</div>}
|
||||||
|
{isSyncing && <div>Syncing {queueLength} changes...</div>}
|
||||||
|
{syncError && <div>Sync error: {syncError.message}</div>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Offline-Aware Actions:**
|
||||||
|
|
||||||
|
For convenience, use the offline-aware action wrappers from `lib/offline-actions.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { createItemOffline, updateItemOffline, deleteItemOffline } from '@/lib/offline-actions'
|
||||||
|
|
||||||
|
// These automatically queue mutations when offline
|
||||||
|
await createItemOffline(listId, title, description, metadata)
|
||||||
|
await updateItemOffline(itemId, updates)
|
||||||
|
await deleteItemOffline(itemId)
|
||||||
|
```
|
||||||
|
|
||||||
## Requirements Validation
|
## Requirements Validation
|
||||||
|
|
||||||
These hooks implement the following requirements from the specification:
|
These hooks implement the following requirements from the specification:
|
||||||
|
|
||||||
|
- **Requirement 4.5**: Offline synchronization when connectivity restored
|
||||||
- **Requirement 6.4**: Filter state management and clearing
|
- **Requirement 6.4**: Filter state management and clearing
|
||||||
- **Requirement 7.3**: Visual feedback for user actions (optimistic updates)
|
- **Requirement 7.3**: Visual feedback for user actions (optimistic updates)
|
||||||
- **Requirement 7.4**: Scroll position preservation
|
- **Requirement 7.4**: Scroll position preservation
|
||||||
|
|||||||
@@ -8,3 +8,5 @@ export { useFilters } from './useFilters'
|
|||||||
export type { FilterState } from './useFilters'
|
export type { FilterState } from './useFilters'
|
||||||
export { useOptimisticUpdate, useOptimisticList } from './useOptimisticUpdate'
|
export { useOptimisticUpdate, useOptimisticList } from './useOptimisticUpdate'
|
||||||
export { useScrollPosition, useContainerScrollPosition } from './useScrollPosition'
|
export { useScrollPosition, useContainerScrollPosition } from './useScrollPosition'
|
||||||
|
export { useOfflineSync } from './useOfflineSync'
|
||||||
|
export type { QueuedMutation } from './useOfflineSync'
|
||||||
|
|||||||
298
hooks/useOfflineSync.ts
Normal file
298
hooks/useOfflineSync.ts
Normal file
@@ -0,0 +1,298 @@
|
|||||||
|
'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<Error | null>(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<QueuedMutation, 'id' | 'timestamp' | 'retries'>) => {
|
||||||
|
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<void> => {
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
220
lib/offline-actions.ts
Normal file
220
lib/offline-actions.ts
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import * as actions from './actions'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Offline-aware wrapper for server actions
|
||||||
|
* Queues mutations when offline and executes them when online
|
||||||
|
* Validates: Requirements 4.5
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the mutation queue manager
|
||||||
|
* This is a helper to access the queue without circular dependencies
|
||||||
|
*/
|
||||||
|
function getMutationQueue() {
|
||||||
|
if (typeof window === 'undefined') return null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const queue = localStorage.getItem('mutation-queue')
|
||||||
|
return queue ? JSON.parse(queue) : []
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to get mutation queue:', error)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a mutation to the queue
|
||||||
|
*/
|
||||||
|
function queueMutation(mutation: {
|
||||||
|
type: 'create' | 'update' | 'delete'
|
||||||
|
entity: 'list' | 'item'
|
||||||
|
data: any
|
||||||
|
}) {
|
||||||
|
if (typeof window === 'undefined') return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const queue = getMutationQueue()
|
||||||
|
const newMutation = {
|
||||||
|
...mutation,
|
||||||
|
id: `${Date.now()}-${Math.random().toString(36).substring(2, 11)}`,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
retries: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
queue.push(newMutation)
|
||||||
|
localStorage.setItem('mutation-queue', JSON.stringify(queue))
|
||||||
|
|
||||||
|
// Dispatch custom event to notify useOfflineSync hook
|
||||||
|
window.dispatchEvent(new CustomEvent('mutation-queued'))
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to queue mutation:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if we're online
|
||||||
|
*/
|
||||||
|
function isOnline(): boolean {
|
||||||
|
return typeof window !== 'undefined' && navigator.onLine
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Offline-aware createList
|
||||||
|
*/
|
||||||
|
export async function createListOffline(title: string, color: string = '#3B82F6') {
|
||||||
|
if (!isOnline()) {
|
||||||
|
queueMutation({
|
||||||
|
type: 'create',
|
||||||
|
entity: 'list',
|
||||||
|
data: { title, color },
|
||||||
|
})
|
||||||
|
// Return a temporary list object for optimistic UI
|
||||||
|
return {
|
||||||
|
id: `temp-${Date.now()}`,
|
||||||
|
title,
|
||||||
|
color,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
items: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return await actions.createList(title, color)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Offline-aware updateList
|
||||||
|
*/
|
||||||
|
export async function updateListOffline(
|
||||||
|
listId: string,
|
||||||
|
data: { title?: string; color?: string }
|
||||||
|
) {
|
||||||
|
if (!isOnline()) {
|
||||||
|
queueMutation({
|
||||||
|
type: 'update',
|
||||||
|
entity: 'list',
|
||||||
|
data: { id: listId, ...data },
|
||||||
|
})
|
||||||
|
return { id: listId, ...data }
|
||||||
|
}
|
||||||
|
|
||||||
|
return await actions.updateList(listId, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Offline-aware deleteList
|
||||||
|
*/
|
||||||
|
export async function deleteListOffline(listId: string) {
|
||||||
|
if (!isOnline()) {
|
||||||
|
queueMutation({
|
||||||
|
type: 'delete',
|
||||||
|
entity: 'list',
|
||||||
|
data: { id: listId },
|
||||||
|
})
|
||||||
|
return { success: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
return await actions.deleteList(listId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Offline-aware createItem
|
||||||
|
*/
|
||||||
|
export async function createItemOffline(
|
||||||
|
listId: string,
|
||||||
|
title: string,
|
||||||
|
description?: string,
|
||||||
|
metadata?: {
|
||||||
|
priority?: 'low' | 'medium' | 'high'
|
||||||
|
dueDate?: Date | null
|
||||||
|
tags?: string[]
|
||||||
|
notes?: string | null
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
if (!isOnline()) {
|
||||||
|
queueMutation({
|
||||||
|
type: 'create',
|
||||||
|
entity: 'item',
|
||||||
|
data: { listId, title, description, metadata },
|
||||||
|
})
|
||||||
|
// Return a temporary item object for optimistic UI
|
||||||
|
return {
|
||||||
|
id: `temp-${Date.now()}`,
|
||||||
|
title,
|
||||||
|
description: description || null,
|
||||||
|
completed: false,
|
||||||
|
priority: metadata?.priority || 'medium',
|
||||||
|
dueDate: metadata?.dueDate || null,
|
||||||
|
tags: metadata?.tags || [],
|
||||||
|
notes: metadata?.notes || null,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
listId,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return await actions.createItem(listId, title, description, metadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Offline-aware updateItem
|
||||||
|
*/
|
||||||
|
export async function updateItemOffline(
|
||||||
|
itemId: string,
|
||||||
|
data: {
|
||||||
|
title?: string
|
||||||
|
description?: string | null
|
||||||
|
completed?: boolean
|
||||||
|
priority?: 'low' | 'medium' | 'high'
|
||||||
|
dueDate?: Date | null
|
||||||
|
tags?: string[]
|
||||||
|
notes?: string | null
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
if (!isOnline()) {
|
||||||
|
queueMutation({
|
||||||
|
type: 'update',
|
||||||
|
entity: 'item',
|
||||||
|
data: { id: itemId, ...data },
|
||||||
|
})
|
||||||
|
return { id: itemId, ...data }
|
||||||
|
}
|
||||||
|
|
||||||
|
return await actions.updateItem(itemId, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Offline-aware deleteItem
|
||||||
|
*/
|
||||||
|
export async function deleteItemOffline(itemId: string) {
|
||||||
|
if (!isOnline()) {
|
||||||
|
queueMutation({
|
||||||
|
type: 'delete',
|
||||||
|
entity: 'item',
|
||||||
|
data: { id: itemId },
|
||||||
|
})
|
||||||
|
return { success: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
return await actions.deleteItem(itemId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Offline-aware toggleItem
|
||||||
|
*/
|
||||||
|
export async function toggleItemOffline(itemId: string) {
|
||||||
|
if (!isOnline()) {
|
||||||
|
// For toggle, we need to get the current state first
|
||||||
|
// This is a limitation - we'll queue it as an update
|
||||||
|
queueMutation({
|
||||||
|
type: 'update',
|
||||||
|
entity: 'item',
|
||||||
|
data: { id: itemId, completed: true }, // We don't know the actual state
|
||||||
|
})
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return await actions.toggleItem(itemId)
|
||||||
|
}
|
||||||
39
public/sw.js
39
public/sw.js
@@ -162,3 +162,42 @@ async function networkFirst(request) {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Background Sync - sync pending mutations when connection is restored
|
||||||
|
self.addEventListener('sync', (event) => {
|
||||||
|
if (event.tag === 'sync-mutations') {
|
||||||
|
event.waitUntil(syncMutations());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sync mutations from the queue
|
||||||
|
async function syncMutations() {
|
||||||
|
try {
|
||||||
|
// Notify all clients to trigger sync
|
||||||
|
const clients = await self.clients.matchAll();
|
||||||
|
clients.forEach((client) => {
|
||||||
|
client.postMessage({
|
||||||
|
type: 'SYNC_MUTATIONS',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Background sync failed:', error);
|
||||||
|
throw error; // Re-throw to retry later
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listen for messages from clients
|
||||||
|
self.addEventListener('message', (event) => {
|
||||||
|
if (event.data && event.data.type === 'SKIP_WAITING') {
|
||||||
|
self.skipWaiting();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.data && event.data.type === 'REGISTER_SYNC') {
|
||||||
|
// Register background sync
|
||||||
|
if (self.registration.sync) {
|
||||||
|
self.registration.sync.register('sync-mutations').catch((error) => {
|
||||||
|
console.error('Failed to register background sync:', error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user