- 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
310 lines
9.5 KiB
TypeScript
310 lines
9.5 KiB
TypeScript
'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>
|
|
);
|
|
}
|