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:
281
maternal-web/store/slices/activitiesSlice.ts
Normal file
281
maternal-web/store/slices/activitiesSlice.ts
Normal file
@@ -0,0 +1,281 @@
|
||||
import { createSlice, createAsyncThunk, PayloadAction, createEntityAdapter } from '@reduxjs/toolkit';
|
||||
import { RootState } from '../store';
|
||||
|
||||
// Define Activity type
|
||||
export interface Activity {
|
||||
id: string;
|
||||
childId: string;
|
||||
type: 'feeding' | 'sleep' | 'diaper' | 'medication' | 'milestone' | 'note';
|
||||
timestamp: string;
|
||||
data: Record<string, any>;
|
||||
notes?: string;
|
||||
createdBy: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
// Offline metadata
|
||||
_optimistic?: boolean;
|
||||
_localId?: string;
|
||||
_version?: number;
|
||||
}
|
||||
|
||||
// Create entity adapter for normalized state
|
||||
const activitiesAdapter = createEntityAdapter<Activity>({
|
||||
selectId: (activity) => activity.id,
|
||||
sortComparer: (a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime(),
|
||||
});
|
||||
|
||||
// Async thunks with offline support
|
||||
export const createActivity = createAsyncThunk(
|
||||
'activities/create',
|
||||
async (activity: Omit<Activity, 'id' | 'createdAt' | 'updatedAt'>, { rejectWithValue }) => {
|
||||
try {
|
||||
const response = await fetch('/api/v1/activities', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(activity),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to create activity');
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error: any) {
|
||||
return rejectWithValue(error.message);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const fetchActivities = createAsyncThunk(
|
||||
'activities/fetch',
|
||||
async ({ childId, startDate, endDate }: { childId: string; startDate?: string; endDate?: string }) => {
|
||||
const params = new URLSearchParams({
|
||||
childId,
|
||||
...(startDate && { startDate }),
|
||||
...(endDate && { endDate }),
|
||||
});
|
||||
|
||||
const response = await fetch(`/api/v1/activities?${params}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch activities');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data.data;
|
||||
}
|
||||
);
|
||||
|
||||
export const updateActivity = createAsyncThunk(
|
||||
'activities/update',
|
||||
async ({ id, updates }: { id: string; updates: Partial<Activity> }, { rejectWithValue }) => {
|
||||
try {
|
||||
const response = await fetch(`/api/v1/activities/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(updates),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to update activity');
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error: any) {
|
||||
return rejectWithValue(error.message);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const deleteActivity = createAsyncThunk(
|
||||
'activities/delete',
|
||||
async (id: string, { rejectWithValue }) => {
|
||||
try {
|
||||
const response = await fetch(`/api/v1/activities/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to delete activity');
|
||||
}
|
||||
|
||||
return id;
|
||||
} catch (error: any) {
|
||||
return rejectWithValue(error.message);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const activitiesSlice = createSlice({
|
||||
name: 'activities',
|
||||
initialState: activitiesAdapter.getInitialState({
|
||||
loading: false,
|
||||
error: null as string | null,
|
||||
syncStatus: 'idle' as 'idle' | 'syncing' | 'synced' | 'error',
|
||||
lastSyncTime: null as string | null,
|
||||
}),
|
||||
reducers: {
|
||||
// Optimistic create - immediately add to local state
|
||||
optimisticCreate: (state, action: PayloadAction<Activity>) => {
|
||||
const optimisticActivity = {
|
||||
...action.payload,
|
||||
_optimistic: true,
|
||||
_localId: action.payload.id,
|
||||
_version: 1,
|
||||
};
|
||||
activitiesAdapter.addOne(state, optimisticActivity);
|
||||
},
|
||||
// Optimistic update
|
||||
optimisticUpdate: (state, action: PayloadAction<{ id: string; changes: Partial<Activity> }>) => {
|
||||
const { id, changes } = action.payload;
|
||||
const existing = state.entities[id];
|
||||
if (existing) {
|
||||
activitiesAdapter.updateOne(state, {
|
||||
id,
|
||||
changes: {
|
||||
...changes,
|
||||
_optimistic: true,
|
||||
_version: (existing._version || 0) + 1,
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
// Optimistic delete
|
||||
optimisticDelete: (state, action: PayloadAction<string>) => {
|
||||
activitiesAdapter.removeOne(state, action.payload);
|
||||
},
|
||||
// Rollback optimistic action
|
||||
rollbackOptimistic: (state, action: PayloadAction<string>) => {
|
||||
// Remove optimistic entry or restore previous version
|
||||
const activity = state.entities[action.payload];
|
||||
if (activity?._optimistic) {
|
||||
activitiesAdapter.removeOne(state, action.payload);
|
||||
}
|
||||
},
|
||||
// Clear all activities (for logout)
|
||||
clearActivities: (state) => {
|
||||
activitiesAdapter.removeAll(state);
|
||||
state.error = null;
|
||||
state.lastSyncTime = null;
|
||||
},
|
||||
// Mark activity as synced
|
||||
markSynced: (state, action: PayloadAction<{ localId: string; serverId: string; serverData: Activity }>) => {
|
||||
const { localId, serverId, serverData } = action.payload;
|
||||
// Remove optimistic entry
|
||||
activitiesAdapter.removeOne(state, localId);
|
||||
// Add server version
|
||||
activitiesAdapter.addOne(state, {
|
||||
...serverData,
|
||||
_optimistic: false,
|
||||
});
|
||||
},
|
||||
},
|
||||
extraReducers: (builder) => {
|
||||
builder
|
||||
// Create activity
|
||||
.addCase(createActivity.pending, (state) => {
|
||||
state.loading = true;
|
||||
state.error = null;
|
||||
})
|
||||
.addCase(createActivity.fulfilled, (state, action) => {
|
||||
state.loading = false;
|
||||
const serverActivity = action.payload.data;
|
||||
// Replace optimistic entry with server data
|
||||
const optimisticId = action.meta.arg.id || action.meta.requestId;
|
||||
if (state.entities[optimisticId]?._optimistic) {
|
||||
activitiesAdapter.removeOne(state, optimisticId);
|
||||
}
|
||||
activitiesAdapter.addOne(state, {
|
||||
...serverActivity,
|
||||
_optimistic: false,
|
||||
});
|
||||
})
|
||||
.addCase(createActivity.rejected, (state, action) => {
|
||||
state.loading = false;
|
||||
state.error = action.payload as string;
|
||||
// Rollback optimistic entry
|
||||
const optimisticId = action.meta.arg.id || action.meta.requestId;
|
||||
if (state.entities[optimisticId]?._optimistic) {
|
||||
activitiesAdapter.removeOne(state, optimisticId);
|
||||
}
|
||||
})
|
||||
// Fetch activities
|
||||
.addCase(fetchActivities.pending, (state) => {
|
||||
state.loading = true;
|
||||
state.syncStatus = 'syncing';
|
||||
})
|
||||
.addCase(fetchActivities.fulfilled, (state, action) => {
|
||||
state.loading = false;
|
||||
state.syncStatus = 'synced';
|
||||
state.lastSyncTime = new Date().toISOString();
|
||||
// Merge server data with local optimistic entries
|
||||
const serverActivities = action.payload.map((a: Activity) => ({
|
||||
...a,
|
||||
_optimistic: false,
|
||||
}));
|
||||
activitiesAdapter.upsertMany(state, serverActivities);
|
||||
})
|
||||
.addCase(fetchActivities.rejected, (state, action) => {
|
||||
state.loading = false;
|
||||
state.syncStatus = 'error';
|
||||
state.error = action.error.message || 'Failed to fetch activities';
|
||||
})
|
||||
// Update activity
|
||||
.addCase(updateActivity.fulfilled, (state, action) => {
|
||||
const serverActivity = action.payload.data;
|
||||
activitiesAdapter.updateOne(state, {
|
||||
id: serverActivity.id,
|
||||
changes: {
|
||||
...serverActivity,
|
||||
_optimistic: false,
|
||||
},
|
||||
});
|
||||
})
|
||||
.addCase(updateActivity.rejected, (state, action) => {
|
||||
state.error = action.payload as string;
|
||||
// Rollback optimistic update
|
||||
const id = action.meta.arg.id;
|
||||
if (state.entities[id]?._optimistic) {
|
||||
// TODO: Restore previous version from history
|
||||
}
|
||||
})
|
||||
// Delete activity
|
||||
.addCase(deleteActivity.fulfilled, (state, action) => {
|
||||
activitiesAdapter.removeOne(state, action.payload);
|
||||
})
|
||||
.addCase(deleteActivity.rejected, (state, action) => {
|
||||
state.error = action.payload as string;
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const {
|
||||
optimisticCreate,
|
||||
optimisticUpdate,
|
||||
optimisticDelete,
|
||||
rollbackOptimistic,
|
||||
clearActivities,
|
||||
markSynced,
|
||||
} = activitiesSlice.actions;
|
||||
|
||||
// Export selectors
|
||||
export const activitiesSelectors = activitiesAdapter.getSelectors<RootState>(
|
||||
(state) => state.activities
|
||||
);
|
||||
|
||||
// Custom selectors
|
||||
export const selectActivitiesByChild = (state: RootState, childId: string) =>
|
||||
activitiesSelectors
|
||||
.selectAll(state)
|
||||
.filter((activity) => activity.childId === childId);
|
||||
|
||||
export const selectPendingActivities = (state: RootState) =>
|
||||
activitiesSelectors
|
||||
.selectAll(state)
|
||||
.filter((activity) => activity._optimistic);
|
||||
|
||||
export const selectActivitiesByType = (state: RootState, type: Activity['type']) =>
|
||||
activitiesSelectors
|
||||
.selectAll(state)
|
||||
.filter((activity) => activity.type === type);
|
||||
|
||||
export default activitiesSlice.reducer;
|
||||
229
maternal-web/store/slices/childrenSlice.ts
Normal file
229
maternal-web/store/slices/childrenSlice.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
import { createSlice, createAsyncThunk, createEntityAdapter, PayloadAction } from '@reduxjs/toolkit';
|
||||
import { RootState } from '../store';
|
||||
|
||||
export interface Child {
|
||||
id: string;
|
||||
familyId: string;
|
||||
name: string;
|
||||
birthDate: string;
|
||||
gender: 'male' | 'female' | 'other';
|
||||
profilePhoto?: string;
|
||||
metadata?: Record<string, any>;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
// Offline metadata
|
||||
_optimistic?: boolean;
|
||||
_localId?: string;
|
||||
_version?: number;
|
||||
}
|
||||
|
||||
// Create entity adapter
|
||||
const childrenAdapter = createEntityAdapter<Child>({
|
||||
selectId: (child) => child.id,
|
||||
sortComparer: (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
|
||||
});
|
||||
|
||||
// Async thunks
|
||||
export const fetchChildren = createAsyncThunk(
|
||||
'children/fetch',
|
||||
async (familyId: string) => {
|
||||
const response = await fetch(`/api/v1/children?familyId=${familyId}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch children');
|
||||
}
|
||||
const data = await response.json();
|
||||
return data.data;
|
||||
}
|
||||
);
|
||||
|
||||
export const createChild = createAsyncThunk(
|
||||
'children/create',
|
||||
async (child: Omit<Child, 'id' | 'createdAt' | 'updatedAt'>, { rejectWithValue }) => {
|
||||
try {
|
||||
const response = await fetch('/api/v1/children', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(child),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to create child');
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error: any) {
|
||||
return rejectWithValue(error.message);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const updateChild = createAsyncThunk(
|
||||
'children/update',
|
||||
async ({ id, updates }: { id: string; updates: Partial<Child> }, { rejectWithValue }) => {
|
||||
try {
|
||||
const response = await fetch(`/api/v1/children/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(updates),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to update child');
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error: any) {
|
||||
return rejectWithValue(error.message);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const childrenSlice = createSlice({
|
||||
name: 'children',
|
||||
initialState: childrenAdapter.getInitialState({
|
||||
loading: false,
|
||||
error: null as string | null,
|
||||
selectedChildId: null as string | null,
|
||||
lastSyncTime: null as string | null,
|
||||
}),
|
||||
reducers: {
|
||||
// Optimistic operations
|
||||
optimisticCreate: (state, action: PayloadAction<Child>) => {
|
||||
childrenAdapter.addOne(state, {
|
||||
...action.payload,
|
||||
_optimistic: true,
|
||||
_localId: action.payload.id,
|
||||
_version: 1,
|
||||
});
|
||||
},
|
||||
optimisticUpdate: (state, action: PayloadAction<{ id: string; changes: Partial<Child> }>) => {
|
||||
const { id, changes } = action.payload;
|
||||
const existing = state.entities[id];
|
||||
if (existing) {
|
||||
childrenAdapter.updateOne(state, {
|
||||
id,
|
||||
changes: {
|
||||
...changes,
|
||||
_optimistic: true,
|
||||
_version: (existing._version || 0) + 1,
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
rollbackOptimistic: (state, action: PayloadAction<string>) => {
|
||||
const child = state.entities[action.payload];
|
||||
if (child?._optimistic) {
|
||||
childrenAdapter.removeOne(state, action.payload);
|
||||
}
|
||||
},
|
||||
markSynced: (state, action: PayloadAction<{ localId: string; serverId: string; serverData: Child }>) => {
|
||||
const { localId, serverId, serverData } = action.payload;
|
||||
childrenAdapter.removeOne(state, localId);
|
||||
childrenAdapter.addOne(state, {
|
||||
...serverData,
|
||||
_optimistic: false,
|
||||
});
|
||||
},
|
||||
// Select child
|
||||
selectChild: (state, action: PayloadAction<string>) => {
|
||||
state.selectedChildId = action.payload;
|
||||
},
|
||||
clearChildren: (state) => {
|
||||
childrenAdapter.removeAll(state);
|
||||
state.selectedChildId = null;
|
||||
state.error = null;
|
||||
},
|
||||
},
|
||||
extraReducers: (builder) => {
|
||||
builder
|
||||
// Fetch children
|
||||
.addCase(fetchChildren.pending, (state) => {
|
||||
state.loading = true;
|
||||
state.error = null;
|
||||
})
|
||||
.addCase(fetchChildren.fulfilled, (state, action) => {
|
||||
state.loading = false;
|
||||
state.lastSyncTime = new Date().toISOString();
|
||||
const serverChildren = action.payload.map((c: Child) => ({
|
||||
...c,
|
||||
_optimistic: false,
|
||||
}));
|
||||
childrenAdapter.upsertMany(state, serverChildren);
|
||||
})
|
||||
.addCase(fetchChildren.rejected, (state, action) => {
|
||||
state.loading = false;
|
||||
state.error = action.error.message || 'Failed to fetch children';
|
||||
})
|
||||
// Create child
|
||||
.addCase(createChild.pending, (state) => {
|
||||
state.loading = true;
|
||||
state.error = null;
|
||||
})
|
||||
.addCase(createChild.fulfilled, (state, action) => {
|
||||
state.loading = false;
|
||||
const serverChild = action.payload.data;
|
||||
const optimisticId = action.meta.arg.id || action.meta.requestId;
|
||||
if (state.entities[optimisticId]?._optimistic) {
|
||||
childrenAdapter.removeOne(state, optimisticId);
|
||||
}
|
||||
childrenAdapter.addOne(state, {
|
||||
...serverChild,
|
||||
_optimistic: false,
|
||||
});
|
||||
})
|
||||
.addCase(createChild.rejected, (state, action) => {
|
||||
state.loading = false;
|
||||
state.error = action.payload as string;
|
||||
const optimisticId = action.meta.arg.id || action.meta.requestId;
|
||||
if (state.entities[optimisticId]?._optimistic) {
|
||||
childrenAdapter.removeOne(state, optimisticId);
|
||||
}
|
||||
})
|
||||
// Update child
|
||||
.addCase(updateChild.fulfilled, (state, action) => {
|
||||
const serverChild = action.payload.data;
|
||||
childrenAdapter.updateOne(state, {
|
||||
id: serverChild.id,
|
||||
changes: {
|
||||
...serverChild,
|
||||
_optimistic: false,
|
||||
},
|
||||
});
|
||||
})
|
||||
.addCase(updateChild.rejected, (state, action) => {
|
||||
state.error = action.payload as string;
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const {
|
||||
optimisticCreate,
|
||||
optimisticUpdate,
|
||||
rollbackOptimistic,
|
||||
markSynced,
|
||||
selectChild,
|
||||
clearChildren,
|
||||
} = childrenSlice.actions;
|
||||
|
||||
// Export selectors
|
||||
export const childrenSelectors = childrenAdapter.getSelectors<RootState>(
|
||||
(state) => state.children
|
||||
);
|
||||
|
||||
// Custom selectors
|
||||
export const selectSelectedChild = (state: RootState) => {
|
||||
const id = state.children.selectedChildId;
|
||||
return id ? state.children.entities[id] : null;
|
||||
};
|
||||
|
||||
export const selectChildrenByFamily = (state: RootState, familyId: string) =>
|
||||
childrenSelectors
|
||||
.selectAll(state)
|
||||
.filter((child) => child.familyId === familyId);
|
||||
|
||||
export const selectPendingChildren = (state: RootState) =>
|
||||
childrenSelectors
|
||||
.selectAll(state)
|
||||
.filter((child) => child._optimistic);
|
||||
|
||||
export default childrenSlice.reducer;
|
||||
64
maternal-web/store/slices/networkSlice.ts
Normal file
64
maternal-web/store/slices/networkSlice.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||
|
||||
interface NetworkState {
|
||||
isOnline: boolean;
|
||||
isConnected: boolean; // API server reachability
|
||||
lastOnlineTime: string | null;
|
||||
lastOfflineTime: string | null;
|
||||
connectionQuality: 'excellent' | 'good' | 'poor' | 'offline';
|
||||
latency: number | null;
|
||||
}
|
||||
|
||||
const initialState: NetworkState = {
|
||||
isOnline: typeof navigator !== 'undefined' ? navigator.onLine : true,
|
||||
isConnected: true,
|
||||
lastOnlineTime: null,
|
||||
lastOfflineTime: null,
|
||||
connectionQuality: 'excellent',
|
||||
latency: null,
|
||||
};
|
||||
|
||||
const networkSlice = createSlice({
|
||||
name: 'network',
|
||||
initialState,
|
||||
reducers: {
|
||||
setOnlineStatus: (state, action: PayloadAction<boolean>) => {
|
||||
const wasOnline = state.isOnline;
|
||||
state.isOnline = action.payload;
|
||||
|
||||
if (action.payload && !wasOnline) {
|
||||
// Just came online
|
||||
state.lastOnlineTime = new Date().toISOString();
|
||||
} else if (!action.payload && wasOnline) {
|
||||
// Just went offline
|
||||
state.lastOfflineTime = new Date().toISOString();
|
||||
}
|
||||
},
|
||||
setServerConnection: (state, action: PayloadAction<boolean>) => {
|
||||
state.isConnected = action.payload;
|
||||
},
|
||||
setConnectionQuality: (state, action: PayloadAction<NetworkState['connectionQuality']>) => {
|
||||
state.connectionQuality = action.payload;
|
||||
},
|
||||
setLatency: (state, action: PayloadAction<number>) => {
|
||||
state.latency = action.payload;
|
||||
// Update connection quality based on latency
|
||||
if (action.payload < 100) {
|
||||
state.connectionQuality = 'excellent';
|
||||
} else if (action.payload < 300) {
|
||||
state.connectionQuality = 'good';
|
||||
} else {
|
||||
state.connectionQuality = 'poor';
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const {
|
||||
setOnlineStatus,
|
||||
setServerConnection,
|
||||
setConnectionQuality,
|
||||
setLatency,
|
||||
} = networkSlice.actions;
|
||||
|
||||
export default networkSlice.reducer;
|
||||
Reference in New Issue
Block a user