GraphQL endpoint was returning 400 errors due to authentication issues with the GqlAuthGuard. Replaced GraphQL query with REST API calls to match the working insights page pattern. Changes: - Removed useQuery(GET_DASHBOARD) GraphQL call - Added REST API calls: childrenApi.getChildren(), trackingApi.getActivities() - Calculate today's summary from activities client-side - Load children and dashboard data separately - Removed all GraphQL debug logging Now home page uses same REST pattern as insights page: 1. Load children via childrenApi 2. Load activities via trackingApi 3. Calculate summary from filtered activities This eliminates the GraphQL 400 errors and makes Today's Summary display correctly with feeding count, sleep duration, diaper count. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
366 lines
14 KiB
TypeScript
366 lines
14 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect, useCallback } from 'react';
|
|
import { Box, Typography, Button, Paper, CircularProgress, Grid } from '@mui/material';
|
|
import { AppShell } from '@/components/layouts/AppShell/AppShell';
|
|
import { ProtectedRoute } from '@/components/common/ProtectedRoute';
|
|
import { EmailVerificationBanner } from '@/components/common/EmailVerificationBanner';
|
|
import { ErrorBoundary } from '@/components/common/ErrorBoundary';
|
|
import { DataErrorFallback } from '@/components/common/ErrorFallbacks';
|
|
import { NetworkStatusIndicator } from '@/components/common/NetworkStatusIndicator';
|
|
import { StatGridSkeleton } from '@/components/common/LoadingSkeletons';
|
|
import DynamicChildDashboard from '@/components/dashboard/DynamicChildDashboard';
|
|
import {
|
|
Restaurant,
|
|
Hotel,
|
|
BabyChangingStation,
|
|
Insights,
|
|
SmartToy,
|
|
Analytics,
|
|
MedicalServices,
|
|
} from '@mui/icons-material';
|
|
import { motion } from 'framer-motion';
|
|
import { useAuth } from '@/lib/auth/AuthContext';
|
|
import { useRouter } from 'next/navigation';
|
|
import { childrenApi, Child } from '@/lib/api/children';
|
|
import { trackingApi } from '@/lib/api/tracking';
|
|
import { format, formatDistanceToNow } from 'date-fns';
|
|
import { useRealTimeActivities } from '@/hooks/useWebSocket';
|
|
import { useTranslation } from '@/hooks/useTranslation';
|
|
import { analyticsApi } from '@/lib/api/analytics';
|
|
import { useTheme } from '@mui/material/styles';
|
|
import { useDispatch, useSelector } from 'react-redux';
|
|
import { fetchChildren, selectSelectedChild } from '@/store/slices/childrenSlice';
|
|
import { AppDispatch, RootState } from '@/store/store';
|
|
|
|
export default function HomePage() {
|
|
const { t } = useTranslation('dashboard');
|
|
const theme = useTheme();
|
|
const { user, isLoading: authLoading } = useAuth();
|
|
const router = useRouter();
|
|
const dispatch = useDispatch<AppDispatch>();
|
|
const selectedChild = useSelector(selectSelectedChild);
|
|
const familyId = user?.families?.[0]?.familyId;
|
|
const [selectedChildId, setSelectedChildId] = useState<string | null>(null);
|
|
const [predictions, setPredictions] = useState<any>(null);
|
|
const [predictionsLoading, setPredictionsLoading] = useState(false);
|
|
const [children, setChildren] = useState<Child[]>([]);
|
|
const [recentActivities, setRecentActivities] = useState<any[]>([]);
|
|
const [todaySummary, setTodaySummary] = useState<any>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<any>(null);
|
|
|
|
// Load children
|
|
useEffect(() => {
|
|
if (familyId && !authLoading) {
|
|
loadChildren();
|
|
}
|
|
}, [familyId, authLoading]);
|
|
|
|
const loadChildren = async () => {
|
|
if (!familyId) return;
|
|
|
|
try {
|
|
const data = await childrenApi.getChildren(familyId);
|
|
setChildren(data);
|
|
if (data.length > 0 && !selectedChildId) {
|
|
setSelectedChildId(data[0].id);
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to load children:', err);
|
|
setError(err);
|
|
}
|
|
};
|
|
|
|
// Load dashboard data when child selected
|
|
useEffect(() => {
|
|
if (selectedChildId) {
|
|
loadDashboardData();
|
|
}
|
|
}, [selectedChildId]);
|
|
|
|
const loadDashboardData = async () => {
|
|
if (!selectedChildId) return;
|
|
|
|
setLoading(true);
|
|
try {
|
|
// Load recent activities
|
|
const activities = await trackingApi.getActivities(selectedChildId);
|
|
setRecentActivities(activities.slice(0, 10));
|
|
|
|
// Calculate today's summary from activities
|
|
const today = new Date();
|
|
today.setHours(0, 0, 0, 0);
|
|
const todayActivities = activities.filter((a: any) => {
|
|
const activityDate = new Date(a.timestamp || a.startedAt);
|
|
return activityDate >= today;
|
|
});
|
|
|
|
const summary = {
|
|
feedingCount: todayActivities.filter((a: any) => a.type === 'feeding').length,
|
|
sleepCount: todayActivities.filter((a: any) => a.type === 'sleep').length,
|
|
diaperCount: todayActivities.filter((a: any) => a.type === 'diaper').length,
|
|
medicationCount: todayActivities.filter((a: any) => ['medication', 'medicine'].includes(a.type)).length,
|
|
totalFeedingAmount: todayActivities
|
|
.filter((a: any) => a.type === 'feeding')
|
|
.reduce((sum: number, a: any) => sum + (a.data?.amount || 0), 0),
|
|
totalSleepDuration: todayActivities
|
|
.filter((a: any) => a.type === 'sleep')
|
|
.reduce((sum: number, a: any) => {
|
|
const start = new Date(a.startedAt);
|
|
const end = a.endedAt ? new Date(a.endedAt) : new Date();
|
|
return sum + (end.getTime() - start.getTime()) / (1000 * 60);
|
|
}, 0),
|
|
};
|
|
|
|
setTodaySummary(summary);
|
|
setError(null);
|
|
} catch (err) {
|
|
console.error('Failed to load dashboard data:', err);
|
|
setError(err);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const refetch = loadDashboardData;
|
|
|
|
// Real-time activity handler
|
|
const refreshDashboard = useCallback(async () => {
|
|
console.log('[HomePage] Refreshing dashboard data...');
|
|
await refetch();
|
|
}, [refetch]);
|
|
|
|
// Subscribe to real-time activity updates
|
|
useRealTimeActivities(
|
|
refreshDashboard, // On activity created
|
|
refreshDashboard, // On activity updated
|
|
refreshDashboard // On activity deleted
|
|
);
|
|
|
|
// Sync selectedChildId with Redux selectedChild
|
|
useEffect(() => {
|
|
if (selectedChild?.id && selectedChild.id !== selectedChildId) {
|
|
setSelectedChildId(selectedChild.id);
|
|
}
|
|
}, [selectedChild, selectedChildId]);
|
|
|
|
// Fetch predictions when selectedChildId changes
|
|
useEffect(() => {
|
|
const fetchPredictions = async () => {
|
|
if (!selectedChildId) return;
|
|
|
|
setPredictionsLoading(true);
|
|
try {
|
|
const predictionData = await analyticsApi.getPredictions(selectedChildId);
|
|
setPredictions(predictionData);
|
|
} catch (err) {
|
|
console.error('Failed to fetch predictions:', err);
|
|
setPredictions(null);
|
|
} finally {
|
|
setPredictionsLoading(false);
|
|
}
|
|
};
|
|
|
|
fetchPredictions();
|
|
}, [selectedChildId]);
|
|
|
|
const quickActions = [
|
|
{ icon: <Restaurant />, label: t('quickActions.feeding'), color: theme.palette.primary.main, path: '/track/feeding' },
|
|
{ icon: <Hotel />, label: t('quickActions.sleep'), color: theme.palette.secondary.main, path: '/track/sleep' },
|
|
{ icon: <BabyChangingStation />, label: t('quickActions.diaper'), color: theme.palette.warning.main, path: '/track/diaper' },
|
|
{ icon: <MedicalServices />, label: t('quickActions.medical'), color: theme.palette.error.main, path: '/track/medicine' },
|
|
{ icon: <Insights />, label: t('quickActions.activities'), color: theme.palette.success.main, path: '/track/activity' },
|
|
{ icon: <SmartToy />, label: t('quickActions.aiAssistant'), color: theme.palette.info.main, path: '/ai-assistant' },
|
|
];
|
|
|
|
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`;
|
|
}
|
|
};
|
|
|
|
const isLoading = authLoading || loading;
|
|
|
|
// Build child metrics object for DynamicChildDashboard
|
|
const childMetrics = children.reduce((acc: any, child: any) => {
|
|
// Use todaySummary for selected child, or zero for others
|
|
if (child.id === selectedChildId && todaySummary) {
|
|
acc[child.id] = {
|
|
feedingCount: todaySummary.feedingCount || 0,
|
|
sleepDuration: todaySummary.totalSleepDuration || 0,
|
|
diaperCount: todaySummary.diaperCount || 0,
|
|
medicationCount: todaySummary.medicationCount || 0,
|
|
};
|
|
} else {
|
|
acc[child.id] = {
|
|
feedingCount: 0,
|
|
sleepDuration: 0,
|
|
diaperCount: 0,
|
|
medicationCount: 0,
|
|
};
|
|
}
|
|
return acc;
|
|
}, {});
|
|
|
|
const handleChildSelect = (childId: string) => {
|
|
setSelectedChildId(childId);
|
|
};
|
|
|
|
return (
|
|
<ProtectedRoute>
|
|
<AppShell>
|
|
<NetworkStatusIndicator />
|
|
<Box>
|
|
<EmailVerificationBanner />
|
|
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 20 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ duration: 0.5 }}
|
|
>
|
|
<Typography variant="h4" component="h1" gutterBottom fontWeight="600" sx={{ mb: 1 }}>
|
|
{user?.name ? t('welcomeBackWithName', { name: user.name }) : t('welcomeBack')} 👋
|
|
</Typography>
|
|
<Typography variant="body1" sx={{ mb: 4, color: 'text.primary' }}>
|
|
{t('subtitle')}
|
|
</Typography>
|
|
|
|
{/* Quick Actions */}
|
|
<Typography variant="h6" component="h2" gutterBottom fontWeight="600" sx={{ mb: 2 }}>
|
|
{t('quickActions.title')}
|
|
</Typography>
|
|
<Grid container spacing={2} sx={{ mb: 4 }}>
|
|
{quickActions.map((action, index) => (
|
|
<Grid size={{ xs: 6, sm: 4, md: 2 }} key={action.label}>
|
|
<motion.div
|
|
initial={{ opacity: 0, scale: 0.9 }}
|
|
animate={{ opacity: 1, scale: 1 }}
|
|
transition={{ duration: 0.3, delay: index * 0.1 }}
|
|
style={{ height: '100%' }}
|
|
>
|
|
<Paper
|
|
component="button"
|
|
onClick={() => router.push(action.path)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
e.preventDefault();
|
|
router.push(action.path);
|
|
}
|
|
}}
|
|
aria-label={t('quickActions.navigateTo', { action: action.label })}
|
|
sx={{
|
|
p: 3,
|
|
height: '140px', // Fixed height for consistency
|
|
minHeight: '140px', // Ensure minimum height
|
|
width: '100%', // Full width of grid container
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
textAlign: 'center',
|
|
cursor: 'pointer',
|
|
bgcolor: action.color,
|
|
color: 'white',
|
|
border: 'none',
|
|
transition: 'transform 0.2s',
|
|
'&:hover': {
|
|
transform: 'scale(1.05)',
|
|
},
|
|
'&:focus-visible': {
|
|
outline: '3px solid white',
|
|
outlineOffset: '-3px',
|
|
transform: 'scale(1.05)',
|
|
},
|
|
}}
|
|
>
|
|
<Box sx={{ fontSize: 48, mb: 1 }} aria-hidden="true">{action.icon}</Box>
|
|
<Typography variant="body1" fontWeight="600">
|
|
{action.label}
|
|
</Typography>
|
|
</Paper>
|
|
</motion.div>
|
|
</Grid>
|
|
))}
|
|
</Grid>
|
|
|
|
{/* Today's Summary - Dynamic Multi-Child Dashboard */}
|
|
<Typography variant="h6" component="h2" gutterBottom fontWeight="600" sx={{ mb: 2 }}>
|
|
{t('summary.title')}
|
|
</Typography>
|
|
<ErrorBoundary
|
|
isolate
|
|
fallback={<DataErrorFallback error={new Error('Failed to load summary')} />}
|
|
>
|
|
{isLoading ? (
|
|
<StatGridSkeleton count={3} />
|
|
) : children.length === 0 ? (
|
|
<Paper sx={{ p: 3 }}>
|
|
<Box sx={{ textAlign: 'center', py: 4 }}>
|
|
<Typography variant="body2" sx={{ color: 'rgba(0, 0, 0, 0.7)' }}>
|
|
{t('summary.noChild')}
|
|
</Typography>
|
|
</Box>
|
|
</Paper>
|
|
) : (
|
|
<DynamicChildDashboard
|
|
childMetrics={childMetrics}
|
|
onChildSelect={handleChildSelect}
|
|
/>
|
|
)}
|
|
</ErrorBoundary>
|
|
|
|
{/* Next Predicted Activity */}
|
|
<Box sx={{ mt: 4 }}>
|
|
<Paper sx={{ p: 3, bgcolor: 'primary.light' }}>
|
|
<Typography variant="body2" sx={{ color: 'rgba(0, 0, 0, 0.7)' }} gutterBottom>
|
|
{t('predictions.title')}
|
|
</Typography>
|
|
{predictionsLoading ? (
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
|
<CircularProgress size={20} />
|
|
<Typography variant="body2">Loading predictions...</Typography>
|
|
</Box>
|
|
) : predictions?.sleep?.nextNapTime || predictions?.feeding?.nextFeedingTime ? (
|
|
<>
|
|
{predictions.sleep?.nextNapTime && (
|
|
<>
|
|
<Typography variant="h6" fontWeight="600" gutterBottom>
|
|
Nap time {formatDistanceToNow(new Date(predictions.sleep.nextNapTime), { addSuffix: true })}
|
|
</Typography>
|
|
<Typography variant="body2" sx={{ color: 'rgba(0, 0, 0, 0.7)' }}>
|
|
{predictions.sleep.reasoning || t('predictions.basedOnPatterns')}
|
|
</Typography>
|
|
</>
|
|
)}
|
|
{!predictions.sleep?.nextNapTime && predictions.feeding?.nextFeedingTime && (
|
|
<>
|
|
<Typography variant="h6" fontWeight="600" gutterBottom>
|
|
Feeding time {formatDistanceToNow(new Date(predictions.feeding.nextFeedingTime), { addSuffix: true })}
|
|
</Typography>
|
|
<Typography variant="body2" sx={{ color: 'rgba(0, 0, 0, 0.7)' }}>
|
|
{predictions.feeding.reasoning || t('predictions.basedOnPatterns')}
|
|
</Typography>
|
|
</>
|
|
)}
|
|
</>
|
|
) : (
|
|
<Typography variant="body1" sx={{ color: 'rgba(0, 0, 0, 0.7)' }}>
|
|
{t('predictions.notEnoughData')}
|
|
</Typography>
|
|
)}
|
|
</Paper>
|
|
</Box>
|
|
</motion.div>
|
|
</Box>
|
|
</AppShell>
|
|
</ProtectedRoute>
|
|
);
|
|
}
|