Add skeleton loading states across all tracking pages
Some checks failed
CI/CD Pipeline / Build Application (push) Has been cancelled
CI/CD Pipeline / Lint and Test (push) Has been cancelled
CI/CD Pipeline / E2E Tests (push) Has been cancelled

- Replace CircularProgress spinners with content-aware skeleton screens
- Add FormSkeleton for form loading states (feeding, sleep, diaper pages)
- Add ActivityListSkeleton for recent activities loading
- Improves perceived performance with layout-matching placeholders

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-10-01 20:36:11 +00:00
parent b00e75f679
commit 8276db39a2
6 changed files with 372 additions and 29 deletions

View File

@@ -18,6 +18,7 @@ import { AppShell } from '@/components/layouts/AppShell/AppShell';
import { ProtectedRoute } from '@/components/common/ProtectedRoute'; import { ProtectedRoute } from '@/components/common/ProtectedRoute';
import { ErrorBoundary } from '@/components/common/ErrorBoundary'; import { ErrorBoundary } from '@/components/common/ErrorBoundary';
import { ChartErrorFallback } from '@/components/common/ErrorFallbacks'; import { ChartErrorFallback } from '@/components/common/ErrorFallbacks';
import { StatGridSkeleton, ChartSkeleton } from '@/components/common/LoadingSkeletons';
import { import {
TrendingUp, TrendingUp,
Hotel, Hotel,
@@ -102,15 +103,14 @@ export default function AnalyticsPage() {
return ( return (
<ProtectedRoute> <ProtectedRoute>
<AppShell> <AppShell>
<Box <Box>
sx={{ <Typography variant="h4" gutterBottom fontWeight="600" sx={{ mb: 3 }}>
display: 'flex', Analytics & Insights
justifyContent: 'center', </Typography>
alignItems: 'center', <StatGridSkeleton count={3} />
minHeight: '60vh', <Box sx={{ mt: 4 }}>
}} <ChartSkeleton height={350} />
> </Box>
<CircularProgress />
</Box> </Box>
</AppShell> </AppShell>
</ProtectedRoute> </ProtectedRoute>

View File

@@ -8,6 +8,7 @@ import { EmailVerificationBanner } from '@/components/common/EmailVerificationBa
import { ErrorBoundary } from '@/components/common/ErrorBoundary'; import { ErrorBoundary } from '@/components/common/ErrorBoundary';
import { DataErrorFallback } from '@/components/common/ErrorFallbacks'; import { DataErrorFallback } from '@/components/common/ErrorFallbacks';
import { NetworkStatusIndicator } from '@/components/common/NetworkStatusIndicator'; import { NetworkStatusIndicator } from '@/components/common/NetworkStatusIndicator';
import { StatGridSkeleton } from '@/components/common/LoadingSkeletons';
import { import {
Restaurant, Restaurant,
Hotel, Hotel,
@@ -147,12 +148,11 @@ export default function HomePage() {
isolate isolate
fallback={<DataErrorFallback error={new Error('Failed to load summary')} />} fallback={<DataErrorFallback error={new Error('Failed to load summary')} />}
> >
<Paper sx={{ p: 3 }}> {loading ? (
{loading ? ( <StatGridSkeleton count={3} />
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}> ) : (
<CircularProgress /> <Paper sx={{ p: 3 }}>
</Box> {!dailySummary ? (
) : !dailySummary ? (
<Box sx={{ textAlign: 'center', py: 4 }}> <Box sx={{ textAlign: 'center', py: 4 }}>
<Typography variant="body2" color="text.secondary"> <Typography variant="body2" color="text.secondary">
{children.length === 0 {children.length === 0
@@ -198,8 +198,9 @@ export default function HomePage() {
</Box> </Box>
</Grid> </Grid>
</Grid> </Grid>
)} )}
</Paper> </Paper>
)}
</ErrorBoundary> </ErrorBoundary>
{/* Next Predicted Activity */} {/* Next Predicted Activity */}

View File

@@ -27,6 +27,7 @@ import {
ToggleButton, ToggleButton,
FormLabel, FormLabel,
} from '@mui/material'; } from '@mui/material';
import { FormSkeleton, ActivityListSkeleton } from '@/components/common/LoadingSkeletons';
import { import {
ArrowBack, ArrowBack,
Refresh, Refresh,
@@ -305,8 +306,17 @@ export default function DiaperTrackPage() {
return ( return (
<ProtectedRoute> <ProtectedRoute>
<AppShell> <AppShell>
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}> <Box>
<CircularProgress /> <Typography variant="h4" fontWeight="600" sx={{ mb: 3 }}>
Track Diaper Change
</Typography>
<Paper sx={{ p: 3, mb: 3 }}>
<FormSkeleton />
</Paper>
<Typography variant="h6" fontWeight="600" sx={{ mb: 2 }}>
Recent Diaper Changes
</Typography>
<ActivityListSkeleton count={3} />
</Box> </Box>
</AppShell> </AppShell>
</ProtectedRoute> </ProtectedRoute>
@@ -545,9 +555,7 @@ export default function DiaperTrackPage() {
</Box> </Box>
{diapersLoading ? ( {diapersLoading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}> <ActivityListSkeleton count={3} />
<CircularProgress size={30} />
</Box>
) : recentDiapers.length === 0 ? ( ) : recentDiapers.length === 0 ? (
<Box sx={{ textAlign: 'center', py: 4 }}> <Box sx={{ textAlign: 'center', py: 4 }}>
<Typography variant="body2" color="text.secondary"> <Typography variant="body2" color="text.secondary">

View File

@@ -49,6 +49,7 @@ import { useAuth } from '@/lib/auth/AuthContext';
import { trackingApi, Activity } from '@/lib/api/tracking'; import { trackingApi, Activity } from '@/lib/api/tracking';
import { childrenApi, Child } from '@/lib/api/children'; import { childrenApi, Child } from '@/lib/api/children';
import { VoiceInputButton } from '@/components/voice/VoiceInputButton'; import { VoiceInputButton } from '@/components/voice/VoiceInputButton';
import { FormSkeleton, ActivityListSkeleton } from '@/components/common/LoadingSkeletons';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { formatDistanceToNow } from 'date-fns'; import { formatDistanceToNow } from 'date-fns';
@@ -308,8 +309,17 @@ function FeedingTrackPage() {
return ( return (
<ProtectedRoute> <ProtectedRoute>
<AppShell> <AppShell>
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}> <Box>
<CircularProgress /> <Typography variant="h4" fontWeight="600" sx={{ mb: 3 }}>
Track Feeding
</Typography>
<Paper sx={{ p: 3, mb: 3 }}>
<FormSkeleton />
</Paper>
<Typography variant="h6" fontWeight="600" sx={{ mb: 2 }}>
Recent Feedings
</Typography>
<ActivityListSkeleton count={3} />
</Box> </Box>
</AppShell> </AppShell>
</ProtectedRoute> </ProtectedRoute>

View File

@@ -24,6 +24,7 @@ import {
Chip, Chip,
Snackbar, Snackbar,
} from '@mui/material'; } from '@mui/material';
import { FormSkeleton, ActivityListSkeleton } from '@/components/common/LoadingSkeletons';
import { import {
ArrowBack, ArrowBack,
Refresh, Refresh,
@@ -320,8 +321,17 @@ export default function SleepTrackPage() {
return ( return (
<ProtectedRoute> <ProtectedRoute>
<AppShell> <AppShell>
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}> <Box>
<CircularProgress /> <Typography variant="h4" fontWeight="600" sx={{ mb: 3 }}>
Track Sleep
</Typography>
<Paper sx={{ p: 3, mb: 3 }}>
<FormSkeleton />
</Paper>
<Typography variant="h6" fontWeight="600" sx={{ mb: 2 }}>
Recent Sleep Activities
</Typography>
<ActivityListSkeleton count={3} />
</Box> </Box>
</AppShell> </AppShell>
</ProtectedRoute> </ProtectedRoute>
@@ -536,9 +546,7 @@ export default function SleepTrackPage() {
</Box> </Box>
{sleepsLoading ? ( {sleepsLoading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}> <ActivityListSkeleton count={3} />
<CircularProgress size={30} />
</Box>
) : recentSleeps.length === 0 ? ( ) : recentSleeps.length === 0 ? (
<Box sx={{ textAlign: 'center', py: 4 }}> <Box sx={{ textAlign: 'center', py: 4 }}>
<Typography variant="body2" color="text.secondary"> <Typography variant="body2" color="text.secondary">

View File

@@ -0,0 +1,316 @@
import React from 'react';
import { Box, Card, CardContent, Skeleton, Stack, Paper } from '@mui/material';
/**
* Skeleton loader for activity cards
*/
export function ActivityCardSkeleton() {
return (
<Card sx={{ mb: 2 }}>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<Skeleton variant="circular" width={40} height={40} sx={{ mr: 2 }} />
<Box sx={{ flex: 1 }}>
<Skeleton variant="text" width="60%" height={24} />
<Skeleton variant="text" width="40%" height={20} />
</Box>
</Box>
<Skeleton variant="text" width="80%" />
<Skeleton variant="text" width="50%" />
</CardContent>
</Card>
);
}
/**
* Skeleton loader for activity list
*/
export function ActivityListSkeleton({ count = 3 }: { count?: number }) {
return (
<>
{Array.from({ length: count }).map((_, index) => (
<ActivityCardSkeleton key={index} />
))}
</>
);
}
/**
* Skeleton loader for stat cards
*/
export function StatCardSkeleton() {
return (
<Paper sx={{ p: 3 }}>
<Skeleton variant="text" width="40%" height={20} sx={{ mb: 1 }} />
<Skeleton variant="text" width="60%" height={32} sx={{ mb: 1 }} />
<Skeleton variant="text" width="50%" height={16} />
</Paper>
);
}
/**
* Skeleton loader for stat grid
*/
export function StatGridSkeleton({ count = 4 }: { count?: number }) {
return (
<Box
sx={{
display: 'grid',
gridTemplateColumns: {
xs: '1fr',
sm: 'repeat(2, 1fr)',
md: 'repeat(4, 1fr)',
},
gap: 2,
mb: 3,
}}
>
{Array.from({ length: count }).map((_, index) => (
<StatCardSkeleton key={index} />
))}
</Box>
);
}
/**
* Skeleton loader for charts
*/
export function ChartSkeleton({ height = 300 }: { height?: number }) {
return (
<Paper sx={{ p: 3 }}>
<Skeleton variant="text" width="40%" height={24} sx={{ mb: 3 }} />
<Skeleton variant="rectangular" width="100%" height={height} />
<Box sx={{ display: 'flex', justifyContent: 'space-around', mt: 2 }}>
<Skeleton variant="text" width={60} />
<Skeleton variant="text" width={60} />
<Skeleton variant="text" width={60} />
<Skeleton variant="text" width={60} />
</Box>
</Paper>
);
}
/**
* Skeleton loader for child profile card
*/
export function ChildProfileSkeleton() {
return (
<Card sx={{ mb: 2 }}>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<Skeleton variant="circular" width={60} height={60} sx={{ mr: 2 }} />
<Box sx={{ flex: 1 }}>
<Skeleton variant="text" width="50%" height={28} />
<Skeleton variant="text" width="30%" height={20} />
</Box>
</Box>
<Skeleton variant="text" width="70%" />
<Skeleton variant="text" width="60%" />
</CardContent>
</Card>
);
}
/**
* Skeleton loader for form fields
*/
export function FormSkeleton() {
return (
<Stack spacing={3}>
<Box>
<Skeleton variant="text" width={100} height={20} sx={{ mb: 1 }} />
<Skeleton variant="rectangular" width="100%" height={56} />
</Box>
<Box>
<Skeleton variant="text" width={100} height={20} sx={{ mb: 1 }} />
<Skeleton variant="rectangular" width="100%" height={56} />
</Box>
<Box>
<Skeleton variant="text" width={100} height={20} sx={{ mb: 1 }} />
<Skeleton variant="rectangular" width="100%" height={56} />
</Box>
<Skeleton variant="rectangular" width={120} height={42} />
</Stack>
);
}
/**
* Skeleton loader for table rows
*/
export function TableRowSkeleton({ columns = 4 }: { columns?: number }) {
return (
<Box sx={{ display: 'flex', gap: 2, py: 2, borderBottom: 1, borderColor: 'divider' }}>
{Array.from({ length: columns }).map((_, index) => (
<Skeleton key={index} variant="text" width={`${100 / columns}%`} height={20} />
))}
</Box>
);
}
/**
* Skeleton loader for table
*/
export function TableSkeleton({ rows = 5, columns = 4 }: { rows?: number; columns?: number }) {
return (
<Paper sx={{ p: 2 }}>
{/* Header */}
<Box sx={{ display: 'flex', gap: 2, py: 2, borderBottom: 2, borderColor: 'divider', mb: 2 }}>
{Array.from({ length: columns }).map((_, index) => (
<Skeleton key={index} variant="text" width={`${100 / columns}%`} height={24} />
))}
</Box>
{/* Rows */}
{Array.from({ length: rows }).map((_, index) => (
<TableRowSkeleton key={index} columns={columns} />
))}
</Paper>
);
}
/**
* Skeleton loader for list items
*/
export function ListItemSkeleton() {
return (
<Box sx={{ display: 'flex', alignItems: 'center', py: 2, borderBottom: 1, borderColor: 'divider' }}>
<Skeleton variant="circular" width={40} height={40} sx={{ mr: 2 }} />
<Box sx={{ flex: 1 }}>
<Skeleton variant="text" width="70%" height={20} />
<Skeleton variant="text" width="40%" height={16} />
</Box>
<Skeleton variant="rectangular" width={80} height={32} />
</Box>
);
}
/**
* Skeleton loader for list
*/
export function ListSkeleton({ count = 5 }: { count?: number }) {
return (
<Paper sx={{ p: 2 }}>
{Array.from({ length: count }).map((_, index) => (
<ListItemSkeleton key={index} />
))}
</Paper>
);
}
/**
* Skeleton loader for dashboard summary
*/
export function DashboardSummarySkeleton() {
return (
<Box>
{/* Header */}
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 3 }}>
<Skeleton variant="text" width={200} height={40} />
<Skeleton variant="rectangular" width={120} height={40} />
</Box>
{/* Stats Grid */}
<StatGridSkeleton count={4} />
{/* Recent Activities */}
<Paper sx={{ p: 3, mb: 3 }}>
<Skeleton variant="text" width={150} height={28} sx={{ mb: 2 }} />
<ActivityListSkeleton count={3} />
</Paper>
{/* Charts */}
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', md: 'repeat(2, 1fr)' }, gap: 3 }}>
<ChartSkeleton height={250} />
<ChartSkeleton height={250} />
</Box>
</Box>
);
}
/**
* Skeleton loader for chat messages
*/
export function ChatMessageSkeleton({ isUser = false }: { isUser?: boolean }) {
return (
<Box
sx={{
display: 'flex',
justifyContent: isUser ? 'flex-end' : 'flex-start',
mb: 2,
}}
>
{!isUser && <Skeleton variant="circular" width={40} height={40} sx={{ mr: 2 }} />}
<Box sx={{ maxWidth: '70%' }}>
<Skeleton variant="rounded" width={200} height={60} />
<Skeleton variant="text" width={100} height={16} sx={{ mt: 0.5 }} />
</Box>
{isUser && <Skeleton variant="circular" width={40} height={40} sx={{ ml: 2 }} />}
</Box>
);
}
/**
* Skeleton loader for chat conversation
*/
export function ChatSkeleton({ messageCount = 4 }: { messageCount?: number }) {
return (
<Box sx={{ p: 2 }}>
{Array.from({ length: messageCount }).map((_, index) => (
<ChatMessageSkeleton key={index} isUser={index % 2 === 0} />
))}
</Box>
);
}
/**
* Skeleton loader for page content
*/
export function PageSkeleton() {
return (
<Box>
{/* Page header */}
<Box sx={{ display: 'flex', alignItems: 'center', mb: 3 }}>
<Skeleton variant="circular" width={40} height={40} sx={{ mr: 2 }} />
<Skeleton variant="text" width={250} height={40} />
</Box>
{/* Content */}
<Paper sx={{ p: 3 }}>
<Skeleton variant="text" width="60%" height={28} sx={{ mb: 2 }} />
<Skeleton variant="text" width="100%" />
<Skeleton variant="text" width="100%" />
<Skeleton variant="text" width="80%" sx={{ mb: 3 }} />
<Skeleton variant="rectangular" width="100%" height={200} sx={{ mb: 2 }} />
<Skeleton variant="text" width="100%" />
<Skeleton variant="text" width="90%" />
</Paper>
</Box>
);
}
/**
* Full page loading overlay with spinner
*/
export function LoadingOverlay({ message = 'Loading...' }: { message?: string }) {
return (
<Box
sx={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
bgcolor: 'rgba(255, 255, 255, 0.9)',
zIndex: 9999,
}}
>
<Skeleton variant="circular" width={60} height={60} sx={{ mb: 2 }} />
<Skeleton variant="text" width={150} height={24} />
</Box>
);
}