## 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>
62 KiB
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
- Frontend Implementation
- Backend Implementation
- AI & Voice Processing
- Database Schema Updates
- State Management Architecture
- API Enhancements
- Real-Time Sync Updates
- Testing Strategy
- Migration & Rollout Plan
- 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:
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:
// 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:
// 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:
- Child Selection Step (Pre-form)
// 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>
- Bulk Logging Support
// 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`);
};
- Default Child Logic
// 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:
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):
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:
- Add "Compare" tab alongside existing tabs
- Show comparison tab only when 2+ children exist
- Update all charts to support multi-child overlay
- Add child name to chart titles and legends
- Export reports include child identifier
// 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:
// 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:
- Context Awareness
// 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,
})),
};
};
- Multi-Child Comparison Questions
// 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..."
- Child Selector in AI Chat
// 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
// 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
@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
@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:
// /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:
// /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
@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
@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:
// /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
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
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
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
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
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
-- 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
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
-- 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
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
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
// 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
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
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
@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
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
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
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
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
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:
V008_add_child_display_preferences.sql- Add color/sort columns to children tableV009_create_user_preferences.sql- Create user preferences tableV010_add_activity_constraints.sql- Add bulk operation tracking
Rollback plan:
- Each migration includes corresponding
DOWNmigration - 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
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:
// /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
- Deploy database migrations (V017, V018, V019)
- Implement bulk activity endpoints (POST /api/v1/activities/bulk)
- Add comparison endpoints (GET /api/v1/analytics/compare)
- Update existing endpoints for multi-child filtering (GET /api/v1/activities supports childIds)
- Add family statistics endpoint (GET /api/v1/children/family/:familyId/statistics)
- Add child display colors, sort order, and nickname fields
- Create ComparisonService with 4 metric types (sleep, feeding, diaper, activities)
Phase 2: Frontend Foundation (Week 2) ✅ COMPLETED
- Implement ChildSelector component with single/multiple/all modes
- Update Redux store for multi-child state (selectedChildIds, viewMode, etc.)
- Add dynamic dashboard views (tabs for ≤3 children, cards for 4+)
- Update GraphQL dashboard query to include child display fields
- Integrate DynamicChildDashboard in home page
Phase 3: Activity Logging (Week 3) ⏳ IN PROGRESS
- 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:
- Replace local child state with Redux state
- Use ChildSelector component instead of custom selector
- Sync selectedChildIds with Redux store
- Update API calls to use selectedChild.id
Phase 4: Analytics & Comparison (Week 4) ✅ COMPLETED
- Implement comparison view component with chart visualization
- Add multi-child charts using Recharts (line charts with color-coded children)
- Add compareChildren API method with ComparisonMetric enum
- Support multiple metrics (sleep-patterns, feeding-frequency, diaper-changes, activities)
- Add date range filtering with DatePicker
- 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:
-- 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:
-- 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:
// 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:
// 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:
// 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:
// 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:
- Frontend: Dynamic UI (tabs/cards), child selector, activity logging, analytics comparison
- Backend: Bulk operations, multi-child queries, comparison endpoints, family statistics
- AI/Voice: Child name detection, context building, clarification flows, multi-child prompts
- Database: Schema enhancements, indexes, materialized views, user preferences
- State Management: Redux updates, per-screen selection, comparison preferences
- API: Enhanced endpoints, bulk operations, comparison queries
- Real-Time: WebSocket child-specific rooms, bulk activity events
- Testing: Unit, integration, and E2E tests for multi-child scenarios
- Migration: Phased rollout with feature flags and monitoring
- 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:
- Review and approve this plan
- Create GitHub issues/tickets for each major component
- Set up feature flags in production
- Begin Phase 1 (Backend Infrastructure)