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:
286
maternal-web/store/README.md
Normal file
286
maternal-web/store/README.md
Normal file
@@ -0,0 +1,286 @@
|
||||
# Offline-First Redux Architecture
|
||||
|
||||
This directory contains the offline-first Redux store implementation for the Maternal App.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
The store is built with:
|
||||
- **Redux Toolkit** - Modern Redux with less boilerplate
|
||||
- **@redux-offline/redux-offline** - Offline-first middleware
|
||||
- **redux-persist** - State persistence to IndexedDB
|
||||
- **Entity Adapters** - Normalized state management
|
||||
|
||||
## Key Features
|
||||
|
||||
### 1. Normalized State
|
||||
All entities (activities, children) are stored in a normalized format with `byId` and `allIds` structures for optimal performance and consistency.
|
||||
|
||||
### 2. Optimistic Updates
|
||||
Actions are immediately applied to the UI, then synced with the server:
|
||||
```typescript
|
||||
// Component usage
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
// Optimistic create - UI updates immediately
|
||||
dispatch(optimisticCreate(newActivity));
|
||||
// Actual API call - syncs with server
|
||||
dispatch(createActivity(newActivity));
|
||||
```
|
||||
|
||||
### 3. Offline Queue
|
||||
When offline, mutations are queued and automatically synced when connection is restored:
|
||||
- Automatic exponential backoff retry (1s, 2s, 4s, 8s, 16s)
|
||||
- Discard after 5 retries or on 4xx errors
|
||||
- Persisted to IndexedDB
|
||||
|
||||
### 4. Conflict Resolution
|
||||
Multiple strategies available:
|
||||
- `SERVER_WINS` - Server data takes precedence
|
||||
- `CLIENT_WINS` - Local data takes precedence
|
||||
- `LAST_WRITE_WINS` - Compare timestamps (default)
|
||||
- `MERGE` - Merge local and server data
|
||||
|
||||
### 5. Network Detection
|
||||
- Browser online/offline events
|
||||
- Periodic server connectivity checks (every 30s)
|
||||
- Latency-based connection quality (`excellent`, `good`, `poor`, `offline`)
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
store/
|
||||
├── store.ts # Main store configuration
|
||||
├── hooks.ts # Custom Redux hooks
|
||||
├── slices/ # Redux slices
|
||||
│ ├── activitiesSlice.ts # Activities with normalized state
|
||||
│ ├── childrenSlice.ts # Children with normalized state
|
||||
│ ├── networkSlice.ts # Network status
|
||||
│ └── offlineSlice.ts # Offline sync queue
|
||||
└── middleware/ # Custom middleware
|
||||
├── offlineMiddleware.ts # Offline action queuing
|
||||
└── syncMiddleware.ts # Sync and conflict resolution
|
||||
```
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Basic Component Usage
|
||||
|
||||
```typescript
|
||||
import { useAppDispatch, useAppSelector, useIsOnline } from '@/store/hooks';
|
||||
import { createActivity, optimisticCreate } from '@/store/slices/activitiesSlice';
|
||||
|
||||
function TrackingComponent() {
|
||||
const dispatch = useAppDispatch();
|
||||
const isOnline = useIsOnline();
|
||||
const activities = useAppSelector(activitiesSelectors.selectAll);
|
||||
|
||||
const handleAddActivity = async (activity) => {
|
||||
// 1. Optimistic update (immediate UI feedback)
|
||||
const localId = `temp_${Date.now()}`;
|
||||
dispatch(optimisticCreate({ ...activity, id: localId }));
|
||||
|
||||
// 2. Actual API call (will be queued if offline)
|
||||
try {
|
||||
await dispatch(createActivity(activity));
|
||||
} catch (error) {
|
||||
// Rollback handled automatically
|
||||
console.error('Failed to create activity:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{!isOnline && <p>You are offline. Changes will sync when you reconnect.</p>}
|
||||
{/* ... */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Using Custom Hooks
|
||||
|
||||
```typescript
|
||||
import { useOptimisticAction, useSyncStatus, useHasPendingSync } from '@/store/hooks';
|
||||
|
||||
function MyComponent() {
|
||||
// Check sync status
|
||||
const { syncing, pendingCount, lastSync } = useSyncStatus();
|
||||
const hasPending = useHasPendingSync();
|
||||
|
||||
// Optimistic action hook (combines optimistic + actual)
|
||||
const handleCreate = useOptimisticAction(
|
||||
optimisticCreate,
|
||||
createActivity
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{syncing && <p>Syncing {pendingCount} changes...</p>}
|
||||
<button onClick={() => handleCreate(newActivity)}>
|
||||
Add Activity
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Network Status Indicator
|
||||
|
||||
```typescript
|
||||
import { NetworkStatusIndicator, NetworkStatusBadge } from '@/components/common/NetworkStatusIndicator';
|
||||
|
||||
function AppLayout() {
|
||||
return (
|
||||
<>
|
||||
<NetworkStatusIndicator /> {/* Snackbar at top */}
|
||||
<AppBar>
|
||||
<NetworkStatusBadge /> {/* Small badge in app bar */}
|
||||
</AppBar>
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Checking if Entity is Optimistic
|
||||
|
||||
```typescript
|
||||
import { useIsOptimistic } from '@/store/hooks';
|
||||
|
||||
function ActivityCard({ activityId }) {
|
||||
const isOptimistic = useIsOptimistic('activities', activityId);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
{isOptimistic && (
|
||||
<Chip label="Syncing..." size="small" />
|
||||
)}
|
||||
{/* ... */}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## State Shape
|
||||
|
||||
```typescript
|
||||
{
|
||||
activities: {
|
||||
ids: ['act_1', 'act_2', 'temp_123'],
|
||||
entities: {
|
||||
'act_1': {
|
||||
id: 'act_1',
|
||||
childId: 'child_1',
|
||||
type: 'feeding',
|
||||
timestamp: '2025-10-01T12:00:00Z',
|
||||
data: { amount: 120, unit: 'ml' },
|
||||
_optimistic: false,
|
||||
_version: 1
|
||||
},
|
||||
'temp_123': {
|
||||
id: 'temp_123',
|
||||
childId: 'child_1',
|
||||
type: 'sleep',
|
||||
timestamp: '2025-10-01T14:00:00Z',
|
||||
data: { duration: 90 },
|
||||
_optimistic: true, // Not yet synced
|
||||
_localId: 'temp_123',
|
||||
_version: 1
|
||||
}
|
||||
},
|
||||
loading: false,
|
||||
error: null,
|
||||
syncStatus: 'synced',
|
||||
lastSyncTime: '2025-10-01T14:05:00Z'
|
||||
},
|
||||
children: { /* similar structure */ },
|
||||
network: {
|
||||
isOnline: true,
|
||||
isConnected: true,
|
||||
lastOnlineTime: '2025-10-01T14:00:00Z',
|
||||
connectionQuality: 'excellent',
|
||||
latency: 45
|
||||
},
|
||||
offline: {
|
||||
isOnline: true,
|
||||
pendingActions: [],
|
||||
lastSyncTime: '2025-10-01T14:05:00Z',
|
||||
syncInProgress: false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Metadata Fields
|
||||
|
||||
All entities have optional metadata fields for offline support:
|
||||
|
||||
- `_optimistic` (boolean) - True if not yet synced with server
|
||||
- `_localId` (string) - Temporary ID before server assigns real ID
|
||||
- `_version` (number) - Version counter for conflict detection
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Always use optimistic updates** for write operations (create, update, delete)
|
||||
2. **Use entity selectors** instead of accessing state directly
|
||||
3. **Handle offline state** in your UI
|
||||
4. **Show sync indicators** to users
|
||||
5. **Test offline scenarios** thoroughly
|
||||
6. **Use normalized state** for related entities
|
||||
7. **Implement proper error handling** and rollback logic
|
||||
|
||||
## Testing Offline Behavior
|
||||
|
||||
### In Chrome DevTools:
|
||||
1. Open DevTools → Network tab
|
||||
2. Change "Online" dropdown to "Offline"
|
||||
3. Try creating/updating activities
|
||||
4. Go back "Online"
|
||||
5. Watch automatic sync happen
|
||||
|
||||
### Programmatically:
|
||||
```typescript
|
||||
// Simulate offline
|
||||
dispatch(setOnlineStatus(false));
|
||||
|
||||
// Simulate online
|
||||
dispatch(setOnlineStatus(true));
|
||||
```
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
- **IndexedDB** used for persistence (faster than localStorage)
|
||||
- **Entity adapters** provide memoized selectors
|
||||
- **Normalized state** prevents duplication
|
||||
- **Selective persistence** (blacklist for sensitive data)
|
||||
- **Batched updates** when syncing multiple actions
|
||||
|
||||
## Security Notes
|
||||
|
||||
- Sensitive data can be excluded from persistence using `persistOptions.blacklist`
|
||||
- Auth tokens should NOT be stored in Redux (use secure cookie or AuthContext)
|
||||
- Offline queue is cleared on logout
|
||||
- All API calls still require valid authentication
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Actions not being queued
|
||||
- Check if action type includes '/create', '/update', or '/delete'
|
||||
- Ensure `meta.requestId` exists on the action
|
||||
|
||||
### Sync not happening
|
||||
- Check browser console for network errors
|
||||
- Verify API endpoints are correct
|
||||
- Check if `isOnline` state is accurate
|
||||
|
||||
### State not persisting
|
||||
- Check browser IndexedDB (DevTools → Application → IndexedDB)
|
||||
- Verify localforage is configured correctly
|
||||
- Check for storage quota issues
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
- [ ] Batch sync for multiple actions
|
||||
- [ ] Conflict UI for user resolution
|
||||
- [ ] Sync progress indicators
|
||||
- [ ] Selective sync (by entity type)
|
||||
- [ ] Background sync (Service Workers)
|
||||
- [ ] Delta sync (only changed fields)
|
||||
Reference in New Issue
Block a user