## AI Chat Fixes - **CRITICAL**: Fixed AI chat responding only with sleep-related info - Root cause: Current user message was never added to context before sending to AI - Added user message to context in ai.service.ts before API call - Fixed conversation ID handling for new conversations (undefined check) - Fixed children query to properly use FamilyMember join instead of incorrect familyId lookup - Added FamilyMember entity to AI module imports - **Context improvements**: - New conversations now use empty history array (not the current message) - Properly query user's children across all their families via family membership ## Children Authorization Fix - **CRITICAL SECURITY**: Fixed authorization bug where all users could see all children - Root cause: Controllers used `user.sub` but JWT strategy returns `user.userId` - Changed all children controller methods to use `user.userId` instead of `user.sub` - Added comprehensive logging to track userId and returned children - Backend now correctly filters children by family membership ## WebSocket Authentication - **Enhanced error handling** in families gateway - Better error messages for connection failures - Added debug logging for token validation - More descriptive error emissions to client - Added userId fallback (checks both payload.userId and payload.sub) ## User Experience - **Auto-clear cache on logout**: - Logout now clears localStorage and sessionStorage - Prevents stale cached data from persisting across sessions - Users get fresh data on every login without manual cache clearing ## Testing - Backend correctly returns only user's own children (verified in logs) - AI chat now responds to all types of questions, not just sleep-related - WebSocket authentication provides clearer error feedback 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
2287 lines
62 KiB
Markdown
2287 lines
62 KiB
Markdown
# Multi-Child Families - Comprehensive Implementation Plan
|
|
|
|
## Document Overview
|
|
|
|
This document provides a detailed, actionable implementation plan for enhancing ParentFlow's multi-child support across all application layers: frontend, backend, AI/voice processing, and infrastructure. The plan builds upon the basic multi-child implementation currently in place and the specifications in `multi-child-implementation.md`.
|
|
|
|
**Current Status:** Basic multi-child support exists with child selection in insights/analytics pages.
|
|
|
|
**Goal:** Comprehensive multi-child support with dynamic UI, voice commands, AI context awareness, and family-wide analytics.
|
|
|
|
---
|
|
|
|
## Table of Contents
|
|
|
|
1. [Frontend Implementation](#frontend-implementation)
|
|
2. [Backend Implementation](#backend-implementation)
|
|
3. [AI & Voice Processing](#ai--voice-processing)
|
|
4. [Database Schema Updates](#database-schema-updates)
|
|
5. [State Management Architecture](#state-management-architecture)
|
|
6. [API Enhancements](#api-enhancements)
|
|
7. [Real-Time Sync Updates](#real-time-sync-updates)
|
|
8. [Testing Strategy](#testing-strategy)
|
|
9. [Migration & Rollout Plan](#migration--rollout-plan)
|
|
10. [Performance Optimization](#performance-optimization)
|
|
|
|
---
|
|
|
|
## Frontend Implementation
|
|
|
|
### 1.1 Global Child Selector Component
|
|
|
|
**Location:** `/maternal-web/components/common/ChildSelector.tsx`
|
|
|
|
**Features:**
|
|
- Dropdown/select component with child avatars
|
|
- "All Children" option for aggregate views
|
|
- "Multiple Children" option for bulk operations
|
|
- Persistent selection state across navigation
|
|
- Real-time family member updates
|
|
- Keyboard navigation support
|
|
- Screen reader announcements
|
|
|
|
**Implementation Details:**
|
|
```typescript
|
|
interface ChildSelectorProps {
|
|
mode: 'single' | 'multiple' | 'all';
|
|
selectedChildIds: string[];
|
|
onChange: (childIds: string[]) => void;
|
|
showAllOption?: boolean;
|
|
showProfilePhotos?: boolean;
|
|
compact?: boolean;
|
|
}
|
|
|
|
// Features to implement:
|
|
// 1. Fetch children from Redux store (children slice)
|
|
// 2. Show profile photos with fallback avatars
|
|
// 3. Support keyboard shortcuts (1-9 for quick select)
|
|
// 4. Persist selection to localStorage + Redux
|
|
// 5. Announce changes to screen readers
|
|
// 6. Show child age alongside name
|
|
// 7. Color-code children for consistency across charts
|
|
```
|
|
|
|
**Integration Points:**
|
|
- App header (below navigation, above page content)
|
|
- Activity logging flows
|
|
- Analytics pages
|
|
- AI assistant context
|
|
- Voice command feedback
|
|
|
|
**Visibility Rules:**
|
|
- Hide when family has only 1 child
|
|
- Show as dropdown for 2-3 children
|
|
- Show as modal/sheet for 4+ children (mobile)
|
|
- Always visible in comparison views
|
|
|
|
---
|
|
|
|
### 1.2 Dashboard Dynamic Views
|
|
|
|
#### 1.2.1 Tab View (1-3 Children)
|
|
|
|
**Location:** `/maternal-web/app/page.tsx` (Home Dashboard)
|
|
|
|
**Implementation:**
|
|
```typescript
|
|
// Add MUI Tabs component at top of dashboard
|
|
<Tabs
|
|
value={activeTab}
|
|
onChange={handleTabChange}
|
|
variant="scrollable"
|
|
scrollButtons="auto"
|
|
>
|
|
{children.map(child => (
|
|
<Tab
|
|
key={child.id}
|
|
label={child.name}
|
|
icon={<Avatar src={child.photoUrl} />}
|
|
iconPosition="start"
|
|
/>
|
|
))}
|
|
<Tab label="All" icon={<GroupIcon />} iconPosition="start" />
|
|
</Tabs>
|
|
|
|
// Dashboard content filters by selected child(ren)
|
|
// Quick actions update to show child-specific data
|
|
// Recent activities filter by active tab
|
|
```
|
|
|
|
**Features:**
|
|
- Horizontal scrollable tabs for mobile
|
|
- Child avatar + name in each tab
|
|
- "All" tab shows aggregated stats
|
|
- Active tab indicator using theme primary color
|
|
- Smooth transitions between tabs
|
|
- Persist active tab to localStorage
|
|
- Swipe gestures for tab navigation (mobile)
|
|
|
|
#### 1.2.2 Card View (3+ Children)
|
|
|
|
**Location:** `/maternal-web/components/features/dashboard/MultiChildCardView.tsx`
|
|
|
|
**Implementation:**
|
|
```typescript
|
|
// Vertical scrollable card list
|
|
{children.map(child => (
|
|
<Card key={child.id} sx={{ mb: 1 }}>
|
|
<CardHeader
|
|
avatar={<Avatar src={child.photoUrl}>{child.name[0]}</Avatar>}
|
|
title={child.name}
|
|
subheader={`${calculateAge(child.birthDate)} old`}
|
|
/>
|
|
<CardContent>
|
|
{/* Compact metrics */}
|
|
<Grid container spacing={1}>
|
|
<Grid item xs={4}>
|
|
<Stat icon={<BedtimeIcon />} value={todaySleepHours} label="Sleep" />
|
|
</Grid>
|
|
<Grid item xs={4}>
|
|
<Stat icon={<RestaurantIcon />} value={todayFeedings} label="Fed" />
|
|
</Grid>
|
|
<Grid item xs={4}>
|
|
<Stat icon={<ChildCareIcon />} value={todayDiapers} label="Diapers" />
|
|
</Grid>
|
|
</Grid>
|
|
|
|
{/* Last activity timestamp */}
|
|
<Typography variant="caption" color="text.secondary">
|
|
Last activity: {formatDistanceToNow(lastActivity.timestamp)} ago
|
|
</Typography>
|
|
</CardContent>
|
|
<CardActions>
|
|
<Button onClick={() => viewChildDetails(child.id)}>View Details</Button>
|
|
<Button onClick={() => logActivityFor(child.id)}>Log Activity</Button>
|
|
</CardActions>
|
|
</Card>
|
|
))}
|
|
```
|
|
|
|
**Features:**
|
|
- Pull-to-refresh for all cards
|
|
- Click card to expand/navigate to child-specific view
|
|
- Show alerts/notifications badge on cards
|
|
- Color-coded left border per child
|
|
- Skeleton loading states
|
|
- Empty state for no activities today
|
|
|
|
---
|
|
|
|
### 1.3 Activity Logging Updates
|
|
|
|
**Files to Update:**
|
|
- `/maternal-web/app/track/feeding/page.tsx`
|
|
- `/maternal-web/app/track/sleep/page.tsx`
|
|
- `/maternal-web/app/track/diaper/page.tsx`
|
|
- `/maternal-web/app/track/activity/page.tsx`
|
|
- `/maternal-web/app/track/growth/page.tsx`
|
|
|
|
**Required Changes:**
|
|
|
|
1. **Child Selection Step (Pre-form)**
|
|
```typescript
|
|
// Add child selector at top of each tracking form
|
|
<Box sx={{ mb: 3, p: 2, bgcolor: 'background.paper', borderRadius: 2 }}>
|
|
<Typography variant="subtitle2" gutterBottom>
|
|
Who is this for?
|
|
</Typography>
|
|
<ChildSelector
|
|
mode="multiple" // Allow bulk logging
|
|
selectedChildIds={selectedChildren}
|
|
onChange={setSelectedChildren}
|
|
showAllOption={false}
|
|
/>
|
|
|
|
{/* Quick select buttons for last active child */}
|
|
<Button
|
|
size="small"
|
|
onClick={() => setSelectedChildren([lastActiveChildId])}
|
|
>
|
|
Same as last time ({lastActiveChildName})
|
|
</Button>
|
|
</Box>
|
|
```
|
|
|
|
2. **Bulk Logging Support**
|
|
```typescript
|
|
// When multiple children selected, show batch confirmation
|
|
if (selectedChildren.length > 1) {
|
|
return (
|
|
<Alert severity="info" sx={{ mb: 2 }}>
|
|
This will be logged for: {selectedChildren.map(id =>
|
|
children.find(c => c.id === id)?.name
|
|
).join(', ')}
|
|
</Alert>
|
|
);
|
|
}
|
|
|
|
// Submit handler creates multiple activities
|
|
const handleSubmit = async (formData) => {
|
|
const promises = selectedChildren.map(childId =>
|
|
trackingApi.createActivity({
|
|
...formData,
|
|
childId,
|
|
timestamp: new Date().toISOString(),
|
|
})
|
|
);
|
|
|
|
await Promise.all(promises);
|
|
|
|
// Show success with child names
|
|
toast.success(`Activity logged for ${selectedChildren.length} children`);
|
|
};
|
|
```
|
|
|
|
3. **Default Child Logic**
|
|
```typescript
|
|
// On mount, set default child selection:
|
|
useEffect(() => {
|
|
// 1. Last child used for this activity type
|
|
const lastUsed = localStorage.getItem(`lastChild_${activityType}`);
|
|
if (lastUsed && children.some(c => c.id === lastUsed)) {
|
|
setSelectedChildren([lastUsed]);
|
|
return;
|
|
}
|
|
|
|
// 2. Globally selected child from header
|
|
if (globalSelectedChild) {
|
|
setSelectedChildren([globalSelectedChild]);
|
|
return;
|
|
}
|
|
|
|
// 3. First child in family
|
|
if (children.length > 0) {
|
|
setSelectedChildren([children[0].id]);
|
|
}
|
|
}, []);
|
|
|
|
// Save last used child on submit
|
|
const saveLastUsedChild = (childId: string) => {
|
|
localStorage.setItem(`lastChild_${activityType}`, childId);
|
|
};
|
|
```
|
|
|
|
---
|
|
|
|
### 1.4 Analytics & Insights Updates
|
|
|
|
#### 1.4.1 Comparison Tab Component
|
|
|
|
**Location:** `/maternal-web/components/features/analytics/ComparisonView.tsx`
|
|
|
|
**Features:**
|
|
- Side-by-side comparison for 2 children
|
|
- Overlay comparison for 3+ children
|
|
- Metric selector (sleep, feeding, growth, development)
|
|
- Date range picker
|
|
- Export comparison report
|
|
- Color-coded legends
|
|
|
|
**Implementation:**
|
|
```typescript
|
|
interface ComparisonViewProps {
|
|
childIds: string[];
|
|
dateRange: { start: Date; end: Date };
|
|
metrics: ComparisonMetric[];
|
|
}
|
|
|
|
type ComparisonMetric =
|
|
| 'sleep-patterns'
|
|
| 'feeding-frequency'
|
|
| 'growth-curves'
|
|
| 'diaper-changes'
|
|
| 'activities'
|
|
| 'milestones';
|
|
|
|
// Component structure:
|
|
// 1. Child selector (checkboxes for each child)
|
|
// 2. Metric selector (tabs or dropdown)
|
|
// 3. Date range selector
|
|
// 4. Comparison chart area
|
|
// 5. Export button
|
|
|
|
// Chart types based on metric:
|
|
// - Sleep: Overlay line chart with different colors
|
|
// - Feeding: Bar chart comparison
|
|
// - Growth: Multi-line chart with percentile curves
|
|
// - Diapers: Stacked bar chart
|
|
// - Activities: Heatmap comparison
|
|
// - Milestones: Timeline comparison
|
|
```
|
|
|
|
**Chart Color Palette (consistent across app):**
|
|
```typescript
|
|
const childColors = [
|
|
'#FF6B9D', // Pink
|
|
'#4ECDC4', // Teal
|
|
'#FFD93D', // Yellow
|
|
'#95E1D3', // Mint
|
|
'#C7CEEA', // Lavender
|
|
'#FF8C42', // Orange
|
|
'#A8E6CF', // Green
|
|
'#B8B8FF', // Purple
|
|
];
|
|
|
|
// Assign colors to children consistently
|
|
const getChildColor = (childId: string, allChildIds: string[]) => {
|
|
const index = allChildIds.indexOf(childId);
|
|
return childColors[index % childColors.length];
|
|
};
|
|
```
|
|
|
|
#### 1.4.2 Update Existing Analytics Pages
|
|
|
|
**Files:**
|
|
- `/maternal-web/app/analytics/page.tsx`
|
|
- `/maternal-web/components/features/analytics/InsightsDashboard.tsx`
|
|
- `/maternal-web/components/features/analytics/UnifiedInsightsDashboard.tsx`
|
|
|
|
**Changes:**
|
|
1. Add "Compare" tab alongside existing tabs
|
|
2. Show comparison tab only when 2+ children exist
|
|
3. Update all charts to support multi-child overlay
|
|
4. Add child name to chart titles and legends
|
|
5. Export reports include child identifier
|
|
|
|
```typescript
|
|
// Add tab to analytics page
|
|
<Tabs value={tabValue} onChange={setTabValue}>
|
|
<Tab label="Insights" />
|
|
<Tab label="Predictions" />
|
|
{children.length >= 2 && <Tab label="Compare" />}
|
|
</Tabs>
|
|
|
|
// Tab panel for comparison
|
|
{tabValue === 2 && children.length >= 2 && (
|
|
<ComparisonView
|
|
childIds={selectedChildIds}
|
|
dateRange={dateRange}
|
|
metrics={['sleep-patterns', 'feeding-frequency']}
|
|
/>
|
|
)}
|
|
```
|
|
|
|
---
|
|
|
|
### 1.5 Recent Activities List Enhancement
|
|
|
|
**Location:** `/maternal-web/components/features/dashboard/RecentActivities.tsx`
|
|
|
|
**Updates:**
|
|
```typescript
|
|
// Add child indicator to each activity item
|
|
<ListItem>
|
|
<ListItemAvatar>
|
|
<Avatar
|
|
src={child.photoUrl}
|
|
sx={{
|
|
border: `2px solid ${getChildColor(activity.childId, allChildIds)}`
|
|
}}
|
|
>
|
|
{child.name[0]}
|
|
</Avatar>
|
|
</ListItemAvatar>
|
|
<ListItemText
|
|
primary={
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
|
<Typography variant="body1">
|
|
{activity.type}
|
|
</Typography>
|
|
{/* Show child name badge */}
|
|
<Chip
|
|
label={child.name}
|
|
size="small"
|
|
sx={{
|
|
bgcolor: alpha(getChildColor(activity.childId, allChildIds), 0.2),
|
|
color: getChildColor(activity.childId, allChildIds),
|
|
}}
|
|
/>
|
|
</Box>
|
|
}
|
|
secondary={formatDistanceToNow(activity.timestamp)}
|
|
/>
|
|
</ListItem>
|
|
|
|
// Add filter buttons at top
|
|
<ButtonGroup size="small" sx={{ mb: 2 }}>
|
|
<Button
|
|
variant={filter === 'all' ? 'contained' : 'outlined'}
|
|
onClick={() => setFilter('all')}
|
|
>
|
|
All
|
|
</Button>
|
|
{children.map(child => (
|
|
<Button
|
|
key={child.id}
|
|
variant={filter === child.id ? 'contained' : 'outlined'}
|
|
onClick={() => setFilter(child.id)}
|
|
>
|
|
{child.name}
|
|
</Button>
|
|
))}
|
|
</ButtonGroup>
|
|
```
|
|
|
|
---
|
|
|
|
### 1.6 AI Assistant Updates
|
|
|
|
**Location:** `/maternal-web/app/ai/page.tsx` and AI components
|
|
|
|
**Changes:**
|
|
|
|
1. **Context Awareness**
|
|
```typescript
|
|
// Include selected child in AI context
|
|
const buildAIContext = (selectedChildId: string | null) => {
|
|
if (!selectedChildId) {
|
|
return {
|
|
mode: 'general',
|
|
children: children.map(c => ({ id: c.id, name: c.name, age: calculateAge(c.birthDate) })),
|
|
};
|
|
}
|
|
|
|
const child = children.find(c => c.id === selectedChildId);
|
|
const recentActivities = activities.filter(a =>
|
|
a.childId === selectedChildId &&
|
|
isAfter(parseISO(a.timestamp), subDays(new Date(), 2))
|
|
);
|
|
|
|
return {
|
|
mode: 'child-specific',
|
|
child: {
|
|
id: child.id,
|
|
name: child.name,
|
|
age: calculateAge(child.birthDate),
|
|
gender: child.gender,
|
|
},
|
|
recentActivities: recentActivities.map(a => ({
|
|
type: a.type,
|
|
timestamp: a.timestamp,
|
|
details: a.details,
|
|
})),
|
|
};
|
|
};
|
|
```
|
|
|
|
2. **Multi-Child Comparison Questions**
|
|
```typescript
|
|
// Support questions like:
|
|
// "Why is Emma sleeping less than Noah?"
|
|
// "Compare feeding patterns for all my children"
|
|
// "Is it normal that Sarah eats more than her brother?"
|
|
|
|
// AI responses should include child names:
|
|
// "Based on Emma's recent sleep data (averaging 11 hours),
|
|
// compared to Noah's 13 hours..."
|
|
```
|
|
|
|
3. **Child Selector in AI Chat**
|
|
```typescript
|
|
// Add child selector above chat input
|
|
<Box sx={{ mb: 2 }}>
|
|
<Typography variant="caption" color="text.secondary">
|
|
Ask about:
|
|
</Typography>
|
|
<ChildSelector
|
|
mode="single"
|
|
selectedChildIds={[selectedChildForAI]}
|
|
onChange={(ids) => setSelectedChildForAI(ids[0])}
|
|
showAllOption={true}
|
|
compact={true}
|
|
/>
|
|
</Box>
|
|
```
|
|
|
|
---
|
|
|
|
## Backend Implementation
|
|
|
|
### 2.1 Children Module Enhancements
|
|
|
|
**Location:** `/maternal-app-backend/src/modules/children/`
|
|
|
|
**Current Status:** Basic CRUD operations exist with family-based filtering.
|
|
|
|
**Required Updates:**
|
|
|
|
#### 2.1.1 Color Assignment Service
|
|
|
|
**File:** `/maternal-app-backend/src/modules/children/children.service.ts`
|
|
|
|
```typescript
|
|
// Auto-assign consistent colors to children
|
|
private readonly CHILD_COLORS = [
|
|
'#FF6B9D', '#4ECDC4', '#FFD93D', '#95E1D3',
|
|
'#C7CEEA', '#FF8C42', '#A8E6CF', '#B8B8FF',
|
|
];
|
|
|
|
async create(createChildDto: CreateChildDto): Promise<Child> {
|
|
// Get family's existing children count
|
|
const familyChildren = await this.childRepository.find({
|
|
where: { familyId: createChildDto.familyId, deletedAt: IsNull() },
|
|
order: { createdAt: 'ASC' },
|
|
});
|
|
|
|
// Assign color based on birth order
|
|
const colorIndex = familyChildren.length % this.CHILD_COLORS.length;
|
|
const assignedColor = this.CHILD_COLORS[colorIndex];
|
|
|
|
const child = this.childRepository.create({
|
|
...createChildDto,
|
|
displayColor: assignedColor,
|
|
});
|
|
|
|
return this.childRepository.save(child);
|
|
}
|
|
```
|
|
|
|
#### 2.1.2 Family Statistics Endpoint
|
|
|
|
**File:** `/maternal-app-backend/src/modules/children/children.controller.ts`
|
|
|
|
```typescript
|
|
@Get('family/:familyId/statistics')
|
|
@UseGuards(JwtAuthGuard)
|
|
async getFamilyStatistics(
|
|
@Param('familyId') familyId: string,
|
|
@Request() req,
|
|
) {
|
|
// Verify user has access to family
|
|
const hasAccess = await this.familiesService.userHasAccess(req.user.id, familyId);
|
|
if (!hasAccess) {
|
|
throw new ForbiddenException('Access denied to this family');
|
|
}
|
|
|
|
const stats = await this.childrenService.getFamilyStatistics(familyId);
|
|
return { success: true, data: stats };
|
|
}
|
|
|
|
// Service implementation
|
|
async getFamilyStatistics(familyId: string) {
|
|
const children = await this.findAllForFamily(familyId);
|
|
|
|
return {
|
|
totalChildren: children.length,
|
|
viewMode: children.length <= 3 ? 'tabs' : 'cards',
|
|
ageRange: {
|
|
youngest: Math.min(...children.map(c => this.calculateAge(c.birthDate))),
|
|
oldest: Math.max(...children.map(c => this.calculateAge(c.birthDate))),
|
|
},
|
|
genderDistribution: {
|
|
male: children.filter(c => c.gender === 'male').length,
|
|
female: children.filter(c => c.gender === 'female').length,
|
|
other: children.filter(c => !['male', 'female'].includes(c.gender)).length,
|
|
},
|
|
};
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### 2.2 Activities/Tracking Module Updates
|
|
|
|
**Location:** `/maternal-app-backend/src/modules/tracking/`
|
|
|
|
#### 2.2.1 Bulk Activity Creation
|
|
|
|
**File:** `/maternal-app-backend/src/modules/tracking/tracking.controller.ts`
|
|
|
|
```typescript
|
|
@Post('bulk')
|
|
@UseGuards(JwtAuthGuard)
|
|
async createBulkActivities(
|
|
@Body() createBulkDto: CreateBulkActivitiesDto,
|
|
@Request() req,
|
|
) {
|
|
// Validate user has access to all children
|
|
for (const childId of createBulkDto.childIds) {
|
|
const hasAccess = await this.childrenService.userHasAccessToChild(
|
|
req.user.id,
|
|
childId,
|
|
);
|
|
if (!hasAccess) {
|
|
throw new ForbiddenException(`Access denied to child ${childId}`);
|
|
}
|
|
}
|
|
|
|
const activities = await this.trackingService.createBulkActivities(
|
|
createBulkDto,
|
|
req.user.id,
|
|
);
|
|
|
|
return { success: true, data: { activities, count: activities.length } };
|
|
}
|
|
```
|
|
|
|
**DTO:**
|
|
```typescript
|
|
// /maternal-app-backend/src/modules/tracking/dto/create-bulk-activities.dto.ts
|
|
export class CreateBulkActivitiesDto {
|
|
@IsArray()
|
|
@IsString({ each: true })
|
|
childIds: string[];
|
|
|
|
@IsString()
|
|
type: string;
|
|
|
|
@IsISO8601()
|
|
timestamp: string;
|
|
|
|
@IsOptional()
|
|
@IsObject()
|
|
details?: Record<string, any>;
|
|
|
|
@IsOptional()
|
|
@IsString()
|
|
notes?: string;
|
|
}
|
|
```
|
|
|
|
**Service:**
|
|
```typescript
|
|
// /maternal-app-backend/src/modules/tracking/tracking.service.ts
|
|
async createBulkActivities(
|
|
dto: CreateBulkActivitiesDto,
|
|
userId: string,
|
|
): Promise<Activity[]> {
|
|
const activities = dto.childIds.map(childId =>
|
|
this.activityRepository.create({
|
|
childId,
|
|
type: dto.type,
|
|
timestamp: dto.timestamp,
|
|
details: dto.details,
|
|
notes: dto.notes,
|
|
createdBy: userId,
|
|
}),
|
|
);
|
|
|
|
const saved = await this.activityRepository.save(activities);
|
|
|
|
// Emit WebSocket events for each child
|
|
for (const activity of saved) {
|
|
this.eventEmitter.emit('activity.created', {
|
|
activity,
|
|
childId: activity.childId,
|
|
});
|
|
}
|
|
|
|
return saved;
|
|
}
|
|
```
|
|
|
|
#### 2.2.2 Multi-Child Activity Queries
|
|
|
|
**File:** `/maternal-app-backend/src/modules/tracking/tracking.controller.ts`
|
|
|
|
```typescript
|
|
@Get()
|
|
@UseGuards(JwtAuthGuard)
|
|
async getActivities(
|
|
@Query('childIds') childIdsParam?: string, // Comma-separated IDs
|
|
@Query('childId') childId?: string, // Single ID (backward compatibility)
|
|
@Query('type') type?: string,
|
|
@Query('startDate') startDate?: string,
|
|
@Query('endDate') endDate?: string,
|
|
@Request() req,
|
|
) {
|
|
// Parse child IDs
|
|
let childIds: string[] = [];
|
|
if (childIdsParam) {
|
|
childIds = childIdsParam.split(',');
|
|
} else if (childId) {
|
|
childIds = [childId];
|
|
}
|
|
|
|
// Validate access to all children
|
|
for (const id of childIds) {
|
|
const hasAccess = await this.childrenService.userHasAccessToChild(req.user.id, id);
|
|
if (!hasAccess) {
|
|
throw new ForbiddenException(`Access denied to child ${id}`);
|
|
}
|
|
}
|
|
|
|
const activities = await this.trackingService.getActivities({
|
|
childIds,
|
|
type,
|
|
startDate: startDate ? new Date(startDate) : undefined,
|
|
endDate: endDate ? new Date(endDate) : undefined,
|
|
});
|
|
|
|
return { success: true, data: activities };
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### 2.3 Analytics Module Updates
|
|
|
|
**Location:** `/maternal-app-backend/src/modules/analytics/`
|
|
|
|
#### 2.3.1 Comparison Endpoint
|
|
|
|
**File:** `/maternal-app-backend/src/modules/analytics/analytics.controller.ts`
|
|
|
|
```typescript
|
|
@Get('compare')
|
|
@UseGuards(JwtAuthGuard)
|
|
async compareChildren(
|
|
@Query('childIds') childIdsParam: string, // Required, comma-separated
|
|
@Query('metric') metric: ComparisonMetric,
|
|
@Query('startDate') startDate: string,
|
|
@Query('endDate') endDate: string,
|
|
@Request() req,
|
|
) {
|
|
const childIds = childIdsParam.split(',');
|
|
|
|
// Validate minimum 2 children
|
|
if (childIds.length < 2) {
|
|
throw new BadRequestException('At least 2 children required for comparison');
|
|
}
|
|
|
|
// Validate access
|
|
for (const childId of childIds) {
|
|
const hasAccess = await this.childrenService.userHasAccessToChild(req.user.id, childId);
|
|
if (!hasAccess) {
|
|
throw new ForbiddenException(`Access denied to child ${childId}`);
|
|
}
|
|
}
|
|
|
|
const comparison = await this.analyticsService.compareChildren({
|
|
childIds,
|
|
metric,
|
|
startDate: new Date(startDate),
|
|
endDate: new Date(endDate),
|
|
});
|
|
|
|
return { success: true, data: comparison };
|
|
}
|
|
```
|
|
|
|
**Service Implementation:**
|
|
```typescript
|
|
// /maternal-app-backend/src/modules/analytics/analytics.service.ts
|
|
async compareChildren(params: ComparisonParams): Promise<ComparisonResult> {
|
|
const { childIds, metric, startDate, endDate } = params;
|
|
|
|
switch (metric) {
|
|
case 'sleep-patterns':
|
|
return this.compareSleepPatterns(childIds, startDate, endDate);
|
|
|
|
case 'feeding-frequency':
|
|
return this.compareFeedingFrequency(childIds, startDate, endDate);
|
|
|
|
case 'growth-curves':
|
|
return this.compareGrowthCurves(childIds, startDate, endDate);
|
|
|
|
case 'activities':
|
|
return this.compareActivities(childIds, startDate, endDate);
|
|
|
|
default:
|
|
throw new BadRequestException(`Unknown metric: ${metric}`);
|
|
}
|
|
}
|
|
|
|
private async compareSleepPatterns(
|
|
childIds: string[],
|
|
startDate: Date,
|
|
endDate: Date,
|
|
): Promise<SleepComparisonResult> {
|
|
const dataByChild = await Promise.all(
|
|
childIds.map(async (childId) => {
|
|
const sleepActivities = await this.activityRepository.find({
|
|
where: {
|
|
childId,
|
|
type: 'sleep',
|
|
timestamp: Between(startDate, endDate),
|
|
},
|
|
order: { timestamp: 'ASC' },
|
|
});
|
|
|
|
return {
|
|
childId,
|
|
data: this.aggregateSleepData(sleepActivities),
|
|
};
|
|
}),
|
|
);
|
|
|
|
return {
|
|
metric: 'sleep-patterns',
|
|
dateRange: { start: startDate, end: endDate },
|
|
children: dataByChild,
|
|
summary: this.calculateSleepSummary(dataByChild),
|
|
};
|
|
}
|
|
```
|
|
|
|
#### 2.3.2 Aggregated Statistics
|
|
|
|
**File:** `/maternal-app-backend/src/modules/analytics/analytics.service.ts`
|
|
|
|
```typescript
|
|
async getAggregatedStatistics(
|
|
familyId: string,
|
|
startDate: Date,
|
|
endDate: Date,
|
|
): Promise<AggregatedStats> {
|
|
const children = await this.childrenService.findAllForFamily(familyId);
|
|
|
|
const childStats = await Promise.all(
|
|
children.map(child =>
|
|
this.getChildStatistics(child.id, startDate, endDate)
|
|
),
|
|
);
|
|
|
|
return {
|
|
familyId,
|
|
dateRange: { start: startDate, end: endDate },
|
|
totalChildren: children.length,
|
|
perChild: childStats,
|
|
aggregate: {
|
|
totalActivities: childStats.reduce((sum, s) => sum + s.totalActivities, 0),
|
|
totalSleepHours: childStats.reduce((sum, s) => sum + s.totalSleepHours, 0),
|
|
totalFeedings: childStats.reduce((sum, s) => sum + s.totalFeedings, 0),
|
|
averageSleepPerChild: childStats.reduce((sum, s) => sum + s.totalSleepHours, 0) / children.length,
|
|
},
|
|
};
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## AI & Voice Processing
|
|
|
|
### 3.1 Voice Command Multi-Child Detection
|
|
|
|
**Location:** `/maternal-app-backend/src/modules/ai/services/voice-processing.service.ts`
|
|
|
|
**Current Status:** Basic voice-to-text exists. Needs child name extraction.
|
|
|
|
#### 3.1.1 Child Name Entity Recognition
|
|
|
|
```typescript
|
|
interface VoiceProcessingResult {
|
|
transcription: string;
|
|
detectedChildIds: string[];
|
|
confidence: number;
|
|
activityType?: string;
|
|
activityDetails?: Record<string, any>;
|
|
requiresClarity?: boolean;
|
|
clarificationPrompt?: string;
|
|
}
|
|
|
|
@Injectable()
|
|
export class VoiceProcessingService {
|
|
private readonly MULTI_CHILD_KEYWORDS = [
|
|
'both', 'all', 'everyone', 'kids', 'children', 'twins', 'both kids',
|
|
];
|
|
|
|
async processVoiceInput(
|
|
audioBuffer: Buffer,
|
|
familyId: string,
|
|
): Promise<VoiceProcessingResult> {
|
|
// 1. Transcribe audio using Whisper API
|
|
const transcription = await this.transcribeAudio(audioBuffer);
|
|
|
|
// 2. Get family's children
|
|
const children = await this.childrenService.findAllForFamily(familyId);
|
|
|
|
// 3. Detect child names in transcription
|
|
const detectedChildren = this.detectChildNames(transcription, children);
|
|
|
|
// 4. Check for multi-child keywords
|
|
const hasMultiKeyword = this.MULTI_CHILD_KEYWORDS.some(keyword =>
|
|
transcription.toLowerCase().includes(keyword),
|
|
);
|
|
|
|
if (hasMultiKeyword) {
|
|
// Apply to all children
|
|
return {
|
|
transcription,
|
|
detectedChildIds: children.map(c => c.id),
|
|
confidence: 0.9,
|
|
activityType: this.extractActivityType(transcription),
|
|
};
|
|
}
|
|
|
|
if (detectedChildren.length === 0) {
|
|
// No child detected - need clarification
|
|
return {
|
|
transcription,
|
|
detectedChildIds: [],
|
|
confidence: 0.0,
|
|
requiresClarity: true,
|
|
clarificationPrompt: 'Which child is this for?',
|
|
};
|
|
}
|
|
|
|
if (detectedChildren.length === 1) {
|
|
// Single child detected
|
|
return {
|
|
transcription,
|
|
detectedChildIds: [detectedChildren[0].id],
|
|
confidence: 0.95,
|
|
activityType: this.extractActivityType(transcription),
|
|
activityDetails: this.extractActivityDetails(transcription),
|
|
};
|
|
}
|
|
|
|
// Multiple children detected
|
|
return {
|
|
transcription,
|
|
detectedChildIds: detectedChildren.map(c => c.id),
|
|
confidence: 0.85,
|
|
activityType: this.extractActivityType(transcription),
|
|
};
|
|
}
|
|
|
|
private detectChildNames(
|
|
text: string,
|
|
children: Child[],
|
|
): Child[] {
|
|
const lowerText = text.toLowerCase();
|
|
const detected: Child[] = [];
|
|
|
|
for (const child of children) {
|
|
const nameLower = child.name.toLowerCase();
|
|
|
|
// Exact name match
|
|
if (lowerText.includes(nameLower)) {
|
|
detected.push(child);
|
|
continue;
|
|
}
|
|
|
|
// Nickname/partial match (first name only if multiple words)
|
|
const firstName = child.name.split(' ')[0].toLowerCase();
|
|
if (lowerText.includes(firstName)) {
|
|
detected.push(child);
|
|
}
|
|
}
|
|
|
|
return detected;
|
|
}
|
|
|
|
private extractActivityType(text: string): string | undefined {
|
|
const lowerText = text.toLowerCase();
|
|
|
|
const activityPatterns = [
|
|
{ pattern: /\b(fed|feeding|ate|eat|bottle|breast|milk)\b/, type: 'feeding' },
|
|
{ pattern: /\b(sleep|sleeping|nap|napping|asleep)\b/, type: 'sleep' },
|
|
{ pattern: /\b(diaper|pee|poop|changed)\b/, type: 'diaper' },
|
|
{ pattern: /\b(play|playing|played)\b/, type: 'play' },
|
|
{ pattern: /\b(bath|bathing|bathed)\b/, type: 'bath' },
|
|
];
|
|
|
|
for (const { pattern, type } of activityPatterns) {
|
|
if (pattern.test(lowerText)) {
|
|
return type;
|
|
}
|
|
}
|
|
|
|
return undefined;
|
|
}
|
|
}
|
|
```
|
|
|
|
#### 3.1.2 Voice Clarification Flow
|
|
|
|
**Frontend Component:** `/maternal-web/components/features/voice/VoiceClarification.tsx`
|
|
|
|
```typescript
|
|
interface VoiceClarificationProps {
|
|
transcription: string;
|
|
prompt: string;
|
|
children: Child[];
|
|
onConfirm: (childIds: string[]) => void;
|
|
onCancel: () => void;
|
|
}
|
|
|
|
// Modal/Dialog showing:
|
|
// 1. Transcription: "Emma just had a bottle"
|
|
// 2. Detected uncertainty: "Which Emma?"
|
|
// 3. Child selector buttons with avatars
|
|
// 4. Voice response option: "Say the child's full name"
|
|
```
|
|
|
|
---
|
|
|
|
### 3.2 AI Assistant Context Building
|
|
|
|
**Location:** `/maternal-app-backend/src/modules/ai/services/context-builder.service.ts`
|
|
|
|
#### 3.2.1 Multi-Child Context Strategy
|
|
|
|
```typescript
|
|
interface AIContext {
|
|
mode: 'single-child' | 'multi-child' | 'comparison' | 'general';
|
|
children: ChildContext[];
|
|
selectedChildId?: string;
|
|
tokenBudget: number;
|
|
priorityWeights: Record<string, number>;
|
|
}
|
|
|
|
interface ChildContext {
|
|
childId: string;
|
|
name: string;
|
|
age: string;
|
|
gender: string;
|
|
recentActivities: Activity[];
|
|
patterns: PatternInsights;
|
|
medicalInfo?: Record<string, any>;
|
|
}
|
|
|
|
@Injectable()
|
|
export class ContextBuilderService {
|
|
private readonly TOKEN_BUDGET = 4000;
|
|
|
|
async buildContext(params: {
|
|
childIds?: string[];
|
|
mode: AIContext['mode'];
|
|
query: string;
|
|
}): Promise<AIContext> {
|
|
const { childIds, mode, query } = params;
|
|
|
|
if (mode === 'general' || !childIds || childIds.length === 0) {
|
|
return this.buildGeneralContext(query);
|
|
}
|
|
|
|
if (mode === 'single-child' && childIds.length === 1) {
|
|
return this.buildSingleChildContext(childIds[0], query);
|
|
}
|
|
|
|
if (mode === 'multi-child' || mode === 'comparison') {
|
|
return this.buildMultiChildContext(childIds, query);
|
|
}
|
|
}
|
|
|
|
private async buildSingleChildContext(
|
|
childId: string,
|
|
query: string,
|
|
): Promise<AIContext> {
|
|
const child = await this.childrenService.findOne(childId);
|
|
const recentActivities = await this.getRecentActivities(childId, 48); // 48 hours
|
|
const patterns = await this.analyticsService.getPatterns(childId, 7); // 7 days
|
|
|
|
return {
|
|
mode: 'single-child',
|
|
selectedChildId: childId,
|
|
children: [{
|
|
childId: child.id,
|
|
name: child.name,
|
|
age: this.calculateAge(child.birthDate),
|
|
gender: child.gender,
|
|
recentActivities: this.prioritizeActivities(recentActivities, query),
|
|
patterns,
|
|
medicalInfo: child.medicalInfo,
|
|
}],
|
|
tokenBudget: this.TOKEN_BUDGET,
|
|
priorityWeights: {
|
|
query: 1.0,
|
|
recentActivities: 0.8,
|
|
childProfile: 0.7,
|
|
patterns: 0.6,
|
|
},
|
|
};
|
|
}
|
|
|
|
private async buildMultiChildContext(
|
|
childIds: string[],
|
|
query: string,
|
|
): Promise<AIContext> {
|
|
// Distribute token budget across children
|
|
const tokensPerChild = Math.floor(this.TOKEN_BUDGET * 0.7 / childIds.length);
|
|
|
|
const childrenContexts = await Promise.all(
|
|
childIds.map(async (childId) => {
|
|
const child = await this.childrenService.findOne(childId);
|
|
const recentActivities = await this.getRecentActivities(childId, 24); // 24h for multi
|
|
|
|
return {
|
|
childId: child.id,
|
|
name: child.name,
|
|
age: this.calculateAge(child.birthDate),
|
|
gender: child.gender,
|
|
recentActivities: recentActivities.slice(0, 5), // Limit activities
|
|
patterns: await this.analyticsService.getPatterns(childId, 7),
|
|
};
|
|
}),
|
|
);
|
|
|
|
return {
|
|
mode: 'multi-child',
|
|
children: childrenContexts,
|
|
tokenBudget: this.TOKEN_BUDGET,
|
|
priorityWeights: {
|
|
query: 1.0,
|
|
childComparison: 0.9,
|
|
recentActivities: 0.6,
|
|
patterns: 0.5,
|
|
},
|
|
};
|
|
}
|
|
|
|
private prioritizeActivities(
|
|
activities: Activity[],
|
|
query: string,
|
|
): Activity[] {
|
|
// Filter activities based on query relevance
|
|
const queryLower = query.toLowerCase();
|
|
|
|
const scored = activities.map(activity => {
|
|
let score = 1.0;
|
|
|
|
// Boost if activity type mentioned in query
|
|
if (queryLower.includes(activity.type)) {
|
|
score += 2.0;
|
|
}
|
|
|
|
// Boost recent activities
|
|
const hoursAgo = differenceInHours(new Date(), parseISO(activity.timestamp));
|
|
score += Math.max(0, (48 - hoursAgo) / 48);
|
|
|
|
return { activity, score };
|
|
});
|
|
|
|
return scored
|
|
.sort((a, b) => b.score - a.score)
|
|
.slice(0, 10)
|
|
.map(s => s.activity);
|
|
}
|
|
}
|
|
```
|
|
|
|
#### 3.2.2 AI Prompt Templates for Multi-Child
|
|
|
|
**File:** `/maternal-app-backend/src/modules/ai/prompts/multi-child-prompts.ts`
|
|
|
|
```typescript
|
|
export const MULTI_CHILD_SYSTEM_PROMPT = `
|
|
You are an AI parenting assistant helping a family with multiple children.
|
|
|
|
When answering questions:
|
|
1. ALWAYS mention the child's name when referring to specific data
|
|
2. Use comparison language when discussing multiple children
|
|
3. Acknowledge individual differences between children
|
|
4. Avoid making one child seem "better" or "worse"
|
|
5. Provide age-appropriate context for each child
|
|
|
|
Family context:
|
|
{{#each children}}
|
|
- {{name}} ({{age}} old, {{gender}})
|
|
{{/each}}
|
|
|
|
User's question: {{query}}
|
|
`;
|
|
|
|
export const COMPARISON_PROMPT_TEMPLATE = `
|
|
Compare the following metric for these children:
|
|
{{#each children}}
|
|
Child: {{name}} ({{age}})
|
|
{{metric}} data: {{data}}
|
|
{{/each}}
|
|
|
|
Provide a balanced comparison that:
|
|
1. Highlights patterns for each child
|
|
2. Explains any differences based on age, development, or recent events
|
|
3. Offers actionable insights
|
|
4. Reassures the parent that variations are normal
|
|
`;
|
|
|
|
export const MULTI_CHILD_ACTIVITY_TEMPLATE = `
|
|
The user reported: "{{transcription}}"
|
|
|
|
Detected children: {{#each detectedChildren}}{{name}}{{/each}}
|
|
Activity type: {{activityType}}
|
|
|
|
Generate a natural confirmation message that:
|
|
1. Repeats back what was logged
|
|
2. Mentions all children involved
|
|
3. Provides brief context (e.g., "that's their 3rd feeding today")
|
|
`;
|
|
```
|
|
|
|
---
|
|
|
|
## Database Schema Updates
|
|
|
|
### 4.1 Children Table Enhancement
|
|
|
|
**Migration:** `/maternal-app-backend/src/database/migrations/V008_add_child_display_preferences.sql`
|
|
|
|
```sql
|
|
-- Add display color column
|
|
ALTER TABLE children
|
|
ADD COLUMN display_color VARCHAR(7) DEFAULT '#FF6B9D',
|
|
ADD COLUMN sort_order INTEGER DEFAULT 0,
|
|
ADD COLUMN nickname VARCHAR(50);
|
|
|
|
-- Update existing children with colors based on creation order
|
|
WITH numbered_children AS (
|
|
SELECT
|
|
id,
|
|
ROW_NUMBER() OVER (PARTITION BY family_id ORDER BY created_at) - 1 AS row_num
|
|
FROM children
|
|
WHERE deleted_at IS NULL
|
|
)
|
|
UPDATE children c
|
|
SET display_color = CASE (nc.row_num % 8)
|
|
WHEN 0 THEN '#FF6B9D'
|
|
WHEN 1 THEN '#4ECDC4'
|
|
WHEN 2 THEN '#FFD93D'
|
|
WHEN 3 THEN '#95E1D3'
|
|
WHEN 4 THEN '#C7CEEA'
|
|
WHEN 5 THEN '#FF8C42'
|
|
WHEN 6 THEN '#A8E6CF'
|
|
WHEN 7 THEN '#B8B8FF'
|
|
END,
|
|
sort_order = nc.row_num
|
|
FROM numbered_children nc
|
|
WHERE c.id = nc.id;
|
|
|
|
-- Create index for family queries
|
|
CREATE INDEX idx_children_family_sort ON children(family_id, sort_order, deleted_at);
|
|
```
|
|
|
|
---
|
|
|
|
### 4.2 User Preferences for Multi-Child
|
|
|
|
**Migration:** `/maternal-app-backend/src/database/migrations/V009_create_user_preferences.sql`
|
|
|
|
```sql
|
|
CREATE TABLE user_preferences (
|
|
id VARCHAR(36) PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
user_id VARCHAR(36) NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
preference_key VARCHAR(100) NOT NULL,
|
|
preference_value JSONB NOT NULL,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
|
|
UNIQUE(user_id, preference_key)
|
|
);
|
|
|
|
CREATE INDEX idx_user_preferences_user ON user_preferences(user_id);
|
|
|
|
-- Seed default preferences for multi-child families
|
|
INSERT INTO user_preferences (user_id, preference_key, preference_value)
|
|
SELECT DISTINCT u.id, 'multi_child_view_mode', '"auto"'
|
|
FROM users u
|
|
INNER JOIN family_members fm ON u.id = fm.user_id
|
|
INNER JOIN children c ON fm.family_id = c.family_id
|
|
WHERE c.deleted_at IS NULL
|
|
GROUP BY u.id
|
|
HAVING COUNT(DISTINCT c.id) >= 2
|
|
ON CONFLICT (user_id, preference_key) DO NOTHING;
|
|
|
|
-- Preferences to store:
|
|
-- - multi_child_view_mode: "tabs" | "cards" | "auto"
|
|
-- - default_child_id: child_id for quick actions
|
|
-- - last_selected_child_per_screen: { "/track/feeding": "child_id", ... }
|
|
-- - comparison_default_children: ["child_id_1", "child_id_2"]
|
|
-- - comparison_default_metric: "sleep-patterns"
|
|
```
|
|
|
|
---
|
|
|
|
### 4.3 Activity Association Validation
|
|
|
|
**Migration:** `/maternal-app-backend/src/database/migrations/V010_add_activity_constraints.sql`
|
|
|
|
```sql
|
|
-- Ensure all activities have valid child_id
|
|
ALTER TABLE activities
|
|
ADD CONSTRAINT fk_activities_child
|
|
FOREIGN KEY (child_id)
|
|
REFERENCES children(id)
|
|
ON DELETE CASCADE;
|
|
|
|
-- Add index for multi-child queries
|
|
CREATE INDEX idx_activities_multiple_children
|
|
ON activities(child_id, type, timestamp DESC);
|
|
|
|
-- Add check constraint for future bulk operation tracking
|
|
ALTER TABLE activities
|
|
ADD COLUMN bulk_operation_id VARCHAR(36),
|
|
ADD COLUMN siblings_count INTEGER DEFAULT 1;
|
|
|
|
CREATE INDEX idx_activities_bulk_operation
|
|
ON activities(bulk_operation_id)
|
|
WHERE bulk_operation_id IS NOT NULL;
|
|
|
|
COMMENT ON COLUMN activities.bulk_operation_id IS
|
|
'Groups activities logged simultaneously for multiple children';
|
|
COMMENT ON COLUMN activities.siblings_count IS
|
|
'Number of children this activity was logged for (for UI display)';
|
|
```
|
|
|
|
---
|
|
|
|
## State Management Architecture
|
|
|
|
### 5.1 Redux Store Updates
|
|
|
|
#### 5.1.1 Children Slice Enhancement
|
|
|
|
**File:** `/maternal-web/lib/store/slices/childrenSlice.ts`
|
|
|
|
```typescript
|
|
interface ChildrenState {
|
|
byId: Record<string, Child>;
|
|
allIds: string[];
|
|
selectedChildId: string | null;
|
|
selectedChildIds: string[]; // Multi-select support
|
|
defaultChildId: string | null; // For quick actions
|
|
viewMode: 'tabs' | 'cards' | 'auto';
|
|
loading: boolean;
|
|
error: string | null;
|
|
lastUpdated: string | null;
|
|
}
|
|
|
|
const childrenSlice = createSlice({
|
|
name: 'children',
|
|
initialState: {
|
|
byId: {},
|
|
allIds: [],
|
|
selectedChildId: null,
|
|
selectedChildIds: [],
|
|
defaultChildId: null,
|
|
viewMode: 'auto',
|
|
loading: false,
|
|
error: null,
|
|
lastUpdated: null,
|
|
} as ChildrenState,
|
|
reducers: {
|
|
setSelectedChild: (state, action: PayloadAction<string>) => {
|
|
state.selectedChildId = action.payload;
|
|
state.selectedChildIds = [action.payload];
|
|
|
|
// Save to localStorage
|
|
if (typeof window !== 'undefined') {
|
|
localStorage.setItem('selectedChildId', action.payload);
|
|
}
|
|
},
|
|
|
|
setSelectedChildren: (state, action: PayloadAction<string[]>) => {
|
|
state.selectedChildIds = action.payload;
|
|
state.selectedChildId = action.payload[0] || null;
|
|
},
|
|
|
|
toggleChildSelection: (state, action: PayloadAction<string>) => {
|
|
const childId = action.payload;
|
|
const index = state.selectedChildIds.indexOf(childId);
|
|
|
|
if (index >= 0) {
|
|
state.selectedChildIds.splice(index, 1);
|
|
} else {
|
|
state.selectedChildIds.push(childId);
|
|
}
|
|
|
|
state.selectedChildId = state.selectedChildIds[0] || null;
|
|
},
|
|
|
|
setDefaultChild: (state, action: PayloadAction<string>) => {
|
|
state.defaultChildId = action.payload;
|
|
localStorage.setItem('defaultChildId', action.payload);
|
|
},
|
|
|
|
setViewMode: (state, action: PayloadAction<'tabs' | 'cards' | 'auto'>) => {
|
|
state.viewMode = action.payload;
|
|
localStorage.setItem('childViewMode', action.payload);
|
|
},
|
|
|
|
calculateViewMode: (state) => {
|
|
if (state.viewMode === 'auto') {
|
|
return state.allIds.length <= 3 ? 'tabs' : 'cards';
|
|
}
|
|
return state.viewMode;
|
|
},
|
|
},
|
|
extraReducers: (builder) => {
|
|
builder
|
|
.addCase(fetchChildren.fulfilled, (state, action) => {
|
|
state.byId = action.payload.reduce((acc, child) => {
|
|
acc[child.id] = child;
|
|
return acc;
|
|
}, {} as Record<string, Child>);
|
|
state.allIds = action.payload.map(c => c.id);
|
|
|
|
// Auto-select first child if none selected
|
|
if (!state.selectedChildId && state.allIds.length > 0) {
|
|
state.selectedChildId = state.allIds[0];
|
|
state.selectedChildIds = [state.allIds[0]];
|
|
}
|
|
|
|
state.loading = false;
|
|
state.lastUpdated = new Date().toISOString();
|
|
});
|
|
},
|
|
});
|
|
|
|
// Selectors
|
|
export const selectAllChildren = (state: RootState) =>
|
|
state.children.allIds.map(id => state.children.byId[id]);
|
|
|
|
export const selectSelectedChild = (state: RootState) =>
|
|
state.children.selectedChildId
|
|
? state.children.byId[state.children.selectedChildId]
|
|
: null;
|
|
|
|
export const selectSelectedChildren = (state: RootState) =>
|
|
state.children.selectedChildIds.map(id => state.children.byId[id]);
|
|
|
|
export const selectChildrenCount = (state: RootState) =>
|
|
state.children.allIds.length;
|
|
|
|
export const selectViewMode = (state: RootState) => {
|
|
if (state.children.viewMode === 'auto') {
|
|
return state.children.allIds.length <= 3 ? 'tabs' : 'cards';
|
|
}
|
|
return state.children.viewMode;
|
|
};
|
|
|
|
export const selectChildColor = (childId: string) => (state: RootState) =>
|
|
state.children.byId[childId]?.displayColor || '#FF6B9D';
|
|
```
|
|
|
|
#### 5.1.2 UI Slice for Per-Screen Child Selection
|
|
|
|
**File:** `/maternal-web/lib/store/slices/uiSlice.ts`
|
|
|
|
```typescript
|
|
interface UIState {
|
|
// ... existing UI state
|
|
lastSelectedChildPerScreen: Record<string, string>; // { "/track/feeding": "child_id" }
|
|
comparisonPreferences: {
|
|
selectedChildIds: string[];
|
|
metric: ComparisonMetric;
|
|
dateRange: { start: string; end: string };
|
|
};
|
|
}
|
|
|
|
const uiSlice = createSlice({
|
|
name: 'ui',
|
|
initialState: {
|
|
// ... existing state
|
|
lastSelectedChildPerScreen: {},
|
|
comparisonPreferences: {
|
|
selectedChildIds: [],
|
|
metric: 'sleep-patterns',
|
|
dateRange: {
|
|
start: subDays(new Date(), 7).toISOString(),
|
|
end: new Date().toISOString(),
|
|
},
|
|
},
|
|
} as UIState,
|
|
reducers: {
|
|
setLastSelectedChildForScreen: (
|
|
state,
|
|
action: PayloadAction<{ screen: string; childId: string }>
|
|
) => {
|
|
state.lastSelectedChildPerScreen[action.payload.screen] = action.payload.childId;
|
|
localStorage.setItem(
|
|
'lastSelectedChildPerScreen',
|
|
JSON.stringify(state.lastSelectedChildPerScreen)
|
|
);
|
|
},
|
|
|
|
setComparisonPreferences: (
|
|
state,
|
|
action: PayloadAction<Partial<UIState['comparisonPreferences']>>
|
|
) => {
|
|
state.comparisonPreferences = {
|
|
...state.comparisonPreferences,
|
|
...action.payload,
|
|
};
|
|
localStorage.setItem(
|
|
'comparisonPreferences',
|
|
JSON.stringify(state.comparisonPreferences)
|
|
);
|
|
},
|
|
},
|
|
});
|
|
|
|
export const selectLastChildForScreen = (screen: string) => (state: RootState) =>
|
|
state.ui.lastSelectedChildPerScreen[screen];
|
|
```
|
|
|
|
---
|
|
|
|
## API Enhancements
|
|
|
|
### 6.1 API Client Updates
|
|
|
|
**File:** `/maternal-web/lib/api/children.ts`
|
|
|
|
```typescript
|
|
// Add method for family statistics
|
|
export const childrenApi = {
|
|
// ... existing methods
|
|
|
|
async getFamilyStatistics(familyId: string) {
|
|
const response = await apiClient.get(
|
|
`/children/family/${familyId}/statistics`
|
|
);
|
|
return response.data.data;
|
|
},
|
|
|
|
async getChildrenWithColors(familyId: string): Promise<Child[]> {
|
|
const children = await this.getChildren(familyId);
|
|
// Colors now come from backend
|
|
return children;
|
|
},
|
|
};
|
|
```
|
|
|
|
**File:** `/maternal-web/lib/api/tracking.ts`
|
|
|
|
```typescript
|
|
export const trackingApi = {
|
|
// ... existing methods
|
|
|
|
async createBulkActivities(params: {
|
|
childIds: string[];
|
|
type: string;
|
|
timestamp: string;
|
|
details?: Record<string, any>;
|
|
notes?: string;
|
|
}) {
|
|
const response = await apiClient.post('/tracking/bulk', params);
|
|
return response.data.data;
|
|
},
|
|
|
|
async getActivitiesForMultipleChildren(params: {
|
|
childIds: string[];
|
|
type?: string;
|
|
startDate?: string;
|
|
endDate?: string;
|
|
}) {
|
|
const response = await apiClient.get('/tracking', {
|
|
params: {
|
|
childIds: params.childIds.join(','),
|
|
type: params.type,
|
|
startDate: params.startDate,
|
|
endDate: params.endDate,
|
|
},
|
|
});
|
|
return response.data.data;
|
|
},
|
|
};
|
|
```
|
|
|
|
**File:** `/maternal-web/lib/api/analytics.ts`
|
|
|
|
```typescript
|
|
export const analyticsApi = {
|
|
// ... existing methods
|
|
|
|
async compareChildren(params: {
|
|
childIds: string[];
|
|
metric: ComparisonMetric;
|
|
startDate: string;
|
|
endDate: string;
|
|
}) {
|
|
const response = await apiClient.get('/analytics/compare', {
|
|
params: {
|
|
childIds: params.childIds.join(','),
|
|
metric: params.metric,
|
|
startDate: params.startDate,
|
|
endDate: params.endDate,
|
|
},
|
|
});
|
|
return response.data.data;
|
|
},
|
|
|
|
async getAggregatedStatistics(params: {
|
|
familyId: string;
|
|
startDate: string;
|
|
endDate: string;
|
|
}) {
|
|
const response = await apiClient.get('/analytics/aggregated', {
|
|
params,
|
|
});
|
|
return response.data.data;
|
|
},
|
|
};
|
|
```
|
|
|
|
---
|
|
|
|
## Real-Time Sync Updates
|
|
|
|
### 7.1 WebSocket Event Enhancements
|
|
|
|
**File:** `/maternal-app-backend/src/modules/websocket/websocket.gateway.ts`
|
|
|
|
```typescript
|
|
@WebSocketGateway({
|
|
cors: { origin: '*' },
|
|
namespace: 'family',
|
|
})
|
|
export class WebSocketGateway implements OnGatewayConnection {
|
|
@WebSocketServer()
|
|
server: Server;
|
|
|
|
// Join child-specific rooms
|
|
@SubscribeMessage('join-child-room')
|
|
handleJoinChildRoom(
|
|
@ConnectedSocket() client: Socket,
|
|
@MessageBody() data: { childId: string },
|
|
) {
|
|
const room = `child:${data.childId}`;
|
|
client.join(room);
|
|
this.logger.log(`Client ${client.id} joined room ${room}`);
|
|
}
|
|
|
|
@SubscribeMessage('leave-child-room')
|
|
handleLeaveChildRoom(
|
|
@ConnectedSocket() client: Socket,
|
|
@MessageBody() data: { childId: string },
|
|
) {
|
|
const room = `child:${data.childId}`;
|
|
client.leave(room);
|
|
}
|
|
|
|
// Emit activity updates to child-specific rooms
|
|
emitActivityCreated(activity: Activity) {
|
|
this.server
|
|
.to(`child:${activity.childId}`)
|
|
.emit('activity:created', {
|
|
activity,
|
|
childId: activity.childId,
|
|
timestamp: new Date().toISOString(),
|
|
});
|
|
}
|
|
|
|
emitBulkActivitiesCreated(activities: Activity[]) {
|
|
// Group by child and emit separately
|
|
const byChild = activities.reduce((acc, activity) => {
|
|
if (!acc[activity.childId]) {
|
|
acc[activity.childId] = [];
|
|
}
|
|
acc[activity.childId].push(activity);
|
|
return acc;
|
|
}, {} as Record<string, Activity[]>);
|
|
|
|
Object.entries(byChild).forEach(([childId, childActivities]) => {
|
|
this.server
|
|
.to(`child:${childId}`)
|
|
.emit('activities:bulk-created', {
|
|
activities: childActivities,
|
|
childId,
|
|
count: childActivities.length,
|
|
});
|
|
});
|
|
}
|
|
}
|
|
```
|
|
|
|
**Frontend WebSocket Client:** `/maternal-web/lib/websocket/client.ts`
|
|
|
|
```typescript
|
|
export class WebSocketClient {
|
|
private socket: Socket | null = null;
|
|
private subscribedChildIds: Set<string> = new Set();
|
|
|
|
connect(userId: string, familyId: string) {
|
|
this.socket = io(`${WS_URL}/family`, {
|
|
auth: { userId, familyId },
|
|
});
|
|
|
|
this.setupEventListeners();
|
|
}
|
|
|
|
subscribeToChild(childId: string) {
|
|
if (!this.subscribedChildIds.has(childId)) {
|
|
this.socket?.emit('join-child-room', { childId });
|
|
this.subscribedChildIds.add(childId);
|
|
}
|
|
}
|
|
|
|
unsubscribeFromChild(childId: string) {
|
|
if (this.subscribedChildIds.has(childId)) {
|
|
this.socket?.emit('leave-child-room', { childId });
|
|
this.subscribedChildIds.delete(childId);
|
|
}
|
|
}
|
|
|
|
subscribeToSelectedChildren(childIds: string[]) {
|
|
// Unsubscribe from old children
|
|
this.subscribedChildIds.forEach(id => {
|
|
if (!childIds.includes(id)) {
|
|
this.unsubscribeFromChild(id);
|
|
}
|
|
});
|
|
|
|
// Subscribe to new children
|
|
childIds.forEach(id => this.subscribeToChild(id));
|
|
}
|
|
|
|
private setupEventListeners() {
|
|
this.socket?.on('activity:created', (data) => {
|
|
store.dispatch(activityCreated(data.activity));
|
|
});
|
|
|
|
this.socket?.on('activities:bulk-created', (data) => {
|
|
store.dispatch(bulkActivitiesCreated(data.activities));
|
|
});
|
|
}
|
|
}
|
|
|
|
// Hook for components
|
|
export const useWebSocketChildSync = (childIds: string[]) => {
|
|
useEffect(() => {
|
|
wsClient.subscribeToSelectedChildren(childIds);
|
|
|
|
return () => {
|
|
childIds.forEach(id => wsClient.unsubscribeFromChild(id));
|
|
};
|
|
}, [childIds]);
|
|
};
|
|
```
|
|
|
|
---
|
|
|
|
## Testing Strategy
|
|
|
|
### 8.1 Unit Tests
|
|
|
|
#### 8.1.1 Child Selection Logic
|
|
|
|
**File:** `/maternal-web/__tests__/store/childrenSlice.test.ts`
|
|
|
|
```typescript
|
|
describe('childrenSlice', () => {
|
|
it('should calculate view mode based on child count', () => {
|
|
const state = {
|
|
...initialState,
|
|
allIds: ['child1', 'child2', 'child3'],
|
|
viewMode: 'auto',
|
|
};
|
|
|
|
expect(selectViewMode({ children: state })).toBe('tabs');
|
|
|
|
state.allIds.push('child4');
|
|
expect(selectViewMode({ children: state })).toBe('cards');
|
|
});
|
|
|
|
it('should toggle child selection', () => {
|
|
const state = {
|
|
...initialState,
|
|
selectedChildIds: ['child1'],
|
|
};
|
|
|
|
const newState = childrenSlice.reducer(
|
|
state,
|
|
toggleChildSelection('child2')
|
|
);
|
|
|
|
expect(newState.selectedChildIds).toEqual(['child1', 'child2']);
|
|
});
|
|
|
|
it('should persist selected child to localStorage', () => {
|
|
const state = initialState;
|
|
childrenSlice.reducer(state, setSelectedChild('child1'));
|
|
|
|
expect(localStorage.getItem('selectedChildId')).toBe('child1');
|
|
});
|
|
});
|
|
```
|
|
|
|
#### 8.1.2 Voice Processing Tests
|
|
|
|
**File:** `/maternal-app-backend/src/modules/ai/services/voice-processing.service.spec.ts`
|
|
|
|
```typescript
|
|
describe('VoiceProcessingService', () => {
|
|
let service: VoiceProcessingService;
|
|
|
|
beforeEach(async () => {
|
|
const module = await Test.createTestingModule({
|
|
providers: [VoiceProcessingService, ...mockProviders],
|
|
}).compile();
|
|
|
|
service = module.get<VoiceProcessingService>(VoiceProcessingService);
|
|
});
|
|
|
|
it('should detect single child name', async () => {
|
|
const children = [
|
|
{ id: 'c1', name: 'Emma' },
|
|
{ id: 'c2', name: 'Noah' },
|
|
];
|
|
|
|
const result = service['detectChildNames']('Emma just had a bottle', children);
|
|
|
|
expect(result).toEqual([{ id: 'c1', name: 'Emma' }]);
|
|
});
|
|
|
|
it('should detect multi-child keywords', async () => {
|
|
const children = [
|
|
{ id: 'c1', name: 'Emma' },
|
|
{ id: 'c2', name: 'Noah' },
|
|
];
|
|
|
|
const result = service['detectChildNames']('both kids are napping', children);
|
|
|
|
expect(result).toEqual(children);
|
|
});
|
|
|
|
it('should extract activity type from transcription', () => {
|
|
expect(service['extractActivityType']('Emma just had a bottle')).toBe('feeding');
|
|
expect(service['extractActivityType']('Noah is sleeping')).toBe('sleep');
|
|
expect(service['extractActivityType']('Changed diaper')).toBe('diaper');
|
|
});
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
### 8.2 Integration Tests
|
|
|
|
**File:** `/maternal-app-backend/test/tracking-multi-child.e2e-spec.ts`
|
|
|
|
```typescript
|
|
describe('Tracking - Multi-Child (e2e)', () => {
|
|
let app: INestApplication;
|
|
let authToken: string;
|
|
let familyId: string;
|
|
let child1Id: string;
|
|
let child2Id: string;
|
|
|
|
beforeAll(async () => {
|
|
// Setup test app, create family with 2 children
|
|
});
|
|
|
|
it('should create bulk activities for multiple children', async () => {
|
|
const response = await request(app.getHttpServer())
|
|
.post('/api/v1/tracking/bulk')
|
|
.set('Authorization', `Bearer ${authToken}`)
|
|
.send({
|
|
childIds: [child1Id, child2Id],
|
|
type: 'feeding',
|
|
timestamp: new Date().toISOString(),
|
|
details: { method: 'bottle', amount: 180 },
|
|
})
|
|
.expect(201);
|
|
|
|
expect(response.body.success).toBe(true);
|
|
expect(response.body.data.count).toBe(2);
|
|
expect(response.body.data.activities).toHaveLength(2);
|
|
});
|
|
|
|
it('should fetch activities for multiple children', async () => {
|
|
const response = await request(app.getHttpServer())
|
|
.get('/api/v1/tracking')
|
|
.query({ childIds: `${child1Id},${child2Id}` })
|
|
.set('Authorization', `Bearer ${authToken}`)
|
|
.expect(200);
|
|
|
|
const activities = response.body.data;
|
|
const childIds = activities.map(a => a.childId);
|
|
|
|
expect(childIds).toContain(child1Id);
|
|
expect(childIds).toContain(child2Id);
|
|
});
|
|
|
|
it('should compare children analytics', async () => {
|
|
const response = await request(app.getHttpServer())
|
|
.get('/api/v1/analytics/compare')
|
|
.query({
|
|
childIds: `${child1Id},${child2Id}`,
|
|
metric: 'sleep-patterns',
|
|
startDate: subDays(new Date(), 7).toISOString(),
|
|
endDate: new Date().toISOString(),
|
|
})
|
|
.set('Authorization', `Bearer ${authToken}`)
|
|
.expect(200);
|
|
|
|
expect(response.body.data.children).toHaveLength(2);
|
|
expect(response.body.data.metric).toBe('sleep-patterns');
|
|
});
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
### 8.3 E2E Tests (Frontend)
|
|
|
|
**File:** `/maternal-web/__tests__/e2e/multi-child-tracking.spec.ts`
|
|
|
|
```typescript
|
|
import { test, expect } from '@playwright/test';
|
|
|
|
test.describe('Multi-Child Tracking Flow', () => {
|
|
test.beforeEach(async ({ page }) => {
|
|
// Login and navigate to dashboard
|
|
await page.goto('/login');
|
|
await page.fill('[name="email"]', 'test@example.com');
|
|
await page.fill('[name="password"]', 'password');
|
|
await page.click('button[type="submit"]');
|
|
await page.waitForURL('/');
|
|
});
|
|
|
|
test('should show tabs for 2 children', async ({ page }) => {
|
|
// Assumes test account has 2 children: Emma and Noah
|
|
await expect(page.locator('role=tab[name="Emma"]')).toBeVisible();
|
|
await expect(page.locator('role=tab[name="Noah"]')).toBeVisible();
|
|
await expect(page.locator('role=tab[name="All"]')).toBeVisible();
|
|
});
|
|
|
|
test('should log activity for multiple children', async ({ page }) => {
|
|
await page.click('text=Track Feeding');
|
|
|
|
// Select both children
|
|
await page.click('[data-testid="child-selector"]');
|
|
await page.click('text=Emma');
|
|
await page.click('text=Noah');
|
|
|
|
// Fill form
|
|
await page.selectOption('[name="method"]', 'bottle');
|
|
await page.fill('[name="amount"]', '180');
|
|
await page.click('button:has-text("Save")');
|
|
|
|
// Verify success
|
|
await expect(page.locator('text=Activity logged for 2 children')).toBeVisible();
|
|
});
|
|
|
|
test('should compare children in analytics', async ({ page }) => {
|
|
await page.click('text=Insights & Analytics');
|
|
await page.click('role=tab[name="Compare"]');
|
|
|
|
// Select children
|
|
await page.check('[data-child-id="emma"]');
|
|
await page.check('[data-child-id="noah"]');
|
|
|
|
// Select metric
|
|
await page.selectOption('[name="metric"]', 'sleep-patterns');
|
|
|
|
// Verify chart shows both children
|
|
await expect(page.locator('.recharts-legend-item:has-text("Emma")')).toBeVisible();
|
|
await expect(page.locator('.recharts-legend-item:has-text("Noah")')).toBeVisible();
|
|
});
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
## Migration & Rollout Plan
|
|
|
|
### 9.1 Database Migration Sequence
|
|
|
|
**Order of execution:**
|
|
1. `V008_add_child_display_preferences.sql` - Add color/sort columns to children table
|
|
2. `V009_create_user_preferences.sql` - Create user preferences table
|
|
3. `V010_add_activity_constraints.sql` - Add bulk operation tracking
|
|
|
|
**Rollback plan:**
|
|
- Each migration includes corresponding `DOWN` migration
|
|
- Test rollbacks in staging environment
|
|
- Keep migrations small and atomic
|
|
|
|
**Pre-migration checklist:**
|
|
- [ ] Backup production database
|
|
- [ ] Test migrations on staging with production data snapshot
|
|
- [ ] Verify application works with new schema
|
|
- [ ] Monitor query performance after migration
|
|
|
|
---
|
|
|
|
### 9.2 Feature Flag Strategy
|
|
|
|
**File:** `/maternal-app-backend/src/common/feature-flags/feature-flags.service.ts`
|
|
|
|
```typescript
|
|
export enum FeatureFlag {
|
|
MULTI_CHILD_COMPARISON = 'multi_child_comparison',
|
|
BULK_ACTIVITY_LOGGING = 'bulk_activity_logging',
|
|
VOICE_MULTI_CHILD = 'voice_multi_child',
|
|
DYNAMIC_DASHBOARD_VIEWS = 'dynamic_dashboard_views',
|
|
}
|
|
|
|
@Injectable()
|
|
export class FeatureFlagsService {
|
|
private flags = new Map<FeatureFlag, boolean>([
|
|
[FeatureFlag.MULTI_CHILD_COMPARISON, false],
|
|
[FeatureFlag.BULK_ACTIVITY_LOGGING, false],
|
|
[FeatureFlag.VOICE_MULTI_CHILD, false],
|
|
[FeatureFlag.DYNAMIC_DASHBOARD_VIEWS, false],
|
|
]);
|
|
|
|
isEnabled(flag: FeatureFlag, userId?: string): boolean {
|
|
// Check environment variable override
|
|
const envKey = `FEATURE_${flag.toUpperCase()}`;
|
|
if (process.env[envKey] === 'true') return true;
|
|
if (process.env[envKey] === 'false') return false;
|
|
|
|
// Check beta user list
|
|
if (userId && this.isBetaUser(userId)) {
|
|
return true;
|
|
}
|
|
|
|
return this.flags.get(flag) || false;
|
|
}
|
|
|
|
private isBetaUser(userId: string): boolean {
|
|
const betaUsers = (process.env.BETA_USER_IDS || '').split(',');
|
|
return betaUsers.includes(userId);
|
|
}
|
|
}
|
|
```
|
|
|
|
**Frontend feature flag hook:**
|
|
|
|
```typescript
|
|
// /maternal-web/lib/hooks/useFeatureFlag.ts
|
|
export const useFeatureFlag = (flag: FeatureFlag): boolean => {
|
|
const { user } = useAuth();
|
|
const [isEnabled, setIsEnabled] = useState(false);
|
|
|
|
useEffect(() => {
|
|
// Check with backend
|
|
apiClient.get(`/feature-flags/${flag}`)
|
|
.then(res => setIsEnabled(res.data.enabled))
|
|
.catch(() => setIsEnabled(false));
|
|
}, [flag, user]);
|
|
|
|
return isEnabled;
|
|
};
|
|
|
|
// Usage in components:
|
|
const showComparison = useFeatureFlag('multi_child_comparison');
|
|
```
|
|
|
|
---
|
|
|
|
### 9.3 Rollout Phases
|
|
|
|
#### Phase 1: Backend Infrastructure (Week 1) ✅ COMPLETED
|
|
- [x] Deploy database migrations (V017, V018, V019)
|
|
- [x] Implement bulk activity endpoints (POST /api/v1/activities/bulk)
|
|
- [x] Add comparison endpoints (GET /api/v1/analytics/compare)
|
|
- [x] Update existing endpoints for multi-child filtering (GET /api/v1/activities supports childIds)
|
|
- [x] Add family statistics endpoint (GET /api/v1/children/family/:familyId/statistics)
|
|
- [x] Add child display colors, sort order, and nickname fields
|
|
- [x] Create ComparisonService with 4 metric types (sleep, feeding, diaper, activities)
|
|
|
|
#### Phase 2: Frontend Foundation (Week 2) ✅ COMPLETED
|
|
- [x] Implement ChildSelector component with single/multiple/all modes
|
|
- [x] Update Redux store for multi-child state (selectedChildIds, viewMode, etc.)
|
|
- [x] Add dynamic dashboard views (tabs for ≤3 children, cards for 4+)
|
|
- [x] Update GraphQL dashboard query to include child display fields
|
|
- [x] Integrate DynamicChildDashboard in home page
|
|
|
|
#### Phase 3: Activity Logging (Week 3) ⏳ IN PROGRESS
|
|
- [x] Update tracking forms with ChildSelector (feeding form complete)
|
|
- [ ] Update remaining tracking forms (sleep, diaper, activity, growth, medicine)
|
|
- [ ] Implement bulk logging UI in tracking forms
|
|
- [ ] Add default child logic and quick select
|
|
- [ ] Test and enable for beta users
|
|
|
|
**Note**: Pattern established for tracking form updates:
|
|
1. Replace local child state with Redux state
|
|
2. Use ChildSelector component instead of custom selector
|
|
3. Sync selectedChildIds with Redux store
|
|
4. Update API calls to use selectedChild.id
|
|
|
|
#### Phase 4: Analytics & Comparison (Week 4) ✅ COMPLETED
|
|
- [x] Implement comparison view component with chart visualization
|
|
- [x] Add multi-child charts using Recharts (line charts with color-coded children)
|
|
- [x] Add compareChildren API method with ComparisonMetric enum
|
|
- [x] Support multiple metrics (sleep-patterns, feeding-frequency, diaper-changes, activities)
|
|
- [x] Add date range filtering with DatePicker
|
|
- [x] Show per-child summary cards with metrics
|
|
|
|
#### Phase 5: AI & Voice (Week 5)
|
|
- [ ] Deploy voice processing updates
|
|
- [ ] Implement AI context building
|
|
- [ ] Add clarification flows
|
|
- [ ] Enable for 75% of users
|
|
|
|
#### Phase 6: Full Rollout (Week 6)
|
|
- [ ] Enable all features for all users
|
|
- [ ] Monitor performance metrics
|
|
- [ ] Collect user feedback
|
|
- [ ] Address bugs and issues
|
|
|
|
---
|
|
|
|
## Performance Optimization
|
|
|
|
### 10.1 Database Query Optimization
|
|
|
|
**Indexes for multi-child queries:**
|
|
```sql
|
|
-- Composite index for activity filtering
|
|
CREATE INDEX idx_activities_child_type_timestamp
|
|
ON activities(child_id, type, timestamp DESC);
|
|
|
|
-- Partial index for recent activities
|
|
CREATE INDEX idx_activities_recent
|
|
ON activities(child_id, timestamp DESC)
|
|
WHERE timestamp > NOW() - INTERVAL '7 days';
|
|
|
|
-- Index for family-wide queries
|
|
CREATE INDEX idx_children_family_active
|
|
ON children(family_id, deleted_at)
|
|
WHERE deleted_at IS NULL;
|
|
```
|
|
|
|
**Materialized views for aggregations:**
|
|
```sql
|
|
-- Daily activity summary per child
|
|
CREATE MATERIALIZED VIEW daily_child_activity_summary AS
|
|
SELECT
|
|
child_id,
|
|
DATE(timestamp) as activity_date,
|
|
type,
|
|
COUNT(*) as count,
|
|
jsonb_object_agg(detail_key, detail_value) as aggregated_details
|
|
FROM activities
|
|
WHERE deleted_at IS NULL
|
|
GROUP BY child_id, DATE(timestamp), type;
|
|
|
|
CREATE UNIQUE INDEX idx_daily_summary
|
|
ON daily_child_activity_summary(child_id, activity_date, type);
|
|
|
|
-- Refresh strategy: hourly or on-demand
|
|
-- SELECT refresh_materialized_view_concurrently('daily_child_activity_summary');
|
|
```
|
|
|
|
---
|
|
|
|
### 10.2 Frontend Performance
|
|
|
|
**Code splitting for multi-child features:**
|
|
```typescript
|
|
// Lazy load comparison view
|
|
const ComparisonView = lazy(() =>
|
|
import('@/components/features/analytics/ComparisonView')
|
|
);
|
|
|
|
// Only load when 2+ children exist
|
|
{children.length >= 2 && (
|
|
<Suspense fallback={<Skeleton variant="rectangular" height={400} />}>
|
|
<ComparisonView childIds={selectedChildIds} />
|
|
</Suspense>
|
|
)}
|
|
```
|
|
|
|
**Memoization for expensive calculations:**
|
|
```typescript
|
|
// Memoize child color calculation
|
|
const getChildColor = useMemo(() => {
|
|
const colorMap = new Map<string, string>();
|
|
children.forEach((child, index) => {
|
|
colorMap.set(child.id, CHILD_COLORS[index % CHILD_COLORS.length]);
|
|
});
|
|
return (childId: string) => colorMap.get(childId) || '#FF6B9D';
|
|
}, [children]);
|
|
|
|
// Memoize filtered activities
|
|
const filteredActivities = useMemo(() => {
|
|
if (selectedChildIds.length === 0) return activities;
|
|
return activities.filter(a => selectedChildIds.includes(a.childId));
|
|
}, [activities, selectedChildIds]);
|
|
```
|
|
|
|
**Virtual scrolling for many children:**
|
|
```typescript
|
|
// Use react-window for card view with 5+ children
|
|
import { FixedSizeList } from 'react-window';
|
|
|
|
<FixedSizeList
|
|
height={600}
|
|
itemCount={children.length}
|
|
itemSize={200}
|
|
width="100%"
|
|
>
|
|
{({ index, style }) => (
|
|
<div style={style}>
|
|
<ChildCard child={children[index]} />
|
|
</div>
|
|
)}
|
|
</FixedSizeList>
|
|
```
|
|
|
|
---
|
|
|
|
### 10.3 Caching Strategy
|
|
|
|
**Redis caching for analytics:**
|
|
```typescript
|
|
// Backend caching for comparison results
|
|
@Injectable()
|
|
export class AnalyticsCacheService {
|
|
constructor(@InjectRedis() private readonly redis: Redis) {}
|
|
|
|
async getCachedComparison(
|
|
childIds: string[],
|
|
metric: string,
|
|
dateRange: string,
|
|
): Promise<ComparisonResult | null> {
|
|
const key = this.buildCacheKey(childIds, metric, dateRange);
|
|
const cached = await this.redis.get(key);
|
|
return cached ? JSON.parse(cached) : null;
|
|
}
|
|
|
|
async setCachedComparison(
|
|
childIds: string[],
|
|
metric: string,
|
|
dateRange: string,
|
|
result: ComparisonResult,
|
|
): Promise<void> {
|
|
const key = this.buildCacheKey(childIds, metric, dateRange);
|
|
// Cache for 5 minutes
|
|
await this.redis.setex(key, 300, JSON.stringify(result));
|
|
}
|
|
|
|
private buildCacheKey(
|
|
childIds: string[],
|
|
metric: string,
|
|
dateRange: string,
|
|
): string {
|
|
const sortedIds = [...childIds].sort().join(',');
|
|
return `analytics:comparison:${sortedIds}:${metric}:${dateRange}`;
|
|
}
|
|
|
|
// Invalidate cache when new activity logged
|
|
async invalidateChildCache(childId: string): Promise<void> {
|
|
const pattern = `analytics:comparison:*${childId}*`;
|
|
const keys = await this.redis.keys(pattern);
|
|
if (keys.length > 0) {
|
|
await this.redis.del(...keys);
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Summary
|
|
|
|
This comprehensive implementation plan covers:
|
|
|
|
1. **Frontend**: Dynamic UI (tabs/cards), child selector, activity logging, analytics comparison
|
|
2. **Backend**: Bulk operations, multi-child queries, comparison endpoints, family statistics
|
|
3. **AI/Voice**: Child name detection, context building, clarification flows, multi-child prompts
|
|
4. **Database**: Schema enhancements, indexes, materialized views, user preferences
|
|
5. **State Management**: Redux updates, per-screen selection, comparison preferences
|
|
6. **API**: Enhanced endpoints, bulk operations, comparison queries
|
|
7. **Real-Time**: WebSocket child-specific rooms, bulk activity events
|
|
8. **Testing**: Unit, integration, and E2E tests for multi-child scenarios
|
|
9. **Migration**: Phased rollout with feature flags and monitoring
|
|
10. **Performance**: Query optimization, caching, code splitting, memoization
|
|
|
|
**Estimated Timeline**: 6 weeks for full implementation and rollout
|
|
|
|
**Key Success Metrics**:
|
|
- Zero data leakage between families (security)
|
|
- <200ms query time for multi-child analytics
|
|
- 90%+ user satisfaction with bulk logging
|
|
- 70%+ adoption of comparison features (families with 2+ children)
|
|
|
|
**Next Steps**:
|
|
1. Review and approve this plan
|
|
2. Create GitHub issues/tickets for each major component
|
|
3. Set up feature flags in production
|
|
4. Begin Phase 1 (Backend Infrastructure)
|