724 lines
15 KiB
Markdown
724 lines
15 KiB
Markdown
# State Management Schema - Maternal Organization App
|
|
|
|
## Store Architecture Overview
|
|
|
|
### Redux Toolkit Structure
|
|
```typescript
|
|
// Core principles:
|
|
// - Single source of truth
|
|
// - Normalized state shape
|
|
// - Offline-first design
|
|
// - Optimistic updates
|
|
// - Automatic sync queue
|
|
```
|
|
|
|
---
|
|
|
|
## Root Store Structure
|
|
|
|
```typescript
|
|
interface RootState {
|
|
auth: AuthState;
|
|
user: UserState;
|
|
family: FamilyState;
|
|
children: ChildrenState;
|
|
activities: ActivitiesState;
|
|
ai: AIState;
|
|
sync: SyncState;
|
|
offline: OfflineState;
|
|
ui: UIState;
|
|
notifications: NotificationState;
|
|
analytics: AnalyticsState;
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Auth Slice
|
|
|
|
### State Shape
|
|
```typescript
|
|
interface AuthState {
|
|
isAuthenticated: boolean;
|
|
accessToken: string | null;
|
|
refreshToken: string | null;
|
|
tokenExpiry: number | null;
|
|
deviceFingerprint: string;
|
|
trustedDevices: string[];
|
|
authStatus: 'idle' | 'loading' | 'succeeded' | 'failed';
|
|
error: string | null;
|
|
}
|
|
```
|
|
|
|
### Actions
|
|
```typescript
|
|
// authSlice.ts
|
|
const authSlice = createSlice({
|
|
name: 'auth',
|
|
initialState,
|
|
reducers: {
|
|
loginStart: (state) => {
|
|
state.authStatus = 'loading';
|
|
},
|
|
loginSuccess: (state, action) => {
|
|
state.isAuthenticated = true;
|
|
state.accessToken = action.payload.accessToken;
|
|
state.refreshToken = action.payload.refreshToken;
|
|
state.tokenExpiry = action.payload.expiresAt;
|
|
state.authStatus = 'succeeded';
|
|
},
|
|
loginFailure: (state, action) => {
|
|
state.authStatus = 'failed';
|
|
state.error = action.payload;
|
|
},
|
|
tokenRefreshed: (state, action) => {
|
|
state.accessToken = action.payload.accessToken;
|
|
state.tokenExpiry = action.payload.expiresAt;
|
|
},
|
|
logout: (state) => {
|
|
return initialState;
|
|
},
|
|
},
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
## User Slice
|
|
|
|
### State Shape
|
|
```typescript
|
|
interface UserState {
|
|
currentUser: {
|
|
id: string;
|
|
email: string;
|
|
name: string;
|
|
locale: string;
|
|
timezone: string;
|
|
photoUrl?: string;
|
|
preferences: UserPreferences;
|
|
} | null;
|
|
subscription: {
|
|
tier: 'free' | 'premium' | 'plus';
|
|
expiresAt?: string;
|
|
aiQueriesUsed: number;
|
|
aiQueriesLimit: number;
|
|
};
|
|
}
|
|
|
|
interface UserPreferences {
|
|
darkMode: 'auto' | 'light' | 'dark';
|
|
notifications: {
|
|
push: boolean;
|
|
email: boolean;
|
|
quietHoursStart?: string;
|
|
quietHoursEnd?: string;
|
|
};
|
|
measurementUnit: 'metric' | 'imperial';
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Family Slice
|
|
|
|
### State Shape
|
|
```typescript
|
|
interface FamilyState {
|
|
currentFamily: {
|
|
id: string;
|
|
name: string;
|
|
shareCode: string;
|
|
createdBy: string;
|
|
} | null;
|
|
members: {
|
|
byId: Record<string, FamilyMember>;
|
|
allIds: string[];
|
|
};
|
|
invitations: Invitation[];
|
|
loadingStatus: LoadingStatus;
|
|
}
|
|
|
|
interface FamilyMember {
|
|
id: string;
|
|
name: string;
|
|
email: string;
|
|
role: 'parent' | 'caregiver' | 'viewer';
|
|
permissions: Permissions;
|
|
lastActive: string;
|
|
isOnline: boolean;
|
|
}
|
|
```
|
|
|
|
### Normalized Actions
|
|
```typescript
|
|
const familySlice = createSlice({
|
|
name: 'family',
|
|
initialState,
|
|
reducers: {
|
|
memberAdded: (state, action) => {
|
|
const member = action.payload;
|
|
state.members.byId[member.id] = member;
|
|
state.members.allIds.push(member.id);
|
|
},
|
|
memberUpdated: (state, action) => {
|
|
const { id, changes } = action.payload;
|
|
state.members.byId[id] = {
|
|
...state.members.byId[id],
|
|
...changes,
|
|
};
|
|
},
|
|
memberRemoved: (state, action) => {
|
|
const id = action.payload;
|
|
delete state.members.byId[id];
|
|
state.members.allIds = state.members.allIds.filter(mid => mid !== id);
|
|
},
|
|
},
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
## Children Slice
|
|
|
|
### State Shape
|
|
```typescript
|
|
interface ChildrenState {
|
|
children: {
|
|
byId: Record<string, Child>;
|
|
allIds: string[];
|
|
};
|
|
activeChildId: string | null;
|
|
milestones: {
|
|
byChildId: Record<string, Milestone[]>;
|
|
};
|
|
}
|
|
|
|
interface Child {
|
|
id: string;
|
|
name: string;
|
|
birthDate: string;
|
|
gender?: string;
|
|
photoUrl?: string;
|
|
medical: {
|
|
bloodType?: string;
|
|
allergies: string[];
|
|
conditions: string[];
|
|
medications: Medication[];
|
|
};
|
|
metrics: {
|
|
currentWeight?: Measurement;
|
|
currentHeight?: Measurement;
|
|
headCircumference?: Measurement;
|
|
};
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Activities Slice (Normalized)
|
|
|
|
### State Shape
|
|
```typescript
|
|
interface ActivitiesState {
|
|
activities: {
|
|
byId: Record<string, Activity>;
|
|
allIds: string[];
|
|
byChild: Record<string, string[]>; // childId -> activityIds
|
|
byDate: Record<string, string[]>; // date -> activityIds
|
|
};
|
|
activeTimers: {
|
|
[childId: string]: ActiveTimer;
|
|
};
|
|
filters: {
|
|
childId?: string;
|
|
dateRange?: { start: string; end: string };
|
|
types?: ActivityType[];
|
|
};
|
|
pagination: {
|
|
cursor: string | null;
|
|
hasMore: boolean;
|
|
isLoading: boolean;
|
|
};
|
|
}
|
|
|
|
interface Activity {
|
|
id: string;
|
|
childId: string;
|
|
type: ActivityType;
|
|
timestamp: string;
|
|
duration?: number;
|
|
details: ActivityDetails;
|
|
loggedBy: string;
|
|
syncStatus: 'synced' | 'pending' | 'error';
|
|
version: number; // For conflict resolution
|
|
}
|
|
|
|
interface ActiveTimer {
|
|
activityType: ActivityType;
|
|
startTime: number;
|
|
pausedDuration: number;
|
|
isPaused: boolean;
|
|
}
|
|
```
|
|
|
|
### Activity Actions
|
|
```typescript
|
|
const activitiesSlice = createSlice({
|
|
name: 'activities',
|
|
initialState,
|
|
reducers: {
|
|
// Optimistic update
|
|
activityLogged: (state, action) => {
|
|
const activity = {
|
|
...action.payload,
|
|
syncStatus: 'pending',
|
|
};
|
|
state.activities.byId[activity.id] = activity;
|
|
state.activities.allIds.unshift(activity.id);
|
|
|
|
// Update indexes
|
|
if (!state.activities.byChild[activity.childId]) {
|
|
state.activities.byChild[activity.childId] = [];
|
|
}
|
|
state.activities.byChild[activity.childId].unshift(activity.id);
|
|
},
|
|
|
|
// Sync confirmed
|
|
activitySynced: (state, action) => {
|
|
const { localId, serverId } = action.payload;
|
|
state.activities.byId[localId].id = serverId;
|
|
state.activities.byId[localId].syncStatus = 'synced';
|
|
},
|
|
|
|
// Timer management
|
|
timerStarted: (state, action) => {
|
|
const { childId, activityType } = action.payload;
|
|
state.activeTimers[childId] = {
|
|
activityType,
|
|
startTime: Date.now(),
|
|
pausedDuration: 0,
|
|
isPaused: false,
|
|
};
|
|
},
|
|
},
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
## AI Slice
|
|
|
|
### State Shape
|
|
```typescript
|
|
interface AIState {
|
|
conversations: {
|
|
byId: Record<string, Conversation>;
|
|
activeId: string | null;
|
|
};
|
|
insights: {
|
|
byChildId: Record<string, Insight[]>;
|
|
pending: Insight[];
|
|
};
|
|
predictions: {
|
|
byChildId: Record<string, Predictions>;
|
|
};
|
|
quotas: {
|
|
dailyQueries: number;
|
|
dailyLimit: number;
|
|
resetAt: string;
|
|
};
|
|
}
|
|
|
|
interface Conversation {
|
|
id: string;
|
|
childId?: string;
|
|
messages: Message[];
|
|
context: ConversationContext;
|
|
lastMessageAt: string;
|
|
}
|
|
|
|
interface Predictions {
|
|
nextNapTime?: { time: string; confidence: number };
|
|
nextFeedingTime?: { time: string; confidence: number };
|
|
growthSpurt?: { likelihood: number; expectedIn: string };
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Sync Slice (Critical for Offline)
|
|
|
|
### State Shape
|
|
```typescript
|
|
interface SyncState {
|
|
queue: SyncQueueItem[];
|
|
conflicts: ConflictItem[];
|
|
lastSync: {
|
|
[entityType: string]: string; // ISO timestamp
|
|
};
|
|
syncStatus: 'idle' | 'syncing' | 'error' | 'offline';
|
|
retryCount: number;
|
|
webSocket: {
|
|
connected: boolean;
|
|
reconnectAttempts: number;
|
|
};
|
|
}
|
|
|
|
interface SyncQueueItem {
|
|
id: string;
|
|
type: 'CREATE' | 'UPDATE' | 'DELETE';
|
|
entity: 'activity' | 'child' | 'family';
|
|
payload: any;
|
|
timestamp: string;
|
|
retries: number;
|
|
error?: string;
|
|
}
|
|
|
|
interface ConflictItem {
|
|
id: string;
|
|
localVersion: any;
|
|
serverVersion: any;
|
|
strategy: 'manual' | 'local' | 'server' | 'merge';
|
|
}
|
|
```
|
|
|
|
### Sync Actions
|
|
```typescript
|
|
const syncSlice = createSlice({
|
|
name: 'sync',
|
|
initialState,
|
|
reducers: {
|
|
addToQueue: (state, action) => {
|
|
state.queue.push({
|
|
id: nanoid(),
|
|
...action.payload,
|
|
timestamp: new Date().toISOString(),
|
|
retries: 0,
|
|
});
|
|
},
|
|
|
|
removeFromQueue: (state, action) => {
|
|
state.queue = state.queue.filter(item => item.id !== action.payload);
|
|
},
|
|
|
|
conflictDetected: (state, action) => {
|
|
state.conflicts.push(action.payload);
|
|
},
|
|
|
|
conflictResolved: (state, action) => {
|
|
const { id, resolution } = action.payload;
|
|
state.conflicts = state.conflicts.filter(c => c.id !== id);
|
|
// Apply resolution...
|
|
},
|
|
|
|
syncCompleted: (state, action) => {
|
|
state.lastSync[action.payload.entity] = new Date().toISOString();
|
|
state.syncStatus = 'idle';
|
|
},
|
|
},
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
## Offline Slice
|
|
|
|
### State Shape
|
|
```typescript
|
|
interface OfflineState {
|
|
isOnline: boolean;
|
|
queuedActions: OfflineAction[];
|
|
cachedData: {
|
|
[key: string]: {
|
|
data: any;
|
|
timestamp: string;
|
|
ttl: number;
|
|
};
|
|
};
|
|
retryPolicy: {
|
|
maxRetries: number;
|
|
retryDelay: number;
|
|
backoffMultiplier: number;
|
|
};
|
|
}
|
|
|
|
interface OfflineAction {
|
|
id: string;
|
|
action: AnyAction;
|
|
meta: {
|
|
offline: {
|
|
effect: any;
|
|
commit: AnyAction;
|
|
rollback: AnyAction;
|
|
};
|
|
};
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## UI Slice
|
|
|
|
### State Shape
|
|
```typescript
|
|
interface UIState {
|
|
theme: 'light' | 'dark' | 'auto';
|
|
activeScreen: string;
|
|
modals: {
|
|
[modalId: string]: {
|
|
isOpen: boolean;
|
|
data?: any;
|
|
};
|
|
};
|
|
loading: {
|
|
[key: string]: boolean;
|
|
};
|
|
errors: {
|
|
[key: string]: ErrorInfo;
|
|
};
|
|
toasts: Toast[];
|
|
bottomSheet: {
|
|
isOpen: boolean;
|
|
content: 'quickActions' | 'activityDetails' | null;
|
|
};
|
|
}
|
|
|
|
interface Toast {
|
|
id: string;
|
|
type: 'success' | 'error' | 'info';
|
|
message: string;
|
|
duration: number;
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Middleware Configuration
|
|
|
|
### Store Setup
|
|
```typescript
|
|
// store/index.ts
|
|
import { configureStore } from '@reduxjs/toolkit';
|
|
import {
|
|
persistStore,
|
|
persistReducer,
|
|
FLUSH,
|
|
REHYDRATE,
|
|
PAUSE,
|
|
PERSIST,
|
|
PURGE,
|
|
REGISTER,
|
|
} from 'redux-persist';
|
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
|
|
|
const persistConfig = {
|
|
key: 'root',
|
|
storage: AsyncStorage,
|
|
whitelist: ['auth', 'user', 'children', 'activities'],
|
|
blacklist: ['ui', 'sync'], // Don't persist UI state
|
|
};
|
|
|
|
const rootReducer = combineReducers({
|
|
auth: authReducer,
|
|
user: userReducer,
|
|
family: familyReducer,
|
|
children: childrenReducer,
|
|
activities: activitiesReducer,
|
|
ai: aiReducer,
|
|
sync: syncReducer,
|
|
offline: offlineReducer,
|
|
ui: uiReducer,
|
|
notifications: notificationsReducer,
|
|
analytics: analyticsReducer,
|
|
});
|
|
|
|
const persistedReducer = persistReducer(persistConfig, rootReducer);
|
|
|
|
export const store = configureStore({
|
|
reducer: persistedReducer,
|
|
middleware: (getDefaultMiddleware) =>
|
|
getDefaultMiddleware({
|
|
serializableCheck: {
|
|
ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER],
|
|
},
|
|
}).concat([
|
|
syncMiddleware,
|
|
offlineMiddleware,
|
|
analyticsMiddleware,
|
|
conflictResolutionMiddleware,
|
|
]),
|
|
});
|
|
|
|
export const persistor = persistStore(store);
|
|
```
|
|
|
|
### Sync Middleware
|
|
```typescript
|
|
// middleware/syncMiddleware.ts
|
|
export const syncMiddleware: Middleware = (store) => (next) => (action) => {
|
|
const result = next(action);
|
|
|
|
// Queue actions for sync
|
|
if (action.type.includes('activities/') && !action.meta?.skipSync) {
|
|
const state = store.getState();
|
|
|
|
if (!state.offline.isOnline) {
|
|
store.dispatch(addToQueue({
|
|
type: 'UPDATE',
|
|
entity: 'activity',
|
|
payload: action.payload,
|
|
}));
|
|
} else {
|
|
// Sync immediately
|
|
syncActivity(action.payload);
|
|
}
|
|
}
|
|
|
|
return result;
|
|
};
|
|
```
|
|
|
|
### Offline Middleware
|
|
```typescript
|
|
// middleware/offlineMiddleware.ts
|
|
export const offlineMiddleware: Middleware = (store) => (next) => (action) => {
|
|
// Check network status
|
|
if (action.type === 'network/statusChanged') {
|
|
const isOnline = action.payload;
|
|
|
|
if (isOnline && store.getState().sync.queue.length > 0) {
|
|
// Process offline queue
|
|
store.dispatch(processOfflineQueue());
|
|
}
|
|
}
|
|
|
|
// Handle optimistic updates
|
|
if (action.meta?.offline) {
|
|
const { effect, commit, rollback } = action.meta.offline;
|
|
|
|
// Apply optimistic update
|
|
next(action);
|
|
|
|
// Attempt sync
|
|
effect()
|
|
.then(() => next(commit))
|
|
.catch(() => next(rollback));
|
|
|
|
return;
|
|
}
|
|
|
|
return next(action);
|
|
};
|
|
```
|
|
|
|
---
|
|
|
|
## Selectors
|
|
|
|
### Memoized Selectors
|
|
```typescript
|
|
// selectors/activities.ts
|
|
import { createSelector } from '@reduxjs/toolkit';
|
|
|
|
export const selectActivitiesByChild = createSelector(
|
|
[(state: RootState) => state.activities.activities.byId,
|
|
(state: RootState, childId: string) => state.activities.activities.byChild[childId]],
|
|
(byId, activityIds = []) =>
|
|
activityIds.map(id => byId[id]).filter(Boolean)
|
|
);
|
|
|
|
export const selectTodaysSummary = createSelector(
|
|
[(state: RootState, childId: string) => selectActivitiesByChild(state, childId)],
|
|
(activities) => {
|
|
const today = new Date().toDateString();
|
|
const todaysActivities = activities.filter(
|
|
a => new Date(a.timestamp).toDateString() === today
|
|
);
|
|
|
|
return {
|
|
feedings: todaysActivities.filter(a => a.type === 'feeding').length,
|
|
sleepHours: calculateSleepHours(todaysActivities),
|
|
diapers: todaysActivities.filter(a => a.type === 'diaper').length,
|
|
};
|
|
}
|
|
);
|
|
```
|
|
|
|
---
|
|
|
|
## Conflict Resolution
|
|
|
|
### Conflict Resolution Strategy
|
|
```typescript
|
|
// utils/conflictResolution.ts
|
|
export const resolveConflict = (
|
|
local: Activity,
|
|
remote: Activity
|
|
): Activity => {
|
|
// Last write wins for simple conflicts
|
|
if (local.version === remote.version) {
|
|
return local.timestamp > remote.timestamp ? local : remote;
|
|
}
|
|
|
|
// Server version is higher - merge changes
|
|
if (remote.version > local.version) {
|
|
return {
|
|
...remote,
|
|
// Preserve local notes if different
|
|
details: {
|
|
...remote.details,
|
|
notes: local.details.notes !== remote.details.notes
|
|
? `${remote.details.notes}\n---\n${local.details.notes}`
|
|
: remote.details.notes,
|
|
},
|
|
};
|
|
}
|
|
|
|
// Local version is higher
|
|
return local;
|
|
};
|
|
```
|
|
|
|
---
|
|
|
|
## Performance Optimizations
|
|
|
|
### Normalized State Updates
|
|
```typescript
|
|
// Batch updates for performance
|
|
const activitiesSlice = createSlice({
|
|
name: 'activities',
|
|
reducers: {
|
|
activitiesBatchUpdated: (state, action) => {
|
|
const activities = action.payload;
|
|
|
|
// Use immer's batching
|
|
activities.forEach(activity => {
|
|
state.activities.byId[activity.id] = activity;
|
|
if (!state.activities.allIds.includes(activity.id)) {
|
|
state.activities.allIds.push(activity.id);
|
|
}
|
|
});
|
|
},
|
|
},
|
|
});
|
|
```
|
|
|
|
### Lazy Loading
|
|
```typescript
|
|
// Lazy load historical data
|
|
export const loadMoreActivities = createAsyncThunk(
|
|
'activities/loadMore',
|
|
async ({ cursor, limit = 20 }, { getState }) => {
|
|
const response = await api.getActivities({ cursor, limit });
|
|
return response.data;
|
|
},
|
|
{
|
|
condition: (_, { getState }) => {
|
|
const state = getState() as RootState;
|
|
return !state.activities.pagination.isLoading;
|
|
},
|
|
}
|
|
);
|
|
``` |