Files
maternal-app/maternal-web/components/features/analytics/InsightsDashboard.tsx
Andrei 95ef0e5e78
Some checks failed
CI/CD Pipeline / Lint and Test (push) Has been cancelled
CI/CD Pipeline / E2E Tests (push) Has been cancelled
CI/CD Pipeline / Build Application (push) Has been cancelled
docs: Add comprehensive multi-child implementation plan
Added detailed implementation plan covering:
- Frontend: Dynamic UI, child selector, bulk activity logging, comparison analytics
- Backend: Bulk operations, multi-child queries, family statistics
- AI/Voice: Child name detection, context building, clarification flows
- Database: Schema enhancements, user preferences, bulk operation tracking
- State management, API enhancements, real-time sync updates
- Testing strategy: Unit, integration, and E2E tests
- Migration plan with feature flags for phased rollout
- Performance optimizations: Caching, indexes, code splitting

Also includes:
- Security fixes for multi-family data leakage in analytics pages
- ParentFlow branding updates
- Activity tracking navigation improvements
- Backend DTO and error handling fixes

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-04 21:05:14 +00:00

757 lines
28 KiB
TypeScript

'use client';
import { useState, useEffect } from 'react';
import {
Box,
Typography,
Grid,
Card,
CardContent,
Select,
MenuItem,
FormControl,
InputLabel,
CircularProgress,
Alert,
Paper,
Divider,
List,
ListItem,
ListItemAvatar,
ListItemText,
Avatar,
Chip,
ToggleButtonGroup,
ToggleButton,
Button,
} from '@mui/material';
import {
Restaurant,
Hotel,
BabyChangingStation,
TrendingUp,
Timeline,
Assessment,
ChildCare,
Add,
} from '@mui/icons-material';
import { useRouter } from 'next/navigation';
import { motion } from 'framer-motion';
import { trackingApi, Activity, ActivityType } from '@/lib/api/tracking';
import { childrenApi, Child } from '@/lib/api/children';
import { subDays, startOfDay, endOfDay, parseISO, differenceInMinutes } from 'date-fns';
import { useLocalizedDate } from '@/hooks/useLocalizedDate';
import { useTranslation } from '@/hooks/useTranslation';
import { useFormatting } from '@/hooks/useFormatting';
import { BarChart, Bar, LineChart, Line, PieChart, Pie, Cell, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
import { useAuth } from '@/lib/auth/AuthContext';
type DateRange = '7days' | '30days' | '3months';
interface DayData {
date: string;
feedings: number;
sleepHours: number;
diapers: number;
activities: number;
}
interface DiaperTypeData {
name: string;
value: number;
color: string;
[key: string]: string | number;
}
interface ActivityTypeData {
name: string;
count: number;
color: string;
}
const COLORS = {
feeding: '#E91E63',
sleep: '#1976D2',
diaper: '#F57C00',
medication: '#C62828',
milestone: '#558B2F',
note: '#FFD3B6',
wet: '#87CEEB',
dirty: '#D2691E',
both: '#FF8C00',
dry: '#90EE90',
};
const getActivityIcon = (type: ActivityType) => {
switch (type) {
case 'feeding':
return <Restaurant />;
case 'sleep':
return <Hotel />;
case 'diaper':
return <BabyChangingStation />;
default:
return <Timeline />;
}
};
const getActivityColor = (type: ActivityType) => {
return COLORS[type as keyof typeof COLORS] || '#CCCCCC';
};
export const InsightsDashboard: React.FC = () => {
const router = useRouter();
const { user } = useAuth();
const { format, formatDistanceToNow } = useLocalizedDate();
const { t } = useTranslation('insights');
const { formatNumber } = useFormatting();
const [children, setChildren] = useState<Child[]>([]);
const [selectedChild, setSelectedChild] = useState<string>('');
const [dateRange, setDateRange] = useState<DateRange>('7days');
const [activities, setActivities] = useState<Activity[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const familyId = user?.families?.[0]?.familyId;
// Fetch children on mount
useEffect(() => {
const fetchChildren = async () => {
if (!familyId) {
setError('No family found');
return;
}
try {
console.log('[InsightsDashboard] Loading children for familyId:', familyId);
const childrenData = await childrenApi.getChildren(familyId);
console.log('[InsightsDashboard] Loaded children:', childrenData);
setChildren(childrenData);
if (childrenData.length > 0) {
// Validate selected child or pick first one
const validChild = childrenData.find(c => c.id === selectedChild);
if (!validChild) {
setSelectedChild(childrenData[0].id);
}
}
} catch (err: any) {
console.error('[InsightsDashboard] Failed to load children:', err);
setError(err.response?.data?.message || t('errors.loadChildren'));
}
};
fetchChildren();
}, [familyId]);
// Fetch activities when child or date range changes
useEffect(() => {
if (!selectedChild || children.length === 0) return;
// Validate that selectedChild belongs to current user's children
const childExists = children.some(child => child.id === selectedChild);
if (!childExists) {
console.warn('[InsightsDashboard] Selected child not found in user\'s children, resetting');
setSelectedChild(children[0].id);
setError('Selected child not found. Showing data for your first child.');
return;
}
const fetchActivities = async () => {
setLoading(true);
setError(null);
try {
console.log('[InsightsDashboard] Fetching activities for child:', selectedChild);
const days = dateRange === '7days' ? 7 : dateRange === '30days' ? 30 : 90;
const endDate = endOfDay(new Date());
const startDate = startOfDay(subDays(new Date(), days - 1));
const activitiesData = await trackingApi.getActivities(
selectedChild,
undefined,
startDate.toISOString(),
endDate.toISOString()
);
console.log('[InsightsDashboard] Fetched activities:', activitiesData.length);
setActivities(activitiesData);
} catch (err: any) {
console.error('[InsightsDashboard] Failed to load activities:', err);
setError(err.response?.data?.message || t('errors.loadActivities'));
} finally {
setLoading(false);
}
};
fetchActivities();
}, [selectedChild, dateRange, children]);
// Calculate statistics
const calculateStats = () => {
const totalFeedings = activities.filter((a) => a.type === 'feeding').length;
const totalDiapers = activities.filter((a) => a.type === 'diaper').length;
const sleepActivities = activities.filter((a) => a.type === 'sleep');
const totalSleepMinutes = sleepActivities.reduce((acc, activity) => {
if (activity.data?.endTime && activity.data?.startTime) {
const start = parseISO(activity.data.startTime);
const end = parseISO(activity.data.endTime);
return acc + differenceInMinutes(end, start);
}
return acc;
}, 0);
const days = dateRange === '7days' ? 7 : dateRange === '30days' ? 30 : 90;
const avgSleepHours = days > 0 ? (totalSleepMinutes / 60 / days).toFixed(1) : '0.0';
const typeCounts: Record<string, number> = {};
activities.forEach((a) => {
typeCounts[a.type] = (typeCounts[a.type] || 0) + 1;
});
const mostCommonType = Object.entries(typeCounts).sort((a, b) => b[1] - a[1])[0]?.[0] || 'none';
return {
totalFeedings,
avgSleepHours,
totalDiapers,
mostCommonType,
};
};
// Prepare chart data
const prepareDailyData = (): DayData[] => {
const days = dateRange === '7days' ? 7 : dateRange === '30days' ? 30 : 90;
const dailyMap = new Map<string, DayData>();
for (let i = days - 1; i >= 0; i--) {
const date = format(subDays(new Date(), i), 'yyyy-MM-dd');
dailyMap.set(date, {
date: format(subDays(new Date(), i), 'MMM dd'),
feedings: 0,
sleepHours: 0,
diapers: 0,
activities: 0,
});
}
activities.forEach((activity) => {
const dateKey = format(parseISO(activity.timestamp), 'yyyy-MM-dd');
const data = dailyMap.get(dateKey);
if (data) {
data.activities += 1;
if (activity.type === 'feeding') data.feedings += 1;
if (activity.type === 'diaper') data.diapers += 1;
if (activity.type === 'sleep' && activity.data?.endTime && activity.data?.startTime) {
const start = parseISO(activity.data.startTime);
const end = parseISO(activity.data.endTime);
const hours = differenceInMinutes(end, start) / 60;
data.sleepHours += hours;
}
}
});
return Array.from(dailyMap.values()).map((d) => ({
...d,
sleepHours: Number(d.sleepHours.toFixed(1)),
}));
};
const prepareDiaperData = (): DiaperTypeData[] => {
const diaperActivities = activities.filter((a) => a.type === 'diaper');
const typeCount: Record<string, number> = {};
diaperActivities.forEach((activity) => {
const type = activity.data?.type || 'unknown';
typeCount[type] = (typeCount[type] || 0) + 1;
});
return Object.entries(typeCount).map(([name, value]) => ({
name: t(`diaperTypes.${name}`),
value,
color: COLORS[name as keyof typeof COLORS] || '#CCCCCC',
}));
};
const prepareActivityTypeData = (): ActivityTypeData[] => {
const typeCount: Record<string, number> = {};
activities.forEach((activity) => {
typeCount[activity.type] = (typeCount[activity.type] || 0) + 1;
});
return Object.entries(typeCount).map(([name, count]) => ({
name: t(`activityTypes.${name}`),
count,
color: COLORS[name as keyof typeof COLORS] || '#CCCCCC',
}));
};
const stats = calculateStats();
const dailyData = prepareDailyData();
const diaperData = prepareDiaperData();
const activityTypeData = prepareActivityTypeData();
const recentActivities = [...activities]
.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime())
.slice(0, 20);
const noChildren = children.length === 0;
const noActivities = activities.length === 0 && !loading;
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
>
<Box sx={{ px: { xs: 2, sm: 3 }, py: 3 }}>
<Typography variant="h4" sx={{ mb: 1, fontWeight: 600 }}>
{t('title')}
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 4 }}>
{t('subtitle')}
</Typography>
{/* Time period selector */}
<Box sx={{ mb: 4, display: 'flex', gap: 2, flexWrap: 'wrap', alignItems: 'center' }}>
{children.length > 1 && (
<FormControl sx={{ minWidth: 200 }}>
<InputLabel>{t('filters.child')}</InputLabel>
<Select
value={selectedChild}
onChange={(e) => setSelectedChild(e.target.value)}
label={t('filters.child')}
>
{children.map((child) => (
<MenuItem key={child.id} value={child.id}>
{child.name}
</MenuItem>
))}
</Select>
</FormControl>
)}
<ToggleButtonGroup
value={dateRange}
exclusive
onChange={(_, newValue) => newValue && setDateRange(newValue)}
sx={{
'& .MuiToggleButton-root': {
textTransform: 'none',
fontWeight: 500,
minWidth: { xs: 80, sm: 120 }
}
}}
>
<ToggleButton value="7days">{t('filters.dateRange.7days')}</ToggleButton>
<ToggleButton value="30days">{t('filters.dateRange.30days')}</ToggleButton>
<ToggleButton value="3months">{t('filters.dateRange.3months')}</ToggleButton>
</ToggleButtonGroup>
</Box>
{error && (
<Alert severity="error" sx={{ mb: 3 }}>
{error}
</Alert>
)}
{noChildren && !loading && (
<Card>
<CardContent sx={{ textAlign: 'center', py: 8 }}>
<ChildCare sx={{ fontSize: 64, color: 'text.secondary', mb: 2 }} />
<Typography variant="h6" color="text.secondary" gutterBottom>
{t('emptyStates.noChildren.title')}
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
{t('emptyStates.noChildren.message')}
</Typography>
<Button
variant="contained"
startIcon={<Add />}
onClick={() => router.push('/children')}
>
{t('emptyStates.noChildren.action')}
</Button>
</CardContent>
</Card>
)}
{loading && (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', py: 8 }}>
<CircularProgress />
</Box>
)}
{noActivities && !noChildren && (
<Alert severity="info" sx={{ mb: 3 }}>
{t('emptyStates.noActivities')}
</Alert>
)}
{!loading && !noChildren && !noActivities && (
<>
{/* Stats cards */}
<Grid container spacing={2} sx={{ mb: 4 }} justifyContent="center">
<Grid item xs={6} sm={3} sx={{ minWidth: 200 }}>
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.3, delay: 0 }}
>
<Paper
elevation={0}
sx={{
p: 3,
height: '140px',
minHeight: '140px',
width: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
textAlign: 'center',
bgcolor: COLORS.feeding,
color: 'white',
}}
>
<Box sx={{ fontSize: 48, mb: 1 }} aria-hidden="true">
<Restaurant sx={{ fontSize: 48 }} />
</Box>
<Typography variant="h3" fontWeight={600}>
{formatNumber(stats.totalFeedings)}
</Typography>
<Typography variant="caption" sx={{ opacity: 0.9 }}>
{t('stats.feedings.subtitle')}
</Typography>
</Paper>
</motion.div>
</Grid>
<Grid item xs={6} sm={3} sx={{ minWidth: 200 }}>
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.3, delay: 0.1 }}
>
<Paper
elevation={0}
sx={{
p: 3,
height: '140px',
minHeight: '140px',
width: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
textAlign: 'center',
bgcolor: COLORS.sleep,
color: 'white',
}}
>
<Box sx={{ fontSize: 48, mb: 1 }} aria-hidden="true">
<Hotel sx={{ fontSize: 48 }} />
</Box>
<Typography variant="h3" fontWeight={600}>
{formatNumber(stats.avgSleepHours, { minimumFractionDigits: 1, maximumFractionDigits: 1 })}h
</Typography>
<Typography variant="caption" sx={{ opacity: 0.9 }}>
{t('stats.sleep.subtitle')}
</Typography>
</Paper>
</motion.div>
</Grid>
<Grid item xs={6} sm={3} sx={{ minWidth: 200 }}>
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.3, delay: 0.2 }}
>
<Paper
elevation={0}
sx={{
p: 3,
height: '140px',
minHeight: '140px',
width: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
textAlign: 'center',
bgcolor: COLORS.diaper,
color: 'white',
}}
>
<Box sx={{ fontSize: 48, mb: 1 }} aria-hidden="true">
<BabyChangingStation sx={{ fontSize: 48 }} />
</Box>
<Typography variant="h3" fontWeight={600}>
{formatNumber(stats.totalDiapers)}
</Typography>
<Typography variant="caption" sx={{ opacity: 0.9 }}>
{t('stats.diapers.subtitle')}
</Typography>
</Paper>
</motion.div>
</Grid>
<Grid item xs={6} sm={3} sx={{ minWidth: 200 }}>
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.3, delay: 0.3 }}
>
<Paper
elevation={0}
sx={{
p: 3,
height: '140px',
minHeight: '140px',
width: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
textAlign: 'center',
bgcolor: COLORS.milestone,
color: 'white',
}}
>
<Box sx={{ fontSize: 48, mb: 1 }} aria-hidden="true">
<TrendingUp sx={{ fontSize: 48 }} />
</Box>
<Typography variant="h3" fontWeight={600} sx={{ textTransform: 'capitalize' }}>
{t(`activityTypes.${stats.mostCommonType}`)}
</Typography>
<Typography variant="caption" sx={{ opacity: 0.9 }}>
{t('stats.topActivity.subtitle')}
</Typography>
</Paper>
</motion.div>
</Grid>
</Grid>
{/* Charts grid */}
<Grid container spacing={3} justifyContent="center" sx={{ mb: 3 }}>
<Grid item xs={12} md={6} sx={{ minWidth: 400 }}>
<Paper
elevation={0}
sx={{
p: 3,
bgcolor: 'background.paper',
height: 400,
width: '100%',
display: 'flex',
flexDirection: 'column'
}}
>
<Typography variant="h6" sx={{ mb: 2, display: 'flex', alignItems: 'center', gap: 1, fontWeight: 600 }}>
<Restaurant sx={{ color: COLORS.feeding }} /> {t('charts.feedingFrequency')}
</Typography>
<Box sx={{ flexGrow: 1, position: 'relative' }}>
<ResponsiveContainer width="100%" height={250}>
<BarChart data={dailyData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="date" tick={{ fontSize: 12 }} />
<YAxis />
<Tooltip />
<Bar dataKey="feedings" fill={COLORS.feeding} name={t('charts.chartLabels.feedings')} />
</BarChart>
</ResponsiveContainer>
</Box>
</Paper>
</Grid>
<Grid item xs={12} md={6} sx={{ minWidth: 400 }}>
<Paper
elevation={0}
sx={{
p: 3,
bgcolor: 'background.paper',
height: 400,
width: '100%',
display: 'flex',
flexDirection: 'column'
}}
>
<Typography variant="h6" sx={{ mb: 2, display: 'flex', alignItems: 'center', gap: 1, fontWeight: 600 }}>
<Hotel sx={{ color: COLORS.sleep }} /> {t('charts.sleepDuration')}
</Typography>
<Box sx={{ flexGrow: 1, position: 'relative' }}>
<ResponsiveContainer width="100%" height={250}>
<LineChart data={dailyData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="date" tick={{ fontSize: 12 }} />
<YAxis />
<Tooltip />
<Line
type="monotone"
dataKey="sleepHours"
stroke={COLORS.sleep}
strokeWidth={3}
name={t('charts.chartLabels.sleepHours')}
dot={{ fill: COLORS.sleep, r: 4 }}
/>
</LineChart>
</ResponsiveContainer>
</Box>
</Paper>
</Grid>
{diaperData.length > 0 && (
<Grid item xs={12} md={6} sx={{ minWidth: 400 }}>
<Paper
elevation={0}
sx={{
p: 3,
bgcolor: 'background.paper',
height: 400,
width: '100%',
display: 'flex',
flexDirection: 'column'
}}
>
<Typography variant="h6" sx={{ mb: 2, display: 'flex', alignItems: 'center', gap: 1, fontWeight: 600 }}>
<BabyChangingStation sx={{ color: COLORS.diaper }} /> {t('charts.diaperChangesByType')}
</Typography>
<Box sx={{ flexGrow: 1, position: 'relative' }}>
<ResponsiveContainer width="100%" height={250}>
<PieChart>
<Pie
data={diaperData}
cx="50%"
cy="50%"
labelLine={false}
label={({ name, percent }: any) => `${name} ${(percent * 100).toFixed(0)}%`}
outerRadius={80}
fill="#8884d8"
dataKey="value"
>
{diaperData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
))}
</Pie>
<Tooltip />
</PieChart>
</ResponsiveContainer>
</Box>
</Paper>
</Grid>
)}
<Grid item xs={12} md={diaperData.length > 0 ? 6 : 12} sx={{ minWidth: 400 }}>
<Paper
elevation={0}
sx={{
p: 3,
bgcolor: 'background.paper',
height: 400,
width: '100%',
display: 'flex',
flexDirection: 'column'
}}
>
<Typography variant="h6" sx={{ mb: 2, display: 'flex', alignItems: 'center', gap: 1, fontWeight: 600 }}>
<Assessment sx={{ color: 'primary.main' }} /> {t('charts.activityTimeline')}
</Typography>
<Box sx={{ flexGrow: 1, position: 'relative' }}>
<ResponsiveContainer width="100%" height={250}>
<BarChart data={dailyData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="date" tick={{ fontSize: 12 }} />
<YAxis />
<Tooltip />
<Legend />
<Bar dataKey="feedings" fill={COLORS.feeding} name={t('charts.chartLabels.feedings')} />
<Bar dataKey="diapers" fill={COLORS.diaper} name={t('charts.chartLabels.diapers')} />
<Bar dataKey="sleepHours" fill={COLORS.sleep} name={t('charts.chartLabels.sleepHours')} />
</BarChart>
</ResponsiveContainer>
</Box>
</Paper>
</Grid>
</Grid>
{activityTypeData.length > 0 && (
<Card sx={{ mb: 3 }}>
<CardContent>
<Typography variant="h6" fontWeight="600" gutterBottom>
{t('charts.activityDistribution')}
</Typography>
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap', mt: 2 }}>
{activityTypeData.map((activity) => (
<Chip
key={activity.name}
icon={getActivityIcon(activity.name.toLowerCase() as ActivityType)}
label={`${activity.name}: ${activity.count}`}
sx={{
bgcolor: activity.color,
color: 'white',
fontSize: '0.9rem',
fontWeight: 600,
px: 1,
py: 2,
}}
/>
))}
</Box>
</CardContent>
</Card>
)}
{/* Recent Activities */}
<Card>
<CardContent>
<Typography variant="h6" fontWeight="600" gutterBottom>
{t('recentActivities.title')}
</Typography>
<Divider sx={{ my: 2 }} />
<List sx={{ maxHeight: 400, overflow: 'auto' }}>
{recentActivities.map((activity, index) => (
<motion.div
key={activity.id}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.3, delay: index * 0.02 }}
>
<ListItem
sx={{
borderBottom: index < recentActivities.length - 1 ? '1px solid' : 'none',
borderColor: 'divider',
py: 1.5,
}}
>
<ListItemAvatar>
<Avatar sx={{ bgcolor: getActivityColor(activity.type) }}>
{getActivityIcon(activity.type)}
</Avatar>
</ListItemAvatar>
<ListItemText
primary={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="body1" fontWeight="600" sx={{ textTransform: 'capitalize' }}>
{t(`activityTypes.${activity.type}`)}
</Typography>
<Chip
label={formatDistanceToNow(parseISO(activity.timestamp), { addSuffix: true })}
size="small"
sx={{ height: 20, fontSize: '0.7rem' }}
/>
</Box>
}
secondary={
<Typography variant="body2" color="text.secondary">
{activity.notes || format(parseISO(activity.timestamp), 'MMM dd, yyyy HH:mm')}
</Typography>
}
/>
</ListItem>
</motion.div>
))}
</List>
</CardContent>
</Card>
</>
)}
</Box>
</motion.div>
);
};