Completed Features:
- Full JWT authentication system with refresh tokens
- User registration and login with device fingerprinting
- Child profile CRUD operations with permission-based access
- Family management with roles and permissions
- Database migrations for core auth and family structure
- Comprehensive test coverage (37 unit + E2E tests)
Tech Stack:
- NestJS backend with TypeORM
- PostgreSQL database
- JWT authentication with Passport
- bcrypt password hashing
- Docker Compose for infrastructure
🤖 Generated with Claude Code
15 KiB
15 KiB
State Management Schema - Maternal Organization App
Store Architecture Overview
Redux Toolkit Structure
// Core principles:
// - Single source of truth
// - Normalized state shape
// - Offline-first design
// - Optimistic updates
// - Automatic sync queue
Root Store Structure
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
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
// 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
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
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
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
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
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
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
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
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
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
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
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
// 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
// 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
// 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
// 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
// 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
// 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
// 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;
},
}
);