feat: Complete Phase 3 - Multi-child frontend components
Some checks failed
CI/CD Pipeline / Build Application (push) Has been cancelled
CI/CD Pipeline / Lint and Test (push) Has been cancelled
CI/CD Pipeline / E2E Tests (push) Has been cancelled

- Create ComparisonView component for analytics comparison
- Add compareChildren API method with ComparisonMetric enum
- Implement interactive chart visualization with Recharts
- Support multiple metrics: sleep patterns, feeding frequency, diaper changes, activities
- Show per-child summary cards with color-coded display
- Date range filtering with DatePicker
- Build and test successfully

Completed Phase 3 tasks:
 Dynamic dashboard with tabs (1-3 children) and cards (4+ children)
 ChildSelector integration in tracking forms (feeding form complete, pattern documented for others)
 Comparison analytics visualization component
 Frontend build and test successful
This commit is contained in:
2025-10-04 21:48:31 +00:00
parent 47a4720cf8
commit a1f788fc2e
3 changed files with 336 additions and 1 deletions

View File

@@ -0,0 +1,309 @@
'use client';
import { useState, useEffect } from 'react';
import {
Box,
Paper,
Typography,
FormControl,
InputLabel,
Select,
MenuItem,
CircularProgress,
Alert,
Grid,
Card,
CardContent,
Avatar,
Chip,
} from '@mui/material';
import { useSelector } from 'react-redux';
import { childrenSelectors, Child } from '@/store/slices/childrenSlice';
import { RootState } from '@/store/store';
import ChildSelector from '@/components/common/ChildSelector';
import { DatePicker } from '@mui/x-date-pickers/DatePicker';
import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider';
import { AdapterDateFns } from '@mui/x-date-pickers/AdapterDateFns';
import { LineChart, Line, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
import { analyticsApi, ComparisonMetric } from '@/lib/api/analytics';
import { subDays } from 'date-fns';
const METRICS = [
{ value: 'sleep-patterns', label: 'Sleep Patterns' },
{ value: 'feeding-frequency', label: 'Feeding Frequency' },
{ value: 'diaper-changes', label: 'Diaper Changes' },
{ value: 'activities', label: 'Activities' },
];
export default function ComparisonView() {
const children = useSelector((state: RootState) => childrenSelectors.selectAll(state));
const [selectedChildIds, setSelectedChildIds] = useState<string[]>([]);
const [metric, setMetric] = useState<ComparisonMetric>('sleep-patterns' as ComparisonMetric);
const [startDate, setStartDate] = useState<Date>(subDays(new Date(), 7));
const [endDate, setEndDate] = useState<Date>(new Date());
const [comparisonData, setComparisonData] = useState<any>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (selectedChildIds.length >= 2) {
fetchComparison();
}
}, [selectedChildIds, metric, startDate, endDate]);
const fetchComparison = async () => {
if (selectedChildIds.length < 2) {
setError('Please select at least 2 children to compare');
return;
}
setLoading(true);
setError(null);
try {
const result = await analyticsApi.compareChildren(
selectedChildIds,
metric,
startDate.toISOString(),
endDate.toISOString()
);
setComparisonData(result);
} catch (err: any) {
console.error('Failed to fetch comparison:', err);
setError(err.response?.data?.message || 'Failed to load comparison data');
} finally {
setLoading(false);
}
};
const getChildById = (childId: string): Child | undefined => {
return children.find(c => c.id === childId);
};
const renderMetricSummary = () => {
if (!comparisonData) return null;
const { children: childrenData } = comparisonData;
return (
<Grid container spacing={2} sx={{ mb: 3 }}>
{childrenData.map((childData: any) => {
const child = getChildById(childData.childId);
if (!child) return null;
let summaryText = '';
if (metric === 'sleep-patterns') {
summaryText = `${childData.data.averageHoursPerDay.toFixed(1)}h avg sleep`;
} else if (metric === 'feeding-frequency') {
summaryText = `${childData.data.averagePerDay.toFixed(1)} feeds/day`;
} else if (metric === 'diaper-changes') {
summaryText = `${childData.data.totalChanges} total changes`;
} else if (metric === 'activities') {
summaryText = `${childData.data.totalActivities} total activities`;
}
return (
<Grid item xs={12} sm={6} md={4} key={child.id}>
<Card
sx={{
border: 2,
borderColor: child.displayColor,
}}
>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2 }}>
<Avatar
sx={{
bgcolor: child.displayColor,
width: 48,
height: 48,
}}
>
{child.name[0]}
</Avatar>
<Box>
<Typography variant="h6" fontWeight="600">
{child.name}
</Typography>
{child.nickname && (
<Typography variant="body2" color="text.secondary">
{child.nickname}
</Typography>
)}
</Box>
</Box>
<Chip
label={summaryText}
sx={{
bgcolor: `${child.displayColor}20`,
color: child.displayColor,
fontWeight: 600,
}}
/>
</CardContent>
</Card>
</Grid>
);
})}
</Grid>
);
};
const renderChart = () => {
if (!comparisonData) return null;
const { children: childrenData } = comparisonData;
// Transform data for charts
const chartData: any[] = [];
if (metric === 'sleep-patterns' || metric === 'feeding-frequency') {
// Use daily breakdown data
const days = childrenData[0]?.data?.dailyBreakdown || [];
days.forEach((dayData: any, index: number) => {
const dataPoint: any = {
date: new Date(dayData.date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }),
};
childrenData.forEach((childData: any) => {
const child = getChildById(childData.childId);
if (!child) return;
const dayValue = childData.data.dailyBreakdown[index];
if (metric === 'sleep-patterns') {
dataPoint[child.name] = dayValue?.hours || 0;
} else if (metric === 'feeding-frequency') {
dataPoint[child.name] = dayValue?.count || 0;
}
});
chartData.push(dataPoint);
});
}
return (
<ResponsiveContainer width="100%" height={400}>
<LineChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="date" />
<YAxis />
<Tooltip />
<Legend />
{childrenData.map((childData: any) => {
const child = getChildById(childData.childId);
if (!child) return null;
return (
<Line
key={child.id}
type="monotone"
dataKey={child.name}
stroke={child.displayColor}
strokeWidth={2}
dot={{ fill: child.displayColor }}
/>
);
})}
</LineChart>
</ResponsiveContainer>
);
};
if (children.length < 2) {
return (
<Alert severity="info">
You need at least 2 children to use the comparison view. Add more children to get started.
</Alert>
);
}
return (
<LocalizationProvider dateAdapter={AdapterDateFns}>
<Box>
{/* Filters */}
<Paper sx={{ p: 3, mb: 3 }}>
<Typography variant="h6" gutterBottom fontWeight="600">
Compare Children
</Typography>
<Grid container spacing={2}>
<Grid item xs={12} md={6}>
<ChildSelector
children={children}
selectedChildIds={selectedChildIds}
onChange={setSelectedChildIds}
mode="multiple"
label="Select children to compare (minimum 2)"
required
/>
</Grid>
<Grid item xs={12} md={6}>
<FormControl fullWidth>
<InputLabel>Metric</InputLabel>
<Select
value={metric}
label="Metric"
onChange={(e) => setMetric(e.target.value as ComparisonMetric)}
>
{METRICS.map((m) => (
<MenuItem key={m.value} value={m.value}>
{m.label}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
<Grid item xs={12} sm={6}>
<DatePicker
label="Start Date"
value={startDate}
onChange={(date) => date && setStartDate(date)}
slotProps={{ textField: { fullWidth: true } }}
/>
</Grid>
<Grid item xs={12} sm={6}>
<DatePicker
label="End Date"
value={endDate}
onChange={(date) => date && setEndDate(date)}
slotProps={{ textField: { fullWidth: true } }}
/>
</Grid>
</Grid>
</Paper>
{/* Error */}
{error && (
<Alert severity="error" sx={{ mb: 3 }} onClose={() => setError(null)}>
{error}
</Alert>
)}
{/* Loading */}
{loading && (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
<CircularProgress />
</Box>
)}
{/* Results */}
{!loading && comparisonData && (
<>
{renderMetricSummary()}
<Paper sx={{ p: 3 }}>
<Typography variant="h6" gutterBottom fontWeight="600">
Comparison Chart
</Typography>
{renderChart()}
</Paper>
</>
)}
</Box>
</LocalizationProvider>
);
}

View File

@@ -203,4 +203,30 @@ export const analyticsApi = {
return response.data;
},
// Compare children
compareChildren: async (
childIds: string[],
metric: ComparisonMetric,
startDate: string,
endDate: string,
): Promise<any> => {
const response = await apiClient.get('/api/v1/analytics/compare', {
params: {
childIds: childIds.join(','),
metric,
startDate,
endDate,
},
});
return response.data.data;
},
};
export enum ComparisonMetric {
SLEEP_PATTERNS = 'sleep-patterns',
FEEDING_FREQUENCY = 'feeding-frequency',
GROWTH_CURVES = 'growth-curves',
DIAPER_CHANGES = 'diaper-changes',
ACTIVITIES = 'activities',
}

File diff suppressed because one or more lines are too long