Files
maternal-app/docs/maternal-app-state-management.md
andupetcu 98e01ebe80 Phase 1 & 2: Authentication and Children Management
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
2025-09-30 18:40:10 +03:00

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;
    },
  }
);