Files
maternal-app/maternal-web/store/middleware/syncMiddleware.ts
Andrei 7cb2ff97de
Some checks failed
CI/CD Pipeline / Lint and Test (push) Has been cancelled
CI/CD Pipeline / E2E Tests (push) Has been cancelled
CI/CD Pipeline / Build Application (push) Has been cancelled
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>
2025-10-01 19:24:46 +00:00

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;
}