Files
maternal-app/maternal-web/app/page.tsx
Andrei d0b78181a3
Some checks failed
CI/CD Pipeline / Lint and Test (push) Has been cancelled
CI/CD Pipeline / E2E Tests (push) Has been cancelled
CI/CD Pipeline / Build Application (push) Has been cancelled
fix: Comprehensive authentication and UI fixes
Authentication & Token Management:
- Add deviceId to token refresh flow (backend requires both refreshToken and deviceId)
- Fix React Strict Mode token clearing race condition with retry logic
- Improve AuthContext to handle all token state combinations properly
- Store deviceId in localStorage alongside tokens

UI/UX Improvements:
- Remove deprecated legacyBehavior from Next.js Link components
- Update primary theme color to WCAG AA compliant #7c3aed
- Fix nested button error in TabBar voice navigation
- Fix invalid Tabs value error in DynamicChildDashboard

Multi-Child Dashboard:
- Load all children into Redux store properly
- Fetch metrics for all children, not just selected one
- Remove mock data to prevent unauthorized API calls

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-05 16:10:11 +00:00

398 lines
15 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 [allChildMetrics, setAllChildMetrics] = useState<any>({});
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) {
console.warn('[HomePage] No familyId available, cannot load children');
return;
}
try {
console.log('[HomePage] Loading children for familyId:', familyId);
// Dispatch to Redux to load children into store
await dispatch(fetchChildren(familyId)).unwrap();
// Also get children for local state
const data = await childrenApi.getChildren(familyId);
console.log('[HomePage] Loaded children:', data.map(c => ({ id: c.id, name: c.name, familyId: c.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 children are loaded
useEffect(() => {
if (children.length > 0) {
loadDashboardData();
}
}, [children]);
const loadDashboardData = async () => {
setLoading(true);
try {
// Load activities for ALL children to populate metrics
const allMetrics: any = {};
for (const child of children) {
try {
const activities = await trackingApi.getActivities(child.id);
// 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;
});
allMetrics[child.id] = {
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 || a.metadata?.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),
};
// If this is the selected child, also set recent activities
if (child.id === selectedChildId) {
setRecentActivities(activities.slice(0, 10));
setTodaySummary(allMetrics[child.id]);
}
} catch (err) {
console.error(`Failed to load activities for child ${child.id}:`, err);
allMetrics[child.id] = {
feedingCount: 0,
sleepCount: 0,
diaperCount: 0,
medicationCount: 0,
totalFeedingAmount: 0,
totalSleepDuration: 0,
};
}
}
// Store all metrics in state
setAllChildMetrics(allMetrics);
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 from allChildMetrics
const childMetrics = children.reduce((acc: any, child: any) => {
const metrics = allChildMetrics[child.id];
if (metrics) {
acc[child.id] = {
feedingCount: metrics.feedingCount || 0,
sleepDuration: metrics.totalSleepDuration || 0,
diaperCount: metrics.diaperCount || 0,
medicationCount: metrics.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>
);
}