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:
92
maternal-web/store/middleware/offlineMiddleware.ts
Normal file
92
maternal-web/store/middleware/offlineMiddleware.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { Middleware } from '@reduxjs/toolkit';
|
||||
import { RootState } from '../store';
|
||||
import { setOnlineStatus } from '../slices/networkSlice';
|
||||
import { addPendingAction, removePendingAction, incrementRetryCount } from '../slices/offlineSlice';
|
||||
|
||||
/**
|
||||
* Offline middleware - intercepts actions and queues them when offline
|
||||
*/
|
||||
export const offlineMiddleware: Middleware<{}, RootState> = (store) => (next) => (action: any) => {
|
||||
const state = store.getState();
|
||||
const isOnline = state.network?.isOnline ?? true;
|
||||
|
||||
// Check if this is an async action that should be queued
|
||||
const isOfflineableAction =
|
||||
action.type?.includes('/create') ||
|
||||
action.type?.includes('/update') ||
|
||||
action.type?.includes('/delete');
|
||||
|
||||
// If offline and action should be queued
|
||||
if (!isOnline && isOfflineableAction && action.meta?.requestId) {
|
||||
console.log('[Offline Middleware] Queuing action:', action.type);
|
||||
|
||||
// Queue the action
|
||||
store.dispatch(
|
||||
addPendingAction({
|
||||
type: action.type,
|
||||
payload: action.payload,
|
||||
})
|
||||
);
|
||||
|
||||
// Still process the optimistic update
|
||||
return next(action);
|
||||
}
|
||||
|
||||
// If online, process normally
|
||||
return next(action);
|
||||
};
|
||||
|
||||
/**
|
||||
* Network status detector - listens for online/offline events
|
||||
*/
|
||||
export const setupNetworkDetection = (dispatch: any) => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const handleOnline = () => {
|
||||
console.log('[Network] Connection restored');
|
||||
dispatch(setOnlineStatus(true));
|
||||
};
|
||||
|
||||
const handleOffline = () => {
|
||||
console.log('[Network] Connection lost');
|
||||
dispatch(setOnlineStatus(false));
|
||||
};
|
||||
|
||||
// Listen to browser online/offline events
|
||||
window.addEventListener('online', handleOnline);
|
||||
window.addEventListener('offline', handleOffline);
|
||||
|
||||
// Periodic connectivity check
|
||||
const checkConnectivity = async () => {
|
||||
try {
|
||||
const startTime = Date.now();
|
||||
const response = await fetch('/api/health', {
|
||||
method: 'HEAD',
|
||||
cache: 'no-cache',
|
||||
});
|
||||
const latency = Date.now() - startTime;
|
||||
|
||||
if (response.ok) {
|
||||
dispatch(setOnlineStatus(true));
|
||||
// You could also dispatch latency here
|
||||
} else {
|
||||
dispatch(setOnlineStatus(false));
|
||||
}
|
||||
} catch (error) {
|
||||
dispatch(setOnlineStatus(false));
|
||||
}
|
||||
};
|
||||
|
||||
// Check connectivity every 30 seconds
|
||||
const intervalId = setInterval(checkConnectivity, 30000);
|
||||
|
||||
// Initial check
|
||||
checkConnectivity();
|
||||
|
||||
// Cleanup function
|
||||
return () => {
|
||||
window.removeEventListener('online', handleOnline);
|
||||
window.removeEventListener('offline', handleOffline);
|
||||
clearInterval(intervalId);
|
||||
};
|
||||
};
|
||||
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