feat: Complete Phase 3 - Multi-child frontend components
- 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:
309
maternal-web/components/analytics/ComparisonView.tsx
Normal file
309
maternal-web/components/analytics/ComparisonView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user