Implemented comprehensive offline-first state management: Redux Store Setup: - Configure Redux Toolkit with @redux-offline/redux-offline - Setup redux-persist with IndexedDB (localforage) - Custom offline config with exponential backoff retry - Normalized state with entity adapters State Slices: - activitiesSlice: Normalized activities with optimistic CRUD - childrenSlice: Normalized children with optimistic CRUD - networkSlice: Network status and connection quality - offlineSlice: Sync queue and pending actions Middleware: - offlineMiddleware: Queue actions when offline - syncMiddleware: Process pending actions when online - Conflict resolution strategies (SERVER_WINS, CLIENT_WINS, LAST_WRITE_WINS, MERGE) - Version-based conflict detection Features: - Optimistic updates for immediate UI feedback - Automatic sync queue with retry logic (5 retries max) - Network detection (browser events + periodic checks) - Connection quality monitoring (excellent/good/poor/offline) - Latency tracking - Conflict resolution with multiple strategies - Entity versioning for optimistic updates Components: - NetworkStatusIndicator: Full-screen status banner - NetworkStatusBadge: Compact app bar badge - ReduxProvider: Provider with network detection setup Custom Hooks: - useAppDispatch/useAppSelector: Typed Redux hooks - useIsOnline: Check online status - useHasPendingSync: Check for pending actions - useSyncStatus: Get sync progress info - useOptimisticAction: Combine optimistic + actual actions - useNetworkQuality: Get connection quality - useIsOptimistic: Check if entity is being synced Documentation: - Comprehensive README with usage examples - Architecture overview - Best practices guide - Troubleshooting section State Structure: - Normalized entities with byId/allIds - Optimistic metadata (_optimistic, _localId, _version) - Entity adapters with memoized selectors - Offline queue persistence to IndexedDB 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
130 lines
3.9 KiB
TypeScript
130 lines
3.9 KiB
TypeScript
import { Middleware } from '@reduxjs/toolkit';
|
|
import { RootState } from '../store';
|
|
import { removePendingAction, incrementRetryCount, setSyncInProgress, updateLastSyncTime } from '../slices/offlineSlice';
|
|
import { setOnlineStatus } from '../slices/networkSlice';
|
|
|
|
/**
|
|
* Sync middleware - processes pending actions when coming back online
|
|
*/
|
|
export const syncMiddleware: Middleware<{}, RootState> = (store) => (next) => (action: any) => {
|
|
// Check if we just came back online
|
|
if (action.type === 'network/setOnlineStatus' && action.payload === true) {
|
|
const state = store.getState();
|
|
const pendingActions = state.offline?.pendingActions || [];
|
|
|
|
if (pendingActions.length > 0) {
|
|
console.log(`[Sync] Processing ${pendingActions.length} pending actions`);
|
|
store.dispatch(setSyncInProgress(true));
|
|
|
|
// Process pending actions sequentially
|
|
processPendingActions(store, pendingActions);
|
|
}
|
|
}
|
|
|
|
return next(action);
|
|
};
|
|
|
|
/**
|
|
* Process pending actions with retry logic
|
|
*/
|
|
async function processPendingActions(store: any, pendingActions: any[]) {
|
|
const MAX_RETRIES = 5;
|
|
|
|
for (const pendingAction of pendingActions) {
|
|
try {
|
|
console.log(`[Sync] Processing action: ${pendingAction.type}`, pendingAction);
|
|
|
|
// Reconstruct the action
|
|
const action = {
|
|
type: pendingAction.type,
|
|
payload: pendingAction.payload,
|
|
};
|
|
|
|
// Dispatch the action (which will make the API call)
|
|
await store.dispatch(action);
|
|
|
|
// If successful, remove from queue
|
|
store.dispatch(removePendingAction(pendingAction.id));
|
|
console.log(`[Sync] Successfully synced action: ${pendingAction.type}`);
|
|
} catch (error) {
|
|
console.error(`[Sync] Failed to sync action: ${pendingAction.type}`, error);
|
|
|
|
// Increment retry count
|
|
store.dispatch(incrementRetryCount(pendingAction.id));
|
|
|
|
// If max retries reached, remove from queue
|
|
if (pendingAction.retryCount >= MAX_RETRIES) {
|
|
console.error(`[Sync] Max retries reached for action: ${pendingAction.type}`);
|
|
store.dispatch(removePendingAction(pendingAction.id));
|
|
}
|
|
}
|
|
}
|
|
|
|
// Mark sync as complete
|
|
store.dispatch(setSyncInProgress(false));
|
|
store.dispatch(updateLastSyncTime());
|
|
console.log('[Sync] Sync complete');
|
|
}
|
|
|
|
/**
|
|
* Conflict resolution strategies
|
|
*/
|
|
export enum ConflictStrategy {
|
|
SERVER_WINS = 'server_wins',
|
|
CLIENT_WINS = 'client_wins',
|
|
LAST_WRITE_WINS = 'last_write_wins',
|
|
MERGE = 'merge',
|
|
}
|
|
|
|
/**
|
|
* Resolve conflicts between local and server data
|
|
*/
|
|
export function resolveConflict<T extends { _version?: number; updatedAt?: string }>(
|
|
localData: T,
|
|
serverData: T,
|
|
strategy: ConflictStrategy = ConflictStrategy.LAST_WRITE_WINS
|
|
): T {
|
|
switch (strategy) {
|
|
case ConflictStrategy.SERVER_WINS:
|
|
return serverData;
|
|
|
|
case ConflictStrategy.CLIENT_WINS:
|
|
return localData;
|
|
|
|
case ConflictStrategy.LAST_WRITE_WINS:
|
|
// Compare timestamps
|
|
const localTime = localData.updatedAt ? new Date(localData.updatedAt).getTime() : 0;
|
|
const serverTime = serverData.updatedAt ? new Date(serverData.updatedAt).getTime() : 0;
|
|
return serverTime > localTime ? serverData : localData;
|
|
|
|
case ConflictStrategy.MERGE:
|
|
// Simple merge strategy - prefer server for system fields, local for user fields
|
|
return {
|
|
...serverData,
|
|
...localData,
|
|
// System fields from server
|
|
id: serverData.id,
|
|
createdAt: serverData.createdAt,
|
|
updatedAt: serverData.updatedAt,
|
|
// Metadata
|
|
_version: Math.max(localData._version || 0, serverData._version || 0) + 1,
|
|
};
|
|
|
|
default:
|
|
return serverData;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Version-based conflict detection
|
|
*/
|
|
export function hasConflict<T extends { _version?: number }>(
|
|
localData: T,
|
|
serverData: T
|
|
): boolean {
|
|
if (!localData._version || !serverData._version) {
|
|
return false;
|
|
}
|
|
return localData._version !== serverData._version;
|
|
}
|