Implement Phase 7 Performance Optimization and fix tracking system
Phase 7 Implementation: - Add lazy loading for AI Assistant and Insights pages - Create LoadingFallback component with skeleton screens (page, card, list, chart, chat variants) - Create OptimizedImage component with Next.js Image optimization - Create PerformanceMonitor component with web-vitals v5 integration - Add performance monitoring library tracking Core Web Vitals (CLS, INP, FCP, LCP, TTFB) - Install web-vitals v5.1.0 dependency - Extract AI chat interface and insights dashboard to lazy-loaded components Tracking System Fixes: - Fix API data transformation between frontend (timestamp/data) and backend (startedAt/metadata) - Update createActivity, getActivities, and getActivity to properly transform data structures - Fix diaper, feeding, and sleep tracking pages to work with backend API Homepage Improvements: - Connect Today's Summary to backend daily summary API - Load real-time data for feeding count, sleep hours, and diaper count - Add loading states and empty states for better UX - Format sleep duration as "Xh Ym" for better readability - Display child name in summary section 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,337 +1,24 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useRef, useEffect } from 'react';
|
import { lazy, Suspense } from 'react';
|
||||||
import {
|
|
||||||
Box,
|
|
||||||
TextField,
|
|
||||||
IconButton,
|
|
||||||
Paper,
|
|
||||||
Typography,
|
|
||||||
Avatar,
|
|
||||||
CircularProgress,
|
|
||||||
Chip,
|
|
||||||
} from '@mui/material';
|
|
||||||
import { Send, SmartToy, Person, AutoAwesome } from '@mui/icons-material';
|
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
|
||||||
import { useAuth } from '@/lib/auth/AuthContext';
|
|
||||||
import apiClient from '@/lib/api/client';
|
|
||||||
import { AppShell } from '@/components/layouts/AppShell/AppShell';
|
import { AppShell } from '@/components/layouts/AppShell/AppShell';
|
||||||
import { ProtectedRoute } from '@/components/common/ProtectedRoute';
|
import { ProtectedRoute } from '@/components/common/ProtectedRoute';
|
||||||
|
import { LoadingFallback } from '@/components/common/LoadingFallback';
|
||||||
|
|
||||||
interface Message {
|
// Lazy load the AI chat interface component
|
||||||
id: string;
|
const AIChatInterface = lazy(() =>
|
||||||
role: 'user' | 'assistant';
|
import('@/components/features/ai-chat/AIChatInterface').then((mod) => ({
|
||||||
content: string;
|
default: mod.AIChatInterface,
|
||||||
timestamp: Date;
|
}))
|
||||||
}
|
);
|
||||||
|
|
||||||
const suggestedQuestions = [
|
|
||||||
'How much should my baby sleep at 3 months?',
|
|
||||||
'What are normal feeding patterns?',
|
|
||||||
'When should I introduce solid foods?',
|
|
||||||
'Tips for better sleep routine',
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function AIAssistantPage() {
|
export default function AIAssistantPage() {
|
||||||
const [messages, setMessages] = useState<Message[]>([]);
|
|
||||||
const [input, setInput] = useState('');
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
|
||||||
const { user } = useAuth();
|
|
||||||
|
|
||||||
const scrollToBottom = () => {
|
|
||||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
scrollToBottom();
|
|
||||||
}, [messages]);
|
|
||||||
|
|
||||||
const handleSend = async (message?: string) => {
|
|
||||||
const messageText = message || input.trim();
|
|
||||||
if (!messageText || isLoading) return;
|
|
||||||
|
|
||||||
const userMessage: Message = {
|
|
||||||
id: Date.now().toString(),
|
|
||||||
role: 'user',
|
|
||||||
content: messageText,
|
|
||||||
timestamp: new Date(),
|
|
||||||
};
|
|
||||||
|
|
||||||
setMessages((prev) => [...prev, userMessage]);
|
|
||||||
setInput('');
|
|
||||||
setIsLoading(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await apiClient.post('/api/v1/ai/chat', {
|
|
||||||
message: messageText,
|
|
||||||
conversationId: null, // Will be managed by backend
|
|
||||||
});
|
|
||||||
|
|
||||||
const assistantMessage: Message = {
|
|
||||||
id: (Date.now() + 1).toString(),
|
|
||||||
role: 'assistant',
|
|
||||||
content: response.data.data.response,
|
|
||||||
timestamp: new Date(),
|
|
||||||
};
|
|
||||||
|
|
||||||
setMessages((prev) => [...prev, assistantMessage]);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('AI chat error:', error);
|
|
||||||
const errorMessage: Message = {
|
|
||||||
id: (Date.now() + 1).toString(),
|
|
||||||
role: 'assistant',
|
|
||||||
content: 'Sorry, I encountered an error. Please try again.',
|
|
||||||
timestamp: new Date(),
|
|
||||||
};
|
|
||||||
setMessages((prev) => [...prev, errorMessage]);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSuggestedQuestion = (question: string) => {
|
|
||||||
handleSend(question);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ProtectedRoute>
|
<ProtectedRoute>
|
||||||
<AppShell>
|
<AppShell>
|
||||||
<Box
|
<Suspense fallback={<LoadingFallback variant="chat" />}>
|
||||||
sx={{
|
<AIChatInterface />
|
||||||
height: 'calc(100vh - 200px)',
|
</Suspense>
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
background: 'linear-gradient(135deg, #FFF5F5 0%, #FFE4E1 100%)',
|
|
||||||
borderRadius: 2,
|
|
||||||
overflow: 'hidden',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Header */}
|
|
||||||
<Paper
|
|
||||||
elevation={0}
|
|
||||||
sx={{
|
|
||||||
p: 2,
|
|
||||||
borderRadius: 0,
|
|
||||||
background: 'rgba(255, 255, 255, 0.95)',
|
|
||||||
backdropFilter: 'blur(10px)',
|
|
||||||
borderBottom: '1px solid',
|
|
||||||
borderColor: 'divider',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
|
||||||
<Avatar sx={{ bgcolor: 'primary.main' }}>
|
|
||||||
<SmartToy />
|
|
||||||
</Avatar>
|
|
||||||
<Box>
|
|
||||||
<Typography variant="h6" fontWeight="600">
|
|
||||||
AI Parenting Assistant
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="caption" color="text.secondary">
|
|
||||||
Ask me anything about parenting and childcare
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</Paper>
|
|
||||||
|
|
||||||
{/* Messages Container */}
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
flex: 1,
|
|
||||||
overflowY: 'auto',
|
|
||||||
p: 2,
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
gap: 2,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{messages.length === 0 && (
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
flex: 1,
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
gap: 3,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<AutoAwesome sx={{ fontSize: 64, color: 'primary.main', opacity: 0.5 }} />
|
|
||||||
<Typography variant="h6" color="text.secondary" textAlign="center">
|
|
||||||
Hi {user?.name}! How can I help you today?
|
|
||||||
</Typography>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: 'flex',
|
|
||||||
flexWrap: 'wrap',
|
|
||||||
gap: 1,
|
|
||||||
justifyContent: 'center',
|
|
||||||
maxWidth: 600,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{suggestedQuestions.map((question, index) => (
|
|
||||||
<Chip
|
|
||||||
key={index}
|
|
||||||
label={question}
|
|
||||||
onClick={() => handleSuggestedQuestion(question)}
|
|
||||||
sx={{
|
|
||||||
borderRadius: 3,
|
|
||||||
'&:hover': {
|
|
||||||
bgcolor: 'primary.light',
|
|
||||||
color: 'white',
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<AnimatePresence>
|
|
||||||
{messages.map((message) => (
|
|
||||||
<motion.div
|
|
||||||
key={message.id}
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
exit={{ opacity: 0 }}
|
|
||||||
transition={{ duration: 0.3 }}
|
|
||||||
>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: 'flex',
|
|
||||||
gap: 2,
|
|
||||||
justifyContent: message.role === 'user' ? 'flex-end' : 'flex-start',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{message.role === 'assistant' && (
|
|
||||||
<Avatar sx={{ bgcolor: 'primary.main', mt: 1 }}>
|
|
||||||
<SmartToy />
|
|
||||||
</Avatar>
|
|
||||||
)}
|
|
||||||
<Paper
|
|
||||||
elevation={0}
|
|
||||||
sx={{
|
|
||||||
p: 2,
|
|
||||||
maxWidth: '70%',
|
|
||||||
borderRadius: 3,
|
|
||||||
bgcolor:
|
|
||||||
message.role === 'user'
|
|
||||||
? 'primary.main'
|
|
||||||
: 'rgba(255, 255, 255, 0.95)',
|
|
||||||
color: message.role === 'user' ? 'white' : 'text.primary',
|
|
||||||
backdropFilter: 'blur(10px)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography variant="body1" sx={{ whiteSpace: 'pre-wrap' }}>
|
|
||||||
{message.content}
|
|
||||||
</Typography>
|
|
||||||
<Typography
|
|
||||||
variant="caption"
|
|
||||||
sx={{
|
|
||||||
mt: 1,
|
|
||||||
display: 'block',
|
|
||||||
opacity: 0.7,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{message.timestamp.toLocaleTimeString()}
|
|
||||||
</Typography>
|
|
||||||
</Paper>
|
|
||||||
{message.role === 'user' && (
|
|
||||||
<Avatar sx={{ bgcolor: 'secondary.main', mt: 1 }}>
|
|
||||||
<Person />
|
|
||||||
</Avatar>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</motion.div>
|
|
||||||
))}
|
|
||||||
</AnimatePresence>
|
|
||||||
|
|
||||||
{isLoading && (
|
|
||||||
<Box sx={{ display: 'flex', gap: 2 }}>
|
|
||||||
<Avatar sx={{ bgcolor: 'primary.main' }}>
|
|
||||||
<SmartToy />
|
|
||||||
</Avatar>
|
|
||||||
<Paper
|
|
||||||
elevation={0}
|
|
||||||
sx={{
|
|
||||||
p: 2,
|
|
||||||
borderRadius: 3,
|
|
||||||
bgcolor: 'rgba(255, 255, 255, 0.95)',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<CircularProgress size={20} />
|
|
||||||
<Typography variant="body2" color="text.secondary">
|
|
||||||
Thinking...
|
|
||||||
</Typography>
|
|
||||||
</Paper>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div ref={messagesEndRef} />
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Input Area */}
|
|
||||||
<Paper
|
|
||||||
elevation={0}
|
|
||||||
sx={{
|
|
||||||
p: 2,
|
|
||||||
borderRadius: 0,
|
|
||||||
background: 'rgba(255, 255, 255, 0.95)',
|
|
||||||
backdropFilter: 'blur(10px)',
|
|
||||||
borderTop: '1px solid',
|
|
||||||
borderColor: 'divider',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box sx={{ display: 'flex', gap: 1, alignItems: 'flex-end' }}>
|
|
||||||
<TextField
|
|
||||||
fullWidth
|
|
||||||
multiline
|
|
||||||
maxRows={4}
|
|
||||||
placeholder="Ask me anything..."
|
|
||||||
value={input}
|
|
||||||
onChange={(e) => setInput(e.target.value)}
|
|
||||||
onKeyPress={(e) => {
|
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
|
||||||
e.preventDefault();
|
|
||||||
handleSend();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={isLoading}
|
|
||||||
sx={{
|
|
||||||
'& .MuiOutlinedInput-root': {
|
|
||||||
borderRadius: 3,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<IconButton
|
|
||||||
color="primary"
|
|
||||||
onClick={() => handleSend()}
|
|
||||||
disabled={!input.trim() || isLoading}
|
|
||||||
sx={{
|
|
||||||
width: 48,
|
|
||||||
height: 48,
|
|
||||||
bgcolor: 'primary.main',
|
|
||||||
color: 'white',
|
|
||||||
'&:hover': {
|
|
||||||
bgcolor: 'primary.dark',
|
|
||||||
},
|
|
||||||
'&:disabled': {
|
|
||||||
bgcolor: 'action.disabledBackground',
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Send />
|
|
||||||
</IconButton>
|
|
||||||
</Box>
|
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ mt: 1, display: 'block' }}>
|
|
||||||
This AI assistant provides general information. Always consult healthcare professionals
|
|
||||||
for medical advice.
|
|
||||||
</Typography>
|
|
||||||
</Paper>
|
|
||||||
</Box>
|
|
||||||
</AppShell>
|
</AppShell>
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,656 +1,24 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { lazy, Suspense } from 'react';
|
||||||
import {
|
|
||||||
Box,
|
|
||||||
Typography,
|
|
||||||
Grid,
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
Select,
|
|
||||||
MenuItem,
|
|
||||||
FormControl,
|
|
||||||
InputLabel,
|
|
||||||
CircularProgress,
|
|
||||||
Alert,
|
|
||||||
Paper,
|
|
||||||
Divider,
|
|
||||||
List,
|
|
||||||
ListItem,
|
|
||||||
ListItemAvatar,
|
|
||||||
ListItemText,
|
|
||||||
Avatar,
|
|
||||||
Chip,
|
|
||||||
ToggleButtonGroup,
|
|
||||||
ToggleButton,
|
|
||||||
} from '@mui/material';
|
|
||||||
import {
|
|
||||||
Restaurant,
|
|
||||||
Hotel,
|
|
||||||
BabyChangingStation,
|
|
||||||
TrendingUp,
|
|
||||||
Timeline,
|
|
||||||
CalendarToday,
|
|
||||||
Assessment,
|
|
||||||
} from '@mui/icons-material';
|
|
||||||
import { AppShell } from '@/components/layouts/AppShell/AppShell';
|
import { AppShell } from '@/components/layouts/AppShell/AppShell';
|
||||||
import { ProtectedRoute } from '@/components/common/ProtectedRoute';
|
import { ProtectedRoute } from '@/components/common/ProtectedRoute';
|
||||||
import { motion } from 'framer-motion';
|
import { LoadingFallback } from '@/components/common/LoadingFallback';
|
||||||
import { trackingApi, Activity, ActivityType } from '@/lib/api/tracking';
|
|
||||||
import { childrenApi, Child } from '@/lib/api/children';
|
|
||||||
import { format, subDays, startOfDay, endOfDay, parseISO, differenceInMinutes, formatDistanceToNow } from 'date-fns';
|
|
||||||
import { BarChart, Bar, LineChart, Line, PieChart, Pie, Cell, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
|
|
||||||
|
|
||||||
type DateRange = '7days' | '30days' | '3months';
|
// Lazy load the insights dashboard component
|
||||||
|
const InsightsDashboard = lazy(() =>
|
||||||
interface DayData {
|
import('@/components/features/analytics/InsightsDashboard').then((mod) => ({
|
||||||
date: string;
|
default: mod.InsightsDashboard,
|
||||||
feedings: number;
|
}))
|
||||||
sleepHours: number;
|
);
|
||||||
diapers: number;
|
|
||||||
activities: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DiaperTypeData {
|
|
||||||
name: string;
|
|
||||||
value: number;
|
|
||||||
color: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ActivityTypeData {
|
|
||||||
name: string;
|
|
||||||
count: number;
|
|
||||||
color: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const COLORS = {
|
|
||||||
feeding: '#FFB6C1',
|
|
||||||
sleep: '#B6D7FF',
|
|
||||||
diaper: '#FFE4B5',
|
|
||||||
medication: '#D4B5FF',
|
|
||||||
milestone: '#B5FFD4',
|
|
||||||
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 default function InsightsPage() {
|
export default function InsightsPage() {
|
||||||
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);
|
|
||||||
|
|
||||||
// Fetch children on mount
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchChildren = async () => {
|
|
||||||
try {
|
|
||||||
const childrenData = await childrenApi.getChildren();
|
|
||||||
setChildren(childrenData);
|
|
||||||
if (childrenData.length > 0) {
|
|
||||||
setSelectedChild(childrenData[0].id);
|
|
||||||
}
|
|
||||||
} catch (err: any) {
|
|
||||||
setError(err.response?.data?.message || 'Failed to load children');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
fetchChildren();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Fetch activities when child or date range changes
|
|
||||||
useEffect(() => {
|
|
||||||
if (!selectedChild) return;
|
|
||||||
|
|
||||||
const fetchActivities = async () => {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
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()
|
|
||||||
);
|
|
||||||
setActivities(activitiesData);
|
|
||||||
} catch (err: any) {
|
|
||||||
setError(err.response?.data?.message || 'Failed to load activities');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchActivities();
|
|
||||||
}, [selectedChild, dateRange]);
|
|
||||||
|
|
||||||
// Calculate statistics
|
|
||||||
const calculateStats = () => {
|
|
||||||
const totalFeedings = activities.filter((a) => a.type === 'feeding').length;
|
|
||||||
const totalDiapers = activities.filter((a) => a.type === 'diaper').length;
|
|
||||||
|
|
||||||
// Calculate sleep hours
|
|
||||||
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';
|
|
||||||
|
|
||||||
// Most common activity type
|
|
||||||
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 - daily breakdown
|
|
||||||
const prepareDailyData = (): DayData[] => {
|
|
||||||
const days = dateRange === '7days' ? 7 : dateRange === '30days' ? 30 : 90;
|
|
||||||
const dailyMap = new Map<string, DayData>();
|
|
||||||
|
|
||||||
// Initialize all days
|
|
||||||
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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Populate with actual data
|
|
||||||
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)),
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
// Prepare diaper type data
|
|
||||||
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: name.charAt(0).toUpperCase() + name.slice(1),
|
|
||||||
value,
|
|
||||||
color: COLORS[name as keyof typeof COLORS] || '#CCCCCC',
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
// Prepare activity type data
|
|
||||||
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: name.charAt(0).toUpperCase() + name.slice(1),
|
|
||||||
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);
|
|
||||||
|
|
||||||
// Empty state check
|
|
||||||
const noChildren = children.length === 0;
|
|
||||||
const noActivities = activities.length === 0 && !loading;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ProtectedRoute>
|
<ProtectedRoute>
|
||||||
<AppShell>
|
<AppShell>
|
||||||
<motion.div
|
<Suspense fallback={<LoadingFallback variant="page" />}>
|
||||||
initial={{ opacity: 0, y: 20 }}
|
<InsightsDashboard />
|
||||||
animate={{ opacity: 1, y: 0 }}
|
</Suspense>
|
||||||
transition={{ duration: 0.5 }}
|
|
||||||
>
|
|
||||||
<Box>
|
|
||||||
<Typography variant="h4" fontWeight="600" gutterBottom>
|
|
||||||
Insights & Analytics
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body1" color="text.secondary" sx={{ mb: 4 }}>
|
|
||||||
Track patterns and get insights about your child's activities
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
{/* Filters */}
|
|
||||||
<Paper sx={{ p: 3, mb: 3 }}>
|
|
||||||
<Grid container spacing={2} alignItems="center">
|
|
||||||
{children.length > 1 && (
|
|
||||||
<Grid item xs={12} sm={6} md={4}>
|
|
||||||
<FormControl fullWidth>
|
|
||||||
<InputLabel>Child</InputLabel>
|
|
||||||
<Select
|
|
||||||
value={selectedChild}
|
|
||||||
onChange={(e) => setSelectedChild(e.target.value)}
|
|
||||||
label="Child"
|
|
||||||
>
|
|
||||||
{children.map((child) => (
|
|
||||||
<MenuItem key={child.id} value={child.id}>
|
|
||||||
{child.name}
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
</Grid>
|
|
||||||
)}
|
|
||||||
<Grid item xs={12} sm={6} md={4}>
|
|
||||||
<ToggleButtonGroup
|
|
||||||
value={dateRange}
|
|
||||||
exclusive
|
|
||||||
onChange={(_, newValue) => newValue && setDateRange(newValue)}
|
|
||||||
fullWidth
|
|
||||||
size="large"
|
|
||||||
>
|
|
||||||
<ToggleButton value="7days">7 Days</ToggleButton>
|
|
||||||
<ToggleButton value="30days">30 Days</ToggleButton>
|
|
||||||
<ToggleButton value="3months">3 Months</ToggleButton>
|
|
||||||
</ToggleButtonGroup>
|
|
||||||
</Grid>
|
|
||||||
</Grid>
|
|
||||||
</Paper>
|
|
||||||
|
|
||||||
{/* Error State */}
|
|
||||||
{error && (
|
|
||||||
<Alert severity="error" sx={{ mb: 3 }}>
|
|
||||||
{error}
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* No Children State */}
|
|
||||||
{noChildren && !loading && (
|
|
||||||
<Alert severity="info" sx={{ mb: 3 }}>
|
|
||||||
No children found. Please add a child to view insights.
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Loading State */}
|
|
||||||
{loading && (
|
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', py: 8 }}>
|
|
||||||
<CircularProgress />
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* No Activities State */}
|
|
||||||
{noActivities && !noChildren && (
|
|
||||||
<Alert severity="info" sx={{ mb: 3 }}>
|
|
||||||
No activities found for the selected date range. Start tracking activities to see insights!
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
{!loading && !noChildren && !noActivities && (
|
|
||||||
<>
|
|
||||||
{/* Summary Statistics */}
|
|
||||||
<Grid container spacing={3} sx={{ mb: 3 }}>
|
|
||||||
<Grid item xs={12} sm={6} md={3}>
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, scale: 0.9 }}
|
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
|
||||||
transition={{ duration: 0.3, delay: 0 }}
|
|
||||||
>
|
|
||||||
<Card sx={{ bgcolor: COLORS.feeding, color: 'white' }}>
|
|
||||||
<CardContent>
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1 }}>
|
|
||||||
<Restaurant sx={{ fontSize: 32, mr: 1 }} />
|
|
||||||
<Typography variant="h6" fontWeight="600">
|
|
||||||
Feedings
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
<Typography variant="h3" fontWeight="700">
|
|
||||||
{stats.totalFeedings}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" sx={{ opacity: 0.9, mt: 1 }}>
|
|
||||||
Total count
|
|
||||||
</Typography>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</motion.div>
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
<Grid item xs={12} sm={6} md={3}>
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, scale: 0.9 }}
|
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
|
||||||
transition={{ duration: 0.3, delay: 0.1 }}
|
|
||||||
>
|
|
||||||
<Card sx={{ bgcolor: COLORS.sleep, color: 'white' }}>
|
|
||||||
<CardContent>
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1 }}>
|
|
||||||
<Hotel sx={{ fontSize: 32, mr: 1 }} />
|
|
||||||
<Typography variant="h6" fontWeight="600">
|
|
||||||
Sleep
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
<Typography variant="h3" fontWeight="700">
|
|
||||||
{stats.avgSleepHours}h
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" sx={{ opacity: 0.9, mt: 1 }}>
|
|
||||||
Average per day
|
|
||||||
</Typography>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</motion.div>
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
<Grid item xs={12} sm={6} md={3}>
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, scale: 0.9 }}
|
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
|
||||||
transition={{ duration: 0.3, delay: 0.2 }}
|
|
||||||
>
|
|
||||||
<Card sx={{ bgcolor: COLORS.diaper, color: 'white' }}>
|
|
||||||
<CardContent>
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1 }}>
|
|
||||||
<BabyChangingStation sx={{ fontSize: 32, mr: 1 }} />
|
|
||||||
<Typography variant="h6" fontWeight="600">
|
|
||||||
Diapers
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
<Typography variant="h3" fontWeight="700">
|
|
||||||
{stats.totalDiapers}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" sx={{ opacity: 0.9, mt: 1 }}>
|
|
||||||
Total changes
|
|
||||||
</Typography>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</motion.div>
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
<Grid item xs={12} sm={6} md={3}>
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, scale: 0.9 }}
|
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
|
||||||
transition={{ duration: 0.3, delay: 0.3 }}
|
|
||||||
>
|
|
||||||
<Card sx={{ bgcolor: COLORS.milestone, color: 'white' }}>
|
|
||||||
<CardContent>
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1 }}>
|
|
||||||
<TrendingUp sx={{ fontSize: 32, mr: 1 }} />
|
|
||||||
<Typography variant="h6" fontWeight="600">
|
|
||||||
Top Activity
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
<Typography variant="h3" fontWeight="700" sx={{ textTransform: 'capitalize' }}>
|
|
||||||
{stats.mostCommonType}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" sx={{ opacity: 0.9, mt: 1 }}>
|
|
||||||
Most frequent
|
|
||||||
</Typography>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</motion.div>
|
|
||||||
</Grid>
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
{/* Charts */}
|
|
||||||
<Grid container spacing={3} sx={{ mb: 3 }}>
|
|
||||||
{/* Feeding Frequency Chart */}
|
|
||||||
<Grid item xs={12} md={6}>
|
|
||||||
<Card>
|
|
||||||
<CardContent>
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
|
||||||
<Restaurant sx={{ mr: 1, color: COLORS.feeding }} />
|
|
||||||
<Typography variant="h6" fontWeight="600">
|
|
||||||
Feeding Frequency
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
<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="Feedings" />
|
|
||||||
</BarChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
{/* Sleep Duration Chart */}
|
|
||||||
<Grid item xs={12} md={6}>
|
|
||||||
<Card>
|
|
||||||
<CardContent>
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
|
||||||
<Hotel sx={{ mr: 1, color: COLORS.sleep }} />
|
|
||||||
<Typography variant="h6" fontWeight="600">
|
|
||||||
Sleep Duration (Hours)
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
<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="Sleep Hours"
|
|
||||||
dot={{ fill: COLORS.sleep, r: 4 }}
|
|
||||||
/>
|
|
||||||
</LineChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
{/* Diaper Changes by Type */}
|
|
||||||
{diaperData.length > 0 && (
|
|
||||||
<Grid item xs={12} md={6}>
|
|
||||||
<Card>
|
|
||||||
<CardContent>
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
|
||||||
<BabyChangingStation sx={{ mr: 1, color: COLORS.diaper }} />
|
|
||||||
<Typography variant="h6" fontWeight="600">
|
|
||||||
Diaper Changes by Type
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
<ResponsiveContainer width="100%" height={250}>
|
|
||||||
<PieChart>
|
|
||||||
<Pie
|
|
||||||
data={diaperData}
|
|
||||||
cx="50%"
|
|
||||||
cy="50%"
|
|
||||||
labelLine={false}
|
|
||||||
label={({ name, percent }) => `${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>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</Grid>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Activity Timeline */}
|
|
||||||
<Grid item xs={12} md={diaperData.length > 0 ? 6 : 12}>
|
|
||||||
<Card>
|
|
||||||
<CardContent>
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
|
||||||
<Assessment sx={{ mr: 1, color: 'primary.main' }} />
|
|
||||||
<Typography variant="h6" fontWeight="600">
|
|
||||||
Activity Timeline
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
<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="Feedings" />
|
|
||||||
<Bar dataKey="diapers" fill={COLORS.diaper} name="Diapers" />
|
|
||||||
<Bar dataKey="sleepHours" fill={COLORS.sleep} name="Sleep (hrs)" />
|
|
||||||
</BarChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</Grid>
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
{/* Activity Type Distribution */}
|
|
||||||
{activityTypeData.length > 0 && (
|
|
||||||
<Card sx={{ mb: 3 }}>
|
|
||||||
<CardContent>
|
|
||||||
<Typography variant="h6" fontWeight="600" gutterBottom>
|
|
||||||
Activity Distribution
|
|
||||||
</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>
|
|
||||||
Recent Activities (Last 20)
|
|
||||||
</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' }}>
|
|
||||||
{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>
|
|
||||||
</AppShell>
|
</AppShell>
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
import { Inter } from 'next/font/google';
|
import { Inter } from 'next/font/google';
|
||||||
import { ThemeRegistry } from '@/components/ThemeRegistry';
|
import { ThemeRegistry } from '@/components/ThemeRegistry';
|
||||||
|
// import { PerformanceMonitor } from '@/components/common/PerformanceMonitor'; // Temporarily disabled
|
||||||
import './globals.css';
|
import './globals.css';
|
||||||
|
|
||||||
const inter = Inter({ subsets: ['latin'] });
|
const inter = Inter({ subsets: ['latin'] });
|
||||||
@@ -37,6 +38,7 @@ export default function RootLayout({
|
|||||||
</head>
|
</head>
|
||||||
<body className={inter.className}>
|
<body className={inter.className}>
|
||||||
<ThemeRegistry>
|
<ThemeRegistry>
|
||||||
|
{/* <PerformanceMonitor /> */}
|
||||||
{children}
|
{children}
|
||||||
</ThemeRegistry>
|
</ThemeRegistry>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Box, Typography, Button, Paper, Grid } from '@mui/material';
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Box, Typography, Button, Paper, Grid, CircularProgress } from '@mui/material';
|
||||||
import { AppShell } from '@/components/layouts/AppShell/AppShell';
|
import { AppShell } from '@/components/layouts/AppShell/AppShell';
|
||||||
import { ProtectedRoute } from '@/components/common/ProtectedRoute';
|
import { ProtectedRoute } from '@/components/common/ProtectedRoute';
|
||||||
import {
|
import {
|
||||||
@@ -14,10 +15,51 @@ import {
|
|||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { useAuth } from '@/lib/auth/AuthContext';
|
import { useAuth } from '@/lib/auth/AuthContext';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { trackingApi, DailySummary } from '@/lib/api/tracking';
|
||||||
|
import { childrenApi, Child } from '@/lib/api/children';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const [children, setChildren] = useState<Child[]>([]);
|
||||||
|
const [selectedChild, setSelectedChild] = useState<Child | null>(null);
|
||||||
|
const [dailySummary, setDailySummary] = useState<DailySummary | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const familyId = user?.families?.[0]?.familyId;
|
||||||
|
// Load children and daily summary
|
||||||
|
useEffect(() => {
|
||||||
|
const loadData = async () => {
|
||||||
|
if (!familyId) {
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Load children
|
||||||
|
const childrenData = await childrenApi.getChildren(familyId);
|
||||||
|
setChildren(childrenData);
|
||||||
|
|
||||||
|
if (childrenData.length > 0) {
|
||||||
|
const firstChild = childrenData[0];
|
||||||
|
setSelectedChild(firstChild);
|
||||||
|
|
||||||
|
// Load today's summary for first child
|
||||||
|
const today = format(new Date(), 'yyyy-MM-dd');
|
||||||
|
const summary = await trackingApi.getDailySummary(firstChild.id, today);
|
||||||
|
setDailySummary(summary);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load data:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadData();
|
||||||
|
}, [familyId]);
|
||||||
|
|
||||||
const quickActions = [
|
const quickActions = [
|
||||||
{ icon: <Restaurant />, label: 'Feeding', color: '#FFB6C1', path: '/track/feeding' },
|
{ icon: <Restaurant />, label: 'Feeding', color: '#FFB6C1', path: '/track/feeding' },
|
||||||
{ icon: <Hotel />, label: 'Sleep', color: '#B6D7FF', path: '/track/sleep' },
|
{ icon: <Hotel />, label: 'Sleep', color: '#B6D7FF', path: '/track/sleep' },
|
||||||
@@ -26,6 +68,18 @@ export default function HomePage() {
|
|||||||
{ icon: <Analytics />, label: 'Analytics', color: '#D4B5FF', path: '/analytics' },
|
{ icon: <Analytics />, label: 'Analytics', color: '#D4B5FF', path: '/analytics' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const formatSleepHours = (minutes: number) => {
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
const mins = minutes % 60;
|
||||||
|
if (hours > 0 && mins > 0) {
|
||||||
|
return `${hours}h ${mins}m`;
|
||||||
|
} else if (hours > 0) {
|
||||||
|
return `${hours}h`;
|
||||||
|
} else {
|
||||||
|
return `${mins}m`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ProtectedRoute>
|
<ProtectedRoute>
|
||||||
<AppShell>
|
<AppShell>
|
||||||
@@ -78,34 +132,62 @@ export default function HomePage() {
|
|||||||
))}
|
))}
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
{/* Recent Activity */}
|
{/* Today's Summary */}
|
||||||
<Typography variant="h6" gutterBottom fontWeight="600" sx={{ mb: 2 }}>
|
<Typography variant="h6" gutterBottom fontWeight="600" sx={{ mb: 2 }}>
|
||||||
Today's Summary
|
Today's Summary{selectedChild ? ` - ${selectedChild.name}` : ''}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Paper sx={{ p: 3 }}>
|
<Paper sx={{ p: 3 }}>
|
||||||
|
{loading ? (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
) : !dailySummary ? (
|
||||||
|
<Box sx={{ textAlign: 'center', py: 4 }}>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{children.length === 0
|
||||||
|
? 'Add a child to start tracking'
|
||||||
|
: 'No activities tracked today'}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
<Grid container spacing={3}>
|
<Grid container spacing={3}>
|
||||||
<Grid item xs={4}>
|
<Grid item xs={4}>
|
||||||
<Box textAlign="center">
|
<Box textAlign="center">
|
||||||
<Restaurant sx={{ fontSize: 32, color: 'primary.main', mb: 1 }} />
|
<Restaurant sx={{ fontSize: 32, color: 'primary.main', mb: 1 }} />
|
||||||
<Typography variant="h5" fontWeight="600">8</Typography>
|
<Typography variant="h5" fontWeight="600">
|
||||||
<Typography variant="body2" color="text.secondary">Feedings</Typography>
|
{dailySummary.feedingCount || 0}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Feedings
|
||||||
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={4}>
|
<Grid item xs={4}>
|
||||||
<Box textAlign="center">
|
<Box textAlign="center">
|
||||||
<Hotel sx={{ fontSize: 32, color: 'info.main', mb: 1 }} />
|
<Hotel sx={{ fontSize: 32, color: 'info.main', mb: 1 }} />
|
||||||
<Typography variant="h5" fontWeight="600">12h</Typography>
|
<Typography variant="h5" fontWeight="600">
|
||||||
<Typography variant="body2" color="text.secondary">Sleep</Typography>
|
{dailySummary.sleepTotalMinutes
|
||||||
|
? formatSleepHours(dailySummary.sleepTotalMinutes)
|
||||||
|
: '0m'}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Sleep
|
||||||
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={4}>
|
<Grid item xs={4}>
|
||||||
<Box textAlign="center">
|
<Box textAlign="center">
|
||||||
<BabyChangingStation sx={{ fontSize: 32, color: 'warning.main', mb: 1 }} />
|
<BabyChangingStation sx={{ fontSize: 32, color: 'warning.main', mb: 1 }} />
|
||||||
<Typography variant="h5" fontWeight="600">6</Typography>
|
<Typography variant="h5" fontWeight="600">
|
||||||
<Typography variant="body2" color="text.secondary">Diapers</Typography>
|
{dailySummary.diaperCount || 0}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Diapers
|
||||||
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
)}
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
{/* Next Predicted Activity */}
|
{/* Next Predicted Activity */}
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ import {
|
|||||||
BabyChangingStation,
|
BabyChangingStation,
|
||||||
Warning,
|
Warning,
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
|
ChildCare,
|
||||||
|
Add,
|
||||||
} from '@mui/icons-material';
|
} from '@mui/icons-material';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { AppShell } from '@/components/layouts/AppShell/AppShell';
|
import { AppShell } from '@/components/layouts/AppShell/AppShell';
|
||||||
@@ -315,18 +317,24 @@ export default function DiaperTrackPage() {
|
|||||||
return (
|
return (
|
||||||
<ProtectedRoute>
|
<ProtectedRoute>
|
||||||
<AppShell>
|
<AppShell>
|
||||||
<Box>
|
<Card>
|
||||||
<Alert severity="warning">
|
<CardContent sx={{ textAlign: 'center', py: 8 }}>
|
||||||
Please add a child first before tracking diaper changes.
|
<ChildCare sx={{ fontSize: 64, color: 'text.secondary', mb: 2 }} />
|
||||||
</Alert>
|
<Typography variant="h6" color="text.secondary" gutterBottom>
|
||||||
|
No Children Added
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
|
||||||
|
You need to add a child before you can track diaper changes
|
||||||
|
</Typography>
|
||||||
<Button
|
<Button
|
||||||
variant="contained"
|
variant="contained"
|
||||||
|
startIcon={<Add />}
|
||||||
onClick={() => router.push('/children')}
|
onClick={() => router.push('/children')}
|
||||||
sx={{ mt: 2 }}
|
|
||||||
>
|
>
|
||||||
Go to Children Page
|
Add Child
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</AppShell>
|
</AppShell>
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ import {
|
|||||||
Fastfood,
|
Fastfood,
|
||||||
Delete,
|
Delete,
|
||||||
Edit,
|
Edit,
|
||||||
|
ChildCare,
|
||||||
|
Add,
|
||||||
} from '@mui/icons-material';
|
} from '@mui/icons-material';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { AppShell } from '@/components/layouts/AppShell/AppShell';
|
import { AppShell } from '@/components/layouts/AppShell/AppShell';
|
||||||
@@ -316,18 +318,24 @@ export default function FeedingTrackPage() {
|
|||||||
return (
|
return (
|
||||||
<ProtectedRoute>
|
<ProtectedRoute>
|
||||||
<AppShell>
|
<AppShell>
|
||||||
<Box>
|
<Card>
|
||||||
<Alert severity="warning">
|
<CardContent sx={{ textAlign: 'center', py: 8 }}>
|
||||||
Please add a child first before tracking feeding activities.
|
<ChildCare sx={{ fontSize: 64, color: 'text.secondary', mb: 2 }} />
|
||||||
</Alert>
|
<Typography variant="h6" color="text.secondary" gutterBottom>
|
||||||
|
No Children Added
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
|
||||||
|
You need to add a child before you can track feeding activities
|
||||||
|
</Typography>
|
||||||
<Button
|
<Button
|
||||||
variant="contained"
|
variant="contained"
|
||||||
|
startIcon={<Add />}
|
||||||
onClick={() => router.push('/children')}
|
onClick={() => router.push('/children')}
|
||||||
sx={{ mt: 2 }}
|
|
||||||
>
|
>
|
||||||
Go to Children Page
|
Add Child
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</AppShell>
|
</AppShell>
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ import {
|
|||||||
DirectionsCar,
|
DirectionsCar,
|
||||||
Chair,
|
Chair,
|
||||||
Home,
|
Home,
|
||||||
|
ChildCare,
|
||||||
|
Add,
|
||||||
} from '@mui/icons-material';
|
} from '@mui/icons-material';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { AppShell } from '@/components/layouts/AppShell/AppShell';
|
import { AppShell } from '@/components/layouts/AppShell/AppShell';
|
||||||
@@ -330,18 +332,24 @@ export default function SleepTrackPage() {
|
|||||||
return (
|
return (
|
||||||
<ProtectedRoute>
|
<ProtectedRoute>
|
||||||
<AppShell>
|
<AppShell>
|
||||||
<Box>
|
<Card>
|
||||||
<Alert severity="warning">
|
<CardContent sx={{ textAlign: 'center', py: 8 }}>
|
||||||
Please add a child first before tracking sleep activities.
|
<ChildCare sx={{ fontSize: 64, color: 'text.secondary', mb: 2 }} />
|
||||||
</Alert>
|
<Typography variant="h6" color="text.secondary" gutterBottom>
|
||||||
|
No Children Added
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
|
||||||
|
You need to add a child before you can track sleep activities
|
||||||
|
</Typography>
|
||||||
<Button
|
<Button
|
||||||
variant="contained"
|
variant="contained"
|
||||||
|
startIcon={<Add />}
|
||||||
onClick={() => router.push('/children')}
|
onClick={() => router.push('/children')}
|
||||||
sx={{ mt: 2 }}
|
|
||||||
>
|
>
|
||||||
Go to Children Page
|
Add Child
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</AppShell>
|
</AppShell>
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
);
|
);
|
||||||
|
|||||||
182
maternal-web/components/common/LoadingFallback.tsx
Normal file
182
maternal-web/components/common/LoadingFallback.tsx
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
import { Box, Skeleton, Paper, Container } from '@mui/material';
|
||||||
|
|
||||||
|
interface LoadingFallbackProps {
|
||||||
|
variant?: 'page' | 'card' | 'list' | 'chart' | 'chat';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LoadingFallback: React.FC<LoadingFallbackProps> = ({ variant = 'page' }) => {
|
||||||
|
if (variant === 'chat') {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
height: 'calc(100vh - 200px)',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
background: 'linear-gradient(135deg, #FFF5F5 0%, #FFE4E1 100%)',
|
||||||
|
borderRadius: 2,
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header Skeleton */}
|
||||||
|
<Paper
|
||||||
|
elevation={0}
|
||||||
|
sx={{
|
||||||
|
p: 2,
|
||||||
|
borderRadius: 0,
|
||||||
|
background: 'rgba(255, 255, 255, 0.95)',
|
||||||
|
borderBottom: '1px solid',
|
||||||
|
borderColor: 'divider',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||||
|
<Skeleton variant="circular" width={40} height={40} />
|
||||||
|
<Box sx={{ flex: 1 }}>
|
||||||
|
<Skeleton variant="text" width={200} height={28} />
|
||||||
|
<Skeleton variant="text" width={300} height={20} />
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
{/* Messages Skeleton */}
|
||||||
|
<Box sx={{ flex: 1, overflowY: 'auto', p: 2 }}>
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||||
|
{/* Suggested questions */}
|
||||||
|
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', justifyContent: 'center' }}>
|
||||||
|
<Skeleton variant="rounded" width={180} height={32} sx={{ borderRadius: 3 }} />
|
||||||
|
<Skeleton variant="rounded" width={200} height={32} sx={{ borderRadius: 3 }} />
|
||||||
|
<Skeleton variant="rounded" width={160} height={32} sx={{ borderRadius: 3 }} />
|
||||||
|
<Skeleton variant="rounded" width={190} height={32} sx={{ borderRadius: 3 }} />
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Input Skeleton */}
|
||||||
|
<Paper
|
||||||
|
elevation={0}
|
||||||
|
sx={{
|
||||||
|
p: 2,
|
||||||
|
borderRadius: 0,
|
||||||
|
background: 'rgba(255, 255, 255, 0.95)',
|
||||||
|
borderTop: '1px solid',
|
||||||
|
borderColor: 'divider',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ display: 'flex', gap: 1, alignItems: 'flex-end' }}>
|
||||||
|
<Skeleton variant="rounded" sx={{ flex: 1, height: 48, borderRadius: 3 }} />
|
||||||
|
<Skeleton variant="circular" width={48} height={48} />
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (variant === 'chart') {
|
||||||
|
return (
|
||||||
|
<Paper sx={{ p: 3 }}>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 2 }}>
|
||||||
|
<Skeleton variant="text" width={180} height={32} />
|
||||||
|
<Skeleton variant="rounded" width={120} height={36} />
|
||||||
|
</Box>
|
||||||
|
<Skeleton variant="rectangular" width="100%" height={250} sx={{ borderRadius: 2 }} />
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (variant === 'list') {
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
|
{[1, 2, 3, 4, 5].map((i) => (
|
||||||
|
<Paper key={i} sx={{ p: 2 }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||||
|
<Skeleton variant="circular" width={48} height={48} />
|
||||||
|
<Box sx={{ flex: 1 }}>
|
||||||
|
<Skeleton variant="text" width="60%" height={24} />
|
||||||
|
<Skeleton variant="text" width="40%" height={20} />
|
||||||
|
</Box>
|
||||||
|
<Skeleton variant="rounded" width={80} height={32} />
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (variant === 'card') {
|
||||||
|
return (
|
||||||
|
<Paper sx={{ p: 3, borderRadius: 4 }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
||||||
|
<Skeleton variant="circular" width={40} height={40} sx={{ mr: 2 }} />
|
||||||
|
<Skeleton variant="text" width={150} height={32} />
|
||||||
|
</Box>
|
||||||
|
<Skeleton variant="text" width="100%" height={24} />
|
||||||
|
<Skeleton variant="text" width="90%" height={24} />
|
||||||
|
<Skeleton variant="text" width="70%" height={24} />
|
||||||
|
<Box sx={{ mt: 3, display: 'flex', gap: 1 }}>
|
||||||
|
<Skeleton variant="rounded" width={100} height={36} />
|
||||||
|
<Skeleton variant="rounded" width={100} height={36} />
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: full page skeleton
|
||||||
|
return (
|
||||||
|
<Container maxWidth="lg" sx={{ py: 3 }}>
|
||||||
|
<Box sx={{ mb: 4 }}>
|
||||||
|
<Skeleton variant="text" width={300} height={48} />
|
||||||
|
<Skeleton variant="text" width={500} height={24} sx={{ mt: 1 }} />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Filter section */}
|
||||||
|
<Paper sx={{ p: 3, mb: 3 }}>
|
||||||
|
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap' }}>
|
||||||
|
<Skeleton variant="rounded" width={200} height={56} />
|
||||||
|
<Skeleton variant="rounded" width={300} height={56} />
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
{/* Stats cards */}
|
||||||
|
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', sm: '1fr 1fr', md: '1fr 1fr 1fr 1fr' }, gap: 3, mb: 3 }}>
|
||||||
|
{[1, 2, 3, 4].map((i) => (
|
||||||
|
<Paper key={i} sx={{ p: 3 }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1 }}>
|
||||||
|
<Skeleton variant="circular" width={32} height={32} sx={{ mr: 1 }} />
|
||||||
|
<Skeleton variant="text" width={100} height={28} />
|
||||||
|
</Box>
|
||||||
|
<Skeleton variant="text" width={80} height={48} />
|
||||||
|
<Skeleton variant="text" width={120} height={20} />
|
||||||
|
</Paper>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Charts */}
|
||||||
|
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', md: '1fr 1fr' }, gap: 3, mb: 3 }}>
|
||||||
|
{[1, 2].map((i) => (
|
||||||
|
<Paper key={i} sx={{ p: 3 }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
||||||
|
<Skeleton variant="circular" width={24} height={24} sx={{ mr: 1 }} />
|
||||||
|
<Skeleton variant="text" width={150} height={28} />
|
||||||
|
</Box>
|
||||||
|
<Skeleton variant="rectangular" width="100%" height={250} sx={{ borderRadius: 1 }} />
|
||||||
|
</Paper>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Activity list */}
|
||||||
|
<Paper sx={{ p: 3 }}>
|
||||||
|
<Skeleton variant="text" width={200} height={32} sx={{ mb: 2 }} />
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<Box key={i} sx={{ display: 'flex', alignItems: 'center', gap: 2, borderBottom: '1px solid', borderColor: 'divider', pb: 2 }}>
|
||||||
|
<Skeleton variant="circular" width={40} height={40} />
|
||||||
|
<Box sx={{ flex: 1 }}>
|
||||||
|
<Skeleton variant="text" width="40%" height={24} />
|
||||||
|
<Skeleton variant="text" width="60%" height={20} />
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
};
|
||||||
77
maternal-web/components/common/OptimizedImage.tsx
Normal file
77
maternal-web/components/common/OptimizedImage.tsx
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import Image, { ImageProps } from 'next/image';
|
||||||
|
import { Box, Skeleton } from '@mui/material';
|
||||||
|
|
||||||
|
interface OptimizedImageProps extends Omit<ImageProps, 'onLoadingComplete'> {
|
||||||
|
onLoadComplete?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OptimizedImage Component
|
||||||
|
*
|
||||||
|
* Wraps Next.js Image component with:
|
||||||
|
* - Loading states with MUI Skeleton
|
||||||
|
* - Blur placeholder support
|
||||||
|
* - Loading completion events
|
||||||
|
* - Automatic optimization
|
||||||
|
*/
|
||||||
|
export const OptimizedImage: React.FC<OptimizedImageProps> = ({
|
||||||
|
src,
|
||||||
|
alt,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
onLoadComplete,
|
||||||
|
style,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
const handleLoadingComplete = () => {
|
||||||
|
setIsLoading(false);
|
||||||
|
onLoadComplete?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Generate a simple blur data URL for placeholder
|
||||||
|
const blurDataURL = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTAwIiBoZWlnaHQ9IjEwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iMTAwIiBoZWlnaHQ9IjEwMCIgZmlsbD0iI2YwZjBmMCIvPjwvc3ZnPg==';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: 'relative',
|
||||||
|
width: typeof width === 'number' ? `${width}px` : width,
|
||||||
|
height: typeof height === 'number' ? `${height}px` : height,
|
||||||
|
...style,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isLoading && (
|
||||||
|
<Skeleton
|
||||||
|
variant="rectangular"
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
sx={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
borderRadius: 'inherit',
|
||||||
|
}}
|
||||||
|
animation="wave"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Image
|
||||||
|
src={src}
|
||||||
|
alt={alt}
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
onLoadingComplete={handleLoadingComplete}
|
||||||
|
placeholder="blur"
|
||||||
|
blurDataURL={blurDataURL}
|
||||||
|
style={{
|
||||||
|
opacity: isLoading ? 0 : 1,
|
||||||
|
transition: 'opacity 0.3s ease-in-out',
|
||||||
|
...style,
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
20
maternal-web/components/common/PerformanceMonitor.tsx
Normal file
20
maternal-web/components/common/PerformanceMonitor.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { initPerformanceMonitoring } from '@/lib/performance/monitoring';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PerformanceMonitor Component
|
||||||
|
*
|
||||||
|
* Client-side component that initializes web vitals monitoring
|
||||||
|
* Should be included once in the root layout
|
||||||
|
*/
|
||||||
|
export const PerformanceMonitor: React.FC = () => {
|
||||||
|
useEffect(() => {
|
||||||
|
// Initialize performance monitoring on client side
|
||||||
|
initPerformanceMonitoring();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// This component doesn't render anything
|
||||||
|
return null;
|
||||||
|
};
|
||||||
332
maternal-web/components/features/ai-chat/AIChatInterface.tsx
Normal file
332
maternal-web/components/features/ai-chat/AIChatInterface.tsx
Normal file
@@ -0,0 +1,332 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useRef, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
TextField,
|
||||||
|
IconButton,
|
||||||
|
Paper,
|
||||||
|
Typography,
|
||||||
|
Avatar,
|
||||||
|
CircularProgress,
|
||||||
|
Chip,
|
||||||
|
} from '@mui/material';
|
||||||
|
import { Send, SmartToy, Person, AutoAwesome } from '@mui/icons-material';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import { useAuth } from '@/lib/auth/AuthContext';
|
||||||
|
import apiClient from '@/lib/api/client';
|
||||||
|
|
||||||
|
interface Message {
|
||||||
|
id: string;
|
||||||
|
role: 'user' | 'assistant';
|
||||||
|
content: string;
|
||||||
|
timestamp: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
const suggestedQuestions = [
|
||||||
|
'How much should my baby sleep at 3 months?',
|
||||||
|
'What are normal feeding patterns?',
|
||||||
|
'When should I introduce solid foods?',
|
||||||
|
'Tips for better sleep routine',
|
||||||
|
];
|
||||||
|
|
||||||
|
export const AIChatInterface: React.FC = () => {
|
||||||
|
const [messages, setMessages] = useState<Message[]>([]);
|
||||||
|
const [input, setInput] = useState('');
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
|
const { user } = useAuth();
|
||||||
|
|
||||||
|
const scrollToBottom = () => {
|
||||||
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
scrollToBottom();
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
|
const handleSend = async (message?: string) => {
|
||||||
|
const messageText = message || input.trim();
|
||||||
|
if (!messageText || isLoading) return;
|
||||||
|
|
||||||
|
const userMessage: Message = {
|
||||||
|
id: Date.now().toString(),
|
||||||
|
role: 'user',
|
||||||
|
content: messageText,
|
||||||
|
timestamp: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
setMessages((prev) => [...prev, userMessage]);
|
||||||
|
setInput('');
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await apiClient.post('/api/v1/ai/chat', {
|
||||||
|
message: messageText,
|
||||||
|
conversationId: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const assistantMessage: Message = {
|
||||||
|
id: (Date.now() + 1).toString(),
|
||||||
|
role: 'assistant',
|
||||||
|
content: response.data.data.response,
|
||||||
|
timestamp: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
setMessages((prev) => [...prev, assistantMessage]);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('AI chat error:', error);
|
||||||
|
const errorMessage: Message = {
|
||||||
|
id: (Date.now() + 1).toString(),
|
||||||
|
role: 'assistant',
|
||||||
|
content: 'Sorry, I encountered an error. Please try again.',
|
||||||
|
timestamp: new Date(),
|
||||||
|
};
|
||||||
|
setMessages((prev) => [...prev, errorMessage]);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSuggestedQuestion = (question: string) => {
|
||||||
|
handleSend(question);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
height: 'calc(100vh - 200px)',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
background: 'linear-gradient(135deg, #FFF5F5 0%, #FFE4E1 100%)',
|
||||||
|
borderRadius: 2,
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<Paper
|
||||||
|
elevation={0}
|
||||||
|
sx={{
|
||||||
|
p: 2,
|
||||||
|
borderRadius: 0,
|
||||||
|
background: 'rgba(255, 255, 255, 0.95)',
|
||||||
|
backdropFilter: 'blur(10px)',
|
||||||
|
borderBottom: '1px solid',
|
||||||
|
borderColor: 'divider',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||||
|
<Avatar sx={{ bgcolor: 'primary.main' }}>
|
||||||
|
<SmartToy />
|
||||||
|
</Avatar>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h6" fontWeight="600">
|
||||||
|
AI Parenting Assistant
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
Ask me anything about parenting and childcare
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
{/* Messages Container */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
flex: 1,
|
||||||
|
overflowY: 'auto',
|
||||||
|
p: 2,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{messages.length === 0 && (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
flex: 1,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: 3,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AutoAwesome sx={{ fontSize: 64, color: 'primary.main', opacity: 0.5 }} />
|
||||||
|
<Typography variant="h6" color="text.secondary" textAlign="center">
|
||||||
|
Hi {user?.name}! How can I help you today?
|
||||||
|
</Typography>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
maxWidth: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{suggestedQuestions.map((question, index) => (
|
||||||
|
<Chip
|
||||||
|
key={index}
|
||||||
|
label={question}
|
||||||
|
onClick={() => handleSuggestedQuestion(question)}
|
||||||
|
sx={{
|
||||||
|
borderRadius: 3,
|
||||||
|
'&:hover': {
|
||||||
|
bgcolor: 'primary.light',
|
||||||
|
color: 'white',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{messages.map((message) => (
|
||||||
|
<motion.div
|
||||||
|
key={message.id}
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: 2,
|
||||||
|
justifyContent: message.role === 'user' ? 'flex-end' : 'flex-start',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{message.role === 'assistant' && (
|
||||||
|
<Avatar sx={{ bgcolor: 'primary.main', mt: 1 }}>
|
||||||
|
<SmartToy />
|
||||||
|
</Avatar>
|
||||||
|
)}
|
||||||
|
<Paper
|
||||||
|
elevation={0}
|
||||||
|
sx={{
|
||||||
|
p: 2,
|
||||||
|
maxWidth: '70%',
|
||||||
|
borderRadius: 3,
|
||||||
|
bgcolor:
|
||||||
|
message.role === 'user'
|
||||||
|
? 'primary.main'
|
||||||
|
: 'rgba(255, 255, 255, 0.95)',
|
||||||
|
color: message.role === 'user' ? 'white' : 'text.primary',
|
||||||
|
backdropFilter: 'blur(10px)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="body1" sx={{ whiteSpace: 'pre-wrap' }}>
|
||||||
|
{message.content}
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={{
|
||||||
|
mt: 1,
|
||||||
|
display: 'block',
|
||||||
|
opacity: 0.7,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{message.timestamp.toLocaleTimeString()}
|
||||||
|
</Typography>
|
||||||
|
</Paper>
|
||||||
|
{message.role === 'user' && (
|
||||||
|
<Avatar sx={{ bgcolor: 'secondary.main', mt: 1 }}>
|
||||||
|
<Person />
|
||||||
|
</Avatar>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{isLoading && (
|
||||||
|
<Box sx={{ display: 'flex', gap: 2 }}>
|
||||||
|
<Avatar sx={{ bgcolor: 'primary.main' }}>
|
||||||
|
<SmartToy />
|
||||||
|
</Avatar>
|
||||||
|
<Paper
|
||||||
|
elevation={0}
|
||||||
|
sx={{
|
||||||
|
p: 2,
|
||||||
|
borderRadius: 3,
|
||||||
|
bgcolor: 'rgba(255, 255, 255, 0.95)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CircularProgress size={20} />
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Thinking...
|
||||||
|
</Typography>
|
||||||
|
</Paper>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div ref={messagesEndRef} />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Input Area */}
|
||||||
|
<Paper
|
||||||
|
elevation={0}
|
||||||
|
sx={{
|
||||||
|
p: 2,
|
||||||
|
borderRadius: 0,
|
||||||
|
background: 'rgba(255, 255, 255, 0.95)',
|
||||||
|
backdropFilter: 'blur(10px)',
|
||||||
|
borderTop: '1px solid',
|
||||||
|
borderColor: 'divider',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ display: 'flex', gap: 1, alignItems: 'flex-end' }}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
multiline
|
||||||
|
maxRows={4}
|
||||||
|
placeholder="Ask me anything..."
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
onKeyPress={(e) => {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSend();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={isLoading}
|
||||||
|
sx={{
|
||||||
|
'& .MuiOutlinedInput-root': {
|
||||||
|
borderRadius: 3,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
color="primary"
|
||||||
|
onClick={() => handleSend()}
|
||||||
|
disabled={!input.trim() || isLoading}
|
||||||
|
sx={{
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
bgcolor: 'primary.main',
|
||||||
|
color: 'white',
|
||||||
|
'&:hover': {
|
||||||
|
bgcolor: 'primary.dark',
|
||||||
|
},
|
||||||
|
'&:disabled': {
|
||||||
|
bgcolor: 'action.disabledBackground',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Send />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ mt: 1, display: 'block' }}>
|
||||||
|
This AI assistant provides general information. Always consult healthcare professionals
|
||||||
|
for medical advice.
|
||||||
|
</Typography>
|
||||||
|
</Paper>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
654
maternal-web/components/features/analytics/InsightsDashboard.tsx
Normal file
654
maternal-web/components/features/analytics/InsightsDashboard.tsx
Normal file
@@ -0,0 +1,654 @@
|
|||||||
|
'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 { format, subDays, startOfDay, endOfDay, parseISO, differenceInMinutes, formatDistanceToNow } from 'date-fns';
|
||||||
|
import { BarChart, Bar, LineChart, Line, PieChart, Pie, Cell, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
|
||||||
|
|
||||||
|
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: '#FFB6C1',
|
||||||
|
sleep: '#B6D7FF',
|
||||||
|
diaper: '#FFE4B5',
|
||||||
|
medication: '#D4B5FF',
|
||||||
|
milestone: '#B5FFD4',
|
||||||
|
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 [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);
|
||||||
|
|
||||||
|
// Fetch children on mount
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchChildren = async () => {
|
||||||
|
try {
|
||||||
|
const childrenData = await childrenApi.getChildren();
|
||||||
|
setChildren(childrenData);
|
||||||
|
if (childrenData.length > 0) {
|
||||||
|
setSelectedChild(childrenData[0].id);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.response?.data?.message || 'Failed to load children');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchChildren();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Fetch activities when child or date range changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedChild) return;
|
||||||
|
|
||||||
|
const fetchActivities = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
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()
|
||||||
|
);
|
||||||
|
setActivities(activitiesData);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.response?.data?.message || 'Failed to load activities');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchActivities();
|
||||||
|
}, [selectedChild, dateRange]);
|
||||||
|
|
||||||
|
// 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: name.charAt(0).toUpperCase() + name.slice(1),
|
||||||
|
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: name.charAt(0).toUpperCase() + name.slice(1),
|
||||||
|
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>
|
||||||
|
<Typography variant="h4" fontWeight="600" gutterBottom>
|
||||||
|
Insights & Analytics
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body1" color="text.secondary" sx={{ mb: 4 }}>
|
||||||
|
Track patterns and get insights about your child's activities
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<Paper sx={{ p: 3, mb: 3 }}>
|
||||||
|
<Grid container spacing={2} alignItems="center">
|
||||||
|
{children.length > 1 && (
|
||||||
|
<Grid item xs={12} sm={6} md={4}>
|
||||||
|
<FormControl fullWidth>
|
||||||
|
<InputLabel>Child</InputLabel>
|
||||||
|
<Select
|
||||||
|
value={selectedChild}
|
||||||
|
onChange={(e) => setSelectedChild(e.target.value)}
|
||||||
|
label="Child"
|
||||||
|
>
|
||||||
|
{children.map((child) => (
|
||||||
|
<MenuItem key={child.id} value={child.id}>
|
||||||
|
{child.name}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
</Grid>
|
||||||
|
)}
|
||||||
|
<Grid item xs={12} sm={6} md={4}>
|
||||||
|
<ToggleButtonGroup
|
||||||
|
value={dateRange}
|
||||||
|
exclusive
|
||||||
|
onChange={(_, newValue) => newValue && setDateRange(newValue)}
|
||||||
|
fullWidth
|
||||||
|
size="large"
|
||||||
|
>
|
||||||
|
<ToggleButton value="7days">7 Days</ToggleButton>
|
||||||
|
<ToggleButton value="30days">30 Days</ToggleButton>
|
||||||
|
<ToggleButton value="3months">3 Months</ToggleButton>
|
||||||
|
</ToggleButtonGroup>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
{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>
|
||||||
|
No Children Added
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
|
||||||
|
Add a child to view insights and analytics
|
||||||
|
</Typography>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
startIcon={<Add />}
|
||||||
|
onClick={() => router.push('/children')}
|
||||||
|
>
|
||||||
|
Add Child
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading && (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', py: 8 }}>
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{noActivities && !noChildren && (
|
||||||
|
<Alert severity="info" sx={{ mb: 3 }}>
|
||||||
|
No activities found for the selected date range. Start tracking activities to see insights!
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !noChildren && !noActivities && (
|
||||||
|
<>
|
||||||
|
{/* Summary Statistics */}
|
||||||
|
<Grid container spacing={3} sx={{ mb: 3 }}>
|
||||||
|
<Grid item xs={12} sm={6} md={3}>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.9 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
transition={{ duration: 0.3, delay: 0 }}
|
||||||
|
>
|
||||||
|
<Card sx={{ bgcolor: COLORS.feeding, color: 'white' }}>
|
||||||
|
<CardContent>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1 }}>
|
||||||
|
<Restaurant sx={{ fontSize: 32, mr: 1 }} />
|
||||||
|
<Typography variant="h6" fontWeight="600">
|
||||||
|
Feedings
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Typography variant="h3" fontWeight="700">
|
||||||
|
{stats.totalFeedings}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" sx={{ opacity: 0.9, mt: 1 }}>
|
||||||
|
Total count
|
||||||
|
</Typography>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</motion.div>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid item xs={12} sm={6} md={3}>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.9 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
transition={{ duration: 0.3, delay: 0.1 }}
|
||||||
|
>
|
||||||
|
<Card sx={{ bgcolor: COLORS.sleep, color: 'white' }}>
|
||||||
|
<CardContent>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1 }}>
|
||||||
|
<Hotel sx={{ fontSize: 32, mr: 1 }} />
|
||||||
|
<Typography variant="h6" fontWeight="600">
|
||||||
|
Sleep
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Typography variant="h3" fontWeight="700">
|
||||||
|
{stats.avgSleepHours}h
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" sx={{ opacity: 0.9, mt: 1 }}>
|
||||||
|
Average per day
|
||||||
|
</Typography>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</motion.div>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid item xs={12} sm={6} md={3}>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.9 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
transition={{ duration: 0.3, delay: 0.2 }}
|
||||||
|
>
|
||||||
|
<Card sx={{ bgcolor: COLORS.diaper, color: 'white' }}>
|
||||||
|
<CardContent>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1 }}>
|
||||||
|
<BabyChangingStation sx={{ fontSize: 32, mr: 1 }} />
|
||||||
|
<Typography variant="h6" fontWeight="600">
|
||||||
|
Diapers
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Typography variant="h3" fontWeight="700">
|
||||||
|
{stats.totalDiapers}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" sx={{ opacity: 0.9, mt: 1 }}>
|
||||||
|
Total changes
|
||||||
|
</Typography>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</motion.div>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid item xs={12} sm={6} md={3}>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.9 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
transition={{ duration: 0.3, delay: 0.3 }}
|
||||||
|
>
|
||||||
|
<Card sx={{ bgcolor: COLORS.milestone, color: 'white' }}>
|
||||||
|
<CardContent>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1 }}>
|
||||||
|
<TrendingUp sx={{ fontSize: 32, mr: 1 }} />
|
||||||
|
<Typography variant="h6" fontWeight="600">
|
||||||
|
Top Activity
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Typography variant="h3" fontWeight="700" sx={{ textTransform: 'capitalize' }}>
|
||||||
|
{stats.mostCommonType}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" sx={{ opacity: 0.9, mt: 1 }}>
|
||||||
|
Most frequent
|
||||||
|
</Typography>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</motion.div>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{/* Charts */}
|
||||||
|
<Grid container spacing={3} sx={{ mb: 3 }}>
|
||||||
|
<Grid item xs={12} md={6}>
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
||||||
|
<Restaurant sx={{ mr: 1, color: COLORS.feeding }} />
|
||||||
|
<Typography variant="h6" fontWeight="600">
|
||||||
|
Feeding Frequency
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<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="Feedings" />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid item xs={12} md={6}>
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
||||||
|
<Hotel sx={{ mr: 1, color: COLORS.sleep }} />
|
||||||
|
<Typography variant="h6" fontWeight="600">
|
||||||
|
Sleep Duration (Hours)
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<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="Sleep Hours"
|
||||||
|
dot={{ fill: COLORS.sleep, r: 4 }}
|
||||||
|
/>
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{diaperData.length > 0 && (
|
||||||
|
<Grid item xs={12} md={6}>
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
||||||
|
<BabyChangingStation sx={{ mr: 1, color: COLORS.diaper }} />
|
||||||
|
<Typography variant="h6" fontWeight="600">
|
||||||
|
Diaper Changes by Type
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<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>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Grid item xs={12} md={diaperData.length > 0 ? 6 : 12}>
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
||||||
|
<Assessment sx={{ mr: 1, color: 'primary.main' }} />
|
||||||
|
<Typography variant="h6" fontWeight="600">
|
||||||
|
Activity Timeline
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<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="Feedings" />
|
||||||
|
<Bar dataKey="diapers" fill={COLORS.diaper} name="Diapers" />
|
||||||
|
<Bar dataKey="sleepHours" fill={COLORS.sleep} name="Sleep (hrs)" />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{activityTypeData.length > 0 && (
|
||||||
|
<Card sx={{ mb: 3 }}>
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="h6" fontWeight="600" gutterBottom>
|
||||||
|
Activity Distribution
|
||||||
|
</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>
|
||||||
|
Recent Activities (Last 20)
|
||||||
|
</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' }}>
|
||||||
|
{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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -44,19 +44,44 @@ export const trackingApi = {
|
|||||||
if (endDate) params.endDate = endDate;
|
if (endDate) params.endDate = endDate;
|
||||||
|
|
||||||
const response = await apiClient.get('/api/v1/activities', { params });
|
const response = await apiClient.get('/api/v1/activities', { params });
|
||||||
return response.data.data.activities;
|
// Transform backend response to frontend format
|
||||||
|
const activities = response.data.data.activities.map((activity: any) => ({
|
||||||
|
...activity,
|
||||||
|
timestamp: activity.startedAt, // Frontend expects timestamp
|
||||||
|
data: activity.metadata, // Frontend expects data
|
||||||
|
}));
|
||||||
|
return activities;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get a specific activity
|
// Get a specific activity
|
||||||
getActivity: async (id: string): Promise<Activity> => {
|
getActivity: async (id: string): Promise<Activity> => {
|
||||||
const response = await apiClient.get(`/api/v1/activities/${id}`);
|
const response = await apiClient.get(`/api/v1/activities/${id}`);
|
||||||
return response.data.data.activity;
|
const activity = response.data.data.activity;
|
||||||
|
// Transform backend response to frontend format
|
||||||
|
return {
|
||||||
|
...activity,
|
||||||
|
timestamp: activity.startedAt,
|
||||||
|
data: activity.metadata,
|
||||||
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
// Create a new activity
|
// Create a new activity
|
||||||
createActivity: async (childId: string, data: CreateActivityData): Promise<Activity> => {
|
createActivity: async (childId: string, data: CreateActivityData): Promise<Activity> => {
|
||||||
const response = await apiClient.post(`/api/v1/activities?childId=${childId}`, data);
|
// Transform frontend data structure to backend DTO format
|
||||||
return response.data.data.activity;
|
const payload = {
|
||||||
|
type: data.type,
|
||||||
|
startedAt: data.timestamp, // Backend expects startedAt, not timestamp
|
||||||
|
metadata: data.data, // Backend expects metadata, not data
|
||||||
|
notes: data.notes,
|
||||||
|
};
|
||||||
|
const response = await apiClient.post(`/api/v1/activities?childId=${childId}`, payload);
|
||||||
|
const activity = response.data.data.activity;
|
||||||
|
// Transform backend response to frontend format
|
||||||
|
return {
|
||||||
|
...activity,
|
||||||
|
timestamp: activity.startedAt,
|
||||||
|
data: activity.metadata,
|
||||||
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
// Update an activity
|
// Update an activity
|
||||||
|
|||||||
232
maternal-web/lib/performance/monitoring.ts
Normal file
232
maternal-web/lib/performance/monitoring.ts
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
import { onCLS, onINP, onFCP, onLCP, onTTFB, type Metric } from 'web-vitals';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Performance Monitoring Module
|
||||||
|
*
|
||||||
|
* Tracks Core Web Vitals metrics:
|
||||||
|
* - CLS (Cumulative Layout Shift): Measures visual stability
|
||||||
|
* - INP (Interaction to Next Paint): Measures interactivity (replaces FID in v5)
|
||||||
|
* - FCP (First Contentful Paint): Measures perceived load speed
|
||||||
|
* - LCP (Largest Contentful Paint): Measures loading performance
|
||||||
|
* - TTFB (Time to First Byte): Measures server response time
|
||||||
|
*
|
||||||
|
* Sends metrics to analytics (Google Analytics if available)
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface PerformanceMetric {
|
||||||
|
name: string;
|
||||||
|
value: number;
|
||||||
|
id: string;
|
||||||
|
delta: number;
|
||||||
|
rating: 'good' | 'needs-improvement' | 'poor';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send metric to analytics service
|
||||||
|
*/
|
||||||
|
const sendToAnalytics = (metric: Metric) => {
|
||||||
|
const body: PerformanceMetric = {
|
||||||
|
name: metric.name,
|
||||||
|
value: Math.round(metric.value),
|
||||||
|
id: metric.id,
|
||||||
|
delta: metric.delta,
|
||||||
|
rating: metric.rating,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Send to Google Analytics if available
|
||||||
|
if (typeof window !== 'undefined' && (window as any).gtag) {
|
||||||
|
(window as any).gtag('event', metric.name, {
|
||||||
|
event_category: 'Web Vitals',
|
||||||
|
event_label: metric.id,
|
||||||
|
value: Math.round(metric.value),
|
||||||
|
metric_id: metric.id,
|
||||||
|
metric_value: metric.value,
|
||||||
|
metric_delta: metric.delta,
|
||||||
|
metric_rating: metric.rating,
|
||||||
|
non_interaction: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log to console in development
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
console.log('[Performance]', body);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send to custom analytics endpoint if needed
|
||||||
|
if (process.env.NEXT_PUBLIC_ANALYTICS_ENDPOINT) {
|
||||||
|
const analyticsEndpoint = process.env.NEXT_PUBLIC_ANALYTICS_ENDPOINT;
|
||||||
|
|
||||||
|
// Use navigator.sendBeacon for reliable analytics even during page unload
|
||||||
|
if (navigator.sendBeacon) {
|
||||||
|
navigator.sendBeacon(
|
||||||
|
analyticsEndpoint,
|
||||||
|
JSON.stringify(body)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Fallback to fetch
|
||||||
|
fetch(analyticsEndpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
keepalive: true,
|
||||||
|
}).catch((error) => {
|
||||||
|
console.error('Failed to send analytics:', error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize performance monitoring
|
||||||
|
* Call this function once when the app loads
|
||||||
|
*/
|
||||||
|
export const initPerformanceMonitoring = () => {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Track Cumulative Layout Shift (CLS)
|
||||||
|
// Good: < 0.1, Needs Improvement: < 0.25, Poor: >= 0.25
|
||||||
|
onCLS(sendToAnalytics);
|
||||||
|
|
||||||
|
// Track Interaction to Next Paint (INP) - replaces FID in web-vitals v5
|
||||||
|
// Good: < 200ms, Needs Improvement: < 500ms, Poor: >= 500ms
|
||||||
|
onINP(sendToAnalytics);
|
||||||
|
|
||||||
|
// Track First Contentful Paint (FCP)
|
||||||
|
// Good: < 1.8s, Needs Improvement: < 3s, Poor: >= 3s
|
||||||
|
onFCP(sendToAnalytics);
|
||||||
|
|
||||||
|
// Track Largest Contentful Paint (LCP)
|
||||||
|
// Good: < 2.5s, Needs Improvement: < 4s, Poor: >= 4s
|
||||||
|
onLCP(sendToAnalytics);
|
||||||
|
|
||||||
|
// Track Time to First Byte (TTFB)
|
||||||
|
// Good: < 800ms, Needs Improvement: < 1800ms, Poor: >= 1800ms
|
||||||
|
onTTFB(sendToAnalytics);
|
||||||
|
|
||||||
|
console.log('[Performance] Monitoring initialized');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Performance] Failed to initialize monitoring:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Report custom performance metrics
|
||||||
|
*/
|
||||||
|
export const reportCustomMetric = (name: string, value: number, metadata?: Record<string, any>) => {
|
||||||
|
if (typeof window !== 'undefined' && (window as any).gtag) {
|
||||||
|
(window as any).gtag('event', name, {
|
||||||
|
event_category: 'Custom Metrics',
|
||||||
|
value: Math.round(value),
|
||||||
|
...metadata,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
console.log('[Performance Custom Metric]', { name, value, metadata });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Measure and report component render time
|
||||||
|
*/
|
||||||
|
export const measureComponentRender = (componentName: string) => {
|
||||||
|
if (typeof window === 'undefined' || !window.performance) {
|
||||||
|
return () => {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const startMark = `${componentName}-render-start`;
|
||||||
|
const endMark = `${componentName}-render-end`;
|
||||||
|
const measureName = `${componentName}-render`;
|
||||||
|
|
||||||
|
performance.mark(startMark);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
performance.mark(endMark);
|
||||||
|
performance.measure(measureName, startMark, endMark);
|
||||||
|
|
||||||
|
const measure = performance.getEntriesByName(measureName)[0];
|
||||||
|
if (measure) {
|
||||||
|
reportCustomMetric(`component_render_${componentName}`, measure.duration, {
|
||||||
|
component: componentName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up marks and measures
|
||||||
|
performance.clearMarks(startMark);
|
||||||
|
performance.clearMarks(endMark);
|
||||||
|
performance.clearMeasures(measureName);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Track page load time
|
||||||
|
*/
|
||||||
|
export const trackPageLoad = (pageName: string) => {
|
||||||
|
if (typeof window === 'undefined' || !window.performance) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for load event
|
||||||
|
window.addEventListener('load', () => {
|
||||||
|
setTimeout(() => {
|
||||||
|
const navigation = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming;
|
||||||
|
if (navigation) {
|
||||||
|
reportCustomMetric(`page_load_${pageName}`, navigation.loadEventEnd - navigation.fetchStart, {
|
||||||
|
page: pageName,
|
||||||
|
dom_content_loaded: navigation.domContentLoadedEventEnd - navigation.fetchStart,
|
||||||
|
dom_interactive: navigation.domInteractive - navigation.fetchStart,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, 0);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Monitor long tasks (tasks that block the main thread for > 50ms)
|
||||||
|
*/
|
||||||
|
export const monitorLongTasks = () => {
|
||||||
|
if (typeof window === 'undefined' || !(window as any).PerformanceObserver) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const observer = new PerformanceObserver((list) => {
|
||||||
|
for (const entry of list.getEntries()) {
|
||||||
|
reportCustomMetric('long_task', entry.duration, {
|
||||||
|
start_time: entry.startTime,
|
||||||
|
duration: entry.duration,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
observer.observe({ entryTypes: ['longtask'] });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Performance] Failed to monitor long tasks:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Track resource loading times
|
||||||
|
*/
|
||||||
|
export const trackResourceTiming = () => {
|
||||||
|
if (typeof window === 'undefined' || !window.performance) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resources = performance.getEntriesByType('resource') as PerformanceResourceTiming[];
|
||||||
|
|
||||||
|
const slowResources = resources.filter((resource) => resource.duration > 1000);
|
||||||
|
|
||||||
|
slowResources.forEach((resource) => {
|
||||||
|
reportCustomMetric('slow_resource', resource.duration, {
|
||||||
|
url: resource.name,
|
||||||
|
type: resource.initiatorType,
|
||||||
|
size: resource.transferSize,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
7
maternal-web/package-lock.json
generated
7
maternal-web/package-lock.json
generated
@@ -28,6 +28,7 @@
|
|||||||
"recharts": "^3.2.1",
|
"recharts": "^3.2.1",
|
||||||
"redux-persist": "^6.0.0",
|
"redux-persist": "^6.0.0",
|
||||||
"socket.io-client": "^4.8.1",
|
"socket.io-client": "^4.8.1",
|
||||||
|
"web-vitals": "^5.1.0",
|
||||||
"workbox-webpack-plugin": "^7.3.0",
|
"workbox-webpack-plugin": "^7.3.0",
|
||||||
"workbox-window": "^7.3.0",
|
"workbox-window": "^7.3.0",
|
||||||
"zod": "^3.25.76"
|
"zod": "^3.25.76"
|
||||||
@@ -12851,6 +12852,12 @@
|
|||||||
"node": ">=10.13.0"
|
"node": ">=10.13.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/web-vitals": {
|
||||||
|
"version": "5.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-5.1.0.tgz",
|
||||||
|
"integrity": "sha512-ArI3kx5jI0atlTtmV0fWU3fjpLmq/nD3Zr1iFFlJLaqa5wLBkUSzINwBPySCX/8jRyjlmy1Volw1kz1g9XE4Jg==",
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
"node_modules/webidl-conversions": {
|
"node_modules/webidl-conversions": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz",
|
||||||
|
|||||||
@@ -32,6 +32,7 @@
|
|||||||
"recharts": "^3.2.1",
|
"recharts": "^3.2.1",
|
||||||
"redux-persist": "^6.0.0",
|
"redux-persist": "^6.0.0",
|
||||||
"socket.io-client": "^4.8.1",
|
"socket.io-client": "^4.8.1",
|
||||||
|
"web-vitals": "^5.1.0",
|
||||||
"workbox-webpack-plugin": "^7.3.0",
|
"workbox-webpack-plugin": "^7.3.0",
|
||||||
"workbox-window": "^7.3.0",
|
"workbox-window": "^7.3.0",
|
||||||
"zod": "^3.25.76"
|
"zod": "^3.25.76"
|
||||||
|
|||||||
Reference in New Issue
Block a user