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; 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({ 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, { 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 }, { 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) => { 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 }>) => { 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) => { activitiesAdapter.removeOne(state, action.payload); }, // Rollback optimistic action rollbackOptimistic: (state, action: PayloadAction) => { // 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( (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;