Add comprehensive .gitignore

This commit is contained in:
2025-10-01 19:01:52 +00:00
commit f3ff07c0ef
254 changed files with 88254 additions and 0 deletions

View File

@@ -0,0 +1,21 @@
'use client';
import { ThemeProvider } from '@mui/material/styles';
import CssBaseline from '@mui/material/CssBaseline';
import { AppRouterCacheProvider } from '@mui/material-nextjs/v14-appRouter';
import { maternalTheme } from '@/styles/themes/maternalTheme';
import { AuthProvider } from '@/lib/auth/AuthContext';
import { ReactNode } from 'react';
export function ThemeRegistry({ children }: { children: ReactNode }) {
return (
<AppRouterCacheProvider>
<ThemeProvider theme={maternalTheme}>
<CssBaseline />
<AuthProvider>
{children}
</AuthProvider>
</ThemeProvider>
</AppRouterCacheProvider>
);
}

View File

@@ -0,0 +1,275 @@
'use client';
import { useState, useEffect } from 'react';
import { Box, Typography, CircularProgress, Alert, ToggleButtonGroup, ToggleButton } from '@mui/material';
import {
BarChart,
Bar,
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
PieChart,
Pie,
Cell,
} from 'recharts';
import { format, subDays } from 'date-fns';
import apiClient from '@/lib/api/client';
interface FeedingData {
date: string;
breastfeeding: number;
bottle: number;
solids: number;
total: number;
}
interface FeedingTypeData {
name: string;
value: number;
color: string;
[key: string]: string | number;
}
const COLORS = {
breastfeeding: '#FFB6C1',
bottle: '#FFA5B0',
solids: '#FF94A5',
};
export default function FeedingFrequencyGraph() {
const [data, setData] = useState<FeedingData[]>([]);
const [typeData, setTypeData] = useState<FeedingTypeData[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [chartType, setChartType] = useState<'bar' | 'line'>('bar');
useEffect(() => {
fetchFeedingData();
}, []);
const fetchFeedingData = async () => {
try {
setIsLoading(true);
const endDate = new Date();
const startDate = subDays(endDate, 6);
const response = await apiClient.get('/api/v1/activities/feeding', {
params: {
startDate: startDate.toISOString(),
endDate: endDate.toISOString(),
},
});
const feedingActivities = response.data.data;
const dailyData: { [key: string]: FeedingData } = {};
// Initialize 7 days of data
for (let i = 0; i < 7; i++) {
const date = subDays(endDate, 6 - i);
const dateStr = format(date, 'MMM dd');
dailyData[dateStr] = {
date: dateStr,
breastfeeding: 0,
bottle: 0,
solids: 0,
total: 0,
};
}
// Count feeding types by day
const typeCounts = {
breastfeeding: 0,
bottle: 0,
solids: 0,
};
feedingActivities.forEach((activity: any) => {
const dateStr = format(new Date(activity.timestamp), 'MMM dd');
if (dailyData[dateStr]) {
const type = activity.type?.toLowerCase() || 'bottle';
if (type === 'breastfeeding' || type === 'breast') {
dailyData[dateStr].breastfeeding += 1;
typeCounts.breastfeeding += 1;
} else if (type === 'bottle' || type === 'formula') {
dailyData[dateStr].bottle += 1;
typeCounts.bottle += 1;
} else if (type === 'solids' || type === 'solid') {
dailyData[dateStr].solids += 1;
typeCounts.solids += 1;
}
dailyData[dateStr].total += 1;
}
});
setData(Object.values(dailyData));
// Prepare pie chart data
const pieData: FeedingTypeData[] = [
{
name: 'Breastfeeding',
value: typeCounts.breastfeeding,
color: COLORS.breastfeeding,
},
{
name: 'Bottle',
value: typeCounts.bottle,
color: COLORS.bottle,
},
{
name: 'Solids',
value: typeCounts.solids,
color: COLORS.solids,
},
].filter((item) => item.value > 0);
setTypeData(pieData);
} catch (err: any) {
console.error('Failed to fetch feeding data:', err);
setError(err.response?.data?.message || 'Failed to load feeding data');
} finally {
setIsLoading(false);
}
};
const handleChartTypeChange = (
event: React.MouseEvent<HTMLElement>,
newType: 'bar' | 'line' | null
) => {
if (newType !== null) {
setChartType(newType);
}
};
if (isLoading) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
<CircularProgress />
</Box>
);
}
if (error) {
return (
<Alert severity="error" sx={{ borderRadius: 2 }}>
{error}
</Alert>
);
}
return (
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
<Box>
<Typography variant="h6" gutterBottom fontWeight="600">
Weekly Feeding Patterns
</Typography>
<Typography variant="body2" color="text.secondary">
Track feeding frequency and types over the past 7 days
</Typography>
</Box>
<ToggleButtonGroup
value={chartType}
exclusive
onChange={handleChartTypeChange}
size="small"
>
<ToggleButton value="bar">Bar</ToggleButton>
<ToggleButton value="line">Line</ToggleButton>
</ToggleButtonGroup>
</Box>
{/* Feeding Frequency Chart */}
<Box sx={{ mb: 4 }}>
<Typography variant="subtitle1" gutterBottom fontWeight="600">
Daily Feeding Frequency by Type
</Typography>
<ResponsiveContainer width="100%" height={300}>
{chartType === 'bar' ? (
<BarChart data={data}>
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" />
<XAxis dataKey="date" stroke="#666" />
<YAxis stroke="#666" label={{ value: 'Count', angle: -90, position: 'insideLeft' }} />
<Tooltip
contentStyle={{
backgroundColor: 'rgba(255, 255, 255, 0.95)',
border: 'none',
borderRadius: 8,
boxShadow: '0 4px 6px rgba(0, 0, 0, 0.1)',
}}
/>
<Legend />
<Bar dataKey="breastfeeding" stackId="a" fill={COLORS.breastfeeding} name="Breastfeeding" />
<Bar dataKey="bottle" stackId="a" fill={COLORS.bottle} name="Bottle" />
<Bar dataKey="solids" stackId="a" fill={COLORS.solids} name="Solids" />
</BarChart>
) : (
<LineChart data={data}>
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" />
<XAxis dataKey="date" stroke="#666" />
<YAxis stroke="#666" label={{ value: 'Count', angle: -90, position: 'insideLeft' }} />
<Tooltip
contentStyle={{
backgroundColor: 'rgba(255, 255, 255, 0.95)',
border: 'none',
borderRadius: 8,
boxShadow: '0 4px 6px rgba(0, 0, 0, 0.1)',
}}
/>
<Legend />
<Line
type="monotone"
dataKey="total"
stroke={COLORS.breastfeeding}
strokeWidth={3}
dot={{ fill: COLORS.breastfeeding, r: 5 }}
name="Total Feedings"
/>
</LineChart>
)}
</ResponsiveContainer>
</Box>
{/* Feeding Type Distribution */}
{typeData.length > 0 && (
<Box>
<Typography variant="subtitle1" gutterBottom fontWeight="600">
Feeding Type Distribution (7 days)
</Typography>
<ResponsiveContainer width="100%" height={300}>
<PieChart>
<Pie
data={typeData}
cx="50%"
cy="50%"
labelLine={false}
label={({ name, percent }: any) => `${name}: ${(percent * 100).toFixed(0)}%`}
outerRadius={100}
fill="#8884d8"
dataKey="value"
>
{typeData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
))}
</Pie>
<Tooltip
contentStyle={{
backgroundColor: 'rgba(255, 255, 255, 0.95)',
border: 'none',
borderRadius: 8,
boxShadow: '0 4px 6px rgba(0, 0, 0, 0.1)',
}}
/>
</PieChart>
</ResponsiveContainer>
</Box>
)}
</Box>
);
}

View File

@@ -0,0 +1,311 @@
'use client';
import { useState, useEffect } from 'react';
import {
Box,
Typography,
CircularProgress,
Alert,
ToggleButtonGroup,
ToggleButton,
FormControl,
InputLabel,
Select,
MenuItem,
} from '@mui/material';
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
ReferenceLine,
} from 'recharts';
import { format } from 'date-fns';
import apiClient from '@/lib/api/client';
interface GrowthData {
age: number; // months
weight?: number; // kg
height?: number; // cm
headCircumference?: number; // cm
date: string;
}
// WHO Growth Standards Percentiles (simplified for 0-24 months)
// These are approximate values - in production, use exact WHO data
const WHO_WEIGHT_PERCENTILES = {
male: {
p3: [3.3, 4.4, 5.1, 5.6, 6.0, 6.4, 6.7, 7.0, 7.2, 7.5, 7.7, 7.9, 8.1, 8.3, 8.5, 8.7, 8.9, 9.0, 9.2, 9.4, 9.5, 9.7, 9.9, 10.0, 10.2],
p15: [3.6, 4.8, 5.5, 6.1, 6.5, 6.9, 7.2, 7.5, 7.8, 8.0, 8.3, 8.5, 8.7, 8.9, 9.1, 9.3, 9.5, 9.7, 9.9, 10.1, 10.3, 10.4, 10.6, 10.8, 11.0],
p50: [4.0, 5.3, 6.1, 6.7, 7.2, 7.6, 8.0, 8.3, 8.6, 8.9, 9.2, 9.4, 9.7, 9.9, 10.1, 10.4, 10.6, 10.8, 11.0, 11.2, 11.5, 11.7, 11.9, 12.1, 12.3],
p85: [4.4, 5.8, 6.7, 7.4, 7.9, 8.4, 8.8, 9.1, 9.5, 9.8, 10.1, 10.4, 10.6, 10.9, 11.2, 11.4, 11.7, 11.9, 12.2, 12.4, 12.7, 13.0, 13.2, 13.5, 13.7],
p97: [4.8, 6.3, 7.3, 8.0, 8.6, 9.1, 9.5, 9.9, 10.3, 10.6, 11.0, 11.3, 11.6, 11.9, 12.2, 12.5, 12.8, 13.1, 13.4, 13.7, 14.0, 14.3, 14.6, 14.9, 15.2],
},
female: {
p3: [3.2, 4.2, 4.8, 5.3, 5.7, 6.0, 6.3, 6.6, 6.8, 7.0, 7.2, 7.4, 7.6, 7.8, 8.0, 8.2, 8.3, 8.5, 8.7, 8.8, 9.0, 9.2, 9.3, 9.5, 9.7],
p15: [3.5, 4.6, 5.3, 5.8, 6.2, 6.5, 6.8, 7.1, 7.4, 7.6, 7.8, 8.1, 8.3, 8.5, 8.7, 8.9, 9.1, 9.3, 9.4, 9.6, 9.8, 10.0, 10.2, 10.4, 10.6],
p50: [3.9, 5.1, 5.8, 6.4, 6.9, 7.3, 7.6, 7.9, 8.2, 8.5, 8.7, 9.0, 9.2, 9.5, 9.7, 10.0, 10.2, 10.4, 10.7, 10.9, 11.1, 11.4, 11.6, 11.8, 12.1],
p85: [4.3, 5.6, 6.5, 7.1, 7.6, 8.0, 8.4, 8.7, 9.1, 9.4, 9.7, 10.0, 10.2, 10.5, 10.8, 11.0, 11.3, 11.6, 11.8, 12.1, 12.4, 12.6, 12.9, 13.2, 13.5],
p97: [4.7, 6.1, 7.0, 7.7, 8.3, 8.7, 9.1, 9.5, 9.9, 10.2, 10.6, 10.9, 11.2, 11.5, 11.8, 12.1, 12.4, 12.7, 13.0, 13.3, 13.6, 13.9, 14.2, 14.5, 14.9],
},
};
export default function GrowthCurve() {
const [data, setData] = useState<GrowthData[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [metric, setMetric] = useState<'weight' | 'height' | 'headCircumference'>('weight');
const [gender, setGender] = useState<'male' | 'female'>('male');
useEffect(() => {
fetchGrowthData();
}, []);
const fetchGrowthData = async () => {
try {
setIsLoading(true);
// Fetch growth measurements from backend
const response = await apiClient.get('/api/v1/activities/growth');
const growthActivities = response.data.data;
// Process growth data
const processedData: GrowthData[] = growthActivities.map((activity: any) => {
const ageInMonths = calculateAgeInMonths(activity.childBirthDate, activity.measurementDate);
return {
age: ageInMonths,
weight: activity.weight,
height: activity.height,
headCircumference: activity.headCircumference,
date: format(new Date(activity.measurementDate), 'MMM dd, yyyy'),
};
});
// Sort by age
processedData.sort((a, b) => a.age - b.age);
setData(processedData);
// Fetch child profile to determine gender
const profileResponse = await apiClient.get('/api/v1/children/profile');
if (profileResponse.data.data.gender) {
setGender(profileResponse.data.data.gender.toLowerCase());
}
} catch (err: any) {
console.error('Failed to fetch growth data:', err);
// If endpoint doesn't exist, create sample data for demonstration
if (err.response?.status === 404) {
setData(createSampleData());
} else {
setError(err.response?.data?.message || 'Failed to load growth data');
}
} finally {
setIsLoading(false);
}
};
const calculateAgeInMonths = (birthDate: string, measurementDate: string): number => {
const birth = new Date(birthDate);
const measurement = new Date(measurementDate);
const months = (measurement.getFullYear() - birth.getFullYear()) * 12 +
(measurement.getMonth() - birth.getMonth());
return Math.max(0, months);
};
const createSampleData = (): GrowthData[] => {
// Sample data for demonstration (0-12 months)
return [
{ age: 0, weight: 3.5, height: 50, headCircumference: 35, date: 'Birth' },
{ age: 1, weight: 4.5, height: 54, headCircumference: 37, date: '1 month' },
{ age: 2, weight: 5.5, height: 58, headCircumference: 39, date: '2 months' },
{ age: 3, weight: 6.3, height: 61, headCircumference: 40, date: '3 months' },
{ age: 4, weight: 7.0, height: 64, headCircumference: 41, date: '4 months' },
{ age: 6, weight: 7.8, height: 67, headCircumference: 43, date: '6 months' },
{ age: 9, weight: 8.9, height: 72, headCircumference: 45, date: '9 months' },
{ age: 12, weight: 9.8, height: 76, headCircumference: 46, date: '12 months' },
];
};
const getPercentileData = () => {
const percentiles = WHO_WEIGHT_PERCENTILES[gender];
const maxAge = Math.max(...data.map(d => d.age), 24);
return Array.from({ length: maxAge + 1 }, (_, i) => ({
age: i,
p3: percentiles.p3[i] || null,
p15: percentiles.p15[i] || null,
p50: percentiles.p50[i] || null,
p85: percentiles.p85[i] || null,
p97: percentiles.p97[i] || null,
}));
};
const handleMetricChange = (event: React.MouseEvent<HTMLElement>, newMetric: 'weight' | 'height' | 'headCircumference' | null) => {
if (newMetric !== null) {
setMetric(newMetric);
}
};
const handleGenderChange = (event: any) => {
setGender(event.target.value);
};
if (isLoading) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
<CircularProgress />
</Box>
);
}
if (error) {
return (
<Alert severity="error" sx={{ borderRadius: 2 }}>
{error}
</Alert>
);
}
const percentileData = getPercentileData();
const combinedData = percentileData.map(p => {
const userDataPoint = data.find(d => Math.round(d.age) === p.age);
return {
...p,
userValue: userDataPoint?.[metric] || null,
};
});
return (
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
<Box>
<Typography variant="h6" gutterBottom fontWeight="600">
Growth Curve (WHO Standards)
</Typography>
<Typography variant="body2" color="text.secondary">
Track your child's growth against WHO percentiles
</Typography>
</Box>
<Box sx={{ display: 'flex', gap: 2 }}>
<FormControl size="small" sx={{ minWidth: 100 }}>
<InputLabel>Gender</InputLabel>
<Select value={gender} label="Gender" onChange={handleGenderChange}>
<MenuItem value="male">Male</MenuItem>
<MenuItem value="female">Female</MenuItem>
</Select>
</FormControl>
<ToggleButtonGroup
value={metric}
exclusive
onChange={handleMetricChange}
size="small"
>
<ToggleButton value="weight">Weight</ToggleButton>
<ToggleButton value="height">Height</ToggleButton>
<ToggleButton value="headCircumference">Head</ToggleButton>
</ToggleButtonGroup>
</Box>
</Box>
{data.length === 0 ? (
<Alert severity="info" sx={{ borderRadius: 2 }}>
No growth measurements recorded yet. Start tracking to see growth curves!
</Alert>
) : (
<ResponsiveContainer width="100%" height={400}>
<LineChart data={combinedData}>
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" />
<XAxis
dataKey="age"
stroke="#666"
label={{ value: 'Age (months)', position: 'insideBottom', offset: -5 }}
/>
<YAxis
stroke="#666"
label={{
value: metric === 'weight' ? 'Weight (kg)' : metric === 'height' ? 'Height (cm)' : 'Head Circumference (cm)',
angle: -90,
position: 'insideLeft',
}}
/>
<Tooltip
contentStyle={{
backgroundColor: 'rgba(255, 255, 255, 0.95)',
border: 'none',
borderRadius: 8,
boxShadow: '0 4px 6px rgba(0, 0, 0, 0.1)',
}}
/>
<Legend />
{/* WHO Percentile Lines */}
<Line
type="monotone"
dataKey="p3"
stroke="#FFE4E1"
strokeWidth={1}
dot={false}
name="3rd Percentile"
strokeDasharray="5 5"
/>
<Line
type="monotone"
dataKey="p15"
stroke="#FFD4C9"
strokeWidth={1}
dot={false}
name="15th Percentile"
strokeDasharray="3 3"
/>
<Line
type="monotone"
dataKey="p50"
stroke="#FFB6C1"
strokeWidth={2}
dot={false}
name="50th Percentile (Median)"
/>
<Line
type="monotone"
dataKey="p85"
stroke="#FFD4C9"
strokeWidth={1}
dot={false}
name="85th Percentile"
strokeDasharray="3 3"
/>
<Line
type="monotone"
dataKey="p97"
stroke="#FFE4E1"
strokeWidth={1}
dot={false}
name="97th Percentile"
strokeDasharray="5 5"
/>
{/* User's Actual Data */}
<Line
type="monotone"
dataKey="userValue"
stroke="#FF1493"
strokeWidth={3}
dot={{ fill: '#FF1493', r: 6 }}
name="Your Child"
/>
</LineChart>
</ResponsiveContainer>
)}
<Box sx={{ mt: 2 }}>
<Typography variant="caption" color="text.secondary">
* WHO (World Health Organization) growth standards are based on healthy breastfed children
from diverse populations. Consult your pediatrician for personalized growth assessment.
</Typography>
</Box>
</Box>
);
}

View File

@@ -0,0 +1,318 @@
'use client';
import { useState, useEffect } from 'react';
import {
Box,
Typography,
Paper,
Grid,
Chip,
Alert,
CircularProgress,
LinearProgress,
Card,
CardContent,
} from '@mui/material';
import {
TrendingUp,
TrendingDown,
Schedule,
Lightbulb,
Warning,
CheckCircle,
} from '@mui/icons-material';
import { motion } from 'framer-motion';
import apiClient from '@/lib/api/client';
interface Pattern {
type: string;
description: string;
confidence: number;
trend: 'up' | 'down' | 'stable';
recommendations?: string[];
}
interface Insight {
category: string;
title: string;
description: string;
severity: 'info' | 'warning' | 'success';
patterns?: Pattern[];
}
interface Props {
insights?: any;
}
export default function PatternInsights({ insights: propInsights }: Props) {
const [insights, setInsights] = useState<Insight[]>([]);
const [patterns, setPatterns] = useState<Pattern[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (propInsights) {
processInsights(propInsights);
setIsLoading(false);
} else {
fetchPatterns();
}
}, [propInsights]);
const fetchPatterns = async () => {
try {
setIsLoading(true);
const response = await apiClient.get('/api/v1/insights/patterns');
const data = response.data.data;
processInsights(data);
} catch (err: any) {
console.error('Failed to fetch patterns:', err);
setError(err.response?.data?.message || 'Failed to load insights');
} finally {
setIsLoading(false);
}
};
const processInsights = (data: any) => {
// Process sleep patterns
const sleepInsights: Insight[] = [];
if (data?.sleep) {
const avgHours = data.sleep.averageHours || 0;
if (avgHours < 10) {
sleepInsights.push({
category: 'Sleep',
title: 'Low Sleep Duration',
description: `Average sleep is ${avgHours}h/day. Recommended: 12-16h for infants, 10-13h for toddlers.`,
severity: 'warning',
});
} else {
sleepInsights.push({
category: 'Sleep',
title: 'Healthy Sleep Duration',
description: `Great! Your child is averaging ${avgHours}h of sleep per day.`,
severity: 'success',
});
}
if (data.sleep.patterns) {
sleepInsights[0].patterns = data.sleep.patterns.map((p: any) => ({
type: p.type || 'sleep',
description: p.description || 'Sleep pattern detected',
confidence: p.confidence || 0.8,
trend: p.trend || 'stable',
}));
}
}
// Process feeding patterns
const feedingInsights: Insight[] = [];
if (data?.feeding) {
const avgPerDay = data.feeding.averagePerDay || 0;
feedingInsights.push({
category: 'Feeding',
title: 'Feeding Frequency',
description: `Your child is feeding ${avgPerDay} times per day on average.`,
severity: 'info',
});
if (data.feeding.patterns) {
feedingInsights[0].patterns = data.feeding.patterns.map((p: any) => ({
type: p.type || 'feeding',
description: p.description || 'Feeding pattern detected',
confidence: p.confidence || 0.8,
trend: p.trend || 'stable',
recommendations: p.recommendations,
}));
}
}
// Process diaper patterns
const diaperInsights: Insight[] = [];
if (data?.diaper) {
const avgPerDay = data.diaper.averagePerDay || 0;
if (avgPerDay < 5) {
diaperInsights.push({
category: 'Diaper',
title: 'Low Diaper Changes',
description: `Average ${avgPerDay} diaper changes/day. Consider checking hydration if this continues.`,
severity: 'warning',
});
} else {
diaperInsights.push({
category: 'Diaper',
title: 'Normal Diaper Activity',
description: `Averaging ${avgPerDay} diaper changes per day - within normal range.`,
severity: 'success',
});
}
}
setInsights([...sleepInsights, ...feedingInsights, ...diaperInsights]);
// Extract all patterns
const allPatterns: Pattern[] = [];
[...sleepInsights, ...feedingInsights, ...diaperInsights].forEach((insight) => {
if (insight.patterns) {
allPatterns.push(...insight.patterns);
}
});
setPatterns(allPatterns);
};
const getSeverityIcon = (severity: string) => {
switch (severity) {
case 'warning':
return <Warning sx={{ color: 'warning.main' }} />;
case 'success':
return <CheckCircle sx={{ color: 'success.main' }} />;
default:
return <Lightbulb sx={{ color: 'info.main' }} />;
}
};
const getTrendIcon = (trend: string) => {
switch (trend) {
case 'up':
return <TrendingUp sx={{ color: 'success.main' }} />;
case 'down':
return <TrendingDown sx={{ color: 'warning.main' }} />;
default:
return <Schedule sx={{ color: 'info.main' }} />;
}
};
if (isLoading) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
<CircularProgress />
</Box>
);
}
if (error) {
return (
<Alert severity="error" sx={{ borderRadius: 2 }}>
{error}
</Alert>
);
}
return (
<Box>
<Typography variant="h6" gutterBottom fontWeight="600">
Pattern Insights & Recommendations
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
AI-powered insights based on your child's activity patterns
</Typography>
{/* Insights Cards */}
<Grid container spacing={2} sx={{ mb: 4 }}>
{insights.map((insight, index) => (
<Grid item xs={12} key={index}>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: index * 0.1 }}
>
<Card
sx={{
borderLeft: 4,
borderColor:
insight.severity === 'warning'
? 'warning.main'
: insight.severity === 'success'
? 'success.main'
: 'info.main',
}}
>
<CardContent>
<Box sx={{ display: 'flex', gap: 2, alignItems: 'flex-start' }}>
<Box sx={{ mt: 0.5 }}>{getSeverityIcon(insight.severity)}</Box>
<Box sx={{ flex: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
<Typography variant="h6" fontWeight="600">
{insight.title}
</Typography>
<Chip label={insight.category} size="small" />
</Box>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
{insight.description}
</Typography>
{/* Pattern Details */}
{insight.patterns && insight.patterns.length > 0 && (
<Box sx={{ mt: 2 }}>
{insight.patterns.map((pattern, pIndex) => (
<Paper
key={pIndex}
elevation={0}
sx={{
p: 2,
mb: 1,
bgcolor: 'background.default',
}}
>
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
mb: 1,
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{getTrendIcon(pattern.trend)}
<Typography variant="body2" fontWeight="600">
{pattern.description}
</Typography>
</Box>
<Chip
label={`${Math.round(pattern.confidence * 100)}% confidence`}
size="small"
color="primary"
variant="outlined"
/>
</Box>
<LinearProgress
variant="determinate"
value={pattern.confidence * 100}
sx={{ mb: 1, borderRadius: 1 }}
/>
{pattern.recommendations && pattern.recommendations.length > 0 && (
<Box sx={{ mt: 1 }}>
<Typography variant="caption" color="text.secondary">
Recommendations:
</Typography>
<ul style={{ margin: '4px 0', paddingLeft: '20px' }}>
{pattern.recommendations.map((rec, rIndex) => (
<li key={rIndex}>
<Typography variant="caption">{rec}</Typography>
</li>
))}
</ul>
</Box>
)}
</Paper>
))}
</Box>
)}
</Box>
</Box>
</CardContent>
</Card>
</motion.div>
</Grid>
))}
</Grid>
{/* No Insights Message */}
{insights.length === 0 && (
<Alert severity="info" sx={{ borderRadius: 2 }}>
Keep tracking activities to see personalized insights and patterns!
</Alert>
)}
</Box>
);
}

View File

@@ -0,0 +1,196 @@
'use client';
import { useState, useEffect } from 'react';
import { Box, Typography, CircularProgress, Alert } from '@mui/material';
import {
LineChart,
Line,
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
} from 'recharts';
import { format, subDays } from 'date-fns';
import apiClient from '@/lib/api/client';
interface SleepData {
date: string;
totalHours: number;
nightSleep: number;
naps: number;
quality: number;
}
export default function WeeklySleepChart() {
const [data, setData] = useState<SleepData[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetchSleepData();
}, []);
const fetchSleepData = async () => {
try {
setIsLoading(true);
const endDate = new Date();
const startDate = subDays(endDate, 6);
const response = await apiClient.get('/api/v1/activities/sleep', {
params: {
startDate: startDate.toISOString(),
endDate: endDate.toISOString(),
},
});
// Process the data to aggregate by day
const sleepActivities = response.data.data;
const dailyData: { [key: string]: SleepData } = {};
// Initialize 7 days of data
for (let i = 0; i < 7; i++) {
const date = subDays(endDate, 6 - i);
const dateStr = format(date, 'MMM dd');
dailyData[dateStr] = {
date: dateStr,
totalHours: 0,
nightSleep: 0,
naps: 0,
quality: 0,
};
}
// Aggregate sleep data by day
sleepActivities.forEach((activity: any) => {
const dateStr = format(new Date(activity.startTime), 'MMM dd');
if (dailyData[dateStr]) {
const duration = activity.duration || 0;
const hours = duration / 60; // Convert minutes to hours
dailyData[dateStr].totalHours += hours;
// Determine if it's night sleep or nap based on time
const hour = new Date(activity.startTime).getHours();
if (hour >= 18 || hour < 6) {
dailyData[dateStr].nightSleep += hours;
} else {
dailyData[dateStr].naps += hours;
}
// Average quality
if (activity.quality) {
dailyData[dateStr].quality =
(dailyData[dateStr].quality + activity.quality) / 2;
}
}
});
// Round values for display
const chartData = Object.values(dailyData).map((day) => ({
...day,
totalHours: Math.round(day.totalHours * 10) / 10,
nightSleep: Math.round(day.nightSleep * 10) / 10,
naps: Math.round(day.naps * 10) / 10,
quality: Math.round(day.quality * 10) / 10,
}));
setData(chartData);
} catch (err: any) {
console.error('Failed to fetch sleep data:', err);
setError(err.response?.data?.message || 'Failed to load sleep data');
} finally {
setIsLoading(false);
}
};
if (isLoading) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
<CircularProgress />
</Box>
);
}
if (error) {
return (
<Alert severity="error" sx={{ borderRadius: 2 }}>
{error}
</Alert>
);
}
return (
<Box>
<Typography variant="h6" gutterBottom fontWeight="600">
Weekly Sleep Patterns
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
Track your child's sleep duration and quality over the past 7 days
</Typography>
{/* Total Sleep Hours Chart */}
<Box sx={{ mb: 4 }}>
<Typography variant="subtitle1" gutterBottom fontWeight="600">
Total Sleep Hours
</Typography>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={data}>
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" />
<XAxis dataKey="date" stroke="#666" />
<YAxis stroke="#666" label={{ value: 'Hours', angle: -90, position: 'insideLeft' }} />
<Tooltip
contentStyle={{
backgroundColor: 'rgba(255, 255, 255, 0.95)',
border: 'none',
borderRadius: 8,
boxShadow: '0 4px 6px rgba(0, 0, 0, 0.1)',
}}
/>
<Legend />
<Bar dataKey="nightSleep" stackId="a" fill="#B6D7FF" name="Night Sleep" />
<Bar dataKey="naps" stackId="a" fill="#A5C9FF" name="Naps" />
</BarChart>
</ResponsiveContainer>
</Box>
{/* Sleep Quality Trend */}
<Box>
<Typography variant="subtitle1" gutterBottom fontWeight="600">
Sleep Quality Trend
</Typography>
<ResponsiveContainer width="100%" height={250}>
<LineChart data={data}>
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" />
<XAxis dataKey="date" stroke="#666" />
<YAxis
stroke="#666"
domain={[0, 5]}
label={{ value: 'Quality (1-5)', angle: -90, position: 'insideLeft' }}
/>
<Tooltip
contentStyle={{
backgroundColor: 'rgba(255, 255, 255, 0.95)',
border: 'none',
borderRadius: 8,
boxShadow: '0 4px 6px rgba(0, 0, 0, 0.1)',
}}
/>
<Legend />
<Line
type="monotone"
dataKey="quality"
stroke="#B6D7FF"
strokeWidth={3}
dot={{ fill: '#B6D7FF', r: 5 }}
name="Quality"
/>
</LineChart>
</ResponsiveContainer>
</Box>
</Box>
);
}

View File

@@ -0,0 +1,157 @@
'use client';
import { useState, useEffect } from 'react';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
TextField,
MenuItem,
Box,
Alert,
} from '@mui/material';
import { Child, CreateChildData } from '@/lib/api/children';
interface ChildDialogProps {
open: boolean;
onClose: () => void;
onSubmit: (data: CreateChildData) => Promise<void>;
child?: Child | null;
isLoading?: boolean;
}
export function ChildDialog({ open, onClose, onSubmit, child, isLoading = false }: ChildDialogProps) {
const [formData, setFormData] = useState<CreateChildData>({
name: '',
birthDate: '',
gender: 'male',
photoUrl: '',
});
const [error, setError] = useState<string>('');
useEffect(() => {
if (child) {
setFormData({
name: child.name,
birthDate: child.birthDate.split('T')[0], // Convert to YYYY-MM-DD format
gender: child.gender,
photoUrl: child.photoUrl || '',
});
} else {
setFormData({
name: '',
birthDate: '',
gender: 'male',
photoUrl: '',
});
}
setError('');
}, [child, open]);
const handleChange = (field: keyof CreateChildData) => (
e: React.ChangeEvent<HTMLInputElement>
) => {
setFormData({ ...formData, [field]: e.target.value });
};
const handleSubmit = async () => {
setError('');
// Validation
if (!formData.name.trim()) {
setError('Please enter a name');
return;
}
if (!formData.birthDate) {
setError('Please select a birth date');
return;
}
// Check if birth date is in the future
const selectedDate = new Date(formData.birthDate);
const today = new Date();
today.setHours(0, 0, 0, 0);
if (selectedDate > today) {
setError('Birth date cannot be in the future');
return;
}
try {
await onSubmit(formData);
onClose();
} catch (err: any) {
setError(err.message || 'Failed to save child');
}
};
return (
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
<DialogTitle>{child ? 'Edit Child' : 'Add Child'}</DialogTitle>
<DialogContent>
<Box sx={{ pt: 2, display: 'flex', flexDirection: 'column', gap: 2 }}>
{error && (
<Alert severity="error" onClose={() => setError('')}>
{error}
</Alert>
)}
<TextField
label="Name"
value={formData.name}
onChange={handleChange('name')}
fullWidth
required
autoFocus
disabled={isLoading}
/>
<TextField
label="Birth Date"
type="date"
value={formData.birthDate}
onChange={handleChange('birthDate')}
fullWidth
required
InputLabelProps={{
shrink: true,
}}
disabled={isLoading}
/>
<TextField
label="Gender"
value={formData.gender}
onChange={handleChange('gender')}
fullWidth
required
select
disabled={isLoading}
>
<MenuItem value="male">Male</MenuItem>
<MenuItem value="female">Female</MenuItem>
<MenuItem value="other">Other</MenuItem>
</TextField>
<TextField
label="Photo URL (Optional)"
value={formData.photoUrl}
onChange={handleChange('photoUrl')}
fullWidth
placeholder="https://example.com/photo.jpg"
disabled={isLoading}
/>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={onClose} disabled={isLoading}>
Cancel
</Button>
<Button onClick={handleSubmit} variant="contained" disabled={isLoading}>
{isLoading ? 'Saving...' : child ? 'Update' : 'Add'}
</Button>
</DialogActions>
</Dialog>
);
}

View File

@@ -0,0 +1,52 @@
'use client';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
Typography,
} from '@mui/material';
import { Warning } from '@mui/icons-material';
interface DeleteConfirmDialogProps {
open: boolean;
onClose: () => void;
onConfirm: () => void;
childName: string;
isLoading?: boolean;
}
export function DeleteConfirmDialog({
open,
onClose,
onConfirm,
childName,
isLoading = false,
}: DeleteConfirmDialogProps) {
return (
<Dialog open={open} onClose={onClose} maxWidth="xs" fullWidth>
<DialogTitle sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Warning color="warning" />
Confirm Delete
</DialogTitle>
<DialogContent>
<Typography variant="body1">
Are you sure you want to delete <strong>{childName}</strong>?
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mt: 2 }}>
This action cannot be undone. All associated data will be permanently removed.
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={onClose} disabled={isLoading}>
Cancel
</Button>
<Button onClick={onConfirm} color="error" variant="contained" disabled={isLoading}>
{isLoading ? 'Deleting...' : 'Delete'}
</Button>
</DialogActions>
</Dialog>
);
}

View 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>
);
};

View File

@@ -0,0 +1,158 @@
'use client';
import { useEffect, useState } from 'react';
import { Alert, LinearProgress, Box, Typography } from '@mui/material';
import { motion, AnimatePresence } from 'framer-motion';
import { CloudOff, CloudQueue, CloudDone } from '@mui/icons-material';
interface OfflineIndicatorProps {
isOnline?: boolean;
pendingActionsCount?: number;
syncInProgress?: boolean;
}
export const OfflineIndicator = ({
isOnline: propIsOnline,
pendingActionsCount = 0,
syncInProgress = false,
}: OfflineIndicatorProps) => {
const [isOnline, setIsOnline] = useState(true);
useEffect(() => {
// Set initial online status
setIsOnline(navigator.onLine);
// Listen for online/offline events
const handleOnline = () => setIsOnline(true);
const handleOffline = () => setIsOnline(false);
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, []);
const effectiveIsOnline = propIsOnline !== undefined ? propIsOnline : isOnline;
return (
<>
<AnimatePresence>
{!effectiveIsOnline && (
<motion.div
initial={{ y: -100, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: -100, opacity: 0 }}
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
zIndex: 9999,
}}
>
<Alert
severity="warning"
icon={<CloudOff />}
sx={{
borderRadius: 0,
boxShadow: 2,
}}
>
<Box>
<Typography variant="body2" fontWeight="600">
You're offline
</Typography>
{pendingActionsCount > 0 && (
<Typography variant="caption">
{pendingActionsCount} action{pendingActionsCount !== 1 ? 's' : ''} will sync when you're back online
</Typography>
)}
</Box>
</Alert>
</motion.div>
)}
{effectiveIsOnline && syncInProgress && (
<motion.div
initial={{ y: -100, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: -100, opacity: 0 }}
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
zIndex: 9999,
}}
>
<Alert
severity="info"
icon={<CloudQueue />}
sx={{
borderRadius: 0,
boxShadow: 2,
}}
>
<Box>
<Typography variant="body2" fontWeight="600">
Syncing data...
</Typography>
{pendingActionsCount > 0 && (
<Typography variant="caption">
{pendingActionsCount} action{pendingActionsCount !== 1 ? 's' : ''} remaining
</Typography>
)}
</Box>
</Alert>
<LinearProgress />
</motion.div>
)}
{effectiveIsOnline && !syncInProgress && pendingActionsCount === 0 &&
typeof propIsOnline !== 'undefined' && propIsOnline && (
<motion.div
initial={{ y: -100, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: -100, opacity: 0 }}
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
onAnimationComplete={() => {
// Auto-hide after 3 seconds
setTimeout(() => {
const element = document.getElementById('sync-complete-alert');
if (element) {
element.style.display = 'none';
}
}, 3000);
}}
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
zIndex: 9999,
}}
id="sync-complete-alert"
>
<Alert
severity="success"
icon={<CloudDone />}
sx={{
borderRadius: 0,
boxShadow: 2,
}}
>
<Typography variant="body2" fontWeight="600">
All data synced successfully!
</Typography>
</Alert>
</motion.div>
)}
</AnimatePresence>
</>
);
};

View 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>
);
};

View 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;
};

View File

@@ -0,0 +1,41 @@
'use client';
import { useEffect } from 'react';
import { useRouter, usePathname } from 'next/navigation';
import { Box, CircularProgress } from '@mui/material';
import { useAuth } from '@/lib/auth/AuthContext';
const PUBLIC_ROUTES = ['/login', '/register', '/forgot-password'];
export function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { isAuthenticated, isLoading } = useAuth();
const router = useRouter();
const pathname = usePathname();
useEffect(() => {
if (!isLoading && !isAuthenticated && !PUBLIC_ROUTES.includes(pathname)) {
router.push('/login');
}
}, [isAuthenticated, isLoading, router, pathname]);
if (isLoading) {
return (
<Box
sx={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
minHeight: '100vh',
}}
>
<CircularProgress size={48} />
</Box>
);
}
if (!isAuthenticated && !PUBLIC_ROUTES.includes(pathname)) {
return null;
}
return <>{children}</>;
}

View File

@@ -0,0 +1,34 @@
import { render } from '@testing-library/react'
import { LoadingFallback } from '../LoadingFallback'
describe('LoadingFallback', () => {
it('renders without crashing for page variant', () => {
const { container } = render(<LoadingFallback variant="page" />)
expect(container.firstChild).toBeInTheDocument()
})
it('renders without crashing for card variant', () => {
const { container } = render(<LoadingFallback variant="card" />)
expect(container.firstChild).toBeInTheDocument()
})
it('renders without crashing for list variant', () => {
const { container } = render(<LoadingFallback variant="list" />)
expect(container.firstChild).toBeInTheDocument()
})
it('renders without crashing for chart variant', () => {
const { container } = render(<LoadingFallback variant="chart" />)
expect(container.firstChild).toBeInTheDocument()
})
it('renders without crashing for chat variant', () => {
const { container } = render(<LoadingFallback variant="chat" />)
expect(container.firstChild).toBeInTheDocument()
})
it('defaults to page variant when no variant is specified', () => {
const { container } = render(<LoadingFallback />)
expect(container.firstChild).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,126 @@
'use client';
import { useState, useEffect } from 'react';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
TextField,
MenuItem,
Box,
Alert,
} from '@mui/material';
import { InviteMemberData } from '@/lib/api/families';
interface InviteMemberDialogProps {
open: boolean;
onClose: () => void;
onSubmit: (data: InviteMemberData) => Promise<void>;
isLoading?: boolean;
}
export function InviteMemberDialog({
open,
onClose,
onSubmit,
isLoading = false,
}: InviteMemberDialogProps) {
const [formData, setFormData] = useState<InviteMemberData>({
email: '',
role: 'viewer',
});
const [error, setError] = useState<string>('');
useEffect(() => {
if (open) {
setFormData({
email: '',
role: 'viewer',
});
setError('');
}
}, [open]);
const handleChange = (field: keyof InviteMemberData) => (
e: React.ChangeEvent<HTMLInputElement>
) => {
setFormData({ ...formData, [field]: e.target.value });
};
const handleSubmit = async () => {
setError('');
// Validation
if (!formData.email.trim()) {
setError('Please enter an email address');
return;
}
// Basic email validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(formData.email)) {
setError('Please enter a valid email address');
return;
}
try {
await onSubmit(formData);
onClose();
} catch (err: any) {
setError(err.message || 'Failed to invite member');
}
};
return (
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
<DialogTitle>Invite Family Member</DialogTitle>
<DialogContent>
<Box sx={{ pt: 2, display: 'flex', flexDirection: 'column', gap: 2 }}>
{error && (
<Alert severity="error" onClose={() => setError('')}>
{error}
</Alert>
)}
<TextField
label="Email Address"
type="email"
value={formData.email}
onChange={handleChange('email')}
fullWidth
required
autoFocus
disabled={isLoading}
placeholder="member@example.com"
helperText="Enter the email address of the person you want to invite"
/>
<TextField
label="Role"
value={formData.role}
onChange={handleChange('role')}
fullWidth
required
select
disabled={isLoading}
helperText="Select the access level for this member"
>
<MenuItem value="parent">Parent - Full access to all features</MenuItem>
<MenuItem value="caregiver">Caregiver - Can manage daily activities</MenuItem>
<MenuItem value="viewer">Viewer - Can only view information</MenuItem>
</TextField>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={onClose} disabled={isLoading}>
Cancel
</Button>
<Button onClick={handleSubmit} variant="contained" disabled={isLoading}>
{isLoading ? 'Sending...' : 'Send Invitation'}
</Button>
</DialogActions>
</Dialog>
);
}

View File

@@ -0,0 +1,95 @@
'use client';
import { useState, useEffect } from 'react';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
TextField,
Box,
Alert,
Typography,
} from '@mui/material';
import { JoinFamilyData } from '@/lib/api/families';
interface JoinFamilyDialogProps {
open: boolean;
onClose: () => void;
onSubmit: (data: JoinFamilyData) => Promise<void>;
isLoading?: boolean;
}
export function JoinFamilyDialog({
open,
onClose,
onSubmit,
isLoading = false,
}: JoinFamilyDialogProps) {
const [shareCode, setShareCode] = useState<string>('');
const [error, setError] = useState<string>('');
useEffect(() => {
if (open) {
setShareCode('');
setError('');
}
}, [open]);
const handleSubmit = async () => {
setError('');
// Validation
if (!shareCode.trim()) {
setError('Please enter a share code');
return;
}
try {
await onSubmit({ shareCode: shareCode.trim() });
onClose();
} catch (err: any) {
setError(err.message || 'Failed to join family');
}
};
return (
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
<DialogTitle>Join a Family</DialogTitle>
<DialogContent>
<Box sx={{ pt: 2, display: 'flex', flexDirection: 'column', gap: 2 }}>
{error && (
<Alert severity="error" onClose={() => setError('')}>
{error}
</Alert>
)}
<Typography variant="body2" color="text.secondary">
Enter the share code provided by the family administrator to join their family.
</Typography>
<TextField
label="Share Code"
value={shareCode}
onChange={(e) => setShareCode(e.target.value)}
fullWidth
required
autoFocus
disabled={isLoading}
placeholder="Enter family share code"
helperText="Ask a family member for their share code"
/>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={onClose} disabled={isLoading}>
Cancel
</Button>
<Button onClick={handleSubmit} variant="contained" disabled={isLoading}>
{isLoading ? 'Joining...' : 'Join Family'}
</Button>
</DialogActions>
</Dialog>
);
}

View File

@@ -0,0 +1,52 @@
'use client';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
Typography,
} from '@mui/material';
import { Warning } from '@mui/icons-material';
interface RemoveMemberDialogProps {
open: boolean;
onClose: () => void;
onConfirm: () => void;
memberName: string;
isLoading?: boolean;
}
export function RemoveMemberDialog({
open,
onClose,
onConfirm,
memberName,
isLoading = false,
}: RemoveMemberDialogProps) {
return (
<Dialog open={open} onClose={onClose} maxWidth="xs" fullWidth>
<DialogTitle sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Warning color="warning" />
Remove Family Member
</DialogTitle>
<DialogContent>
<Typography variant="body1">
Are you sure you want to remove <strong>{memberName}</strong> from your family?
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mt: 2 }}>
This member will lose access to all family data and activities.
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={onClose} disabled={isLoading}>
Cancel
</Button>
<Button onClick={onConfirm} color="error" variant="contained" disabled={isLoading}>
{isLoading ? 'Removing...' : 'Remove Member'}
</Button>
</DialogActions>
</Dialog>
);
}

View 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.message,
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>
);
};

View 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>
);
};

View File

@@ -0,0 +1,41 @@
'use client';
import { Box, Container } from '@mui/material';
import { MobileNav } from '../MobileNav/MobileNav';
import { TabBar } from '../TabBar/TabBar';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { ReactNode } from 'react';
interface AppShellProps {
children: ReactNode;
}
export const AppShell = ({ children }: AppShellProps) => {
const isMobile = useMediaQuery('(max-width: 768px)');
const isTablet = useMediaQuery('(max-width: 1024px)');
return (
<Box sx={{
display: 'flex',
flexDirection: 'column',
minHeight: '100vh',
bgcolor: 'background.default',
pb: isMobile ? '64px' : 0, // Space for tab bar
}}>
{!isMobile && <MobileNav />}
<Container
maxWidth={isTablet ? 'md' : 'lg'}
sx={{
flex: 1,
px: isMobile ? 2 : 3,
py: 3,
}}
>
{children}
</Container>
{isMobile && <TabBar />}
</Box>
);
};

View File

@@ -0,0 +1,114 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import {
AppBar,
Toolbar,
IconButton,
Typography,
Drawer,
List,
ListItem,
ListItemButton,
ListItemIcon,
ListItemText,
Avatar,
Box,
Divider,
} from '@mui/material';
import {
Menu as MenuIcon,
Home,
Timeline,
Chat,
Insights,
Settings,
ChildCare,
Group,
Logout,
} from '@mui/icons-material';
export const MobileNav = () => {
const [drawerOpen, setDrawerOpen] = useState(false);
const router = useRouter();
const menuItems = [
{ label: 'Dashboard', icon: <Home />, path: '/' },
{ label: 'Track Activity', icon: <Timeline />, path: '/track' },
{ label: 'AI Assistant', icon: <Chat />, path: '/ai-assistant' },
{ label: 'Insights', icon: <Insights />, path: '/insights' },
{ label: 'Children', icon: <ChildCare />, path: '/children' },
{ label: 'Family', icon: <Group />, path: '/family' },
{ label: 'Settings', icon: <Settings />, path: '/settings' },
];
const handleNavigate = (path: string) => {
router.push(path);
setDrawerOpen(false);
};
return (
<>
<AppBar position="static" elevation={1} sx={{ bgcolor: 'background.paper' }}>
<Toolbar>
<IconButton
edge="start"
color="primary"
aria-label="menu"
onClick={() => setDrawerOpen(true)}
>
<MenuIcon />
</IconButton>
<Typography variant="h6" component="div" sx={{ flexGrow: 1, color: 'primary.main', fontWeight: 600 }}>
Maternal
</Typography>
<Avatar sx={{ bgcolor: 'primary.main' }}>U</Avatar>
</Toolbar>
</AppBar>
<Drawer
anchor="left"
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
>
<Box
sx={{ width: 280 }}
role="presentation"
>
<Box sx={{ p: 3, bgcolor: 'primary.light' }}>
<Avatar sx={{ width: 64, height: 64, bgcolor: 'primary.main', mb: 2 }}>U</Avatar>
<Typography variant="h6" fontWeight="600">User Name</Typography>
<Typography variant="body2" color="text.secondary">user@example.com</Typography>
</Box>
<List>
{menuItems.map((item) => (
<ListItem key={item.path} disablePadding>
<ListItemButton onClick={() => handleNavigate(item.path)}>
<ListItemIcon sx={{ color: 'primary.main' }}>
{item.icon}
</ListItemIcon>
<ListItemText primary={item.label} />
</ListItemButton>
</ListItem>
))}
</List>
<Divider sx={{ my: 1 }} />
<List>
<ListItem disablePadding>
<ListItemButton onClick={() => handleNavigate('/logout')}>
<ListItemIcon sx={{ color: 'error.main' }}>
<Logout />
</ListItemIcon>
<ListItemText primary="Logout" />
</ListItemButton>
</ListItem>
</List>
</Box>
</Drawer>
</>
);
};

View File

@@ -0,0 +1,63 @@
'use client';
import { usePathname, useRouter } from 'next/navigation';
import { BottomNavigation, BottomNavigationAction, Paper } from '@mui/material';
import {
Home,
Timeline,
Chat,
Insights,
Settings,
} from '@mui/icons-material';
export const TabBar = () => {
const router = useRouter();
const pathname = usePathname();
const tabs = [
{ label: 'Home', icon: <Home />, value: '/' },
{ label: 'Track', icon: <Timeline />, value: '/track' },
{ label: 'AI Chat', icon: <Chat />, value: '/ai-assistant' },
{ label: 'Insights', icon: <Insights />, value: '/insights' },
{ label: 'Settings', icon: <Settings />, value: '/settings' },
];
return (
<Paper
sx={{
position: 'fixed',
bottom: 0,
left: 0,
right: 0,
zIndex: 1000,
}}
elevation={3}
>
<BottomNavigation
value={pathname}
onChange={(event, newValue) => {
router.push(newValue);
}}
showLabels
sx={{
height: 64,
'& .MuiBottomNavigationAction-root': {
minWidth: 60,
'&.Mui-selected': {
color: 'primary.main',
},
},
}}
>
{tabs.map((tab) => (
<BottomNavigationAction
key={tab.value}
label={tab.label}
icon={tab.icon}
value={tab.value}
/>
))}
</BottomNavigation>
</Paper>
);
};