feat: Implement offline-first Redux architecture with optimistic updates
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>
This commit is contained in:
129
maternal-web/store/middleware/syncMiddleware.ts
Normal file
129
maternal-web/store/middleware/syncMiddleware.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user