diff --git a/maternal-web/app/ai-assistant/page.tsx b/maternal-web/app/ai-assistant/page.tsx index e60eeb1..94876d1 100644 --- a/maternal-web/app/ai-assistant/page.tsx +++ b/maternal-web/app/ai-assistant/page.tsx @@ -4,6 +4,8 @@ import { lazy, Suspense } from 'react'; import { AppShell } from '@/components/layouts/AppShell/AppShell'; import { ProtectedRoute } from '@/components/common/ProtectedRoute'; import { LoadingFallback } from '@/components/common/LoadingFallback'; +import { ErrorBoundary } from '@/components/common/ErrorBoundary'; +import { ComponentErrorFallback } from '@/components/common/ErrorFallbacks'; // Lazy load the AI chat interface component const AIChatInterface = lazy(() => @@ -16,9 +18,14 @@ export default function AIAssistantPage() { return ( - }> - - + } + > + }> + + + ); diff --git a/maternal-web/app/analytics/page.tsx b/maternal-web/app/analytics/page.tsx index 238d0ee..7dce600 100644 --- a/maternal-web/app/analytics/page.tsx +++ b/maternal-web/app/analytics/page.tsx @@ -16,6 +16,8 @@ import { } from '@mui/material'; import { AppShell } from '@/components/layouts/AppShell/AppShell'; import { ProtectedRoute } from '@/components/common/ProtectedRoute'; +import { ErrorBoundary } from '@/components/common/ErrorBoundary'; +import { ChartErrorFallback } from '@/components/common/ErrorFallbacks'; import { TrendingUp, Hotel, @@ -232,21 +234,27 @@ export default function AnalyticsPage() { - - - + }> + + + + - - - + }> + + + + - - - + }> + + + + diff --git a/maternal-web/app/layout.tsx b/maternal-web/app/layout.tsx index cbdd359..6f2a289 100644 --- a/maternal-web/app/layout.tsx +++ b/maternal-web/app/layout.tsx @@ -1,6 +1,7 @@ import type { Metadata } from 'next'; import { Inter } from 'next/font/google'; import { ThemeRegistry } from '@/components/ThemeRegistry'; +import { ErrorBoundary } from '@/components/common/ErrorBoundary'; // import { PerformanceMonitor } from '@/components/common/PerformanceMonitor'; // Temporarily disabled import './globals.css'; @@ -37,10 +38,12 @@ export default function RootLayout({ - - {/* */} - {children} - + + + {/* */} + {children} + + ); diff --git a/maternal-web/app/page.tsx b/maternal-web/app/page.tsx index 76ca99c..4b18070 100644 --- a/maternal-web/app/page.tsx +++ b/maternal-web/app/page.tsx @@ -5,6 +5,9 @@ import { Box, Typography, Button, Paper, Grid, CircularProgress } from '@mui/mat 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 { Restaurant, Hotel, @@ -84,6 +87,7 @@ export default function HomePage() { return ( + @@ -139,59 +143,64 @@ export default function HomePage() { Today's Summary{selectedChild ? ` - ${selectedChild.name}` : ''} - - {loading ? ( - - - - ) : !dailySummary ? ( - - - {children.length === 0 - ? 'Add a child to start tracking' - : 'No activities tracked today'} - - - ) : ( - - - - - - {dailySummary.feedingCount || 0} - - - Feedings - - + } + > + + {loading ? ( + + + + ) : !dailySummary ? ( + + + {children.length === 0 + ? 'Add a child to start tracking' + : 'No activities tracked today'} + + + ) : ( + + + + + + {dailySummary.feedingCount || 0} + + + Feedings + + + + + + + + {dailySummary.sleepTotalMinutes + ? formatSleepHours(dailySummary.sleepTotalMinutes) + : '0m'} + + + Sleep + + + + + + + + {dailySummary.diaperCount || 0} + + + Diapers + + + - - - - - {dailySummary.sleepTotalMinutes - ? formatSleepHours(dailySummary.sleepTotalMinutes) - : '0m'} - - - Sleep - - - - - - - - {dailySummary.diaperCount || 0} - - - Diapers - - - - - )} - + )} + + {/* Next Predicted Activity */} diff --git a/maternal-web/app/track/feeding/page.tsx b/maternal-web/app/track/feeding/page.tsx index 1a1ccc7..14cd3b1 100644 --- a/maternal-web/app/track/feeding/page.tsx +++ b/maternal-web/app/track/feeding/page.tsx @@ -44,6 +44,7 @@ import { import { useRouter } from 'next/navigation'; import { AppShell } from '@/components/layouts/AppShell/AppShell'; import { ProtectedRoute } from '@/components/common/ProtectedRoute'; +import { withErrorBoundary } from '@/components/common/ErrorFallbacks'; import { useAuth } from '@/lib/auth/AuthContext'; import { trackingApi, Activity } from '@/lib/api/tracking'; import { childrenApi, Child } from '@/lib/api/children'; @@ -60,7 +61,7 @@ interface FeedingData { amountDescription?: string; } -export default function FeedingTrackPage() { +function FeedingTrackPage() { const router = useRouter(); const { user } = useAuth(); const [children, setChildren] = useState([]); @@ -654,3 +655,5 @@ export default function FeedingTrackPage() { ); } + +export default withErrorBoundary(FeedingTrackPage, 'form'); diff --git a/maternal-web/components/common/ErrorBoundary.tsx b/maternal-web/components/common/ErrorBoundary.tsx new file mode 100644 index 0000000..d22608c --- /dev/null +++ b/maternal-web/components/common/ErrorBoundary.tsx @@ -0,0 +1,209 @@ +'use client'; + +import React, { Component, ReactNode } from 'react'; +import { Box, Button, Typography, Paper, Alert } from '@mui/material'; +import { ErrorOutline, Refresh, Home } from '@mui/icons-material'; +import errorLogger, { ErrorSeverity } from '@/lib/services/errorLogger'; + +interface Props { + children: ReactNode; + fallback?: ReactNode; + onError?: (error: Error, errorInfo: React.ErrorInfo) => void; + isolate?: boolean; // If true, only this section fails, not the whole app +} + +interface State { + hasError: boolean; + error: Error | null; + errorInfo: React.ErrorInfo | null; + errorCount: number; +} + +/** + * Error Boundary component that catches JavaScript errors in child components + * Displays fallback UI and logs errors for monitoring + */ +export class ErrorBoundary extends Component { + constructor(props: Props) { + super(props); + this.state = { + hasError: false, + error: null, + errorInfo: null, + errorCount: 0, + }; + } + + static getDerivedStateFromError(error: Error): Partial { + // Update state so the next render will show the fallback UI + return { + hasError: true, + error, + }; + } + + componentDidCatch(error: Error, errorInfo: React.ErrorInfo) { + // Log error to console in development + if (process.env.NODE_ENV === 'development') { + console.error('ErrorBoundary caught an error:', error, errorInfo); + } + + // Update state with error details + this.setState((prevState) => ({ + errorInfo, + errorCount: prevState.errorCount + 1, + })); + + // Call custom error handler if provided + if (this.props.onError) { + this.props.onError(error, errorInfo); + } + + // Log to error tracking service (e.g., Sentry) + this.logErrorToService(error, errorInfo); + } + + logErrorToService = (error: Error, errorInfo: React.ErrorInfo) => { + // Log to error tracking service + errorLogger.logError( + error, + { + componentStack: errorInfo.componentStack, + errorBoundary: this.props.isolate ? 'isolated' : 'global', + }, + ErrorSeverity.ERROR + ); + }; + + handleReset = () => { + this.setState({ + hasError: false, + error: null, + errorInfo: null, + }); + }; + + handleGoHome = () => { + window.location.href = '/'; + }; + + render() { + if (this.state.hasError) { + // Custom fallback UI if provided + if (this.props.fallback) { + return this.props.fallback; + } + + // Default fallback UI + return ( + + + + + + Oops! Something went wrong + + + + {this.props.isolate + ? 'This section encountered an error. The rest of the app should still work.' + : "We're sorry for the inconvenience. The error has been logged and we'll look into it."} + + + {process.env.NODE_ENV === 'development' && this.state.error && ( + + + {this.state.error.message} + + + {this.state.error.stack} + + + )} + + + + + {!this.props.isolate && ( + + )} + + + {this.state.errorCount > 1 && ( + + This error has occurred {this.state.errorCount} times. You may want to refresh the + entire page or contact support. + + )} + + + ); + } + + return this.props.children; + } +} + +/** + * Hook-based error boundary (for functional components) + * Note: This is a workaround since React doesn't have hook-based error boundaries yet + */ +export function useErrorHandler(error?: Error) { + const [, setError] = React.useState(); + + return React.useCallback( + (error: Error) => { + setError(() => { + throw error; + }); + }, + [setError] + ); +} diff --git a/maternal-web/components/common/ErrorFallbacks.tsx b/maternal-web/components/common/ErrorFallbacks.tsx new file mode 100644 index 0000000..fec9f4c --- /dev/null +++ b/maternal-web/components/common/ErrorFallbacks.tsx @@ -0,0 +1,236 @@ +'use client'; + +import { Box, Button, Typography, Paper } from '@mui/material'; +import { BrokenImage, CloudOff, BugReport, DataObject } from '@mui/icons-material'; +import { ReactNode } from 'react'; + +interface FallbackProps { + error?: Error; + resetError?: () => void; +} + +/** + * Generic minimal error fallback + */ +export function MinimalErrorFallback({ error, resetError }: FallbackProps) { + return ( + + + Something went wrong + + {resetError && ( + + )} + + ); +} + +/** + * Fallback for image loading errors + */ +export function ImageErrorFallback() { + return ( + + + + ); +} + +/** + * Fallback for API/data fetching errors + */ +export function DataErrorFallback({ error, resetError }: FallbackProps) { + return ( + + + + Failed to Load Data + + + {error?.message || 'Unable to fetch the requested data. Please try again.'} + + {resetError && ( + + )} + + ); +} + +/** + * Fallback for rendering errors in components + */ +export function ComponentErrorFallback({ error, resetError }: FallbackProps) { + return ( + + + + + Component Error + + + + This component failed to render. The rest of the page should still work. + + {process.env.NODE_ENV === 'development' && error && ( + + {error.message} + + )} + {resetError && ( + + )} + + ); +} + +/** + * Fallback for form errors + */ +export function FormErrorFallback({ error, resetError }: FallbackProps) { + return ( + + + Form Submission Error + + + {error?.message || 'Unable to process your form. Please try again.'} + + {resetError && ( + + )} + + ); +} + +/** + * Fallback for data visualization/chart errors + */ +export function ChartErrorFallback({ resetError }: FallbackProps) { + return ( + + + + Unable to display chart + + {resetError && ( + + )} + + ); +} + +/** + * Wrapper component to easily apply error boundary with custom fallback + */ +interface ErrorBoundaryWrapperProps { + children: ReactNode; + fallbackType?: 'minimal' | 'component' | 'data' | 'form' | 'chart'; + customFallback?: ReactNode; +} + +export function withErrorBoundary

( + Component: React.ComponentType

, + fallbackType: ErrorBoundaryWrapperProps['fallbackType'] = 'component' +) { + return function WithErrorBoundaryWrapper(props: P) { + const { ErrorBoundary } = require('./ErrorBoundary'); + + const getFallback = (error: Error, resetError: () => void) => { + switch (fallbackType) { + case 'minimal': + return ; + case 'data': + return ; + case 'form': + return ; + case 'chart': + return ; + case 'component': + default: + return ; + } + }; + + return ( + void) => getFallback(error, reset)}> + + + ); + }; +} diff --git a/maternal-web/lib/services/errorLogger.ts b/maternal-web/lib/services/errorLogger.ts new file mode 100644 index 0000000..f74fb82 --- /dev/null +++ b/maternal-web/lib/services/errorLogger.ts @@ -0,0 +1,253 @@ +/** + * Error Logging Service + * Centralized error logging that can integrate with Sentry or other services + */ + +export interface ErrorContext { + componentStack?: string; + errorBoundary?: string; + userId?: string; + userEmail?: string; + url?: string; + userAgent?: string; + timestamp?: string; + [key: string]: any; +} + +export enum ErrorSeverity { + FATAL = 'fatal', + ERROR = 'error', + WARNING = 'warning', + INFO = 'info', + DEBUG = 'debug', +} + +class ErrorLogger { + private enabled: boolean; + private environment: string; + + constructor() { + this.enabled = process.env.NEXT_PUBLIC_SENTRY_ENABLED === 'true'; + this.environment = process.env.NODE_ENV || 'development'; + } + + /** + * Initialize error logging service (e.g., Sentry) + */ + init() { + if (!this.enabled) { + console.log('[ErrorLogger] Error tracking disabled'); + return; + } + + // TODO: Initialize Sentry + // import * as Sentry from '@sentry/nextjs'; + // Sentry.init({ + // dsn: process.env.NEXT_PUBLIC_SENTRY_DSN, + // environment: this.environment, + // tracesSampleRate: 0.1, + // beforeSend(event) { + // // Filter out sensitive data + // return event; + // }, + // }); + + console.log('[ErrorLogger] Error tracking initialized'); + } + + /** + * Log an error with context + */ + logError(error: Error, context?: ErrorContext, severity: ErrorSeverity = ErrorSeverity.ERROR) { + const enrichedContext = this.enrichContext(context); + + // Log to console in development + if (this.environment === 'development') { + console.error(`[ErrorLogger] ${severity.toUpperCase()}:`, error.message, { + stack: error.stack, + context: enrichedContext, + }); + } + + // Send to error tracking service + if (this.enabled) { + this.sendToService(error, enrichedContext, severity); + } + + // Store locally for debugging + this.storeLocally(error, enrichedContext, severity); + } + + /** + * Log an exception (wrapper for logError) + */ + captureException(error: Error, context?: ErrorContext) { + this.logError(error, context, ErrorSeverity.ERROR); + } + + /** + * Log a message (non-error logging) + */ + captureMessage(message: string, context?: ErrorContext, severity: ErrorSeverity = ErrorSeverity.INFO) { + const enrichedContext = this.enrichContext(context); + + if (this.environment === 'development') { + console.log(`[ErrorLogger] ${severity.toUpperCase()}: ${message}`, enrichedContext); + } + + if (this.enabled) { + // TODO: Send to Sentry as message + // Sentry.captureMessage(message, { + // level: severity, + // contexts: { custom: enrichedContext }, + // }); + } + } + + /** + * Add breadcrumb for debugging + */ + addBreadcrumb(message: string, category?: string, data?: Record) { + if (this.environment === 'development') { + console.log(`[ErrorLogger] Breadcrumb: ${category || 'default'} - ${message}`, data); + } + + if (this.enabled) { + // TODO: Add to Sentry + // Sentry.addBreadcrumb({ + // message, + // category, + // data, + // timestamp: Date.now() / 1000, + // }); + } + } + + /** + * Set user context for error tracking + */ + setUser(user: { id: string; email?: string; name?: string }) { + if (this.enabled) { + // TODO: Set in Sentry + // Sentry.setUser({ + // id: user.id, + // email: user.email, + // username: user.name, + // }); + } + } + + /** + * Clear user context (e.g., on logout) + */ + clearUser() { + if (this.enabled) { + // TODO: Clear in Sentry + // Sentry.setUser(null); + } + } + + /** + * Set additional context tags + */ + setTag(key: string, value: string) { + if (this.enabled) { + // TODO: Set in Sentry + // Sentry.setTag(key, value); + } + } + + /** + * Enrich context with additional information + */ + private enrichContext(context?: ErrorContext): ErrorContext { + return { + ...context, + url: typeof window !== 'undefined' ? window.location.href : undefined, + userAgent: typeof navigator !== 'undefined' ? navigator.userAgent : undefined, + timestamp: new Date().toISOString(), + environment: this.environment, + // Add app version if available + appVersion: process.env.NEXT_PUBLIC_APP_VERSION, + }; + } + + /** + * Send error to tracking service + */ + private sendToService(error: Error, context: ErrorContext, severity: ErrorSeverity) { + // TODO: Implement Sentry integration + // Sentry.captureException(error, { + // level: severity, + // contexts: { + // custom: context, + // }, + // tags: { + // errorBoundary: context.errorBoundary, + // }, + // }); + } + + /** + * Store error locally for debugging + */ + private storeLocally(error: Error, context: ErrorContext, severity: ErrorSeverity) { + if (typeof window === 'undefined') return; + + try { + const errorLog = { + message: error.message, + stack: error.stack, + severity, + context, + timestamp: new Date().toISOString(), + }; + + // Store in sessionStorage (limited to 10 most recent errors) + const storedErrors = JSON.parse(sessionStorage.getItem('error_logs') || '[]'); + storedErrors.push(errorLog); + + // Keep only last 10 errors + if (storedErrors.length > 10) { + storedErrors.shift(); + } + + sessionStorage.setItem('error_logs', JSON.stringify(storedErrors)); + } catch (e) { + // Ignore storage errors + console.error('Failed to store error locally:', e); + } + } + + /** + * Get locally stored errors (for debugging) + */ + getLocalErrors() { + if (typeof window === 'undefined') return []; + + try { + return JSON.parse(sessionStorage.getItem('error_logs') || '[]'); + } catch (e) { + return []; + } + } + + /** + * Clear locally stored errors + */ + clearLocalErrors() { + if (typeof window !== 'undefined') { + sessionStorage.removeItem('error_logs'); + } + } +} + +// Export singleton instance +export const errorLogger = new ErrorLogger(); + +// Initialize on import +if (typeof window !== 'undefined') { + errorLogger.init(); +} + +export default errorLogger;