feat: Implement offline-first Redux architecture with optimistic updates
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

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:
2025-10-01 19:24:46 +00:00
parent aaa239121e
commit 7cb2ff97de
12 changed files with 1469 additions and 563 deletions

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

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

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