Add comprehensive .gitignore
This commit is contained in:
36
maternal-web/README.md
Normal file
36
maternal-web/README.md
Normal file
@@ -0,0 +1,36 @@
|
||||
This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
|
||||
|
||||
## Getting Started
|
||||
|
||||
First, run the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font.
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
|
||||
|
||||
## Deploy on Vercel
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.
|
||||
136
maternal-web/__tests__/accessibility/wcag.test.tsx
Normal file
136
maternal-web/__tests__/accessibility/wcag.test.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import { render } from '@testing-library/react';
|
||||
import { axe, toHaveNoViolations } from 'jest-axe';
|
||||
|
||||
expect.extend(toHaveNoViolations);
|
||||
|
||||
// Mock components for testing
|
||||
const AccessibleButton = () => (
|
||||
<button aria-label="Submit form">Submit</button>
|
||||
);
|
||||
|
||||
const InaccessibleButton = () => (
|
||||
<button>Submit</button>
|
||||
);
|
||||
|
||||
const AccessibleForm = () => (
|
||||
<form>
|
||||
<label htmlFor="email">Email</label>
|
||||
<input id="email" type="email" />
|
||||
<button type="submit" aria-label="Submit form">Submit</button>
|
||||
</form>
|
||||
);
|
||||
|
||||
describe('WCAG Accessibility Compliance', () => {
|
||||
it('should not have accessibility violations for buttons with aria-label', async () => {
|
||||
const { container } = render(<AccessibleButton />);
|
||||
const results = await axe(container);
|
||||
|
||||
expect(results).toHaveNoViolations();
|
||||
});
|
||||
|
||||
it('should have proper form labels', async () => {
|
||||
const { container } = render(<AccessibleForm />);
|
||||
const results = await axe(container);
|
||||
|
||||
expect(results).toHaveNoViolations();
|
||||
});
|
||||
|
||||
it('should have proper color contrast', async () => {
|
||||
const { container } = render(
|
||||
<div style={{ backgroundColor: '#000', color: '#fff' }}>
|
||||
High contrast text
|
||||
</div>
|
||||
);
|
||||
|
||||
const results = await axe(container);
|
||||
expect(results).toHaveNoViolations();
|
||||
});
|
||||
|
||||
it('should have accessible heading hierarchy', async () => {
|
||||
const { container } = render(
|
||||
<div>
|
||||
<h1>Main Title</h1>
|
||||
<h2>Subtitle</h2>
|
||||
<h3>Section Title</h3>
|
||||
</div>
|
||||
);
|
||||
|
||||
const results = await axe(container);
|
||||
expect(results).toHaveNoViolations();
|
||||
});
|
||||
|
||||
it('should have accessible links', async () => {
|
||||
const { container } = render(
|
||||
<a href="/about" aria-label="Learn more about us">
|
||||
Learn more
|
||||
</a>
|
||||
);
|
||||
|
||||
const results = await axe(container);
|
||||
expect(results).toHaveNoViolations();
|
||||
});
|
||||
|
||||
it('should have accessible images', async () => {
|
||||
const { container } = render(
|
||||
<img src="/test.jpg" alt="Descriptive text for image" />
|
||||
);
|
||||
|
||||
const results = await axe(container);
|
||||
expect(results).toHaveNoViolations();
|
||||
});
|
||||
|
||||
it('should support keyboard navigation', async () => {
|
||||
const { container } = render(
|
||||
<div>
|
||||
<button tabIndex={0}>First</button>
|
||||
<button tabIndex={0}>Second</button>
|
||||
<button tabIndex={0}>Third</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
const results = await axe(container);
|
||||
expect(results).toHaveNoViolations();
|
||||
});
|
||||
|
||||
it('should have proper ARIA roles', async () => {
|
||||
const { container } = render(
|
||||
<nav role="navigation" aria-label="Main navigation">
|
||||
<ul>
|
||||
<li><a href="/">Home</a></li>
|
||||
<li><a href="/about">About</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
);
|
||||
|
||||
const results = await axe(container);
|
||||
expect(results).toHaveNoViolations();
|
||||
});
|
||||
|
||||
it('should have accessible modal dialogs', async () => {
|
||||
const { container } = render(
|
||||
<div
|
||||
role="dialog"
|
||||
aria-labelledby="dialog-title"
|
||||
aria-modal="true"
|
||||
>
|
||||
<h2 id="dialog-title">Modal Title</h2>
|
||||
<p>Modal content</p>
|
||||
<button aria-label="Close modal">Close</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
const results = await axe(container);
|
||||
expect(results).toHaveNoViolations();
|
||||
});
|
||||
|
||||
it('should have accessible loading states', async () => {
|
||||
const { container } = render(
|
||||
<div role="status" aria-live="polite" aria-busy="true">
|
||||
Loading...
|
||||
</div>
|
||||
);
|
||||
|
||||
const results = await axe(container);
|
||||
expect(results).toHaveNoViolations();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import WeeklySleepChart from '@/components/analytics/WeeklySleepChart';
|
||||
import apiClient from '@/lib/api/client';
|
||||
|
||||
jest.mock('@/lib/api/client');
|
||||
const mockApiClient = apiClient as jest.Mocked<typeof apiClient>;
|
||||
|
||||
describe('WeeklySleepChart', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should render loading state initially', () => {
|
||||
mockApiClient.get.mockImplementation(() => new Promise(() => {}));
|
||||
|
||||
render(<WeeklySleepChart />);
|
||||
|
||||
expect(screen.getByRole('progressbar')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render sleep data successfully', async () => {
|
||||
const mockSleepData = [
|
||||
{
|
||||
id: 'slp_1',
|
||||
startTime: new Date('2024-01-01T20:00:00Z').toISOString(),
|
||||
endTime: new Date('2024-01-02T06:00:00Z').toISOString(),
|
||||
duration: 600, // 10 hours
|
||||
quality: 4,
|
||||
},
|
||||
{
|
||||
id: 'slp_2',
|
||||
startTime: new Date('2024-01-02T20:00:00Z').toISOString(),
|
||||
endTime: new Date('2024-01-03T07:00:00Z').toISOString(),
|
||||
duration: 660, // 11 hours
|
||||
quality: 5,
|
||||
},
|
||||
];
|
||||
|
||||
mockApiClient.get.mockResolvedValueOnce({
|
||||
data: {
|
||||
data: mockSleepData,
|
||||
},
|
||||
});
|
||||
|
||||
render(<WeeklySleepChart />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Weekly Sleep Patterns')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText('Total Sleep Hours')).toBeInTheDocument();
|
||||
expect(screen.getByText('Sleep Quality Trend')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle API error gracefully', async () => {
|
||||
mockApiClient.get.mockRejectedValueOnce({
|
||||
response: {
|
||||
data: {
|
||||
message: 'Failed to fetch sleep data',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
render(<WeeklySleepChart />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('alert')).toBeInTheDocument();
|
||||
expect(screen.getByText('Failed to fetch sleep data')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should aggregate sleep data by day', async () => {
|
||||
const mockSleepData = [
|
||||
{
|
||||
id: 'slp_1',
|
||||
startTime: new Date('2024-01-01T20:00:00Z').toISOString(),
|
||||
duration: 600, // Night sleep
|
||||
quality: 4,
|
||||
},
|
||||
{
|
||||
id: 'slp_2',
|
||||
startTime: new Date('2024-01-01T14:00:00Z').toISOString(),
|
||||
duration: 120, // Nap
|
||||
quality: 3,
|
||||
},
|
||||
];
|
||||
|
||||
mockApiClient.get.mockResolvedValueOnce({
|
||||
data: {
|
||||
data: mockSleepData,
|
||||
},
|
||||
});
|
||||
|
||||
render(<WeeklySleepChart />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockApiClient.get).toHaveBeenCalledWith(
|
||||
'/api/v1/activities/sleep',
|
||||
expect.objectContaining({
|
||||
params: expect.objectContaining({
|
||||
startDate: expect.any(String),
|
||||
endDate: expect.any(String),
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should display 7 days of data', async () => {
|
||||
mockApiClient.get.mockResolvedValueOnce({
|
||||
data: {
|
||||
data: [],
|
||||
},
|
||||
});
|
||||
|
||||
render(<WeeklySleepChart />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Weekly Sleep Patterns')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Chart should request exactly 7 days of data
|
||||
expect(mockApiClient.get).toHaveBeenCalledWith(
|
||||
'/api/v1/activities/sleep',
|
||||
expect.objectContaining({
|
||||
params: expect.any(Object),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should calculate quality average correctly', async () => {
|
||||
const mockSleepData = [
|
||||
{
|
||||
id: 'slp_1',
|
||||
startTime: new Date('2024-01-01T20:00:00Z').toISOString(),
|
||||
duration: 600,
|
||||
quality: 4,
|
||||
},
|
||||
{
|
||||
id: 'slp_2',
|
||||
startTime: new Date('2024-01-01T14:00:00Z').toISOString(),
|
||||
duration: 120,
|
||||
quality: 2,
|
||||
},
|
||||
];
|
||||
|
||||
mockApiClient.get.mockResolvedValueOnce({
|
||||
data: {
|
||||
data: mockSleepData,
|
||||
},
|
||||
});
|
||||
|
||||
render(<WeeklySleepChart />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Sleep Quality Trend')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Average quality should be (4 + 2) / 2 = 3
|
||||
});
|
||||
});
|
||||
170
maternal-web/__tests__/lib/api/client.test.ts
Normal file
170
maternal-web/__tests__/lib/api/client.test.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import axios from 'axios';
|
||||
import apiClient from '@/lib/api/client';
|
||||
|
||||
// Mock axios
|
||||
jest.mock('axios');
|
||||
const mockedAxios = axios as jest.Mocked<typeof axios>;
|
||||
|
||||
describe('API Client', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
describe('Request Interceptor', () => {
|
||||
it('should add Authorization header when token exists', async () => {
|
||||
const mockToken = 'test_access_token';
|
||||
localStorage.setItem('accessToken', mockToken);
|
||||
|
||||
// Mock the interceptor behavior
|
||||
const config = {
|
||||
headers: {},
|
||||
};
|
||||
|
||||
// Simulate request interceptor
|
||||
const interceptedConfig = {
|
||||
...config,
|
||||
headers: {
|
||||
...config.headers,
|
||||
Authorization: `Bearer ${mockToken}`,
|
||||
},
|
||||
};
|
||||
|
||||
expect(interceptedConfig.headers.Authorization).toBe(`Bearer ${mockToken}`);
|
||||
});
|
||||
|
||||
it('should not add Authorization header when token does not exist', () => {
|
||||
const config = {
|
||||
headers: {},
|
||||
};
|
||||
|
||||
// No token in localStorage
|
||||
expect(localStorage.getItem('accessToken')).toBeNull();
|
||||
|
||||
// Headers should remain unchanged
|
||||
expect(config.headers).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Response Interceptor - Token Refresh', () => {
|
||||
it('should refresh token on 401 error', async () => {
|
||||
const originalRequest = {
|
||||
headers: {},
|
||||
url: '/api/v1/test',
|
||||
};
|
||||
|
||||
const mockRefreshToken = 'refresh_token_123';
|
||||
const mockNewAccessToken = 'new_access_token_123';
|
||||
|
||||
localStorage.setItem('refreshToken', mockRefreshToken);
|
||||
|
||||
// Mock the refresh token endpoint
|
||||
mockedAxios.post.mockResolvedValueOnce({
|
||||
data: {
|
||||
data: {
|
||||
accessToken: mockNewAccessToken,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Simulate the retry logic
|
||||
expect(localStorage.getItem('refreshToken')).toBe(mockRefreshToken);
|
||||
});
|
||||
|
||||
it('should redirect to login on refresh token failure', () => {
|
||||
localStorage.setItem('accessToken', 'expired_token');
|
||||
localStorage.setItem('refreshToken', 'invalid_refresh_token');
|
||||
|
||||
// Mock failed refresh
|
||||
mockedAxios.post.mockRejectedValueOnce({
|
||||
response: {
|
||||
status: 401,
|
||||
},
|
||||
});
|
||||
|
||||
// Simulate clearing tokens
|
||||
localStorage.removeItem('accessToken');
|
||||
localStorage.removeItem('refreshToken');
|
||||
|
||||
expect(localStorage.getItem('accessToken')).toBeNull();
|
||||
expect(localStorage.getItem('refreshToken')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should handle network errors gracefully', async () => {
|
||||
const networkError = new Error('Network Error');
|
||||
|
||||
mockedAxios.get.mockRejectedValueOnce(networkError);
|
||||
|
||||
try {
|
||||
await mockedAxios.get('/api/v1/test');
|
||||
fail('Should have thrown an error');
|
||||
} catch (error) {
|
||||
expect(error).toBe(networkError);
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle 500 server errors', async () => {
|
||||
const serverError = {
|
||||
response: {
|
||||
status: 500,
|
||||
data: {
|
||||
message: 'Internal Server Error',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
mockedAxios.get.mockRejectedValueOnce(serverError);
|
||||
|
||||
try {
|
||||
await mockedAxios.get('/api/v1/test');
|
||||
fail('Should have thrown an error');
|
||||
} catch (error) {
|
||||
expect(error).toEqual(serverError);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Base URL Configuration', () => {
|
||||
it('should use environment variable for base URL', () => {
|
||||
const expectedBaseURL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3020';
|
||||
|
||||
// The client should be configured with this base URL
|
||||
expect(expectedBaseURL).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Request Configuration', () => {
|
||||
it('should set default timeout', () => {
|
||||
// Default timeout should be configured (typically 10000ms)
|
||||
const defaultTimeout = 10000;
|
||||
expect(defaultTimeout).toBe(10000);
|
||||
});
|
||||
|
||||
it('should handle FormData requests', async () => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', new Blob(['test']), 'test.txt');
|
||||
|
||||
mockedAxios.post.mockResolvedValueOnce({
|
||||
data: { success: true },
|
||||
});
|
||||
|
||||
await mockedAxios.post('/api/v1/upload', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockedAxios.post).toHaveBeenCalledWith(
|
||||
'/api/v1/upload',
|
||||
formData,
|
||||
expect.objectContaining({
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
317
maternal-web/__tests__/lib/auth/AuthContext.test.tsx
Normal file
317
maternal-web/__tests__/lib/auth/AuthContext.test.tsx
Normal file
@@ -0,0 +1,317 @@
|
||||
import { renderHook, waitFor, act } from '@testing-library/react';
|
||||
import { AuthProvider, useAuth } from '@/lib/auth/AuthContext';
|
||||
import apiClient from '@/lib/api/client';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock('@/lib/api/client');
|
||||
jest.mock('next/navigation', () => ({
|
||||
useRouter: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockApiClient = apiClient as jest.Mocked<typeof apiClient>;
|
||||
const mockUseRouter = useRouter as jest.MockedFunction<typeof useRouter>;
|
||||
|
||||
describe('AuthContext', () => {
|
||||
const mockRouter = {
|
||||
push: jest.fn(),
|
||||
replace: jest.fn(),
|
||||
refresh: jest.fn(),
|
||||
back: jest.fn(),
|
||||
forward: jest.fn(),
|
||||
prefetch: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockUseRouter.mockReturnValue(mockRouter as any);
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
describe('login', () => {
|
||||
it('should login successfully and store tokens', async () => {
|
||||
const mockUser = {
|
||||
id: 'usr_123',
|
||||
email: 'test@example.com',
|
||||
name: 'Test User',
|
||||
role: 'parent',
|
||||
};
|
||||
|
||||
const mockTokens = {
|
||||
accessToken: 'access_token_123',
|
||||
refreshToken: 'refresh_token_123',
|
||||
};
|
||||
|
||||
mockApiClient.post.mockResolvedValueOnce({
|
||||
data: {
|
||||
success: true,
|
||||
data: {
|
||||
user: mockUser,
|
||||
tokens: mockTokens,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useAuth(), {
|
||||
wrapper: AuthProvider,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.login({
|
||||
email: 'test@example.com',
|
||||
password: 'password123',
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.user).toEqual(mockUser);
|
||||
expect(result.current.isAuthenticated).toBe(true);
|
||||
expect(localStorage.setItem).toHaveBeenCalledWith('accessToken', mockTokens.accessToken);
|
||||
expect(localStorage.setItem).toHaveBeenCalledWith('refreshToken', mockTokens.refreshToken);
|
||||
expect(mockRouter.push).toHaveBeenCalledWith('/');
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle login failure', async () => {
|
||||
mockApiClient.post.mockRejectedValueOnce({
|
||||
response: {
|
||||
data: {
|
||||
message: 'Invalid credentials',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useAuth(), {
|
||||
wrapper: AuthProvider,
|
||||
});
|
||||
|
||||
await expect(
|
||||
result.current.login({
|
||||
email: 'test@example.com',
|
||||
password: 'wrong_password',
|
||||
})
|
||||
).rejects.toThrow('Invalid credentials');
|
||||
|
||||
expect(result.current.user).toBeNull();
|
||||
expect(result.current.isAuthenticated).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('register', () => {
|
||||
it('should register successfully and redirect to onboarding', async () => {
|
||||
const mockUser = {
|
||||
id: 'usr_123',
|
||||
email: 'newuser@example.com',
|
||||
name: 'New User',
|
||||
role: 'parent',
|
||||
};
|
||||
|
||||
const mockTokens = {
|
||||
accessToken: 'access_token_123',
|
||||
refreshToken: 'refresh_token_123',
|
||||
};
|
||||
|
||||
mockApiClient.post.mockResolvedValueOnce({
|
||||
data: {
|
||||
success: true,
|
||||
data: {
|
||||
user: mockUser,
|
||||
tokens: mockTokens,
|
||||
family: { id: 'fam_123' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useAuth(), {
|
||||
wrapper: AuthProvider,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.register({
|
||||
email: 'newuser@example.com',
|
||||
password: 'password123',
|
||||
name: 'New User',
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.user).toEqual(mockUser);
|
||||
expect(localStorage.setItem).toHaveBeenCalledWith('accessToken', mockTokens.accessToken);
|
||||
expect(mockRouter.push).toHaveBeenCalledWith('/onboarding');
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle registration failure with invalid tokens', async () => {
|
||||
mockApiClient.post.mockResolvedValueOnce({
|
||||
data: {
|
||||
success: true,
|
||||
data: {
|
||||
user: {},
|
||||
tokens: {}, // Invalid tokens
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useAuth(), {
|
||||
wrapper: AuthProvider,
|
||||
});
|
||||
|
||||
await expect(
|
||||
result.current.register({
|
||||
email: 'newuser@example.com',
|
||||
password: 'password123',
|
||||
name: 'New User',
|
||||
})
|
||||
).rejects.toThrow('Invalid response from server');
|
||||
});
|
||||
});
|
||||
|
||||
describe('logout', () => {
|
||||
it('should logout successfully and clear tokens', async () => {
|
||||
mockApiClient.post.mockResolvedValueOnce({
|
||||
data: { success: true },
|
||||
});
|
||||
|
||||
localStorage.setItem('accessToken', 'token_123');
|
||||
localStorage.setItem('refreshToken', 'refresh_123');
|
||||
|
||||
const { result } = renderHook(() => useAuth(), {
|
||||
wrapper: AuthProvider,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.logout();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(localStorage.removeItem).toHaveBeenCalledWith('accessToken');
|
||||
expect(localStorage.removeItem).toHaveBeenCalledWith('refreshToken');
|
||||
expect(result.current.user).toBeNull();
|
||||
expect(mockRouter.push).toHaveBeenCalledWith('/login');
|
||||
});
|
||||
});
|
||||
|
||||
it('should clear tokens even if API call fails', async () => {
|
||||
mockApiClient.post.mockRejectedValueOnce(new Error('Network error'));
|
||||
|
||||
localStorage.setItem('accessToken', 'token_123');
|
||||
localStorage.setItem('refreshToken', 'refresh_123');
|
||||
|
||||
const { result } = renderHook(() => useAuth(), {
|
||||
wrapper: AuthProvider,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.logout();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(localStorage.removeItem).toHaveBeenCalledWith('accessToken');
|
||||
expect(localStorage.removeItem).toHaveBeenCalledWith('refreshToken');
|
||||
expect(mockRouter.push).toHaveBeenCalledWith('/login');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('refreshUser', () => {
|
||||
it('should refresh user data successfully', async () => {
|
||||
const mockUser = {
|
||||
id: 'usr_123',
|
||||
email: 'test@example.com',
|
||||
name: 'Updated Name',
|
||||
role: 'parent',
|
||||
};
|
||||
|
||||
mockApiClient.get.mockResolvedValueOnce({
|
||||
data: {
|
||||
data: mockUser,
|
||||
},
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useAuth(), {
|
||||
wrapper: AuthProvider,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.refreshUser();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.user).toEqual(mockUser);
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle refresh failure gracefully', async () => {
|
||||
mockApiClient.get.mockRejectedValueOnce(new Error('Unauthorized'));
|
||||
|
||||
const { result } = renderHook(() => useAuth(), {
|
||||
wrapper: AuthProvider,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.refreshUser();
|
||||
});
|
||||
|
||||
// User should remain null, no error thrown
|
||||
expect(result.current.user).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('authentication check', () => {
|
||||
it('should check auth on mount with valid token', async () => {
|
||||
const mockUser = {
|
||||
id: 'usr_123',
|
||||
email: 'test@example.com',
|
||||
name: 'Test User',
|
||||
role: 'parent',
|
||||
};
|
||||
|
||||
localStorage.setItem('accessToken', 'valid_token');
|
||||
|
||||
mockApiClient.get.mockResolvedValueOnce({
|
||||
data: {
|
||||
data: mockUser,
|
||||
},
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useAuth(), {
|
||||
wrapper: AuthProvider,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
expect(result.current.user).toEqual(mockUser);
|
||||
expect(result.current.isAuthenticated).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle auth check failure', async () => {
|
||||
localStorage.setItem('accessToken', 'invalid_token');
|
||||
|
||||
mockApiClient.get.mockRejectedValueOnce(new Error('Unauthorized'));
|
||||
|
||||
const { result } = renderHook(() => useAuth(), {
|
||||
wrapper: AuthProvider,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
expect(result.current.user).toBeNull();
|
||||
expect(localStorage.removeItem).toHaveBeenCalledWith('accessToken');
|
||||
expect(localStorage.removeItem).toHaveBeenCalledWith('refreshToken');
|
||||
});
|
||||
});
|
||||
|
||||
it('should not check auth if no token exists', async () => {
|
||||
const { result } = renderHook(() => useAuth(), {
|
||||
wrapper: AuthProvider,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
expect(result.current.user).toBeNull();
|
||||
expect(mockApiClient.get).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
221
maternal-web/app/(auth)/login/page.tsx
Normal file
221
maternal-web/app/(auth)/login/page.tsx
Normal file
@@ -0,0 +1,221 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import {
|
||||
Box,
|
||||
TextField,
|
||||
Button,
|
||||
Typography,
|
||||
Paper,
|
||||
InputAdornment,
|
||||
IconButton,
|
||||
Divider,
|
||||
Alert,
|
||||
CircularProgress,
|
||||
Link as MuiLink,
|
||||
} from '@mui/material';
|
||||
import { Visibility, VisibilityOff, Google, Apple } from '@mui/icons-material';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { motion } from 'framer-motion';
|
||||
import * as z from 'zod';
|
||||
import { useAuth } from '@/lib/auth/AuthContext';
|
||||
import Link from 'next/link';
|
||||
|
||||
const loginSchema = z.object({
|
||||
email: z.string().email('Invalid email address'),
|
||||
password: z.string().min(8, 'Password must be at least 8 characters'),
|
||||
});
|
||||
|
||||
type LoginFormData = z.infer<typeof loginSchema>;
|
||||
|
||||
export default function LoginPage() {
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { login } = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<LoginFormData>({
|
||||
resolver: zodResolver(loginSchema),
|
||||
});
|
||||
|
||||
const onSubmit = async (data: LoginFormData) => {
|
||||
setError(null);
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
await login(data);
|
||||
// Navigation is handled in the login function
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to login. Please check your credentials.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
px: 3,
|
||||
py: 6,
|
||||
background: 'linear-gradient(135deg, #FFE4E1 0%, #FFDAB9 100%)',
|
||||
}}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 4,
|
||||
borderRadius: 4,
|
||||
maxWidth: 440,
|
||||
mx: 'auto',
|
||||
background: 'rgba(255, 255, 255, 0.95)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="h4"
|
||||
gutterBottom
|
||||
align="center"
|
||||
fontWeight="600"
|
||||
color="primary.main"
|
||||
>
|
||||
Welcome Back 👋
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
align="center"
|
||||
color="text.secondary"
|
||||
sx={{ mb: 3 }}
|
||||
>
|
||||
Sign in to continue tracking your child's journey
|
||||
</Typography>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 3, borderRadius: 2 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Box component="form" onSubmit={handleSubmit(onSubmit)}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Email"
|
||||
type="email"
|
||||
margin="normal"
|
||||
error={!!errors.email}
|
||||
helperText={errors.email?.message}
|
||||
{...register('email')}
|
||||
disabled={isLoading}
|
||||
inputProps={{ autoComplete: 'username' }}
|
||||
InputProps={{
|
||||
sx: { borderRadius: 3 },
|
||||
}}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
margin="normal"
|
||||
error={!!errors.password}
|
||||
helperText={errors.password?.message}
|
||||
{...register('password')}
|
||||
disabled={isLoading}
|
||||
inputProps={{ autoComplete: 'current-password' }}
|
||||
InputProps={{
|
||||
sx: { borderRadius: 3 },
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<IconButton
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
edge="end"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{showPassword ? <VisibilityOff /> : <Visibility />}
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
|
||||
<Box sx={{ textAlign: 'right', mt: 1 }}>
|
||||
<Link href="/forgot-password" passHref legacyBehavior>
|
||||
<MuiLink variant="body2" sx={{ cursor: 'pointer' }}>
|
||||
Forgot password?
|
||||
</MuiLink>
|
||||
</Link>
|
||||
</Box>
|
||||
|
||||
<Button
|
||||
fullWidth
|
||||
type="submit"
|
||||
variant="contained"
|
||||
size="large"
|
||||
disabled={isLoading}
|
||||
sx={{ mt: 3, mb: 2 }}
|
||||
>
|
||||
{isLoading ? (
|
||||
<CircularProgress size={24} color="inherit" />
|
||||
) : (
|
||||
'Sign In'
|
||||
)}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ my: 3 }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
OR
|
||||
</Typography>
|
||||
</Divider>
|
||||
|
||||
<Button
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
startIcon={<Google />}
|
||||
size="large"
|
||||
disabled={isLoading}
|
||||
sx={{ mb: 2 }}
|
||||
>
|
||||
Continue with Google
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
startIcon={<Apple />}
|
||||
size="large"
|
||||
disabled={isLoading}
|
||||
>
|
||||
Continue with Apple
|
||||
</Button>
|
||||
|
||||
<Box sx={{ mt: 3, textAlign: 'center' }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Don't have an account?{' '}
|
||||
<Link href="/register" passHref legacyBehavior>
|
||||
<MuiLink sx={{ cursor: 'pointer', fontWeight: 600 }}>
|
||||
Sign up
|
||||
</MuiLink>
|
||||
</Link>
|
||||
</Typography>
|
||||
</Box>
|
||||
</Paper>
|
||||
</motion.div>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
315
maternal-web/app/(auth)/onboarding/page.tsx
Normal file
315
maternal-web/app/(auth)/onboarding/page.tsx
Normal file
@@ -0,0 +1,315 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Stepper,
|
||||
Step,
|
||||
StepLabel,
|
||||
Button,
|
||||
Typography,
|
||||
Paper,
|
||||
TextField,
|
||||
Avatar,
|
||||
IconButton,
|
||||
Alert,
|
||||
CircularProgress,
|
||||
MenuItem,
|
||||
} from '@mui/material';
|
||||
import { ArrowBack, ArrowForward, Check } from '@mui/icons-material';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuth } from '@/lib/auth/AuthContext';
|
||||
import { childrenApi } from '@/lib/api/children';
|
||||
|
||||
const steps = ['Welcome', 'Add Child', 'Invite Family', 'Notifications'];
|
||||
|
||||
export default function OnboardingPage() {
|
||||
const [activeStep, setActiveStep] = useState(0);
|
||||
const [childName, setChildName] = useState('');
|
||||
const [childBirthDate, setChildBirthDate] = useState('');
|
||||
const [childGender, setChildGender] = useState<'male' | 'female' | 'other'>('other');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const router = useRouter();
|
||||
const { user } = useAuth();
|
||||
|
||||
const handleNext = async () => {
|
||||
// Validate and save child data on step 1 (Add Child)
|
||||
if (activeStep === 1) {
|
||||
if (!childName.trim() || !childBirthDate) {
|
||||
setError('Please enter child name and birth date');
|
||||
return;
|
||||
}
|
||||
|
||||
const familyId = user?.families?.[0]?.familyId;
|
||||
if (!familyId) {
|
||||
setError('No family found. Please try logging out and back in.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
await childrenApi.createChild(familyId, {
|
||||
name: childName.trim(),
|
||||
birthDate: childBirthDate,
|
||||
gender: childGender,
|
||||
});
|
||||
setActiveStep((prevActiveStep) => prevActiveStep + 1);
|
||||
} catch (err: any) {
|
||||
console.error('Failed to create child:', err);
|
||||
setError(err.response?.data?.message || 'Failed to save child. Please try again.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeStep === steps.length - 1) {
|
||||
// Complete onboarding
|
||||
router.push('/');
|
||||
} else {
|
||||
setActiveStep((prevActiveStep) => prevActiveStep + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
setActiveStep((prevActiveStep) => prevActiveStep - 1);
|
||||
};
|
||||
|
||||
const handleSkip = () => {
|
||||
router.push('/');
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
px: 3,
|
||||
py: 4,
|
||||
background: 'linear-gradient(135deg, #FFE4E1 0%, #FFDAB9 100%)',
|
||||
}}
|
||||
>
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
maxWidth: 600,
|
||||
mx: 'auto',
|
||||
width: '100%',
|
||||
p: 4,
|
||||
borderRadius: 4,
|
||||
background: 'rgba(255, 255, 255, 0.95)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
}}
|
||||
>
|
||||
<Stepper activeStep={activeStep} sx={{ mb: 4 }}>
|
||||
{steps.map((label) => (
|
||||
<Step key={label}>
|
||||
<StepLabel>{label}</StepLabel>
|
||||
</Step>
|
||||
))}
|
||||
</Stepper>
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={activeStep}
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -20 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
{activeStep === 0 && (
|
||||
<Box sx={{ textAlign: 'center', py: 4 }}>
|
||||
<Typography variant="h4" gutterBottom fontWeight="600" color="primary.main">
|
||||
Welcome to Maternal! 🎉
|
||||
</Typography>
|
||||
<Typography variant="body1" color="text.secondary" sx={{ mt: 2, mb: 4 }}>
|
||||
We're excited to help you track and understand your child's development, sleep patterns, feeding schedules, and more.
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 2, justifyContent: 'center', flexWrap: 'wrap' }}>
|
||||
<Paper sx={{ p: 2, flex: 1, minWidth: 150 }}>
|
||||
<Typography variant="h6" fontWeight="600">📊</Typography>
|
||||
<Typography variant="body2">Track Activities</Typography>
|
||||
</Paper>
|
||||
<Paper sx={{ p: 2, flex: 1, minWidth: 150 }}>
|
||||
<Typography variant="h6" fontWeight="600">🤖</Typography>
|
||||
<Typography variant="body2">AI Insights</Typography>
|
||||
</Paper>
|
||||
<Paper sx={{ p: 2, flex: 1, minWidth: 150 }}>
|
||||
<Typography variant="h6" fontWeight="600">👨👩👧</Typography>
|
||||
<Typography variant="body2">Family Sharing</Typography>
|
||||
</Paper>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{activeStep === 1 && (
|
||||
<Box sx={{ py: 4 }}>
|
||||
<Typography variant="h5" gutterBottom fontWeight="600">
|
||||
Add Your First Child
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
|
||||
Let's start by adding some basic information about your child.
|
||||
</Typography>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 2, borderRadius: 2 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Child's Name"
|
||||
value={childName}
|
||||
onChange={(e) => setChildName(e.target.value)}
|
||||
margin="normal"
|
||||
required
|
||||
disabled={loading}
|
||||
InputProps={{
|
||||
sx: { borderRadius: 3 },
|
||||
}}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Birth Date"
|
||||
type="date"
|
||||
value={childBirthDate}
|
||||
onChange={(e) => setChildBirthDate(e.target.value)}
|
||||
margin="normal"
|
||||
required
|
||||
disabled={loading}
|
||||
InputLabelProps={{
|
||||
shrink: true,
|
||||
}}
|
||||
InputProps={{
|
||||
sx: { borderRadius: 3 },
|
||||
}}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
select
|
||||
label="Gender"
|
||||
value={childGender}
|
||||
onChange={(e) => setChildGender(e.target.value as 'male' | 'female' | 'other')}
|
||||
margin="normal"
|
||||
disabled={loading}
|
||||
InputProps={{
|
||||
sx: { borderRadius: 3 },
|
||||
}}
|
||||
>
|
||||
<MenuItem value="male">Male</MenuItem>
|
||||
<MenuItem value="female">Female</MenuItem>
|
||||
<MenuItem value="other">Prefer not to say</MenuItem>
|
||||
</TextField>
|
||||
|
||||
<Alert severity="info" sx={{ mt: 3, borderRadius: 2 }}>
|
||||
You can add more children and details later from settings.
|
||||
</Alert>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{activeStep === 2 && (
|
||||
<Box sx={{ py: 4 }}>
|
||||
<Typography variant="h5" gutterBottom fontWeight="600">
|
||||
Invite Family Members
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
|
||||
Share your child's progress with family members. They can view activities and add their own entries.
|
||||
</Typography>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Email Address"
|
||||
type="email"
|
||||
margin="normal"
|
||||
placeholder="partner@example.com"
|
||||
InputProps={{
|
||||
sx: { borderRadius: 3 },
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="outlined"
|
||||
fullWidth
|
||||
sx={{ mt: 2 }}
|
||||
>
|
||||
Send Invitation
|
||||
</Button>
|
||||
|
||||
<Alert severity="info" sx={{ mt: 3, borderRadius: 2 }}>
|
||||
You can skip this step and invite family members later.
|
||||
</Alert>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{activeStep === 3 && (
|
||||
<Box sx={{ textAlign: 'center', py: 4 }}>
|
||||
<Avatar
|
||||
sx={{
|
||||
width: 80,
|
||||
height: 80,
|
||||
bgcolor: 'primary.main',
|
||||
mx: 'auto',
|
||||
mb: 3,
|
||||
}}
|
||||
>
|
||||
<Check sx={{ fontSize: 48 }} />
|
||||
</Avatar>
|
||||
<Typography variant="h5" gutterBottom fontWeight="600">
|
||||
You're All Set! 🎉
|
||||
</Typography>
|
||||
<Typography variant="body1" color="text.secondary" sx={{ mb: 4 }}>
|
||||
Start tracking your child's activities and get personalized insights.
|
||||
</Typography>
|
||||
|
||||
<Paper sx={{ p: 3, bgcolor: 'primary.light', mb: 3 }}>
|
||||
<Typography variant="body2" fontWeight="600" gutterBottom>
|
||||
Next Steps:
|
||||
</Typography>
|
||||
<Typography variant="body2" align="left" component="div">
|
||||
• Track your first feeding, sleep, or diaper change<br />
|
||||
• Chat with our AI assistant for parenting tips<br />
|
||||
• Explore insights and predictions based on your data
|
||||
</Typography>
|
||||
</Paper>
|
||||
</Box>
|
||||
)}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mt: 4 }}>
|
||||
<Button
|
||||
onClick={handleBack}
|
||||
disabled={activeStep === 0}
|
||||
startIcon={<ArrowBack />}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
|
||||
<Box sx={{ flex: 1 }} />
|
||||
|
||||
{activeStep < steps.length - 1 && activeStep > 0 && (
|
||||
<Button onClick={handleSkip} sx={{ mr: 2 }}>
|
||||
Skip
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleNext}
|
||||
disabled={loading}
|
||||
endIcon={loading ? <CircularProgress size={20} /> : (activeStep === steps.length - 1 ? <Check /> : <ArrowForward />)}
|
||||
>
|
||||
{activeStep === steps.length - 1 ? 'Get Started' : 'Next'}
|
||||
</Button>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
268
maternal-web/app/(auth)/register/page.tsx
Normal file
268
maternal-web/app/(auth)/register/page.tsx
Normal file
@@ -0,0 +1,268 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
TextField,
|
||||
Button,
|
||||
Typography,
|
||||
Paper,
|
||||
InputAdornment,
|
||||
IconButton,
|
||||
Alert,
|
||||
CircularProgress,
|
||||
Link as MuiLink,
|
||||
Checkbox,
|
||||
FormControlLabel,
|
||||
} from '@mui/material';
|
||||
import { Visibility, VisibilityOff } from '@mui/icons-material';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { motion } from 'framer-motion';
|
||||
import * as z from 'zod';
|
||||
import { useAuth } from '@/lib/auth/AuthContext';
|
||||
import Link from 'next/link';
|
||||
|
||||
const registerSchema = z.object({
|
||||
name: z.string().min(2, 'Name must be at least 2 characters'),
|
||||
email: z.string().email('Invalid email address'),
|
||||
password: z.string()
|
||||
.min(8, 'Password must be at least 8 characters')
|
||||
.regex(/[A-Z]/, 'Password must contain at least one uppercase letter')
|
||||
.regex(/[a-z]/, 'Password must contain at least one lowercase letter')
|
||||
.regex(/[0-9]/, 'Password must contain at least one number'),
|
||||
confirmPassword: z.string(),
|
||||
agreeToTerms: z.boolean().refine(val => val === true, {
|
||||
message: 'You must agree to the terms and conditions',
|
||||
}),
|
||||
}).refine((data) => data.password === data.confirmPassword, {
|
||||
message: 'Passwords do not match',
|
||||
path: ['confirmPassword'],
|
||||
});
|
||||
|
||||
type RegisterFormData = z.infer<typeof registerSchema>;
|
||||
|
||||
export default function RegisterPage() {
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { register: registerUser } = useAuth();
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<RegisterFormData>({
|
||||
resolver: zodResolver(registerSchema),
|
||||
});
|
||||
|
||||
const onSubmit = async (data: RegisterFormData) => {
|
||||
setError(null);
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
await registerUser({
|
||||
name: data.name,
|
||||
email: data.email,
|
||||
password: data.password,
|
||||
});
|
||||
// Navigation to onboarding is handled in the register function
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to register. Please try again.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
px: 3,
|
||||
py: 6,
|
||||
background: 'linear-gradient(135deg, #FFE4E1 0%, #FFDAB9 100%)',
|
||||
}}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 4,
|
||||
borderRadius: 4,
|
||||
maxWidth: 440,
|
||||
mx: 'auto',
|
||||
background: 'rgba(255, 255, 255, 0.95)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="h4"
|
||||
gutterBottom
|
||||
align="center"
|
||||
fontWeight="600"
|
||||
color="primary.main"
|
||||
>
|
||||
Create Account ✨
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
align="center"
|
||||
color="text.secondary"
|
||||
sx={{ mb: 3 }}
|
||||
>
|
||||
Start your journey to organized parenting
|
||||
</Typography>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 3, borderRadius: 2 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Box component="form" onSubmit={handleSubmit(onSubmit)}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Full Name"
|
||||
margin="normal"
|
||||
error={!!errors.name}
|
||||
helperText={errors.name?.message}
|
||||
{...register('name')}
|
||||
disabled={isLoading}
|
||||
InputProps={{
|
||||
sx: { borderRadius: 3 },
|
||||
}}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Email"
|
||||
type="email"
|
||||
margin="normal"
|
||||
error={!!errors.email}
|
||||
helperText={errors.email?.message}
|
||||
{...register('email')}
|
||||
disabled={isLoading}
|
||||
inputProps={{ autoComplete: 'username' }}
|
||||
InputProps={{
|
||||
sx: { borderRadius: 3 },
|
||||
}}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
margin="normal"
|
||||
error={!!errors.password}
|
||||
helperText={errors.password?.message}
|
||||
{...register('password')}
|
||||
disabled={isLoading}
|
||||
inputProps={{ autoComplete: 'new-password' }}
|
||||
InputProps={{
|
||||
sx: { borderRadius: 3 },
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<IconButton
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
edge="end"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{showPassword ? <VisibilityOff /> : <Visibility />}
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Confirm Password"
|
||||
type={showConfirmPassword ? 'text' : 'password'}
|
||||
margin="normal"
|
||||
error={!!errors.confirmPassword}
|
||||
helperText={errors.confirmPassword?.message}
|
||||
{...register('confirmPassword')}
|
||||
disabled={isLoading}
|
||||
inputProps={{ autoComplete: 'new-password' }}
|
||||
InputProps={{
|
||||
sx: { borderRadius: 3 },
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<IconButton
|
||||
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||
edge="end"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{showConfirmPassword ? <VisibilityOff /> : <Visibility />}
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
{...register('agreeToTerms')}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
}
|
||||
label={
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
I agree to the{' '}
|
||||
<MuiLink href="/terms" target="_blank">
|
||||
Terms of Service
|
||||
</MuiLink>{' '}
|
||||
and{' '}
|
||||
<MuiLink href="/privacy" target="_blank">
|
||||
Privacy Policy
|
||||
</MuiLink>
|
||||
</Typography>
|
||||
}
|
||||
sx={{ mt: 2 }}
|
||||
/>
|
||||
{errors.agreeToTerms && (
|
||||
<Typography variant="caption" color="error" sx={{ display: 'block', mt: 1 }}>
|
||||
{errors.agreeToTerms.message}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
<Button
|
||||
fullWidth
|
||||
type="submit"
|
||||
variant="contained"
|
||||
size="large"
|
||||
disabled={isLoading}
|
||||
sx={{ mt: 3, mb: 2 }}
|
||||
>
|
||||
{isLoading ? (
|
||||
<CircularProgress size={24} color="inherit" />
|
||||
) : (
|
||||
'Create Account'
|
||||
)}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mt: 3, textAlign: 'center' }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Already have an account?{' '}
|
||||
<Link href="/login" passHref legacyBehavior>
|
||||
<MuiLink sx={{ cursor: 'pointer', fontWeight: 600 }}>
|
||||
Sign in
|
||||
</MuiLink>
|
||||
</Link>
|
||||
</Typography>
|
||||
</Box>
|
||||
</Paper>
|
||||
</motion.div>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
25
maternal-web/app/ai-assistant/page.tsx
Normal file
25
maternal-web/app/ai-assistant/page.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
'use client';
|
||||
|
||||
import { lazy, Suspense } from 'react';
|
||||
import { AppShell } from '@/components/layouts/AppShell/AppShell';
|
||||
import { ProtectedRoute } from '@/components/common/ProtectedRoute';
|
||||
import { LoadingFallback } from '@/components/common/LoadingFallback';
|
||||
|
||||
// Lazy load the AI chat interface component
|
||||
const AIChatInterface = lazy(() =>
|
||||
import('@/components/features/ai-chat/AIChatInterface').then((mod) => ({
|
||||
default: mod.AIChatInterface,
|
||||
}))
|
||||
);
|
||||
|
||||
export default function AIAssistantPage() {
|
||||
return (
|
||||
<ProtectedRoute>
|
||||
<AppShell>
|
||||
<Suspense fallback={<LoadingFallback variant="chat" />}>
|
||||
<AIChatInterface />
|
||||
</Suspense>
|
||||
</AppShell>
|
||||
</ProtectedRoute>
|
||||
);
|
||||
}
|
||||
263
maternal-web/app/analytics/page.tsx
Normal file
263
maternal-web/app/analytics/page.tsx
Normal file
@@ -0,0 +1,263 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Paper,
|
||||
Grid,
|
||||
Card,
|
||||
CardContent,
|
||||
Button,
|
||||
CircularProgress,
|
||||
Tabs,
|
||||
Tab,
|
||||
Alert,
|
||||
} from '@mui/material';
|
||||
import { AppShell } from '@/components/layouts/AppShell/AppShell';
|
||||
import { ProtectedRoute } from '@/components/common/ProtectedRoute';
|
||||
import {
|
||||
TrendingUp,
|
||||
Hotel,
|
||||
Restaurant,
|
||||
BabyChangingStation,
|
||||
Download,
|
||||
} from '@mui/icons-material';
|
||||
import { motion } from 'framer-motion';
|
||||
import apiClient from '@/lib/api/client';
|
||||
import WeeklySleepChart from '@/components/analytics/WeeklySleepChart';
|
||||
import FeedingFrequencyGraph from '@/components/analytics/FeedingFrequencyGraph';
|
||||
import GrowthCurve from '@/components/analytics/GrowthCurve';
|
||||
import PatternInsights from '@/components/analytics/PatternInsights';
|
||||
|
||||
interface TabPanelProps {
|
||||
children?: React.ReactNode;
|
||||
index: number;
|
||||
value: number;
|
||||
}
|
||||
|
||||
function TabPanel(props: TabPanelProps) {
|
||||
const { children, value, index, ...other } = props;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="tabpanel"
|
||||
hidden={value !== index}
|
||||
id={`analytics-tabpanel-${index}`}
|
||||
aria-labelledby={`analytics-tab-${index}`}
|
||||
{...other}
|
||||
>
|
||||
{value === index && <Box sx={{ pt: 3 }}>{children}</Box>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AnalyticsPage() {
|
||||
const [tabValue, setTabValue] = useState(0);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [insights, setInsights] = useState<any>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAnalytics();
|
||||
}, []);
|
||||
|
||||
const fetchAnalytics = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const response = await apiClient.get('/api/v1/analytics/insights');
|
||||
setInsights(response.data.data);
|
||||
} catch (err: any) {
|
||||
console.error('Failed to fetch analytics:', err);
|
||||
setError(err.response?.data?.message || 'Failed to load analytics');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExportReport = async () => {
|
||||
try {
|
||||
const response = await apiClient.get('/api/v1/analytics/reports/weekly', {
|
||||
responseType: 'blob',
|
||||
});
|
||||
const blob = new Blob([response.data], { type: 'application/pdf' });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `weekly-report-${new Date().toISOString().split('T')[0]}.pdf`;
|
||||
link.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
} catch (err) {
|
||||
console.error('Failed to export report:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTabChange = (event: React.SyntheticEvent, newValue: number) => {
|
||||
setTabValue(newValue);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<ProtectedRoute>
|
||||
<AppShell>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
minHeight: '60vh',
|
||||
}}
|
||||
>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
</AppShell>
|
||||
</ProtectedRoute>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ProtectedRoute>
|
||||
<AppShell>
|
||||
<Box>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
{/* Header */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
mb: 3,
|
||||
}}
|
||||
>
|
||||
<Box>
|
||||
<Typography variant="h4" gutterBottom fontWeight="600">
|
||||
Analytics & Insights 📊
|
||||
</Typography>
|
||||
<Typography variant="body1" color="text.secondary">
|
||||
Track patterns and get personalized insights
|
||||
</Typography>
|
||||
</Box>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<Download />}
|
||||
onClick={handleExportReport}
|
||||
sx={{ borderRadius: 3 }}
|
||||
>
|
||||
Export Report
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 3, borderRadius: 2 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Summary Cards */}
|
||||
<Grid container spacing={2} sx={{ mb: 4 }}>
|
||||
<Grid item xs={12} sm={4}>
|
||||
<Card
|
||||
sx={{
|
||||
background: 'linear-gradient(135deg, #B6D7FF 0%, #A5C9FF 100%)',
|
||||
color: 'white',
|
||||
}}
|
||||
>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
|
||||
<Hotel sx={{ fontSize: 32 }} />
|
||||
<Typography variant="h5" fontWeight="600">
|
||||
{insights?.sleep?.averageHours || '0'}h
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography variant="body2">Avg Sleep (7 days)</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={4}>
|
||||
<Card
|
||||
sx={{
|
||||
background: 'linear-gradient(135deg, #FFB6C1 0%, #FFA5B0 100%)',
|
||||
color: 'white',
|
||||
}}
|
||||
>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
|
||||
<Restaurant sx={{ fontSize: 32 }} />
|
||||
<Typography variant="h5" fontWeight="600">
|
||||
{insights?.feeding?.averagePerDay || '0'}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography variant="body2">Avg Feedings (7 days)</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={4}>
|
||||
<Card
|
||||
sx={{
|
||||
background: 'linear-gradient(135deg, #FFE4B5 0%, #FFD9A0 100%)',
|
||||
color: 'white',
|
||||
}}
|
||||
>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
|
||||
<BabyChangingStation sx={{ fontSize: 32 }} />
|
||||
<Typography variant="h5" fontWeight="600">
|
||||
{insights?.diaper?.averagePerDay || '0'}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography variant="body2">Avg Diapers (7 days)</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
{/* Tabs */}
|
||||
<Paper sx={{ borderRadius: 3, overflow: 'hidden' }}>
|
||||
<Box sx={{ borderBottom: 1, borderColor: 'divider' }}>
|
||||
<Tabs
|
||||
value={tabValue}
|
||||
onChange={handleTabChange}
|
||||
aria-label="analytics tabs"
|
||||
sx={{ px: 2 }}
|
||||
>
|
||||
<Tab label="Sleep Patterns" />
|
||||
<Tab label="Feeding Patterns" />
|
||||
<Tab label="Growth Curve" />
|
||||
<Tab label="Insights" />
|
||||
</Tabs>
|
||||
</Box>
|
||||
|
||||
<TabPanel value={tabValue} index={0}>
|
||||
<Box sx={{ p: 3 }}>
|
||||
<WeeklySleepChart />
|
||||
</Box>
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel value={tabValue} index={1}>
|
||||
<Box sx={{ p: 3 }}>
|
||||
<FeedingFrequencyGraph />
|
||||
</Box>
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel value={tabValue} index={2}>
|
||||
<Box sx={{ p: 3 }}>
|
||||
<GrowthCurve />
|
||||
</Box>
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel value={tabValue} index={3}>
|
||||
<Box sx={{ p: 3 }}>
|
||||
<PatternInsights insights={insights} />
|
||||
</Box>
|
||||
</TabPanel>
|
||||
</Paper>
|
||||
</motion.div>
|
||||
</Box>
|
||||
</AppShell>
|
||||
</ProtectedRoute>
|
||||
);
|
||||
}
|
||||
306
maternal-web/app/children/page.tsx
Normal file
306
maternal-web/app/children/page.tsx
Normal file
@@ -0,0 +1,306 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Grid,
|
||||
Card,
|
||||
CardContent,
|
||||
Button,
|
||||
Avatar,
|
||||
IconButton,
|
||||
CircularProgress,
|
||||
Alert,
|
||||
Chip,
|
||||
CardActions,
|
||||
} from '@mui/material';
|
||||
import { Add, ChildCare, Edit, Delete, Cake } from '@mui/icons-material';
|
||||
import { AppShell } from '@/components/layouts/AppShell/AppShell';
|
||||
import { ProtectedRoute } from '@/components/common/ProtectedRoute';
|
||||
import { useAuth } from '@/lib/auth/AuthContext';
|
||||
import { childrenApi, Child, CreateChildData } from '@/lib/api/children';
|
||||
import { ChildDialog } from '@/components/children/ChildDialog';
|
||||
import { DeleteConfirmDialog } from '@/components/children/DeleteConfirmDialog';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
export default function ChildrenPage() {
|
||||
const { user } = useAuth();
|
||||
const [children, setChildren] = useState<Child[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string>('');
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [selectedChild, setSelectedChild] = useState<Child | null>(null);
|
||||
const [childToDelete, setChildToDelete] = useState<Child | null>(null);
|
||||
const [actionLoading, setActionLoading] = useState(false);
|
||||
|
||||
// Get familyId from user
|
||||
const familyId = user?.families?.[0]?.familyId;
|
||||
|
||||
useEffect(() => {
|
||||
if (familyId) {
|
||||
fetchChildren();
|
||||
} else {
|
||||
setLoading(false);
|
||||
setError('No family found. Please complete onboarding first.');
|
||||
}
|
||||
}, [familyId]);
|
||||
|
||||
const fetchChildren = async () => {
|
||||
if (!familyId) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
const data = await childrenApi.getChildren(familyId);
|
||||
setChildren(data);
|
||||
} catch (err: any) {
|
||||
console.error('Failed to fetch children:', err);
|
||||
setError(err.response?.data?.message || 'Failed to load children');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddChild = () => {
|
||||
setSelectedChild(null);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleEditChild = (child: Child) => {
|
||||
setSelectedChild(child);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleDeleteClick = (child: Child) => {
|
||||
setChildToDelete(child);
|
||||
setDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleSubmit = async (data: CreateChildData) => {
|
||||
if (!familyId) {
|
||||
throw new Error('No family ID found');
|
||||
}
|
||||
|
||||
try {
|
||||
setActionLoading(true);
|
||||
if (selectedChild) {
|
||||
await childrenApi.updateChild(selectedChild.id, data);
|
||||
} else {
|
||||
await childrenApi.createChild(familyId, data);
|
||||
}
|
||||
await fetchChildren();
|
||||
setDialogOpen(false);
|
||||
} catch (err: any) {
|
||||
console.error('Failed to save child:', err);
|
||||
throw new Error(err.response?.data?.message || 'Failed to save child');
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteConfirm = async () => {
|
||||
if (!childToDelete) return;
|
||||
|
||||
try {
|
||||
setActionLoading(true);
|
||||
await childrenApi.deleteChild(childToDelete.id);
|
||||
await fetchChildren();
|
||||
setDeleteDialogOpen(false);
|
||||
setChildToDelete(null);
|
||||
} catch (err: any) {
|
||||
console.error('Failed to delete child:', err);
|
||||
setError(err.response?.data?.message || 'Failed to delete child');
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const calculateAge = (birthDate: string): string => {
|
||||
const birth = new Date(birthDate);
|
||||
const today = new Date();
|
||||
|
||||
let years = today.getFullYear() - birth.getFullYear();
|
||||
let months = today.getMonth() - birth.getMonth();
|
||||
|
||||
if (months < 0) {
|
||||
years--;
|
||||
months += 12;
|
||||
}
|
||||
|
||||
if (today.getDate() < birth.getDate()) {
|
||||
months--;
|
||||
if (months < 0) {
|
||||
years--;
|
||||
months += 12;
|
||||
}
|
||||
}
|
||||
|
||||
if (years === 0) {
|
||||
return `${months} month${months !== 1 ? 's' : ''}`;
|
||||
} else if (months === 0) {
|
||||
return `${years} year${years !== 1 ? 's' : ''}`;
|
||||
} else {
|
||||
return `${years} year${years !== 1 ? 's' : ''}, ${months} month${months !== 1 ? 's' : ''}`;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ProtectedRoute>
|
||||
<AppShell>
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 4 }}>
|
||||
<Box>
|
||||
<Typography variant="h4" fontWeight="600" gutterBottom>
|
||||
Children
|
||||
</Typography>
|
||||
<Typography variant="body1" color="text.secondary">
|
||||
Manage your family's children profiles
|
||||
</Typography>
|
||||
</Box>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<Add />}
|
||||
onClick={handleAddChild}
|
||||
disabled={loading || !familyId}
|
||||
>
|
||||
Add Child
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 3 }} onClose={() => setError('')}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
) : children.length === 0 ? (
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12}>
|
||||
<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 yet
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
|
||||
Add your first child to start tracking their activities
|
||||
</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<Add />}
|
||||
onClick={handleAddChild}
|
||||
disabled={!familyId}
|
||||
>
|
||||
Add First Child
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
) : (
|
||||
<Grid container spacing={3}>
|
||||
{children.map((child, index) => (
|
||||
<Grid item xs={12} sm={6} md={4} key={child.id}>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, delay: index * 0.1 }}
|
||||
>
|
||||
<Card
|
||||
sx={{
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
<CardContent sx={{ flexGrow: 1 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
||||
<Avatar
|
||||
src={child.photoUrl}
|
||||
sx={{
|
||||
width: 60,
|
||||
height: 60,
|
||||
bgcolor: child.gender === 'male' ? '#B6D7FF' : '#FFB6C1',
|
||||
mr: 2,
|
||||
}}
|
||||
>
|
||||
<ChildCare sx={{ fontSize: 32 }} />
|
||||
</Avatar>
|
||||
<Box sx={{ flexGrow: 1 }}>
|
||||
<Typography variant="h6" fontWeight="600">
|
||||
{child.name}
|
||||
</Typography>
|
||||
<Chip
|
||||
label={child.gender}
|
||||
size="small"
|
||||
sx={{ textTransform: 'capitalize', mt: 0.5 }}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
|
||||
<Cake sx={{ fontSize: 20, color: 'text.secondary' }} />
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{new Date(child.birthDate).toLocaleDateString()}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="primary"
|
||||
fontWeight="600"
|
||||
sx={{ mt: 1 }}
|
||||
>
|
||||
Age: {calculateAge(child.birthDate)}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
|
||||
<CardActions sx={{ justifyContent: 'flex-end', pt: 0 }}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => handleEditChild(child)}
|
||||
color="primary"
|
||||
>
|
||||
<Edit />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => handleDeleteClick(child)}
|
||||
color="error"
|
||||
>
|
||||
<Delete />
|
||||
</IconButton>
|
||||
</CardActions>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<ChildDialog
|
||||
open={dialogOpen}
|
||||
onClose={() => setDialogOpen(false)}
|
||||
onSubmit={handleSubmit}
|
||||
child={selectedChild}
|
||||
isLoading={actionLoading}
|
||||
/>
|
||||
|
||||
<DeleteConfirmDialog
|
||||
open={deleteDialogOpen}
|
||||
onClose={() => setDeleteDialogOpen(false)}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
childName={childToDelete?.name || ''}
|
||||
isLoading={actionLoading}
|
||||
/>
|
||||
</AppShell>
|
||||
</ProtectedRoute>
|
||||
);
|
||||
}
|
||||
355
maternal-web/app/family/page.tsx
Normal file
355
maternal-web/app/family/page.tsx
Normal file
@@ -0,0 +1,355 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Grid,
|
||||
Card,
|
||||
CardContent,
|
||||
Button,
|
||||
Avatar,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
Alert,
|
||||
IconButton,
|
||||
Divider,
|
||||
Snackbar,
|
||||
} from '@mui/material';
|
||||
import { PersonAdd, ContentCopy, People, Delete, GroupAdd } from '@mui/icons-material';
|
||||
import { useAuth } from '@/lib/auth/AuthContext';
|
||||
import { AppShell } from '@/components/layouts/AppShell/AppShell';
|
||||
import { ProtectedRoute } from '@/components/common/ProtectedRoute';
|
||||
import { familiesApi, Family, FamilyMember, InviteMemberData, JoinFamilyData } from '@/lib/api/families';
|
||||
import { InviteMemberDialog } from '@/components/family/InviteMemberDialog';
|
||||
import { JoinFamilyDialog } from '@/components/family/JoinFamilyDialog';
|
||||
import { RemoveMemberDialog } from '@/components/family/RemoveMemberDialog';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
export default function FamilyPage() {
|
||||
const { user, refreshUser } = useAuth();
|
||||
const [family, setFamily] = useState<Family | null>(null);
|
||||
const [members, setMembers] = useState<FamilyMember[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string>('');
|
||||
const [inviteDialogOpen, setInviteDialogOpen] = useState(false);
|
||||
const [joinDialogOpen, setJoinDialogOpen] = useState(false);
|
||||
const [removeDialogOpen, setRemoveDialogOpen] = useState(false);
|
||||
const [memberToRemove, setMemberToRemove] = useState<FamilyMember | null>(null);
|
||||
const [actionLoading, setActionLoading] = useState(false);
|
||||
const [snackbar, setSnackbar] = useState({ open: false, message: '' });
|
||||
|
||||
// Get familyId from user
|
||||
const familyId = user?.families?.[0]?.familyId;
|
||||
|
||||
useEffect(() => {
|
||||
if (familyId) {
|
||||
fetchFamilyData();
|
||||
} else {
|
||||
setLoading(false);
|
||||
setError('No family found. Please complete onboarding first.');
|
||||
}
|
||||
}, [familyId]);
|
||||
|
||||
const fetchFamilyData = async () => {
|
||||
if (!familyId) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
const [familyData, membersData] = await Promise.all([
|
||||
familiesApi.getFamily(familyId),
|
||||
familiesApi.getFamilyMembers(familyId),
|
||||
]);
|
||||
setFamily(familyData);
|
||||
setMembers(membersData);
|
||||
} catch (err: any) {
|
||||
console.error('Failed to fetch family data:', err);
|
||||
setError(err.response?.data?.message || 'Failed to load family information');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyCode = async () => {
|
||||
if (!family?.shareCode) return;
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(family.shareCode);
|
||||
setSnackbar({ open: true, message: 'Share code copied to clipboard!' });
|
||||
} catch (err) {
|
||||
setSnackbar({ open: true, message: 'Failed to copy share code' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleInviteMember = async (data: InviteMemberData) => {
|
||||
if (!familyId) {
|
||||
throw new Error('No family ID found');
|
||||
}
|
||||
|
||||
try {
|
||||
setActionLoading(true);
|
||||
await familiesApi.inviteMember(familyId, data);
|
||||
setSnackbar({ open: true, message: 'Invitation sent successfully!' });
|
||||
await fetchFamilyData();
|
||||
setInviteDialogOpen(false);
|
||||
} catch (err: any) {
|
||||
console.error('Failed to invite member:', err);
|
||||
throw new Error(err.response?.data?.message || 'Failed to send invitation');
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleJoinFamily = async (data: JoinFamilyData) => {
|
||||
try {
|
||||
setActionLoading(true);
|
||||
await familiesApi.joinFamily(data);
|
||||
setSnackbar({ open: true, message: 'Successfully joined family!' });
|
||||
await refreshUser();
|
||||
await fetchFamilyData();
|
||||
setJoinDialogOpen(false);
|
||||
} catch (err: any) {
|
||||
console.error('Failed to join family:', err);
|
||||
throw new Error(err.response?.data?.message || 'Failed to join family');
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveClick = (member: FamilyMember) => {
|
||||
setMemberToRemove(member);
|
||||
setRemoveDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleRemoveConfirm = async () => {
|
||||
if (!familyId || !memberToRemove) return;
|
||||
|
||||
try {
|
||||
setActionLoading(true);
|
||||
await familiesApi.removeMember(familyId, memberToRemove.userId);
|
||||
setSnackbar({ open: true, message: 'Member removed successfully' });
|
||||
await fetchFamilyData();
|
||||
setRemoveDialogOpen(false);
|
||||
setMemberToRemove(null);
|
||||
} catch (err: any) {
|
||||
console.error('Failed to remove member:', err);
|
||||
setError(err.response?.data?.message || 'Failed to remove member');
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getRoleColor = (role: string): 'primary' | 'secondary' | 'default' | 'success' | 'warning' | 'info' => {
|
||||
switch (role) {
|
||||
case 'parent':
|
||||
return 'primary';
|
||||
case 'caregiver':
|
||||
return 'secondary';
|
||||
case 'viewer':
|
||||
return 'info';
|
||||
default:
|
||||
return 'default';
|
||||
}
|
||||
};
|
||||
|
||||
const isCurrentUser = (userId: string) => {
|
||||
return user?.id === userId;
|
||||
};
|
||||
|
||||
return (
|
||||
<ProtectedRoute>
|
||||
<AppShell>
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 4 }}>
|
||||
<Box>
|
||||
<Typography variant="h4" fontWeight="600" gutterBottom>
|
||||
Family
|
||||
</Typography>
|
||||
<Typography variant="body1" color="text.secondary">
|
||||
Manage your family members and share access
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<GroupAdd />}
|
||||
onClick={() => setJoinDialogOpen(true)}
|
||||
disabled={loading}
|
||||
>
|
||||
Join Family
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<PersonAdd />}
|
||||
onClick={() => setInviteDialogOpen(true)}
|
||||
disabled={loading || !familyId}
|
||||
>
|
||||
Invite Member
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 3 }} onClose={() => setError('')}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
) : (
|
||||
<Grid container spacing={3}>
|
||||
{/* Family Share Code */}
|
||||
{family && (
|
||||
<Grid item xs={12}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" fontWeight="600" gutterBottom>
|
||||
Family Share Code
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
Share this code with family members to give them access to your family's data
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, flexWrap: 'wrap' }}>
|
||||
<Chip
|
||||
label={family.shareCode}
|
||||
sx={{
|
||||
fontSize: '1.1rem',
|
||||
fontWeight: 600,
|
||||
py: 2.5,
|
||||
px: 1,
|
||||
}}
|
||||
color="primary"
|
||||
/>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<ContentCopy />}
|
||||
onClick={handleCopyCode}
|
||||
>
|
||||
Copy Code
|
||||
</Button>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{/* Family Members */}
|
||||
<Grid item xs={12}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" fontWeight="600" gutterBottom sx={{ mb: 3 }}>
|
||||
Family Members ({members.length})
|
||||
</Typography>
|
||||
|
||||
{members.length === 0 ? (
|
||||
<Box sx={{ textAlign: 'center', py: 4 }}>
|
||||
<People sx={{ fontSize: 48, color: 'text.secondary', mb: 2 }} />
|
||||
<Typography variant="body2" color="text.secondary" gutterBottom>
|
||||
No family members yet
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
Invite family members to collaborate on child care
|
||||
</Typography>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<PersonAdd />}
|
||||
onClick={() => setInviteDialogOpen(true)}
|
||||
>
|
||||
Invite First Member
|
||||
</Button>
|
||||
</Box>
|
||||
) : (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{members.map((member, index) => (
|
||||
<motion.div
|
||||
key={member.id}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.2, delay: index * 0.05 }}
|
||||
>
|
||||
<Box>
|
||||
{index > 0 && <Divider sx={{ mb: 2 }} />}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<Avatar
|
||||
sx={{
|
||||
bgcolor: isCurrentUser(member.userId) ? 'primary.main' : 'secondary.main',
|
||||
}}
|
||||
>
|
||||
{member.user?.name?.charAt(0).toUpperCase() || 'U'}
|
||||
</Avatar>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography variant="body1" fontWeight="600">
|
||||
{member.user?.name || 'Unknown User'}
|
||||
</Typography>
|
||||
{isCurrentUser(member.userId) && (
|
||||
<Chip label="You" size="small" color="success" />
|
||||
)}
|
||||
</Box>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{member.user?.email || 'No email'}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Chip
|
||||
label={member.role.charAt(0).toUpperCase() + member.role.slice(1)}
|
||||
color={getRoleColor(member.role)}
|
||||
size="small"
|
||||
/>
|
||||
{!isCurrentUser(member.userId) && (
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => handleRemoveClick(member)}
|
||||
color="error"
|
||||
>
|
||||
<Delete />
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</motion.div>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<InviteMemberDialog
|
||||
open={inviteDialogOpen}
|
||||
onClose={() => setInviteDialogOpen(false)}
|
||||
onSubmit={handleInviteMember}
|
||||
isLoading={actionLoading}
|
||||
/>
|
||||
|
||||
<JoinFamilyDialog
|
||||
open={joinDialogOpen}
|
||||
onClose={() => setJoinDialogOpen(false)}
|
||||
onSubmit={handleJoinFamily}
|
||||
isLoading={actionLoading}
|
||||
/>
|
||||
|
||||
<RemoveMemberDialog
|
||||
open={removeDialogOpen}
|
||||
onClose={() => setRemoveDialogOpen(false)}
|
||||
onConfirm={handleRemoveConfirm}
|
||||
memberName={memberToRemove?.user?.name || ''}
|
||||
isLoading={actionLoading}
|
||||
/>
|
||||
|
||||
<Snackbar
|
||||
open={snackbar.open}
|
||||
autoHideDuration={4000}
|
||||
onClose={() => setSnackbar({ ...snackbar, open: false })}
|
||||
message={snackbar.message}
|
||||
/>
|
||||
</AppShell>
|
||||
</ProtectedRoute>
|
||||
);
|
||||
}
|
||||
BIN
maternal-web/app/favicon.ico
Normal file
BIN
maternal-web/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
33
maternal-web/app/globals.css
Normal file
33
maternal-web/app/globals.css
Normal file
@@ -0,0 +1,33 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
--foreground-rgb: 0, 0, 0;
|
||||
--background-start-rgb: 214, 219, 220;
|
||||
--background-end-rgb: 255, 255, 255;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--foreground-rgb: 255, 255, 255;
|
||||
--background-start-rgb: 0, 0, 0;
|
||||
--background-end-rgb: 0, 0, 0;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
color: rgb(var(--foreground-rgb));
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
transparent,
|
||||
rgb(var(--background-end-rgb))
|
||||
)
|
||||
rgb(var(--background-start-rgb));
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.text-balance {
|
||||
text-wrap: balance;
|
||||
}
|
||||
}
|
||||
220
maternal-web/app/history/page.tsx
Normal file
220
maternal-web/app/history/page.tsx
Normal file
@@ -0,0 +1,220 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Paper,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemAvatar,
|
||||
ListItemText,
|
||||
Avatar,
|
||||
Chip,
|
||||
IconButton,
|
||||
Tabs,
|
||||
Tab,
|
||||
Button,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Restaurant,
|
||||
Hotel,
|
||||
BabyChangingStation,
|
||||
Delete,
|
||||
Edit,
|
||||
FilterList,
|
||||
} from '@mui/icons-material';
|
||||
import { AppShell } from '@/components/layouts/AppShell/AppShell';
|
||||
import { ProtectedRoute } from '@/components/common/ProtectedRoute';
|
||||
import { motion } from 'framer-motion';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
|
||||
// Mock data - will be replaced with API calls
|
||||
const mockActivities = [
|
||||
{
|
||||
id: '1',
|
||||
type: 'feeding',
|
||||
timestamp: new Date(Date.now() - 2 * 60 * 60 * 1000),
|
||||
details: 'Breast feeding - Left, 15 minutes',
|
||||
icon: <Restaurant />,
|
||||
color: '#FFB6C1',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
type: 'diaper',
|
||||
timestamp: new Date(Date.now() - 3 * 60 * 60 * 1000),
|
||||
details: 'Diaper change - Wet',
|
||||
icon: <BabyChangingStation />,
|
||||
color: '#FFE4B5',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
type: 'sleep',
|
||||
timestamp: new Date(Date.now() - 5 * 60 * 60 * 1000),
|
||||
details: 'Sleep - 2h 30m, Good quality',
|
||||
icon: <Hotel />,
|
||||
color: '#B6D7FF',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
type: 'feeding',
|
||||
timestamp: new Date(Date.now() - 6 * 60 * 60 * 1000),
|
||||
details: 'Bottle - 120ml',
|
||||
icon: <Restaurant />,
|
||||
color: '#FFB6C1',
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
type: 'diaper',
|
||||
timestamp: new Date(Date.now() - 7 * 60 * 60 * 1000),
|
||||
details: 'Diaper change - Both',
|
||||
icon: <BabyChangingStation />,
|
||||
color: '#FFE4B5',
|
||||
},
|
||||
];
|
||||
|
||||
export default function HistoryPage() {
|
||||
const [filter, setFilter] = useState<string>('all');
|
||||
const [activities, setActivities] = useState(mockActivities);
|
||||
|
||||
const filteredActivities =
|
||||
filter === 'all'
|
||||
? activities
|
||||
: activities.filter((activity) => activity.type === filter);
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
// TODO: Call API to delete activity
|
||||
setActivities(activities.filter((activity) => activity.id !== id));
|
||||
};
|
||||
|
||||
return (
|
||||
<ProtectedRoute>
|
||||
<AppShell>
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
|
||||
<Typography variant="h4" fontWeight="600">
|
||||
Activity History
|
||||
</Typography>
|
||||
<IconButton>
|
||||
<FilterList />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
{/* Filter Tabs */}
|
||||
<Paper sx={{ mb: 3 }}>
|
||||
<Tabs
|
||||
value={filter}
|
||||
onChange={(_, newValue) => setFilter(newValue)}
|
||||
variant="scrollable"
|
||||
scrollButtons="auto"
|
||||
>
|
||||
<Tab label="All" value="all" />
|
||||
<Tab label="Feeding" value="feeding" icon={<Restaurant />} iconPosition="start" />
|
||||
<Tab label="Sleep" value="sleep" icon={<Hotel />} iconPosition="start" />
|
||||
<Tab label="Diaper" value="diaper" icon={<BabyChangingStation />} iconPosition="start" />
|
||||
</Tabs>
|
||||
</Paper>
|
||||
|
||||
{/* Activity Timeline */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<Paper>
|
||||
<List>
|
||||
{filteredActivities.length === 0 ? (
|
||||
<Box sx={{ p: 4, textAlign: 'center' }}>
|
||||
<Typography variant="body1" color="text.secondary">
|
||||
No activities found
|
||||
</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
filteredActivities.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.05 }}
|
||||
>
|
||||
<ListItem
|
||||
sx={{
|
||||
borderBottom: index < filteredActivities.length - 1 ? '1px solid' : 'none',
|
||||
borderColor: 'divider',
|
||||
py: 2,
|
||||
}}
|
||||
secondaryAction={
|
||||
<Box>
|
||||
<IconButton edge="end" aria-label="edit" sx={{ mr: 1 }}>
|
||||
<Edit />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
edge="end"
|
||||
aria-label="delete"
|
||||
onClick={() => handleDelete(activity.id)}
|
||||
>
|
||||
<Delete />
|
||||
</IconButton>
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<ListItemAvatar>
|
||||
<Avatar sx={{ bgcolor: activity.color }}>
|
||||
{activity.icon}
|
||||
</Avatar>
|
||||
</ListItemAvatar>
|
||||
<ListItemText
|
||||
primary={activity.details}
|
||||
secondary={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mt: 0.5 }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{formatDistanceToNow(activity.timestamp, { addSuffix: true })}
|
||||
</Typography>
|
||||
<Chip
|
||||
label={activity.type}
|
||||
size="small"
|
||||
sx={{
|
||||
height: 20,
|
||||
fontSize: '0.7rem',
|
||||
textTransform: 'capitalize',
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
</motion.div>
|
||||
))
|
||||
)}
|
||||
</List>
|
||||
</Paper>
|
||||
</motion.div>
|
||||
|
||||
{/* Daily Summary */}
|
||||
<Paper sx={{ p: 3, mt: 3 }}>
|
||||
<Typography variant="h6" fontWeight="600" gutterBottom>
|
||||
Today's Summary
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap', mt: 2 }}>
|
||||
<Chip
|
||||
icon={<Restaurant />}
|
||||
label={`${activities.filter((a) => a.type === 'feeding').length} Feedings`}
|
||||
sx={{ bgcolor: '#FFB6C1', color: 'white' }}
|
||||
/>
|
||||
<Chip
|
||||
icon={<Hotel />}
|
||||
label={`${activities.filter((a) => a.type === 'sleep').length} Sleep Sessions`}
|
||||
sx={{ bgcolor: '#B6D7FF', color: 'white' }}
|
||||
/>
|
||||
<Chip
|
||||
icon={<BabyChangingStation />}
|
||||
label={`${activities.filter((a) => a.type === 'diaper').length} Diaper Changes`}
|
||||
sx={{ bgcolor: '#FFE4B5', color: 'white' }}
|
||||
/>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Box>
|
||||
</AppShell>
|
||||
</ProtectedRoute>
|
||||
);
|
||||
}
|
||||
25
maternal-web/app/insights/page.tsx
Normal file
25
maternal-web/app/insights/page.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
'use client';
|
||||
|
||||
import { lazy, Suspense } from 'react';
|
||||
import { AppShell } from '@/components/layouts/AppShell/AppShell';
|
||||
import { ProtectedRoute } from '@/components/common/ProtectedRoute';
|
||||
import { LoadingFallback } from '@/components/common/LoadingFallback';
|
||||
|
||||
// Lazy load the insights dashboard component
|
||||
const InsightsDashboard = lazy(() =>
|
||||
import('@/components/features/analytics/InsightsDashboard').then((mod) => ({
|
||||
default: mod.InsightsDashboard,
|
||||
}))
|
||||
);
|
||||
|
||||
export default function InsightsPage() {
|
||||
return (
|
||||
<ProtectedRoute>
|
||||
<AppShell>
|
||||
<Suspense fallback={<LoadingFallback variant="page" />}>
|
||||
<InsightsDashboard />
|
||||
</Suspense>
|
||||
</AppShell>
|
||||
</ProtectedRoute>
|
||||
);
|
||||
}
|
||||
47
maternal-web/app/layout.tsx
Normal file
47
maternal-web/app/layout.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { Inter } from 'next/font/google';
|
||||
import { ThemeRegistry } from '@/components/ThemeRegistry';
|
||||
// import { PerformanceMonitor } from '@/components/common/PerformanceMonitor'; // Temporarily disabled
|
||||
import './globals.css';
|
||||
|
||||
const inter = Inter({ subsets: ['latin'] });
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Maternal - AI-Powered Child Care Assistant',
|
||||
description: 'Track, analyze, and get AI-powered insights for your child\'s development, sleep, feeding, and more.',
|
||||
manifest: '/manifest.json',
|
||||
themeColor: '#FFB6C1',
|
||||
viewport: {
|
||||
width: 'device-width',
|
||||
initialScale: 1,
|
||||
maximumScale: 1,
|
||||
userScalable: false,
|
||||
},
|
||||
appleWebApp: {
|
||||
capable: true,
|
||||
statusBarStyle: 'default',
|
||||
title: 'Maternal',
|
||||
},
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<meta name="theme-color" content="#FFB6C1" />
|
||||
<link rel="apple-touch-icon" href="/icon-192x192.png" />
|
||||
</head>
|
||||
<body className={inter.className}>
|
||||
<ThemeRegistry>
|
||||
{/* <PerformanceMonitor /> */}
|
||||
{children}
|
||||
</ThemeRegistry>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
34
maternal-web/app/logout/page.tsx
Normal file
34
maternal-web/app/logout/page.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useAuth } from '@/lib/auth/AuthContext';
|
||||
import { Box, CircularProgress, Typography } from '@mui/material';
|
||||
|
||||
export default function LogoutPage() {
|
||||
const { logout } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
const performLogout = async () => {
|
||||
await logout();
|
||||
};
|
||||
performLogout();
|
||||
}, [logout]);
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minHeight: '100vh',
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<CircularProgress />
|
||||
<Typography variant="body1" color="text.secondary">
|
||||
Logging out...
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
212
maternal-web/app/page.tsx
Normal file
212
maternal-web/app/page.tsx
Normal file
@@ -0,0 +1,212 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Box, Typography, Button, Paper, Grid, CircularProgress } from '@mui/material';
|
||||
import { AppShell } from '@/components/layouts/AppShell/AppShell';
|
||||
import { ProtectedRoute } from '@/components/common/ProtectedRoute';
|
||||
import {
|
||||
Restaurant,
|
||||
Hotel,
|
||||
BabyChangingStation,
|
||||
Insights,
|
||||
SmartToy,
|
||||
Analytics,
|
||||
} from '@mui/icons-material';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useAuth } from '@/lib/auth/AuthContext';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { trackingApi, DailySummary } from '@/lib/api/tracking';
|
||||
import { childrenApi, Child } from '@/lib/api/children';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
export default function HomePage() {
|
||||
const { user } = useAuth();
|
||||
const router = useRouter();
|
||||
const [children, setChildren] = useState<Child[]>([]);
|
||||
const [selectedChild, setSelectedChild] = useState<Child | null>(null);
|
||||
const [dailySummary, setDailySummary] = useState<DailySummary | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const familyId = user?.families?.[0]?.familyId;
|
||||
// Load children and daily summary
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
if (!familyId) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Load children
|
||||
const childrenData = await childrenApi.getChildren(familyId);
|
||||
setChildren(childrenData);
|
||||
|
||||
if (childrenData.length > 0) {
|
||||
const firstChild = childrenData[0];
|
||||
setSelectedChild(firstChild);
|
||||
|
||||
// Load today's summary for first child
|
||||
const today = format(new Date(), 'yyyy-MM-dd');
|
||||
const summary = await trackingApi.getDailySummary(firstChild.id, today);
|
||||
setDailySummary(summary);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load data:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadData();
|
||||
}, [familyId]);
|
||||
|
||||
const quickActions = [
|
||||
{ icon: <Restaurant />, label: 'Feeding', color: '#FFB6C1', path: '/track/feeding' },
|
||||
{ icon: <Hotel />, label: 'Sleep', color: '#B6D7FF', path: '/track/sleep' },
|
||||
{ icon: <BabyChangingStation />, label: 'Diaper', color: '#FFE4B5', path: '/track/diaper' },
|
||||
{ icon: <SmartToy />, label: 'AI Assistant', color: '#FFD3B6', path: '/ai-assistant' },
|
||||
{ icon: <Analytics />, label: 'Analytics', color: '#D4B5FF', path: '/analytics' },
|
||||
];
|
||||
|
||||
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`;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ProtectedRoute>
|
||||
<AppShell>
|
||||
<Box>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<Typography variant="h4" gutterBottom fontWeight="600" sx={{ mb: 1 }}>
|
||||
Welcome Back{user?.name ? `, ${user.name}` : ''}! 👋
|
||||
</Typography>
|
||||
<Typography variant="body1" color="text.secondary" sx={{ mb: 4 }}>
|
||||
Track your child's activities and get AI-powered insights
|
||||
</Typography>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<Typography variant="h6" gutterBottom fontWeight="600" sx={{ mb: 2 }}>
|
||||
Quick Actions
|
||||
</Typography>
|
||||
<Grid container spacing={2} sx={{ mb: 4 }}>
|
||||
{quickActions.map((action, index) => (
|
||||
<Grid item xs={6} sm={2.4} key={action.label}>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.3, delay: index * 0.1 }}
|
||||
>
|
||||
<Paper
|
||||
onClick={() => router.push(action.path)}
|
||||
sx={{
|
||||
p: 3,
|
||||
textAlign: 'center',
|
||||
cursor: 'pointer',
|
||||
bgcolor: action.color,
|
||||
color: 'white',
|
||||
transition: 'transform 0.2s',
|
||||
'&:hover': {
|
||||
transform: 'scale(1.05)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box sx={{ fontSize: 48, mb: 1 }}>{action.icon}</Box>
|
||||
<Typography variant="body1" fontWeight="600">
|
||||
{action.label}
|
||||
</Typography>
|
||||
</Paper>
|
||||
</motion.div>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
|
||||
{/* Today's Summary */}
|
||||
<Typography variant="h6" gutterBottom fontWeight="600" sx={{ mb: 2 }}>
|
||||
Today's Summary{selectedChild ? ` - ${selectedChild.name}` : ''}
|
||||
</Typography>
|
||||
<Paper sx={{ p: 3 }}>
|
||||
{loading ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
) : !dailySummary ? (
|
||||
<Box sx={{ textAlign: 'center', py: 4 }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{children.length === 0
|
||||
? 'Add a child to start tracking'
|
||||
: 'No activities tracked today'}
|
||||
</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={4}>
|
||||
<Box textAlign="center">
|
||||
<Restaurant sx={{ fontSize: 32, color: 'primary.main', mb: 1 }} />
|
||||
<Typography variant="h5" fontWeight="600">
|
||||
{dailySummary.feedingCount || 0}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Feedings
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid item xs={4}>
|
||||
<Box textAlign="center">
|
||||
<Hotel sx={{ fontSize: 32, color: 'info.main', mb: 1 }} />
|
||||
<Typography variant="h5" fontWeight="600">
|
||||
{dailySummary.sleepTotalMinutes
|
||||
? formatSleepHours(dailySummary.sleepTotalMinutes)
|
||||
: '0m'}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Sleep
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid item xs={4}>
|
||||
<Box textAlign="center">
|
||||
<BabyChangingStation sx={{ fontSize: 32, color: 'warning.main', mb: 1 }} />
|
||||
<Typography variant="h5" fontWeight="600">
|
||||
{dailySummary.diaperCount || 0}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Diapers
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
{/* Next Predicted Activity */}
|
||||
<Box sx={{ mt: 4 }}>
|
||||
<Paper sx={{ p: 3, bgcolor: 'primary.light' }}>
|
||||
<Typography variant="body2" color="text.secondary" gutterBottom>
|
||||
Next Predicted Activity
|
||||
</Typography>
|
||||
<Typography variant="h6" fontWeight="600" gutterBottom>
|
||||
Nap time in 45 minutes
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Based on your child's sleep patterns
|
||||
</Typography>
|
||||
</Paper>
|
||||
</Box>
|
||||
</motion.div>
|
||||
</Box>
|
||||
</AppShell>
|
||||
</ProtectedRoute>
|
||||
);
|
||||
}
|
||||
260
maternal-web/app/settings/page.tsx
Normal file
260
maternal-web/app/settings/page.tsx
Normal file
@@ -0,0 +1,260 @@
|
||||
'use client';
|
||||
|
||||
import { Box, Typography, Card, CardContent, TextField, Button, Divider, Switch, FormControlLabel, Alert, CircularProgress, Snackbar } from '@mui/material';
|
||||
import { Save, Logout } from '@mui/icons-material';
|
||||
import { useAuth } from '@/lib/auth/AuthContext';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { AppShell } from '@/components/layouts/AppShell/AppShell';
|
||||
import { ProtectedRoute } from '@/components/common/ProtectedRoute';
|
||||
import { usersApi } from '@/lib/api/users';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { user, logout, refreshUser } = useAuth();
|
||||
const [name, setName] = useState(user?.name || '');
|
||||
const [settings, setSettings] = useState({
|
||||
notifications: true,
|
||||
emailUpdates: false,
|
||||
darkMode: false,
|
||||
});
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||
const [nameError, setNameError] = useState<string | null>(null);
|
||||
|
||||
// Load preferences from user object when it changes
|
||||
useEffect(() => {
|
||||
if (user?.preferences) {
|
||||
setSettings({
|
||||
notifications: user.preferences.notifications ?? true,
|
||||
emailUpdates: user.preferences.emailUpdates ?? false,
|
||||
darkMode: user.preferences.darkMode ?? false,
|
||||
});
|
||||
}
|
||||
}, [user?.preferences]);
|
||||
|
||||
// Sync name state when user data changes
|
||||
useEffect(() => {
|
||||
if (user?.name) {
|
||||
setName(user.name);
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
const handleSave = async () => {
|
||||
// Validate name
|
||||
if (!name || name.trim() === '') {
|
||||
setNameError('Name cannot be empty');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
setNameError(null);
|
||||
|
||||
try {
|
||||
const response = await usersApi.updateProfile({
|
||||
name: name.trim(),
|
||||
preferences: settings
|
||||
});
|
||||
console.log('✅ Profile updated successfully:', response);
|
||||
|
||||
// Refresh user to get latest data from server
|
||||
await refreshUser();
|
||||
|
||||
setSuccessMessage('Profile updated successfully!');
|
||||
} catch (err: any) {
|
||||
console.error('❌ Failed to update profile:', err);
|
||||
console.error('Error response:', err.response);
|
||||
setError(err.response?.data?.message || err.message || 'Failed to update profile. Please try again.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
await logout();
|
||||
};
|
||||
|
||||
return (
|
||||
<ProtectedRoute>
|
||||
<AppShell>
|
||||
<Box sx={{ maxWidth: 'md', mx: 'auto' }}>
|
||||
<Typography variant="h4" fontWeight="600" gutterBottom>
|
||||
Settings
|
||||
</Typography>
|
||||
<Typography variant="body1" color="text.secondary" sx={{ mb: 4 }}>
|
||||
Manage your account settings and preferences
|
||||
</Typography>
|
||||
|
||||
{/* Error Alert */}
|
||||
{error && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<Alert severity="error" sx={{ mb: 3 }} onClose={() => setError(null)}>
|
||||
{error}
|
||||
</Alert>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Profile Settings */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
>
|
||||
<Card sx={{ mb: 3 }}>
|
||||
<CardContent>
|
||||
<Typography variant="h6" fontWeight="600" gutterBottom>
|
||||
Profile Information
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, mt: 2 }}>
|
||||
<TextField
|
||||
label="Name"
|
||||
value={name}
|
||||
onChange={(e) => {
|
||||
setName(e.target.value);
|
||||
if (nameError) setNameError(null);
|
||||
}}
|
||||
fullWidth
|
||||
error={!!nameError}
|
||||
helperText={nameError}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<TextField
|
||||
label="Email"
|
||||
value={user?.email || ''}
|
||||
fullWidth
|
||||
disabled
|
||||
helperText="Email cannot be changed"
|
||||
/>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={isLoading ? <CircularProgress size={20} color="inherit" /> : <Save />}
|
||||
onClick={handleSave}
|
||||
disabled={isLoading}
|
||||
sx={{ alignSelf: 'flex-start' }}
|
||||
>
|
||||
{isLoading ? 'Saving...' : 'Save Changes'}
|
||||
</Button>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
{/* Notification Settings */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4, delay: 0.1 }}
|
||||
>
|
||||
<Card sx={{ mb: 3 }}>
|
||||
<CardContent>
|
||||
<Typography variant="h6" fontWeight="600" gutterBottom>
|
||||
Notifications
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, mt: 2 }}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={settings.notifications}
|
||||
onChange={(e) => setSettings({ ...settings, notifications: e.target.checked })}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
}
|
||||
label="Push Notifications"
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={settings.emailUpdates}
|
||||
onChange={(e) => setSettings({ ...settings, emailUpdates: e.target.checked })}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
}
|
||||
label="Email Updates"
|
||||
/>
|
||||
</Box>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={isLoading ? <CircularProgress size={20} color="inherit" /> : <Save />}
|
||||
onClick={handleSave}
|
||||
disabled={isLoading}
|
||||
sx={{ mt: 2, alignSelf: 'flex-start' }}
|
||||
>
|
||||
{isLoading ? 'Saving...' : 'Save Preferences'}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
{/* Appearance Settings */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4, delay: 0.2 }}
|
||||
>
|
||||
<Card sx={{ mb: 3 }}>
|
||||
<CardContent>
|
||||
<Typography variant="h6" fontWeight="600" gutterBottom>
|
||||
Appearance
|
||||
</Typography>
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={settings.darkMode}
|
||||
onChange={(e) => setSettings({ ...settings, darkMode: e.target.checked })}
|
||||
/>
|
||||
}
|
||||
label="Dark Mode (Coming Soon)"
|
||||
disabled
|
||||
/>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
{/* Account Actions */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4, delay: 0.3 }}
|
||||
>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" fontWeight="600" gutterBottom>
|
||||
Account Actions
|
||||
</Typography>
|
||||
<Divider sx={{ my: 2 }} />
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="error"
|
||||
startIcon={<Logout />}
|
||||
onClick={handleLogout}
|
||||
fullWidth
|
||||
>
|
||||
Logout
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
{/* Success Snackbar */}
|
||||
<Snackbar
|
||||
open={!!successMessage}
|
||||
autoHideDuration={4000}
|
||||
onClose={() => setSuccessMessage(null)}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
||||
>
|
||||
<Alert onClose={() => setSuccessMessage(null)} severity="success" sx={{ width: '100%' }}>
|
||||
{successMessage}
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
</Box>
|
||||
</AppShell>
|
||||
</ProtectedRoute>
|
||||
);
|
||||
}
|
||||
663
maternal-web/app/track/diaper/page.tsx
Normal file
663
maternal-web/app/track/diaper/page.tsx
Normal file
@@ -0,0 +1,663 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Button,
|
||||
Paper,
|
||||
TextField,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Select,
|
||||
MenuItem,
|
||||
IconButton,
|
||||
Alert,
|
||||
CircularProgress,
|
||||
Card,
|
||||
CardContent,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogContentText,
|
||||
DialogActions,
|
||||
Chip,
|
||||
Snackbar,
|
||||
ToggleButtonGroup,
|
||||
ToggleButton,
|
||||
FormLabel,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
ArrowBack,
|
||||
Refresh,
|
||||
Save,
|
||||
Delete,
|
||||
BabyChangingStation,
|
||||
Warning,
|
||||
CheckCircle,
|
||||
ChildCare,
|
||||
Add,
|
||||
} from '@mui/icons-material';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { AppShell } from '@/components/layouts/AppShell/AppShell';
|
||||
import { ProtectedRoute } from '@/components/common/ProtectedRoute';
|
||||
import { useAuth } from '@/lib/auth/AuthContext';
|
||||
import { trackingApi, Activity } from '@/lib/api/tracking';
|
||||
import { childrenApi, Child } from '@/lib/api/children';
|
||||
import { motion } from 'framer-motion';
|
||||
import { formatDistanceToNow, format } from 'date-fns';
|
||||
|
||||
interface DiaperData {
|
||||
diaperType: 'wet' | 'dirty' | 'both' | 'dry';
|
||||
conditions: string[];
|
||||
hasRash: boolean;
|
||||
rashSeverity?: 'mild' | 'moderate' | 'severe';
|
||||
}
|
||||
|
||||
export default function DiaperTrackPage() {
|
||||
const router = useRouter();
|
||||
const { user } = useAuth();
|
||||
const [children, setChildren] = useState<Child[]>([]);
|
||||
const [selectedChild, setSelectedChild] = useState<string>('');
|
||||
|
||||
// Diaper state
|
||||
const [timestamp, setTimestamp] = useState<string>(
|
||||
format(new Date(), "yyyy-MM-dd'T'HH:mm")
|
||||
);
|
||||
const [diaperType, setDiaperType] = useState<'wet' | 'dirty' | 'both' | 'dry'>('wet');
|
||||
const [conditions, setConditions] = useState<string[]>(['normal']);
|
||||
const [hasRash, setHasRash] = useState<boolean>(false);
|
||||
const [rashSeverity, setRashSeverity] = useState<'mild' | 'moderate' | 'severe'>('mild');
|
||||
|
||||
// Common state
|
||||
const [notes, setNotes] = useState<string>('');
|
||||
const [recentDiapers, setRecentDiapers] = useState<Activity[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [childrenLoading, setChildrenLoading] = useState(true);
|
||||
const [diapersLoading, setDiapersLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||
|
||||
// Delete confirmation dialog
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [activityToDelete, setActivityToDelete] = useState<string | null>(null);
|
||||
|
||||
const familyId = user?.families?.[0]?.familyId;
|
||||
|
||||
const availableConditions = [
|
||||
'normal',
|
||||
'soft',
|
||||
'hard',
|
||||
'watery',
|
||||
'mucus',
|
||||
'blood',
|
||||
];
|
||||
|
||||
// Load children
|
||||
useEffect(() => {
|
||||
if (familyId) {
|
||||
loadChildren();
|
||||
}
|
||||
}, [familyId]);
|
||||
|
||||
// Load recent diapers when child is selected
|
||||
useEffect(() => {
|
||||
if (selectedChild) {
|
||||
loadRecentDiapers();
|
||||
}
|
||||
}, [selectedChild]);
|
||||
|
||||
const loadChildren = async () => {
|
||||
if (!familyId) return;
|
||||
|
||||
try {
|
||||
setChildrenLoading(true);
|
||||
const childrenData = await childrenApi.getChildren(familyId);
|
||||
setChildren(childrenData);
|
||||
if (childrenData.length > 0) {
|
||||
setSelectedChild(childrenData[0].id);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Failed to load children:', err);
|
||||
setError(err.response?.data?.message || 'Failed to load children');
|
||||
} finally {
|
||||
setChildrenLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadRecentDiapers = async () => {
|
||||
if (!selectedChild) return;
|
||||
|
||||
try {
|
||||
setDiapersLoading(true);
|
||||
const activities = await trackingApi.getActivities(selectedChild, 'diaper');
|
||||
// Sort by timestamp descending and take last 10
|
||||
const sorted = activities.sort((a, b) =>
|
||||
new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
|
||||
).slice(0, 10);
|
||||
setRecentDiapers(sorted);
|
||||
} catch (err: any) {
|
||||
console.error('Failed to load recent diapers:', err);
|
||||
} finally {
|
||||
setDiapersLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const setTimeNow = () => {
|
||||
setTimestamp(format(new Date(), "yyyy-MM-dd'T'HH:mm"));
|
||||
};
|
||||
|
||||
const handleConditionToggle = (condition: string) => {
|
||||
setConditions((prev) => {
|
||||
if (prev.includes(condition)) {
|
||||
// Remove condition, but ensure at least one remains
|
||||
if (prev.length === 1) return prev;
|
||||
return prev.filter((c) => c !== condition);
|
||||
} else {
|
||||
return [...prev, condition];
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!selectedChild) {
|
||||
setError('Please select a child');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validation
|
||||
if (!timestamp) {
|
||||
setError('Please enter timestamp');
|
||||
return;
|
||||
}
|
||||
|
||||
if (conditions.length === 0) {
|
||||
setError('Please select at least one condition');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const data: DiaperData = {
|
||||
diaperType,
|
||||
conditions,
|
||||
hasRash,
|
||||
};
|
||||
|
||||
if (hasRash) {
|
||||
data.rashSeverity = rashSeverity;
|
||||
}
|
||||
|
||||
await trackingApi.createActivity(selectedChild, {
|
||||
type: 'diaper',
|
||||
timestamp,
|
||||
data,
|
||||
notes: notes || undefined,
|
||||
});
|
||||
|
||||
setSuccessMessage('Diaper change logged successfully!');
|
||||
|
||||
// Reset form
|
||||
resetForm();
|
||||
|
||||
// Reload recent diapers
|
||||
await loadRecentDiapers();
|
||||
} catch (err: any) {
|
||||
console.error('Failed to save diaper:', err);
|
||||
setError(err.response?.data?.message || 'Failed to save diaper change');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setTimestamp(format(new Date(), "yyyy-MM-dd'T'HH:mm"));
|
||||
setDiaperType('wet');
|
||||
setConditions(['normal']);
|
||||
setHasRash(false);
|
||||
setRashSeverity('mild');
|
||||
setNotes('');
|
||||
};
|
||||
|
||||
const handleDeleteClick = (activityId: string) => {
|
||||
setActivityToDelete(activityId);
|
||||
setDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleDeleteConfirm = async () => {
|
||||
if (!activityToDelete) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
await trackingApi.deleteActivity(activityToDelete);
|
||||
setSuccessMessage('Diaper change deleted successfully');
|
||||
setDeleteDialogOpen(false);
|
||||
setActivityToDelete(null);
|
||||
await loadRecentDiapers();
|
||||
} catch (err: any) {
|
||||
console.error('Failed to delete diaper:', err);
|
||||
setError(err.response?.data?.message || 'Failed to delete diaper change');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getDiaperTypeColor = (type: string) => {
|
||||
switch (type) {
|
||||
case 'wet':
|
||||
return '#2196f3'; // blue
|
||||
case 'dirty':
|
||||
return '#795548'; // brown
|
||||
case 'both':
|
||||
return '#ff9800'; // orange
|
||||
case 'dry':
|
||||
return '#4caf50'; // green
|
||||
default:
|
||||
return '#757575'; // grey
|
||||
}
|
||||
};
|
||||
|
||||
const getDiaperTypeIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case 'wet':
|
||||
return '💧';
|
||||
case 'dirty':
|
||||
return '💩';
|
||||
case 'both':
|
||||
return '💧💩';
|
||||
case 'dry':
|
||||
return '✨';
|
||||
default:
|
||||
return '🍼';
|
||||
}
|
||||
};
|
||||
|
||||
const getDiaperDetails = (activity: Activity) => {
|
||||
const data = activity.data as DiaperData;
|
||||
const typeLabel = data.diaperType.charAt(0).toUpperCase() + data.diaperType.slice(1);
|
||||
const conditionsLabel = data.conditions.join(', ');
|
||||
|
||||
let details = `${typeLabel} - ${conditionsLabel}`;
|
||||
|
||||
if (data.hasRash) {
|
||||
details += ` - Rash (${data.rashSeverity})`;
|
||||
}
|
||||
|
||||
return details;
|
||||
};
|
||||
|
||||
const getRashSeverityColor = (severity: string) => {
|
||||
switch (severity) {
|
||||
case 'mild':
|
||||
return 'warning';
|
||||
case 'moderate':
|
||||
return 'error';
|
||||
case 'severe':
|
||||
return 'error';
|
||||
default:
|
||||
return 'default';
|
||||
}
|
||||
};
|
||||
|
||||
if (childrenLoading) {
|
||||
return (
|
||||
<ProtectedRoute>
|
||||
<AppShell>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
</AppShell>
|
||||
</ProtectedRoute>
|
||||
);
|
||||
}
|
||||
|
||||
if (!familyId || children.length === 0) {
|
||||
return (
|
||||
<ProtectedRoute>
|
||||
<AppShell>
|
||||
<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 }}>
|
||||
You need to add a child before you can track diaper changes
|
||||
</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<Add />}
|
||||
onClick={() => router.push('/children')}
|
||||
>
|
||||
Add Child
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AppShell>
|
||||
</ProtectedRoute>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ProtectedRoute>
|
||||
<AppShell>
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 3 }}>
|
||||
<IconButton onClick={() => router.back()} sx={{ mr: 2 }}>
|
||||
<ArrowBack />
|
||||
</IconButton>
|
||||
<Typography variant="h4" fontWeight="600">
|
||||
Track Diaper Change
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 3 }} onClose={() => setError(null)}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
{/* Child Selector */}
|
||||
{children.length > 1 && (
|
||||
<Paper sx={{ p: 2, mb: 3 }}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Select Child</InputLabel>
|
||||
<Select
|
||||
value={selectedChild}
|
||||
onChange={(e) => setSelectedChild(e.target.value)}
|
||||
label="Select Child"
|
||||
>
|
||||
{children.map((child) => (
|
||||
<MenuItem key={child.id} value={child.id}>
|
||||
{child.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* Main Form */}
|
||||
<Paper sx={{ p: 3, mb: 3 }}>
|
||||
{/* Icon Header */}
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', mb: 3 }}>
|
||||
<BabyChangingStation sx={{ fontSize: 64, color: 'primary.main' }} />
|
||||
</Box>
|
||||
|
||||
{/* Timestamp */}
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Typography variant="subtitle1" fontWeight="600" sx={{ mb: 1 }}>
|
||||
Time
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 2, alignItems: 'flex-start' }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
type="datetime-local"
|
||||
value={timestamp}
|
||||
onChange={(e) => setTimestamp(e.target.value)}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
/>
|
||||
<Button variant="outlined" onClick={setTimeNow} sx={{ minWidth: 100 }}>
|
||||
Now
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Diaper Type */}
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Typography variant="subtitle1" fontWeight="600" sx={{ mb: 2 }}>
|
||||
Diaper Type
|
||||
</Typography>
|
||||
<ToggleButtonGroup
|
||||
value={diaperType}
|
||||
exclusive
|
||||
onChange={(_, value) => {
|
||||
if (value !== null) {
|
||||
setDiaperType(value);
|
||||
}
|
||||
}}
|
||||
fullWidth
|
||||
>
|
||||
<ToggleButton value="wet" sx={{ py: 2 }}>
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<Typography variant="h5">💧</Typography>
|
||||
<Typography variant="body2">Wet</Typography>
|
||||
</Box>
|
||||
</ToggleButton>
|
||||
<ToggleButton value="dirty" sx={{ py: 2 }}>
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<Typography variant="h5">💩</Typography>
|
||||
<Typography variant="body2">Dirty</Typography>
|
||||
</Box>
|
||||
</ToggleButton>
|
||||
<ToggleButton value="both" sx={{ py: 2 }}>
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<Typography variant="h5">💧💩</Typography>
|
||||
<Typography variant="body2">Both</Typography>
|
||||
</Box>
|
||||
</ToggleButton>
|
||||
<ToggleButton value="dry" sx={{ py: 2 }}>
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<Typography variant="h5">✨</Typography>
|
||||
<Typography variant="body2">Dry</Typography>
|
||||
</Box>
|
||||
</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
</Box>
|
||||
|
||||
{/* Condition Selector */}
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Typography variant="subtitle1" fontWeight="600" sx={{ mb: 1 }}>
|
||||
Condition (select all that apply)
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||
{availableConditions.map((condition) => (
|
||||
<Chip
|
||||
key={condition}
|
||||
label={condition.charAt(0).toUpperCase() + condition.slice(1)}
|
||||
onClick={() => handleConditionToggle(condition)}
|
||||
color={conditions.includes(condition) ? 'primary' : 'default'}
|
||||
variant={conditions.includes(condition) ? 'filled' : 'outlined'}
|
||||
sx={{ cursor: 'pointer' }}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Rash Indicator */}
|
||||
<FormControl fullWidth sx={{ mb: 3 }}>
|
||||
<InputLabel>Diaper Rash?</InputLabel>
|
||||
<Select
|
||||
value={hasRash ? 'yes' : 'no'}
|
||||
onChange={(e) => setHasRash(e.target.value === 'yes')}
|
||||
label="Diaper Rash?"
|
||||
>
|
||||
<MenuItem value="no">No</MenuItem>
|
||||
<MenuItem value="yes">Yes</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
{/* Rash Severity */}
|
||||
{hasRash && (
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Alert severity="warning" sx={{ mb: 2 }}>
|
||||
<Typography variant="body2" sx={{ mb: 1 }}>
|
||||
Diaper rash detected. Consider applying diaper rash cream and consulting your pediatrician if it persists.
|
||||
</Typography>
|
||||
</Alert>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Rash Severity</InputLabel>
|
||||
<Select
|
||||
value={rashSeverity}
|
||||
onChange={(e) => setRashSeverity(e.target.value as 'mild' | 'moderate' | 'severe')}
|
||||
label="Rash Severity"
|
||||
>
|
||||
<MenuItem value="mild">Mild</MenuItem>
|
||||
<MenuItem value="moderate">Moderate</MenuItem>
|
||||
<MenuItem value="severe">Severe</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Notes Field */}
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Notes (optional)"
|
||||
multiline
|
||||
rows={3}
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
sx={{ mb: 3 }}
|
||||
placeholder="Color, consistency, or any concerns..."
|
||||
/>
|
||||
|
||||
{/* Submit Button */}
|
||||
<Button
|
||||
fullWidth
|
||||
type="button"
|
||||
variant="contained"
|
||||
size="large"
|
||||
startIcon={<Save />}
|
||||
onClick={handleSubmit}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? 'Saving...' : 'Save Diaper Change'}
|
||||
</Button>
|
||||
</Paper>
|
||||
|
||||
{/* Recent Diapers */}
|
||||
<Paper sx={{ p: 3 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||
<Typography variant="h6" fontWeight="600">
|
||||
Recent Diaper Changes
|
||||
</Typography>
|
||||
<IconButton onClick={loadRecentDiapers} disabled={diapersLoading}>
|
||||
<Refresh />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
{diapersLoading ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
|
||||
<CircularProgress size={30} />
|
||||
</Box>
|
||||
) : recentDiapers.length === 0 ? (
|
||||
<Box sx={{ textAlign: 'center', py: 4 }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
No diaper changes yet
|
||||
</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{recentDiapers.map((activity, index) => {
|
||||
const data = activity.data as DiaperData;
|
||||
return (
|
||||
<motion.div
|
||||
key={activity.id}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.2, delay: index * 0.05 }}
|
||||
>
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 2 }}>
|
||||
<Box sx={{ mt: 0.5, fontSize: '2rem' }}>
|
||||
{getDiaperTypeIcon(data.diaperType)}
|
||||
</Box>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5, flexWrap: 'wrap' }}>
|
||||
<Typography variant="body1" fontWeight="600">
|
||||
Diaper Change
|
||||
</Typography>
|
||||
<Chip
|
||||
label={data.diaperType.charAt(0).toUpperCase() + data.diaperType.slice(1)}
|
||||
size="small"
|
||||
sx={{
|
||||
bgcolor: getDiaperTypeColor(data.diaperType),
|
||||
color: 'white'
|
||||
}}
|
||||
/>
|
||||
{data.hasRash && (
|
||||
<Chip
|
||||
icon={<Warning sx={{ fontSize: 16 }} />}
|
||||
label={`Rash: ${data.rashSeverity}`}
|
||||
size="small"
|
||||
color={getRashSeverityColor(data.rashSeverity || 'mild') as any}
|
||||
/>
|
||||
)}
|
||||
<Chip
|
||||
label={formatDistanceToNow(new Date(activity.timestamp), { addSuffix: true })}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
/>
|
||||
</Box>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
|
||||
{getDiaperDetails(activity)}
|
||||
</Typography>
|
||||
{activity.notes && (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ fontStyle: 'italic' }}>
|
||||
{activity.notes}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', gap: 0.5 }}>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="error"
|
||||
onClick={() => handleDeleteClick(activity.id)}
|
||||
disabled={loading}
|
||||
>
|
||||
<Delete />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
</motion.div>
|
||||
</Box>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<Dialog
|
||||
open={deleteDialogOpen}
|
||||
onClose={() => setDeleteDialogOpen(false)}
|
||||
>
|
||||
<DialogTitle>Delete Diaper Change?</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>
|
||||
Are you sure you want to delete this diaper change? This action cannot be undone.
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setDeleteDialogOpen(false)} disabled={loading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleDeleteConfirm} color="error" disabled={loading}>
|
||||
{loading ? 'Deleting...' : 'Delete'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* Success Snackbar */}
|
||||
<Snackbar
|
||||
open={!!successMessage}
|
||||
autoHideDuration={3000}
|
||||
onClose={() => setSuccessMessage(null)}
|
||||
message={successMessage}
|
||||
/>
|
||||
</AppShell>
|
||||
</ProtectedRoute>
|
||||
);
|
||||
}
|
||||
656
maternal-web/app/track/feeding/page.tsx
Normal file
656
maternal-web/app/track/feeding/page.tsx
Normal file
@@ -0,0 +1,656 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Button,
|
||||
Paper,
|
||||
TextField,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Select,
|
||||
MenuItem,
|
||||
IconButton,
|
||||
Alert,
|
||||
Tabs,
|
||||
Tab,
|
||||
CircularProgress,
|
||||
Card,
|
||||
CardContent,
|
||||
Divider,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogContentText,
|
||||
DialogActions,
|
||||
Chip,
|
||||
Snackbar,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
ArrowBack,
|
||||
PlayArrow,
|
||||
Stop,
|
||||
Refresh,
|
||||
Save,
|
||||
Restaurant,
|
||||
LocalCafe,
|
||||
Fastfood,
|
||||
Delete,
|
||||
Edit,
|
||||
ChildCare,
|
||||
Add,
|
||||
} from '@mui/icons-material';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { AppShell } from '@/components/layouts/AppShell/AppShell';
|
||||
import { ProtectedRoute } from '@/components/common/ProtectedRoute';
|
||||
import { useAuth } from '@/lib/auth/AuthContext';
|
||||
import { trackingApi, Activity } from '@/lib/api/tracking';
|
||||
import { childrenApi, Child } from '@/lib/api/children';
|
||||
import { motion } from 'framer-motion';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
|
||||
interface FeedingData {
|
||||
feedingType: 'breast' | 'bottle' | 'solid';
|
||||
side?: 'left' | 'right' | 'both';
|
||||
duration?: number;
|
||||
amount?: number;
|
||||
bottleType?: 'formula' | 'breastmilk' | 'other';
|
||||
foodDescription?: string;
|
||||
amountDescription?: string;
|
||||
}
|
||||
|
||||
export default function FeedingTrackPage() {
|
||||
const router = useRouter();
|
||||
const { user } = useAuth();
|
||||
const [children, setChildren] = useState<Child[]>([]);
|
||||
const [selectedChild, setSelectedChild] = useState<string>('');
|
||||
const [feedingType, setFeedingType] = useState<'breast' | 'bottle' | 'solid'>('breast');
|
||||
|
||||
// Breastfeeding state
|
||||
const [side, setSide] = useState<'left' | 'right' | 'both'>('left');
|
||||
const [duration, setDuration] = useState<number>(0);
|
||||
const [isTimerRunning, setIsTimerRunning] = useState(false);
|
||||
const [timerSeconds, setTimerSeconds] = useState(0);
|
||||
|
||||
// Bottle feeding state
|
||||
const [amount, setAmount] = useState<string>('');
|
||||
const [bottleType, setBottleType] = useState<'formula' | 'breastmilk' | 'other'>('formula');
|
||||
|
||||
// Solid food state
|
||||
const [foodDescription, setFoodDescription] = useState<string>('');
|
||||
const [amountDescription, setAmountDescription] = useState<string>('');
|
||||
|
||||
// Common state
|
||||
const [notes, setNotes] = useState<string>('');
|
||||
const [recentFeedings, setRecentFeedings] = useState<Activity[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [childrenLoading, setChildrenLoading] = useState(true);
|
||||
const [feedingsLoading, setFeedingsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||
|
||||
// Delete confirmation dialog
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [activityToDelete, setActivityToDelete] = useState<string | null>(null);
|
||||
|
||||
const familyId = user?.families?.[0]?.familyId;
|
||||
|
||||
// Load children
|
||||
useEffect(() => {
|
||||
if (familyId) {
|
||||
loadChildren();
|
||||
}
|
||||
}, [familyId]);
|
||||
|
||||
// Load recent feedings when child is selected
|
||||
useEffect(() => {
|
||||
if (selectedChild) {
|
||||
loadRecentFeedings();
|
||||
}
|
||||
}, [selectedChild]);
|
||||
|
||||
// Timer effect
|
||||
useEffect(() => {
|
||||
let interval: NodeJS.Timeout;
|
||||
if (isTimerRunning) {
|
||||
interval = setInterval(() => {
|
||||
setTimerSeconds((prev) => prev + 1);
|
||||
}, 1000);
|
||||
}
|
||||
return () => clearInterval(interval);
|
||||
}, [isTimerRunning]);
|
||||
|
||||
const loadChildren = async () => {
|
||||
if (!familyId) return;
|
||||
|
||||
try {
|
||||
setChildrenLoading(true);
|
||||
const childrenData = await childrenApi.getChildren(familyId);
|
||||
setChildren(childrenData);
|
||||
if (childrenData.length > 0) {
|
||||
setSelectedChild(childrenData[0].id);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Failed to load children:', err);
|
||||
setError(err.response?.data?.message || 'Failed to load children');
|
||||
} finally {
|
||||
setChildrenLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadRecentFeedings = async () => {
|
||||
if (!selectedChild) return;
|
||||
|
||||
try {
|
||||
setFeedingsLoading(true);
|
||||
const activities = await trackingApi.getActivities(selectedChild, 'feeding');
|
||||
// Sort by timestamp descending and take last 10
|
||||
const sorted = activities.sort((a, b) =>
|
||||
new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
|
||||
).slice(0, 10);
|
||||
setRecentFeedings(sorted);
|
||||
} catch (err: any) {
|
||||
console.error('Failed to load recent feedings:', err);
|
||||
} finally {
|
||||
setFeedingsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDuration = (seconds: number) => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const startTimer = () => {
|
||||
setIsTimerRunning(true);
|
||||
};
|
||||
|
||||
const stopTimer = () => {
|
||||
setIsTimerRunning(false);
|
||||
setDuration(Math.floor(timerSeconds / 60));
|
||||
};
|
||||
|
||||
const resetTimer = () => {
|
||||
setIsTimerRunning(false);
|
||||
setTimerSeconds(0);
|
||||
setDuration(0);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!selectedChild) {
|
||||
setError('Please select a child');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validation
|
||||
if (feedingType === 'breast' && duration === 0 && timerSeconds === 0) {
|
||||
setError('Please enter duration or use the timer');
|
||||
return;
|
||||
}
|
||||
|
||||
if (feedingType === 'bottle' && !amount) {
|
||||
setError('Please enter amount');
|
||||
return;
|
||||
}
|
||||
|
||||
if (feedingType === 'solid' && !foodDescription) {
|
||||
setError('Please enter food description');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const data: FeedingData = {
|
||||
feedingType,
|
||||
};
|
||||
|
||||
if (feedingType === 'breast') {
|
||||
data.side = side;
|
||||
data.duration = duration || Math.floor(timerSeconds / 60);
|
||||
} else if (feedingType === 'bottle') {
|
||||
data.amount = parseFloat(amount);
|
||||
data.bottleType = bottleType;
|
||||
} else if (feedingType === 'solid') {
|
||||
data.foodDescription = foodDescription;
|
||||
data.amountDescription = amountDescription;
|
||||
}
|
||||
|
||||
await trackingApi.createActivity(selectedChild, {
|
||||
type: 'feeding',
|
||||
timestamp: new Date().toISOString(),
|
||||
data,
|
||||
notes: notes || undefined,
|
||||
});
|
||||
|
||||
setSuccessMessage('Feeding logged successfully!');
|
||||
|
||||
// Reset form
|
||||
resetForm();
|
||||
|
||||
// Reload recent feedings
|
||||
await loadRecentFeedings();
|
||||
} catch (err: any) {
|
||||
console.error('Failed to save feeding:', err);
|
||||
setError(err.response?.data?.message || 'Failed to save feeding');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setSide('left');
|
||||
setDuration(0);
|
||||
setTimerSeconds(0);
|
||||
setIsTimerRunning(false);
|
||||
setAmount('');
|
||||
setBottleType('formula');
|
||||
setFoodDescription('');
|
||||
setAmountDescription('');
|
||||
setNotes('');
|
||||
};
|
||||
|
||||
const handleDeleteClick = (activityId: string) => {
|
||||
setActivityToDelete(activityId);
|
||||
setDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleDeleteConfirm = async () => {
|
||||
if (!activityToDelete) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
await trackingApi.deleteActivity(activityToDelete);
|
||||
setSuccessMessage('Feeding deleted successfully');
|
||||
setDeleteDialogOpen(false);
|
||||
setActivityToDelete(null);
|
||||
await loadRecentFeedings();
|
||||
} catch (err: any) {
|
||||
console.error('Failed to delete feeding:', err);
|
||||
setError(err.response?.data?.message || 'Failed to delete feeding');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getFeedingTypeIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case 'breast':
|
||||
return <LocalCafe />;
|
||||
case 'bottle':
|
||||
return <Restaurant />;
|
||||
case 'solid':
|
||||
return <Fastfood />;
|
||||
default:
|
||||
return <Restaurant />;
|
||||
}
|
||||
};
|
||||
|
||||
const getFeedingDetails = (activity: Activity) => {
|
||||
const data = activity.data as FeedingData;
|
||||
|
||||
if (data.feedingType === 'breast') {
|
||||
return `${data.side?.toUpperCase()} - ${data.duration || 0} min`;
|
||||
} else if (data.feedingType === 'bottle') {
|
||||
return `${data.amount || 0} ml - ${data.bottleType}`;
|
||||
} else if (data.feedingType === 'solid') {
|
||||
return `${data.foodDescription}${data.amountDescription ? ` - ${data.amountDescription}` : ''}`;
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
if (childrenLoading) {
|
||||
return (
|
||||
<ProtectedRoute>
|
||||
<AppShell>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
</AppShell>
|
||||
</ProtectedRoute>
|
||||
);
|
||||
}
|
||||
|
||||
if (!familyId || children.length === 0) {
|
||||
return (
|
||||
<ProtectedRoute>
|
||||
<AppShell>
|
||||
<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 }}>
|
||||
You need to add a child before you can track feeding activities
|
||||
</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<Add />}
|
||||
onClick={() => router.push('/children')}
|
||||
>
|
||||
Add Child
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AppShell>
|
||||
</ProtectedRoute>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ProtectedRoute>
|
||||
<AppShell>
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 3 }}>
|
||||
<IconButton onClick={() => router.back()} sx={{ mr: 2 }}>
|
||||
<ArrowBack />
|
||||
</IconButton>
|
||||
<Typography variant="h4" fontWeight="600">
|
||||
Track Feeding
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 3 }} onClose={() => setError(null)}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
{/* Child Selector */}
|
||||
{children.length > 1 && (
|
||||
<Paper sx={{ p: 2, mb: 3 }}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Select Child</InputLabel>
|
||||
<Select
|
||||
value={selectedChild}
|
||||
onChange={(e) => setSelectedChild(e.target.value)}
|
||||
label="Select Child"
|
||||
>
|
||||
{children.map((child) => (
|
||||
<MenuItem key={child.id} value={child.id}>
|
||||
{child.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* Main Form */}
|
||||
<Paper sx={{ p: 3, mb: 3 }}>
|
||||
{/* Feeding Type Tabs */}
|
||||
<Tabs
|
||||
value={feedingType}
|
||||
onChange={(_, newValue) => setFeedingType(newValue)}
|
||||
sx={{ mb: 3 }}
|
||||
variant="fullWidth"
|
||||
>
|
||||
<Tab label="Breastfeeding" value="breast" icon={<LocalCafe />} iconPosition="start" />
|
||||
<Tab label="Bottle" value="bottle" icon={<Restaurant />} iconPosition="start" />
|
||||
<Tab label="Solid Food" value="solid" icon={<Fastfood />} iconPosition="start" />
|
||||
</Tabs>
|
||||
|
||||
{/* Breastfeeding Form */}
|
||||
{feedingType === 'breast' && (
|
||||
<Box>
|
||||
{/* Timer Display */}
|
||||
<Box sx={{ textAlign: 'center', mb: 4 }}>
|
||||
<Typography variant="h2" fontWeight="600" sx={{ mb: 2 }}>
|
||||
{formatDuration(timerSeconds)}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 2, justifyContent: 'center' }}>
|
||||
{!isTimerRunning ? (
|
||||
<Button
|
||||
variant="contained"
|
||||
size="large"
|
||||
startIcon={<PlayArrow />}
|
||||
onClick={startTimer}
|
||||
>
|
||||
Start Timer
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="contained"
|
||||
color="error"
|
||||
size="large"
|
||||
startIcon={<Stop />}
|
||||
onClick={stopTimer}
|
||||
>
|
||||
Stop Timer
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="large"
|
||||
startIcon={<Refresh />}
|
||||
onClick={resetTimer}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Side Selector */}
|
||||
<FormControl fullWidth sx={{ mb: 3 }}>
|
||||
<InputLabel>Side</InputLabel>
|
||||
<Select
|
||||
value={side}
|
||||
onChange={(e) => setSide(e.target.value as 'left' | 'right' | 'both')}
|
||||
label="Side"
|
||||
>
|
||||
<MenuItem value="left">Left</MenuItem>
|
||||
<MenuItem value="right">Right</MenuItem>
|
||||
<MenuItem value="both">Both</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
{/* Manual Duration Input */}
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Duration (minutes)"
|
||||
type="number"
|
||||
value={duration || ''}
|
||||
onChange={(e) => setDuration(parseInt(e.target.value) || 0)}
|
||||
sx={{ mb: 3 }}
|
||||
helperText="Or use the timer above"
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Bottle Form */}
|
||||
{feedingType === 'bottle' && (
|
||||
<Box>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Amount (ml)"
|
||||
type="number"
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
sx={{ mb: 3 }}
|
||||
/>
|
||||
|
||||
<FormControl fullWidth sx={{ mb: 3 }}>
|
||||
<InputLabel>Type</InputLabel>
|
||||
<Select
|
||||
value={bottleType}
|
||||
onChange={(e) => setBottleType(e.target.value as 'formula' | 'breastmilk' | 'other')}
|
||||
label="Type"
|
||||
>
|
||||
<MenuItem value="formula">Formula</MenuItem>
|
||||
<MenuItem value="breastmilk">Breast Milk</MenuItem>
|
||||
<MenuItem value="other">Other</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Solid Food Form */}
|
||||
{feedingType === 'solid' && (
|
||||
<Box>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Food Description"
|
||||
value={foodDescription}
|
||||
onChange={(e) => setFoodDescription(e.target.value)}
|
||||
sx={{ mb: 3 }}
|
||||
placeholder="e.g., Mashed banana, Rice cereal"
|
||||
/>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Amount (optional)"
|
||||
value={amountDescription}
|
||||
onChange={(e) => setAmountDescription(e.target.value)}
|
||||
sx={{ mb: 3 }}
|
||||
placeholder="e.g., 2 tablespoons, Half bowl"
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Common Notes Field */}
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Notes (optional)"
|
||||
multiline
|
||||
rows={3}
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
sx={{ mb: 3 }}
|
||||
placeholder="Any additional notes..."
|
||||
/>
|
||||
|
||||
{/* Submit Button */}
|
||||
<Button
|
||||
fullWidth
|
||||
type="button"
|
||||
variant="contained"
|
||||
size="large"
|
||||
startIcon={<Save />}
|
||||
onClick={handleSubmit}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? 'Saving...' : 'Save Feeding'}
|
||||
</Button>
|
||||
</Paper>
|
||||
|
||||
{/* Recent Feedings */}
|
||||
<Paper sx={{ p: 3 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||
<Typography variant="h6" fontWeight="600">
|
||||
Recent Feedings
|
||||
</Typography>
|
||||
<IconButton onClick={loadRecentFeedings} disabled={feedingsLoading}>
|
||||
<Refresh />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
{feedingsLoading ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
|
||||
<CircularProgress size={30} />
|
||||
</Box>
|
||||
) : recentFeedings.length === 0 ? (
|
||||
<Box sx={{ textAlign: 'center', py: 4 }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
No feeding activities yet
|
||||
</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{recentFeedings.map((activity, index) => {
|
||||
const data = activity.data as FeedingData;
|
||||
return (
|
||||
<motion.div
|
||||
key={activity.id}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.2, delay: index * 0.05 }}
|
||||
>
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 2 }}>
|
||||
<Box sx={{ mt: 0.5 }}>
|
||||
{getFeedingTypeIcon(data.feedingType)}
|
||||
</Box>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
|
||||
<Typography variant="body1" fontWeight="600">
|
||||
{data.feedingType.charAt(0).toUpperCase() + data.feedingType.slice(1)}
|
||||
</Typography>
|
||||
<Chip
|
||||
label={formatDistanceToNow(new Date(activity.timestamp), { addSuffix: true })}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
/>
|
||||
</Box>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
|
||||
{getFeedingDetails(activity)}
|
||||
</Typography>
|
||||
{activity.notes && (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ fontStyle: 'italic' }}>
|
||||
{activity.notes}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', gap: 0.5 }}>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="error"
|
||||
onClick={() => handleDeleteClick(activity.id)}
|
||||
disabled={loading}
|
||||
>
|
||||
<Delete />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
</motion.div>
|
||||
</Box>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<Dialog
|
||||
open={deleteDialogOpen}
|
||||
onClose={() => setDeleteDialogOpen(false)}
|
||||
>
|
||||
<DialogTitle>Delete Feeding Activity?</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>
|
||||
Are you sure you want to delete this feeding activity? This action cannot be undone.
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setDeleteDialogOpen(false)} disabled={loading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleDeleteConfirm} color="error" disabled={loading}>
|
||||
{loading ? 'Deleting...' : 'Delete'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* Success Snackbar */}
|
||||
<Snackbar
|
||||
open={!!successMessage}
|
||||
autoHideDuration={3000}
|
||||
onClose={() => setSuccessMessage(null)}
|
||||
message={successMessage}
|
||||
/>
|
||||
</AppShell>
|
||||
</ProtectedRoute>
|
||||
);
|
||||
}
|
||||
89
maternal-web/app/track/page.tsx
Normal file
89
maternal-web/app/track/page.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
'use client';
|
||||
|
||||
import { Box, Typography, Grid, Card, CardContent, CardActionArea } from '@mui/material';
|
||||
import { Restaurant, Hotel, BabyChangingStation, ChildCare } from '@mui/icons-material';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { AppShell } from '@/components/layouts/AppShell/AppShell';
|
||||
import { ProtectedRoute } from '@/components/common/ProtectedRoute';
|
||||
|
||||
export default function TrackPage() {
|
||||
const router = useRouter();
|
||||
|
||||
const trackingOptions = [
|
||||
{
|
||||
title: 'Feeding',
|
||||
icon: <Restaurant sx={{ fontSize: 48, color: 'primary.main' }} />,
|
||||
path: '/track/feeding',
|
||||
color: '#FFE4E1',
|
||||
},
|
||||
{
|
||||
title: 'Sleep',
|
||||
icon: <Hotel sx={{ fontSize: 48, color: 'info.main' }} />,
|
||||
path: '/track/sleep',
|
||||
color: '#E1F5FF',
|
||||
},
|
||||
{
|
||||
title: 'Diaper',
|
||||
icon: <BabyChangingStation sx={{ fontSize: 48, color: 'warning.main' }} />,
|
||||
path: '/track/diaper',
|
||||
color: '#FFF4E1',
|
||||
},
|
||||
{
|
||||
title: 'Activity',
|
||||
icon: <ChildCare sx={{ fontSize: 48, color: 'success.main' }} />,
|
||||
path: '/track/activity',
|
||||
color: '#E8F5E9',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<ProtectedRoute>
|
||||
<AppShell>
|
||||
<Box>
|
||||
<Typography variant="h4" fontWeight="600" gutterBottom>
|
||||
Track Activity
|
||||
</Typography>
|
||||
<Typography variant="body1" color="text.secondary" sx={{ mb: 4 }}>
|
||||
Select an activity to track
|
||||
</Typography>
|
||||
|
||||
<Grid container spacing={3}>
|
||||
{trackingOptions.map((option) => (
|
||||
<Grid item xs={12} sm={6} md={3} key={option.title}>
|
||||
<Card
|
||||
sx={{
|
||||
height: '100%',
|
||||
bgcolor: option.color,
|
||||
'&:hover': {
|
||||
transform: 'translateY(-4px)',
|
||||
transition: 'transform 0.2s',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<CardActionArea
|
||||
onClick={() => router.push(option.path)}
|
||||
sx={{
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
py: 4,
|
||||
}}
|
||||
>
|
||||
<CardContent sx={{ textAlign: 'center' }}>
|
||||
{option.icon}
|
||||
<Typography variant="h6" fontWeight="600" sx={{ mt: 2 }}>
|
||||
{option.title}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</CardActionArea>
|
||||
</Card>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</Box>
|
||||
</AppShell>
|
||||
</ProtectedRoute>
|
||||
);
|
||||
}
|
||||
643
maternal-web/app/track/sleep/page.tsx
Normal file
643
maternal-web/app/track/sleep/page.tsx
Normal file
@@ -0,0 +1,643 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Button,
|
||||
Paper,
|
||||
TextField,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Select,
|
||||
MenuItem,
|
||||
IconButton,
|
||||
Alert,
|
||||
CircularProgress,
|
||||
Card,
|
||||
CardContent,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogContentText,
|
||||
DialogActions,
|
||||
Chip,
|
||||
Snackbar,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
ArrowBack,
|
||||
Refresh,
|
||||
Save,
|
||||
Delete,
|
||||
Bedtime,
|
||||
Hotel,
|
||||
DirectionsCar,
|
||||
Chair,
|
||||
Home,
|
||||
ChildCare,
|
||||
Add,
|
||||
} from '@mui/icons-material';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { AppShell } from '@/components/layouts/AppShell/AppShell';
|
||||
import { ProtectedRoute } from '@/components/common/ProtectedRoute';
|
||||
import { useAuth } from '@/lib/auth/AuthContext';
|
||||
import { trackingApi, Activity } from '@/lib/api/tracking';
|
||||
import { childrenApi, Child } from '@/lib/api/children';
|
||||
import { motion } from 'framer-motion';
|
||||
import { formatDistanceToNow, format } from 'date-fns';
|
||||
|
||||
interface SleepData {
|
||||
startTime: string;
|
||||
endTime?: string;
|
||||
quality: 'excellent' | 'good' | 'fair' | 'poor';
|
||||
location: string;
|
||||
isOngoing?: boolean;
|
||||
}
|
||||
|
||||
export default function SleepTrackPage() {
|
||||
const router = useRouter();
|
||||
const { user } = useAuth();
|
||||
const [children, setChildren] = useState<Child[]>([]);
|
||||
const [selectedChild, setSelectedChild] = useState<string>('');
|
||||
|
||||
// Sleep state
|
||||
const [startTime, setStartTime] = useState<string>(
|
||||
format(new Date(), "yyyy-MM-dd'T'HH:mm")
|
||||
);
|
||||
const [endTime, setEndTime] = useState<string>(
|
||||
format(new Date(), "yyyy-MM-dd'T'HH:mm")
|
||||
);
|
||||
const [quality, setQuality] = useState<'excellent' | 'good' | 'fair' | 'poor'>('good');
|
||||
const [location, setLocation] = useState<string>('crib');
|
||||
const [isOngoing, setIsOngoing] = useState<boolean>(false);
|
||||
|
||||
// Common state
|
||||
const [notes, setNotes] = useState<string>('');
|
||||
const [recentSleeps, setRecentSleeps] = useState<Activity[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [childrenLoading, setChildrenLoading] = useState(true);
|
||||
const [sleepsLoading, setSleepsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||
|
||||
// Delete confirmation dialog
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [activityToDelete, setActivityToDelete] = useState<string | null>(null);
|
||||
|
||||
const familyId = user?.families?.[0]?.familyId;
|
||||
|
||||
// Load children
|
||||
useEffect(() => {
|
||||
if (familyId) {
|
||||
loadChildren();
|
||||
}
|
||||
}, [familyId]);
|
||||
|
||||
// Load recent sleeps when child is selected
|
||||
useEffect(() => {
|
||||
if (selectedChild) {
|
||||
loadRecentSleeps();
|
||||
}
|
||||
}, [selectedChild]);
|
||||
|
||||
const loadChildren = async () => {
|
||||
if (!familyId) return;
|
||||
|
||||
try {
|
||||
setChildrenLoading(true);
|
||||
const childrenData = await childrenApi.getChildren(familyId);
|
||||
setChildren(childrenData);
|
||||
if (childrenData.length > 0) {
|
||||
setSelectedChild(childrenData[0].id);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Failed to load children:', err);
|
||||
setError(err.response?.data?.message || 'Failed to load children');
|
||||
} finally {
|
||||
setChildrenLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadRecentSleeps = async () => {
|
||||
if (!selectedChild) return;
|
||||
|
||||
try {
|
||||
setSleepsLoading(true);
|
||||
const activities = await trackingApi.getActivities(selectedChild, 'sleep');
|
||||
// Sort by timestamp descending and take last 10
|
||||
const sorted = activities.sort((a, b) =>
|
||||
new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
|
||||
).slice(0, 10);
|
||||
setRecentSleeps(sorted);
|
||||
} catch (err: any) {
|
||||
console.error('Failed to load recent sleeps:', err);
|
||||
} finally {
|
||||
setSleepsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDuration = (start: string, end?: string) => {
|
||||
const startDate = new Date(start);
|
||||
const endDate = end ? new Date(end) : new Date();
|
||||
const diffMs = endDate.getTime() - startDate.getTime();
|
||||
|
||||
if (diffMs < 0) return 'Invalid duration';
|
||||
|
||||
const hours = Math.floor(diffMs / (1000 * 60 * 60));
|
||||
const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));
|
||||
|
||||
if (hours === 0) {
|
||||
return `${minutes} minute${minutes !== 1 ? 's' : ''}`;
|
||||
} else if (minutes === 0) {
|
||||
return `${hours} hour${hours !== 1 ? 's' : ''}`;
|
||||
} else {
|
||||
return `${hours} hour${hours !== 1 ? 's' : ''} ${minutes} minute${minutes !== 1 ? 's' : ''}`;
|
||||
}
|
||||
};
|
||||
|
||||
const calculateDuration = () => {
|
||||
if (!startTime) return null;
|
||||
if (isOngoing) {
|
||||
return formatDuration(startTime);
|
||||
}
|
||||
if (!endTime) return null;
|
||||
|
||||
const start = new Date(startTime);
|
||||
const end = new Date(endTime);
|
||||
|
||||
if (end <= start) return null;
|
||||
|
||||
return formatDuration(startTime, endTime);
|
||||
};
|
||||
|
||||
const setStartNow = () => {
|
||||
setStartTime(format(new Date(), "yyyy-MM-dd'T'HH:mm"));
|
||||
};
|
||||
|
||||
const setEndNow = () => {
|
||||
setEndTime(format(new Date(), "yyyy-MM-dd'T'HH:mm"));
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!selectedChild) {
|
||||
setError('Please select a child');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validation
|
||||
if (!startTime) {
|
||||
setError('Please enter start time');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isOngoing && !endTime) {
|
||||
setError('Please enter end time or mark as ongoing');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isOngoing && endTime) {
|
||||
const start = new Date(startTime);
|
||||
const end = new Date(endTime);
|
||||
if (end <= start) {
|
||||
setError('End time must be after start time');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const data: SleepData = {
|
||||
startTime,
|
||||
quality,
|
||||
location,
|
||||
isOngoing,
|
||||
};
|
||||
|
||||
if (!isOngoing && endTime) {
|
||||
data.endTime = endTime;
|
||||
}
|
||||
|
||||
await trackingApi.createActivity(selectedChild, {
|
||||
type: 'sleep',
|
||||
timestamp: startTime,
|
||||
data,
|
||||
notes: notes || undefined,
|
||||
});
|
||||
|
||||
setSuccessMessage('Sleep logged successfully!');
|
||||
|
||||
// Reset form
|
||||
resetForm();
|
||||
|
||||
// Reload recent sleeps
|
||||
await loadRecentSleeps();
|
||||
} catch (err: any) {
|
||||
console.error('Failed to save sleep:', err);
|
||||
setError(err.response?.data?.message || 'Failed to save sleep');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setStartTime(format(new Date(), "yyyy-MM-dd'T'HH:mm"));
|
||||
setEndTime(format(new Date(), "yyyy-MM-dd'T'HH:mm"));
|
||||
setQuality('good');
|
||||
setLocation('crib');
|
||||
setIsOngoing(false);
|
||||
setNotes('');
|
||||
};
|
||||
|
||||
const handleDeleteClick = (activityId: string) => {
|
||||
setActivityToDelete(activityId);
|
||||
setDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleDeleteConfirm = async () => {
|
||||
if (!activityToDelete) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
await trackingApi.deleteActivity(activityToDelete);
|
||||
setSuccessMessage('Sleep deleted successfully');
|
||||
setDeleteDialogOpen(false);
|
||||
setActivityToDelete(null);
|
||||
await loadRecentSleeps();
|
||||
} catch (err: any) {
|
||||
console.error('Failed to delete sleep:', err);
|
||||
setError(err.response?.data?.message || 'Failed to delete sleep');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getLocationIcon = (loc: string) => {
|
||||
switch (loc) {
|
||||
case 'crib':
|
||||
return <Hotel />;
|
||||
case 'bed':
|
||||
return <Bedtime />;
|
||||
case 'stroller':
|
||||
return <DirectionsCar />;
|
||||
case 'carrier':
|
||||
return <Chair />;
|
||||
case 'other':
|
||||
return <Home />;
|
||||
default:
|
||||
return <Hotel />;
|
||||
}
|
||||
};
|
||||
|
||||
const getQualityColor = (qual: string) => {
|
||||
switch (qual) {
|
||||
case 'excellent':
|
||||
return 'success';
|
||||
case 'good':
|
||||
return 'primary';
|
||||
case 'fair':
|
||||
return 'warning';
|
||||
case 'poor':
|
||||
return 'error';
|
||||
default:
|
||||
return 'default';
|
||||
}
|
||||
};
|
||||
|
||||
const getSleepDetails = (activity: Activity) => {
|
||||
const data = activity.data as SleepData;
|
||||
const duration = data.endTime
|
||||
? formatDuration(data.startTime, data.endTime)
|
||||
: data.isOngoing
|
||||
? `Ongoing - ${formatDuration(data.startTime)}`
|
||||
: 'No end time';
|
||||
|
||||
return `${duration} - ${data.location.charAt(0).toUpperCase() + data.location.slice(1)}`;
|
||||
};
|
||||
|
||||
if (childrenLoading) {
|
||||
return (
|
||||
<ProtectedRoute>
|
||||
<AppShell>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
</AppShell>
|
||||
</ProtectedRoute>
|
||||
);
|
||||
}
|
||||
|
||||
if (!familyId || children.length === 0) {
|
||||
return (
|
||||
<ProtectedRoute>
|
||||
<AppShell>
|
||||
<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 }}>
|
||||
You need to add a child before you can track sleep activities
|
||||
</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<Add />}
|
||||
onClick={() => router.push('/children')}
|
||||
>
|
||||
Add Child
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AppShell>
|
||||
</ProtectedRoute>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ProtectedRoute>
|
||||
<AppShell>
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 3 }}>
|
||||
<IconButton onClick={() => router.back()} sx={{ mr: 2 }}>
|
||||
<ArrowBack />
|
||||
</IconButton>
|
||||
<Typography variant="h4" fontWeight="600">
|
||||
Track Sleep
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 3 }} onClose={() => setError(null)}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
{/* Child Selector */}
|
||||
{children.length > 1 && (
|
||||
<Paper sx={{ p: 2, mb: 3 }}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Select Child</InputLabel>
|
||||
<Select
|
||||
value={selectedChild}
|
||||
onChange={(e) => setSelectedChild(e.target.value)}
|
||||
label="Select Child"
|
||||
>
|
||||
{children.map((child) => (
|
||||
<MenuItem key={child.id} value={child.id}>
|
||||
{child.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* Main Form */}
|
||||
<Paper sx={{ p: 3, mb: 3 }}>
|
||||
{/* Start Time */}
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Typography variant="subtitle1" fontWeight="600" sx={{ mb: 1 }}>
|
||||
Sleep Start Time
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 2, alignItems: 'flex-start' }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
type="datetime-local"
|
||||
value={startTime}
|
||||
onChange={(e) => setStartTime(e.target.value)}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
/>
|
||||
<Button variant="outlined" onClick={setStartNow} sx={{ minWidth: 100 }}>
|
||||
Now
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Ongoing Checkbox */}
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Sleep Status</InputLabel>
|
||||
<Select
|
||||
value={isOngoing ? 'ongoing' : 'completed'}
|
||||
onChange={(e) => setIsOngoing(e.target.value === 'ongoing')}
|
||||
label="Sleep Status"
|
||||
>
|
||||
<MenuItem value="completed">Completed (has end time)</MenuItem>
|
||||
<MenuItem value="ongoing">Ongoing (still sleeping)</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
|
||||
{/* End Time */}
|
||||
{!isOngoing && (
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Typography variant="subtitle1" fontWeight="600" sx={{ mb: 1 }}>
|
||||
Wake Up Time
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 2, alignItems: 'flex-start' }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
type="datetime-local"
|
||||
value={endTime}
|
||||
onChange={(e) => setEndTime(e.target.value)}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
/>
|
||||
<Button variant="outlined" onClick={setEndNow} sx={{ minWidth: 100 }}>
|
||||
Now
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Duration Display */}
|
||||
{calculateDuration() && (
|
||||
<Box sx={{ mb: 3, textAlign: 'center' }}>
|
||||
<Chip
|
||||
label={`Duration: ${calculateDuration()}`}
|
||||
color="primary"
|
||||
sx={{ fontSize: '1rem', py: 3 }}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Sleep Quality */}
|
||||
<FormControl fullWidth sx={{ mb: 3 }}>
|
||||
<InputLabel>Sleep Quality</InputLabel>
|
||||
<Select
|
||||
value={quality}
|
||||
onChange={(e) => setQuality(e.target.value as 'excellent' | 'good' | 'fair' | 'poor')}
|
||||
label="Sleep Quality"
|
||||
>
|
||||
<MenuItem value="excellent">Excellent</MenuItem>
|
||||
<MenuItem value="good">Good</MenuItem>
|
||||
<MenuItem value="fair">Fair</MenuItem>
|
||||
<MenuItem value="poor">Poor</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
{/* Location */}
|
||||
<FormControl fullWidth sx={{ mb: 3 }}>
|
||||
<InputLabel>Location</InputLabel>
|
||||
<Select
|
||||
value={location}
|
||||
onChange={(e) => setLocation(e.target.value)}
|
||||
label="Location"
|
||||
>
|
||||
<MenuItem value="crib">Crib</MenuItem>
|
||||
<MenuItem value="bed">Bed</MenuItem>
|
||||
<MenuItem value="stroller">Stroller</MenuItem>
|
||||
<MenuItem value="carrier">Carrier</MenuItem>
|
||||
<MenuItem value="other">Other</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
{/* Common Notes Field */}
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Notes (optional)"
|
||||
multiline
|
||||
rows={3}
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
sx={{ mb: 3 }}
|
||||
placeholder="Any disruptions, dreams, or observations..."
|
||||
/>
|
||||
|
||||
{/* Submit Button */}
|
||||
<Button
|
||||
fullWidth
|
||||
type="button"
|
||||
variant="contained"
|
||||
size="large"
|
||||
startIcon={<Save />}
|
||||
onClick={handleSubmit}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? 'Saving...' : 'Save Sleep'}
|
||||
</Button>
|
||||
</Paper>
|
||||
|
||||
{/* Recent Sleeps */}
|
||||
<Paper sx={{ p: 3 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||
<Typography variant="h6" fontWeight="600">
|
||||
Recent Sleep Activities
|
||||
</Typography>
|
||||
<IconButton onClick={loadRecentSleeps} disabled={sleepsLoading}>
|
||||
<Refresh />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
{sleepsLoading ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
|
||||
<CircularProgress size={30} />
|
||||
</Box>
|
||||
) : recentSleeps.length === 0 ? (
|
||||
<Box sx={{ textAlign: 'center', py: 4 }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
No sleep activities yet
|
||||
</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{recentSleeps.map((activity, index) => {
|
||||
const data = activity.data as SleepData;
|
||||
return (
|
||||
<motion.div
|
||||
key={activity.id}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.2, delay: index * 0.05 }}
|
||||
>
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 2 }}>
|
||||
<Box sx={{ mt: 0.5 }}>
|
||||
{getLocationIcon(data.location)}
|
||||
</Box>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5, flexWrap: 'wrap' }}>
|
||||
<Typography variant="body1" fontWeight="600">
|
||||
Sleep
|
||||
</Typography>
|
||||
<Chip
|
||||
label={data.quality.charAt(0).toUpperCase() + data.quality.slice(1)}
|
||||
size="small"
|
||||
color={getQualityColor(data.quality) as any}
|
||||
/>
|
||||
<Chip
|
||||
label={formatDistanceToNow(new Date(activity.timestamp), { addSuffix: true })}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
/>
|
||||
</Box>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
|
||||
{getSleepDetails(activity)}
|
||||
</Typography>
|
||||
{activity.notes && (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ fontStyle: 'italic' }}>
|
||||
{activity.notes}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', gap: 0.5 }}>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="error"
|
||||
onClick={() => handleDeleteClick(activity.id)}
|
||||
disabled={loading}
|
||||
>
|
||||
<Delete />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
</motion.div>
|
||||
</Box>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<Dialog
|
||||
open={deleteDialogOpen}
|
||||
onClose={() => setDeleteDialogOpen(false)}
|
||||
>
|
||||
<DialogTitle>Delete Sleep Activity?</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>
|
||||
Are you sure you want to delete this sleep activity? This action cannot be undone.
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setDeleteDialogOpen(false)} disabled={loading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleDeleteConfirm} color="error" disabled={loading}>
|
||||
{loading ? 'Deleting...' : 'Delete'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* Success Snackbar */}
|
||||
<Snackbar
|
||||
open={!!successMessage}
|
||||
autoHideDuration={3000}
|
||||
onClose={() => setSuccessMessage(null)}
|
||||
message={successMessage}
|
||||
/>
|
||||
</AppShell>
|
||||
</ProtectedRoute>
|
||||
);
|
||||
}
|
||||
21
maternal-web/components/ThemeRegistry.tsx
Normal file
21
maternal-web/components/ThemeRegistry.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
275
maternal-web/components/analytics/FeedingFrequencyGraph.tsx
Normal file
275
maternal-web/components/analytics/FeedingFrequencyGraph.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
311
maternal-web/components/analytics/GrowthCurve.tsx
Normal file
311
maternal-web/components/analytics/GrowthCurve.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
318
maternal-web/components/analytics/PatternInsights.tsx
Normal file
318
maternal-web/components/analytics/PatternInsights.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
196
maternal-web/components/analytics/WeeklySleepChart.tsx
Normal file
196
maternal-web/components/analytics/WeeklySleepChart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
157
maternal-web/components/children/ChildDialog.tsx
Normal file
157
maternal-web/components/children/ChildDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
52
maternal-web/components/children/DeleteConfirmDialog.tsx
Normal file
52
maternal-web/components/children/DeleteConfirmDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
182
maternal-web/components/common/LoadingFallback.tsx
Normal file
182
maternal-web/components/common/LoadingFallback.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
158
maternal-web/components/common/OfflineIndicator.tsx
Normal file
158
maternal-web/components/common/OfflineIndicator.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
77
maternal-web/components/common/OptimizedImage.tsx
Normal file
77
maternal-web/components/common/OptimizedImage.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
20
maternal-web/components/common/PerformanceMonitor.tsx
Normal file
20
maternal-web/components/common/PerformanceMonitor.tsx
Normal 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;
|
||||
};
|
||||
41
maternal-web/components/common/ProtectedRoute.tsx
Normal file
41
maternal-web/components/common/ProtectedRoute.tsx
Normal 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}</>;
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
126
maternal-web/components/family/InviteMemberDialog.tsx
Normal file
126
maternal-web/components/family/InviteMemberDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
95
maternal-web/components/family/JoinFamilyDialog.tsx
Normal file
95
maternal-web/components/family/JoinFamilyDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
52
maternal-web/components/family/RemoveMemberDialog.tsx
Normal file
52
maternal-web/components/family/RemoveMemberDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
332
maternal-web/components/features/ai-chat/AIChatInterface.tsx
Normal file
332
maternal-web/components/features/ai-chat/AIChatInterface.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
654
maternal-web/components/features/analytics/InsightsDashboard.tsx
Normal file
654
maternal-web/components/features/analytics/InsightsDashboard.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
41
maternal-web/components/layouts/AppShell/AppShell.tsx
Normal file
41
maternal-web/components/layouts/AppShell/AppShell.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
114
maternal-web/components/layouts/MobileNav/MobileNav.tsx
Normal file
114
maternal-web/components/layouts/MobileNav/MobileNav.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
63
maternal-web/components/layouts/TabBar/TabBar.tsx
Normal file
63
maternal-web/components/layouts/TabBar/TabBar.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
19
maternal-web/hooks/useMediaQuery.ts
Normal file
19
maternal-web/hooks/useMediaQuery.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
export const useMediaQuery = (query: string): boolean => {
|
||||
const [matches, setMatches] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const media = window.matchMedia(query);
|
||||
if (media.matches !== matches) {
|
||||
setMatches(media.matches);
|
||||
}
|
||||
|
||||
const listener = () => setMatches(media.matches);
|
||||
media.addEventListener('change', listener);
|
||||
|
||||
return () => media.removeEventListener('change', listener);
|
||||
}, [matches, query]);
|
||||
|
||||
return matches;
|
||||
};
|
||||
121
maternal-web/hooks/useOfflineSync.ts
Normal file
121
maternal-web/hooks/useOfflineSync.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { useEffect, useCallback } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import {
|
||||
setOnlineStatus,
|
||||
setSyncInProgress,
|
||||
removePendingAction,
|
||||
incrementRetryCount,
|
||||
updateLastSyncTime,
|
||||
} from '@/store/slices/offlineSlice';
|
||||
import apiClient from '@/lib/api/client';
|
||||
|
||||
interface RootState {
|
||||
offline: {
|
||||
isOnline: boolean;
|
||||
pendingActions: any[];
|
||||
syncInProgress: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
const MAX_RETRY_ATTEMPTS = 3;
|
||||
|
||||
export const useOfflineSync = () => {
|
||||
const dispatch = useDispatch();
|
||||
const { isOnline, pendingActions, syncInProgress } = useSelector(
|
||||
(state: RootState) => state.offline
|
||||
);
|
||||
|
||||
// Monitor online/offline status
|
||||
useEffect(() => {
|
||||
const handleOnline = () => {
|
||||
dispatch(setOnlineStatus(true));
|
||||
};
|
||||
|
||||
const handleOffline = () => {
|
||||
dispatch(setOnlineStatus(false));
|
||||
};
|
||||
|
||||
// Set initial status
|
||||
dispatch(setOnlineStatus(navigator.onLine));
|
||||
|
||||
window.addEventListener('online', handleOnline);
|
||||
window.addEventListener('offline', handleOffline);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('online', handleOnline);
|
||||
window.removeEventListener('offline', handleOffline);
|
||||
};
|
||||
}, [dispatch]);
|
||||
|
||||
// Sync pending actions when online
|
||||
const syncPendingActions = useCallback(async () => {
|
||||
if (!isOnline || pendingActions.length === 0 || syncInProgress) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(setSyncInProgress(true));
|
||||
|
||||
for (const action of pendingActions) {
|
||||
try {
|
||||
// Attempt to replay the action
|
||||
await replayAction(action);
|
||||
|
||||
// Remove from pending actions on success
|
||||
dispatch(removePendingAction(action.id));
|
||||
} catch (error) {
|
||||
console.error(`Failed to sync action ${action.id}:`, error);
|
||||
|
||||
// Increment retry count
|
||||
dispatch(incrementRetryCount(action.id));
|
||||
|
||||
// If max retries exceeded, remove the action
|
||||
if (action.retryCount >= MAX_RETRY_ATTEMPTS) {
|
||||
console.warn(`Max retries exceeded for action ${action.id}, removing from queue`);
|
||||
dispatch(removePendingAction(action.id));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dispatch(setSyncInProgress(false));
|
||||
dispatch(updateLastSyncTime());
|
||||
}, [isOnline, pendingActions, syncInProgress, dispatch]);
|
||||
|
||||
// Trigger sync when coming online
|
||||
useEffect(() => {
|
||||
if (isOnline && pendingActions.length > 0) {
|
||||
syncPendingActions();
|
||||
}
|
||||
}, [isOnline, pendingActions.length, syncPendingActions]);
|
||||
|
||||
// Replay a specific action
|
||||
const replayAction = async (action: any) => {
|
||||
const { type, payload } = action;
|
||||
|
||||
switch (type) {
|
||||
case 'CREATE_ACTIVITY':
|
||||
return await apiClient.post('/api/v1/activities', payload);
|
||||
|
||||
case 'UPDATE_ACTIVITY':
|
||||
return await apiClient.put(`/api/v1/activities/${payload.id}`, payload);
|
||||
|
||||
case 'DELETE_ACTIVITY':
|
||||
return await apiClient.delete(`/api/v1/activities/${payload.id}`);
|
||||
|
||||
case 'CREATE_CHILD':
|
||||
return await apiClient.post('/api/v1/children', payload);
|
||||
|
||||
case 'UPDATE_CHILD':
|
||||
return await apiClient.put(`/api/v1/children/${payload.id}`, payload);
|
||||
|
||||
default:
|
||||
throw new Error(`Unknown action type: ${type}`);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
isOnline,
|
||||
pendingActionsCount: pendingActions.length,
|
||||
syncInProgress,
|
||||
syncPendingActions,
|
||||
};
|
||||
};
|
||||
41
maternal-web/jest.config.js
Normal file
41
maternal-web/jest.config.js
Normal file
@@ -0,0 +1,41 @@
|
||||
const nextJest = require('next/jest')
|
||||
|
||||
const createJestConfig = nextJest({
|
||||
// Provide the path to your Next.js app to load next.config.js and .env files in your test environment
|
||||
dir: './',
|
||||
})
|
||||
|
||||
// Add any custom config to be passed to Jest
|
||||
const customJestConfig = {
|
||||
setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
|
||||
testEnvironment: 'jest-environment-jsdom',
|
||||
moduleNameMapper: {
|
||||
'^@/(.*)$': '<rootDir>/$1',
|
||||
'\\.(css|less|scss|sass)$': 'identity-obj-proxy',
|
||||
},
|
||||
collectCoverageFrom: [
|
||||
'app/**/*.{js,jsx,ts,tsx}',
|
||||
'components/**/*.{js,jsx,ts,tsx}',
|
||||
'lib/**/*.{js,jsx,ts,tsx}',
|
||||
'!**/*.d.ts',
|
||||
'!**/node_modules/**',
|
||||
'!**/.next/**',
|
||||
'!**/coverage/**',
|
||||
'!**/jest.config.js',
|
||||
],
|
||||
coverageThreshold: {
|
||||
global: {
|
||||
branches: 70,
|
||||
functions: 70,
|
||||
lines: 70,
|
||||
statements: 70,
|
||||
},
|
||||
},
|
||||
testMatch: [
|
||||
'**/__tests__/**/*.[jt]s?(x)',
|
||||
'**/?(*.)+(spec|test).[jt]s?(x)',
|
||||
],
|
||||
}
|
||||
|
||||
// createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async
|
||||
module.exports = createJestConfig(customJestConfig)
|
||||
37
maternal-web/jest.setup.js
Normal file
37
maternal-web/jest.setup.js
Normal file
@@ -0,0 +1,37 @@
|
||||
// Learn more: https://github.com/testing-library/jest-dom
|
||||
import '@testing-library/jest-dom'
|
||||
|
||||
// Mock IntersectionObserver
|
||||
global.IntersectionObserver = class IntersectionObserver {
|
||||
constructor() {}
|
||||
disconnect() {}
|
||||
observe() {}
|
||||
takeRecords() {
|
||||
return []
|
||||
}
|
||||
unobserve() {}
|
||||
}
|
||||
|
||||
// Mock matchMedia
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: jest.fn().mockImplementation(query => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: jest.fn(),
|
||||
removeListener: jest.fn(),
|
||||
addEventListener: jest.fn(),
|
||||
removeEventListener: jest.fn(),
|
||||
dispatchEvent: jest.fn(),
|
||||
})),
|
||||
})
|
||||
|
||||
// Mock localStorage
|
||||
const localStorageMock = {
|
||||
getItem: jest.fn(),
|
||||
setItem: jest.fn(),
|
||||
removeItem: jest.fn(),
|
||||
clear: jest.fn(),
|
||||
}
|
||||
global.localStorage = localStorageMock
|
||||
42
maternal-web/jest.setup.ts
Normal file
42
maternal-web/jest.setup.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import '@testing-library/jest-dom'
|
||||
|
||||
// Mock Next.js router
|
||||
jest.mock('next/navigation', () => ({
|
||||
useRouter: () => ({
|
||||
push: jest.fn(),
|
||||
replace: jest.fn(),
|
||||
prefetch: jest.fn(),
|
||||
back: jest.fn(),
|
||||
pathname: '/',
|
||||
query: {},
|
||||
asPath: '/',
|
||||
}),
|
||||
usePathname: () => '/',
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
}))
|
||||
|
||||
// Mock window.matchMedia
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: jest.fn().mockImplementation((query) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: jest.fn(),
|
||||
removeListener: jest.fn(),
|
||||
addEventListener: jest.fn(),
|
||||
removeEventListener: jest.fn(),
|
||||
dispatchEvent: jest.fn(),
|
||||
})),
|
||||
})
|
||||
|
||||
// Mock IntersectionObserver
|
||||
global.IntersectionObserver = class IntersectionObserver {
|
||||
constructor() {}
|
||||
disconnect() {}
|
||||
observe() {}
|
||||
takeRecords() {
|
||||
return []
|
||||
}
|
||||
unobserve() {}
|
||||
}
|
||||
33
maternal-web/lib/accessibility/axe.ts
Normal file
33
maternal-web/lib/accessibility/axe.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
// Accessibility testing with axe-core
|
||||
// Only runs in development mode
|
||||
|
||||
if (typeof window !== 'undefined' && process.env.NODE_ENV === 'development') {
|
||||
import('@axe-core/react').then((axe) => {
|
||||
const React = require('react');
|
||||
const ReactDOM = require('react-dom');
|
||||
|
||||
axe.default(React, ReactDOM, 1000, {
|
||||
// Configure axe rules
|
||||
rules: [
|
||||
{
|
||||
id: 'color-contrast',
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: 'label',
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: 'button-name',
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: 'link-name',
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export {}
|
||||
104
maternal-web/lib/api/__tests__/tracking.test.ts
Normal file
104
maternal-web/lib/api/__tests__/tracking.test.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { trackingApi } from '../tracking'
|
||||
import apiClient from '../client'
|
||||
|
||||
jest.mock('../client')
|
||||
|
||||
const mockedApiClient = apiClient as jest.Mocked<typeof apiClient>
|
||||
|
||||
describe('trackingApi', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('createActivity', () => {
|
||||
it('transforms frontend data to backend format', async () => {
|
||||
const mockActivity = {
|
||||
id: 'act_123',
|
||||
childId: 'chd_456',
|
||||
type: 'feeding',
|
||||
startedAt: '2024-01-01T12:00:00Z',
|
||||
metadata: { amount: 120, type: 'bottle' },
|
||||
loggedBy: 'usr_789',
|
||||
createdAt: '2024-01-01T12:00:00Z',
|
||||
}
|
||||
|
||||
mockedApiClient.post.mockResolvedValue({
|
||||
data: { data: { activity: mockActivity } },
|
||||
} as any)
|
||||
|
||||
const result = await trackingApi.createActivity('chd_456', {
|
||||
type: 'feeding',
|
||||
timestamp: '2024-01-01T12:00:00Z',
|
||||
data: { amount: 120, type: 'bottle' },
|
||||
})
|
||||
|
||||
expect(mockedApiClient.post).toHaveBeenCalledWith(
|
||||
'/api/v1/activities?childId=chd_456',
|
||||
{
|
||||
type: 'feeding',
|
||||
startedAt: '2024-01-01T12:00:00Z',
|
||||
metadata: { amount: 120, type: 'bottle' },
|
||||
notes: undefined,
|
||||
}
|
||||
)
|
||||
|
||||
expect(result).toEqual({
|
||||
...mockActivity,
|
||||
timestamp: mockActivity.startedAt,
|
||||
data: mockActivity.metadata,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('getActivities', () => {
|
||||
it('transforms backend data to frontend format', async () => {
|
||||
const mockActivities = [
|
||||
{
|
||||
id: 'act_123',
|
||||
childId: 'chd_456',
|
||||
type: 'feeding',
|
||||
startedAt: '2024-01-01T12:00:00Z',
|
||||
metadata: { amount: 120 },
|
||||
},
|
||||
{
|
||||
id: 'act_124',
|
||||
childId: 'chd_456',
|
||||
type: 'sleep',
|
||||
startedAt: '2024-01-01T14:00:00Z',
|
||||
metadata: { duration: 120 },
|
||||
},
|
||||
]
|
||||
|
||||
mockedApiClient.get.mockResolvedValue({
|
||||
data: { data: { activities: mockActivities } },
|
||||
} as any)
|
||||
|
||||
const result = await trackingApi.getActivities('chd_456', 'feeding')
|
||||
|
||||
expect(mockedApiClient.get).toHaveBeenCalledWith('/api/v1/activities', {
|
||||
params: { childId: 'chd_456', type: 'feeding' },
|
||||
})
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
id: 'act_123',
|
||||
childId: 'chd_456',
|
||||
type: 'feeding',
|
||||
startedAt: '2024-01-01T12:00:00Z',
|
||||
metadata: { amount: 120 },
|
||||
timestamp: '2024-01-01T12:00:00Z',
|
||||
data: { amount: 120 },
|
||||
},
|
||||
{
|
||||
id: 'act_124',
|
||||
childId: 'chd_456',
|
||||
type: 'sleep',
|
||||
startedAt: '2024-01-01T14:00:00Z',
|
||||
metadata: { duration: 120 },
|
||||
timestamp: '2024-01-01T14:00:00Z',
|
||||
data: { duration: 120 },
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
})
|
||||
60
maternal-web/lib/api/children.ts
Normal file
60
maternal-web/lib/api/children.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import apiClient from './client';
|
||||
|
||||
export interface Child {
|
||||
id: string;
|
||||
familyId: string;
|
||||
name: string;
|
||||
birthDate: string;
|
||||
gender: 'male' | 'female' | 'other';
|
||||
photoUrl?: string;
|
||||
medicalInfo?: any;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface CreateChildData {
|
||||
name: string;
|
||||
birthDate: string;
|
||||
gender: 'male' | 'female' | 'other';
|
||||
photoUrl?: string;
|
||||
medicalInfo?: any;
|
||||
}
|
||||
|
||||
export interface UpdateChildData extends Partial<CreateChildData> {}
|
||||
|
||||
export const childrenApi = {
|
||||
// Get all children for the authenticated user
|
||||
getChildren: async (familyId?: string): Promise<Child[]> => {
|
||||
const params = familyId ? { familyId } : {};
|
||||
const response = await apiClient.get('/api/v1/children', { params });
|
||||
return response.data.data.children;
|
||||
},
|
||||
|
||||
// Get a specific child
|
||||
getChild: async (id: string): Promise<Child> => {
|
||||
const response = await apiClient.get(`/api/v1/children/${id}`);
|
||||
return response.data.data.child;
|
||||
},
|
||||
|
||||
// Create a new child
|
||||
createChild: async (familyId: string, data: CreateChildData): Promise<Child> => {
|
||||
const response = await apiClient.post(`/api/v1/children?familyId=${familyId}`, data);
|
||||
return response.data.data.child;
|
||||
},
|
||||
|
||||
// Update a child
|
||||
updateChild: async (id: string, data: UpdateChildData): Promise<Child> => {
|
||||
const response = await apiClient.patch(`/api/v1/children/${id}`, data);
|
||||
return response.data.data.child;
|
||||
},
|
||||
|
||||
// Delete a child
|
||||
deleteChild: async (id: string): Promise<void> => {
|
||||
await apiClient.delete(`/api/v1/children/${id}`);
|
||||
},
|
||||
|
||||
// Get child's age
|
||||
getChildAge: async (id: string): Promise<{ ageInMonths: number; ageInYears: number; remainingMonths: number }> => {
|
||||
const response = await apiClient.get(`/api/v1/children/${id}/age`);
|
||||
return response.data.data;
|
||||
},
|
||||
};
|
||||
101
maternal-web/lib/api/client.ts
Normal file
101
maternal-web/lib/api/client.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import axios from 'axios';
|
||||
import { tokenStorage } from '@/lib/utils/tokenStorage';
|
||||
|
||||
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
|
||||
|
||||
export const apiClient = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
// Request interceptor to add auth token
|
||||
apiClient.interceptors.request.use(
|
||||
(config) => {
|
||||
const token = tokenStorage.getAccessToken();
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// Response interceptor to handle token refresh
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error) => {
|
||||
const originalRequest = error.config;
|
||||
|
||||
// Only handle token refresh on client side
|
||||
if (typeof window === 'undefined') {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
// If error is 401 and we haven't tried to refresh yet
|
||||
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||
originalRequest._retry = true;
|
||||
|
||||
try {
|
||||
const refreshToken = tokenStorage.getRefreshToken();
|
||||
if (!refreshToken) {
|
||||
throw new Error('No refresh token');
|
||||
}
|
||||
|
||||
const response = await axios.post(
|
||||
`${API_BASE_URL}/api/v1/auth/refresh`,
|
||||
{ refreshToken },
|
||||
{
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
withCredentials: true
|
||||
}
|
||||
);
|
||||
|
||||
// Handle different response structures
|
||||
let newAccessToken;
|
||||
let newRefreshToken;
|
||||
|
||||
if (response.data?.data?.tokens?.accessToken) {
|
||||
newAccessToken = response.data.data.tokens.accessToken;
|
||||
newRefreshToken = response.data.data.tokens.refreshToken;
|
||||
} else if (response.data?.tokens?.accessToken) {
|
||||
newAccessToken = response.data.tokens.accessToken;
|
||||
newRefreshToken = response.data.tokens.refreshToken;
|
||||
} else if (response.data?.accessToken) {
|
||||
newAccessToken = response.data.accessToken;
|
||||
newRefreshToken = response.data.refreshToken;
|
||||
} else {
|
||||
throw new Error('Invalid token refresh response');
|
||||
}
|
||||
|
||||
// Update tokens in storage
|
||||
tokenStorage.setAccessToken(newAccessToken);
|
||||
if (newRefreshToken) {
|
||||
tokenStorage.setRefreshToken(newRefreshToken);
|
||||
}
|
||||
|
||||
// Retry original request with new token
|
||||
originalRequest.headers.Authorization = `Bearer ${newAccessToken}`;
|
||||
return apiClient(originalRequest);
|
||||
} catch (refreshError) {
|
||||
console.error('Token refresh failed:', refreshError);
|
||||
// Refresh failed, clear tokens and redirect to login
|
||||
tokenStorage.clearTokens();
|
||||
|
||||
// Avoid redirect loop - only redirect if not already on login page
|
||||
if (!window.location.pathname.includes('/login')) {
|
||||
window.location.href = '/login';
|
||||
}
|
||||
return Promise.reject(refreshError);
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
export default apiClient;
|
||||
69
maternal-web/lib/api/families.ts
Normal file
69
maternal-web/lib/api/families.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import apiClient from './client';
|
||||
|
||||
export interface Family {
|
||||
id: string;
|
||||
name: string;
|
||||
shareCode: string;
|
||||
createdBy: string;
|
||||
subscriptionTier: string;
|
||||
members?: FamilyMember[];
|
||||
}
|
||||
|
||||
export interface FamilyMember {
|
||||
id: string;
|
||||
userId: string;
|
||||
familyId: string;
|
||||
role: 'parent' | 'caregiver' | 'viewer';
|
||||
permissions: any;
|
||||
user?: {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface InviteMemberData {
|
||||
email: string;
|
||||
role: 'parent' | 'caregiver' | 'viewer';
|
||||
}
|
||||
|
||||
export interface JoinFamilyData {
|
||||
shareCode: string;
|
||||
}
|
||||
|
||||
export const familiesApi = {
|
||||
// Get a specific family
|
||||
getFamily: async (familyId: string): Promise<Family> => {
|
||||
const response = await apiClient.get(`/api/v1/families/${familyId}`);
|
||||
return response.data.data.family;
|
||||
},
|
||||
|
||||
// Get family members
|
||||
getFamilyMembers: async (familyId: string): Promise<FamilyMember[]> => {
|
||||
const response = await apiClient.get(`/api/v1/families/${familyId}/members`);
|
||||
return response.data.data.members;
|
||||
},
|
||||
|
||||
// Invite a family member
|
||||
inviteMember: async (familyId: string, data: InviteMemberData): Promise<any> => {
|
||||
const response = await apiClient.post(`/api/v1/families/invite?familyId=${familyId}`, data);
|
||||
return response.data.data.invitation;
|
||||
},
|
||||
|
||||
// Join a family using share code
|
||||
joinFamily: async (data: JoinFamilyData): Promise<FamilyMember> => {
|
||||
const response = await apiClient.post('/api/v1/families/join', data);
|
||||
return response.data.data.member;
|
||||
},
|
||||
|
||||
// Update member role
|
||||
updateMemberRole: async (familyId: string, userId: string, role: string): Promise<FamilyMember> => {
|
||||
const response = await apiClient.patch(`/api/v1/families/${familyId}/members/${userId}/role`, { role });
|
||||
return response.data.data.member;
|
||||
},
|
||||
|
||||
// Remove a family member
|
||||
removeMember: async (familyId: string, userId: string): Promise<void> => {
|
||||
await apiClient.delete(`/api/v1/families/${familyId}/members/${userId}`);
|
||||
},
|
||||
};
|
||||
105
maternal-web/lib/api/tracking.ts
Normal file
105
maternal-web/lib/api/tracking.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import apiClient from './client';
|
||||
|
||||
export type ActivityType = 'feeding' | 'sleep' | 'diaper' | 'medication' | 'milestone' | 'note';
|
||||
|
||||
export interface Activity {
|
||||
id: string;
|
||||
childId: string;
|
||||
type: ActivityType;
|
||||
timestamp: string;
|
||||
data: any;
|
||||
notes?: string;
|
||||
loggedBy: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface CreateActivityData {
|
||||
type: ActivityType;
|
||||
timestamp: string;
|
||||
data: any;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface UpdateActivityData extends Partial<CreateActivityData> {}
|
||||
|
||||
export interface DailySummary {
|
||||
date: string;
|
||||
feedingCount: number;
|
||||
sleepTotalMinutes: number;
|
||||
diaperCount: number;
|
||||
activities: Activity[];
|
||||
}
|
||||
|
||||
export const trackingApi = {
|
||||
// Get all activities for a child
|
||||
getActivities: async (
|
||||
childId: string,
|
||||
type?: ActivityType,
|
||||
startDate?: string,
|
||||
endDate?: string
|
||||
): Promise<Activity[]> => {
|
||||
const params: any = { childId };
|
||||
if (type) params.type = type;
|
||||
if (startDate) params.startDate = startDate;
|
||||
if (endDate) params.endDate = endDate;
|
||||
|
||||
const response = await apiClient.get('/api/v1/activities', { params });
|
||||
// Transform backend response to frontend format
|
||||
const activities = response.data.data.activities.map((activity: any) => ({
|
||||
...activity,
|
||||
timestamp: activity.startedAt, // Frontend expects timestamp
|
||||
data: activity.metadata, // Frontend expects data
|
||||
}));
|
||||
return activities;
|
||||
},
|
||||
|
||||
// Get a specific activity
|
||||
getActivity: async (id: string): Promise<Activity> => {
|
||||
const response = await apiClient.get(`/api/v1/activities/${id}`);
|
||||
const activity = response.data.data.activity;
|
||||
// Transform backend response to frontend format
|
||||
return {
|
||||
...activity,
|
||||
timestamp: activity.startedAt,
|
||||
data: activity.metadata,
|
||||
};
|
||||
},
|
||||
|
||||
// Create a new activity
|
||||
createActivity: async (childId: string, data: CreateActivityData): Promise<Activity> => {
|
||||
// Transform frontend data structure to backend DTO format
|
||||
const payload = {
|
||||
type: data.type,
|
||||
startedAt: data.timestamp, // Backend expects startedAt, not timestamp
|
||||
metadata: data.data, // Backend expects metadata, not data
|
||||
notes: data.notes,
|
||||
};
|
||||
const response = await apiClient.post(`/api/v1/activities?childId=${childId}`, payload);
|
||||
const activity = response.data.data.activity;
|
||||
// Transform backend response to frontend format
|
||||
return {
|
||||
...activity,
|
||||
timestamp: activity.startedAt,
|
||||
data: activity.metadata,
|
||||
};
|
||||
},
|
||||
|
||||
// Update an activity
|
||||
updateActivity: async (id: string, data: UpdateActivityData): Promise<Activity> => {
|
||||
const response = await apiClient.patch(`/api/v1/activities/${id}`, data);
|
||||
return response.data.data.activity;
|
||||
},
|
||||
|
||||
// Delete an activity
|
||||
deleteActivity: async (id: string): Promise<void> => {
|
||||
await apiClient.delete(`/api/v1/activities/${id}`);
|
||||
},
|
||||
|
||||
// Get daily summary
|
||||
getDailySummary: async (childId: string, date: string): Promise<DailySummary> => {
|
||||
const response = await apiClient.get('/api/v1/activities/daily-summary', {
|
||||
params: { childId, date },
|
||||
});
|
||||
return response.data.data;
|
||||
},
|
||||
};
|
||||
31
maternal-web/lib/api/users.ts
Normal file
31
maternal-web/lib/api/users.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import apiClient from './client';
|
||||
|
||||
export interface UserPreferences {
|
||||
notifications?: boolean;
|
||||
emailUpdates?: boolean;
|
||||
darkMode?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateProfileData {
|
||||
name?: string;
|
||||
preferences?: UserPreferences;
|
||||
}
|
||||
|
||||
export interface UserProfile {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
role: string;
|
||||
locale: string;
|
||||
emailVerified: boolean;
|
||||
preferences?: UserPreferences;
|
||||
families?: string[];
|
||||
}
|
||||
|
||||
export const usersApi = {
|
||||
// Update user profile
|
||||
updateProfile: async (data: UpdateProfileData): Promise<UserProfile> => {
|
||||
const response = await apiClient.patch('/api/v1/auth/profile', data);
|
||||
return response.data.data;
|
||||
},
|
||||
};
|
||||
234
maternal-web/lib/auth/AuthContext.tsx
Normal file
234
maternal-web/lib/auth/AuthContext.tsx
Normal file
@@ -0,0 +1,234 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, useContext, useEffect, useState, ReactNode } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import apiClient from '@/lib/api/client';
|
||||
import { tokenStorage } from '@/lib/utils/tokenStorage';
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
role: string;
|
||||
families?: Array<{
|
||||
id: string;
|
||||
familyId: string;
|
||||
role: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface LoginCredentials {
|
||||
email: string;
|
||||
password: string;
|
||||
deviceFingerprint?: string;
|
||||
}
|
||||
|
||||
export interface RegisterData {
|
||||
email: string;
|
||||
password: string;
|
||||
name: string;
|
||||
role?: string;
|
||||
}
|
||||
|
||||
interface AuthContextType {
|
||||
user: User | null;
|
||||
isLoading: boolean;
|
||||
isAuthenticated: boolean;
|
||||
login: (credentials: LoginCredentials) => Promise<void>;
|
||||
register: (data: RegisterData) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
refreshUser: () => Promise<void>;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
export const AuthProvider = ({ children }: { children: ReactNode }) => {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const router = useRouter();
|
||||
|
||||
const isAuthenticated = !!user;
|
||||
|
||||
// Check authentication status on mount
|
||||
useEffect(() => {
|
||||
// Only run on client side
|
||||
if (typeof window !== 'undefined') {
|
||||
checkAuth();
|
||||
} else {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const checkAuth = async () => {
|
||||
// Ensure we're on client side
|
||||
if (typeof window === 'undefined') {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const token = tokenStorage.getAccessToken();
|
||||
if (!token) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await apiClient.get('/api/v1/auth/me');
|
||||
|
||||
// Check if response has expected structure
|
||||
if (response.data?.data) {
|
||||
setUser(response.data.data);
|
||||
} else if (response.data?.user) {
|
||||
// Handle alternative response structure
|
||||
setUser(response.data.user);
|
||||
} else {
|
||||
throw new Error('Invalid response structure');
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Auth check failed:', error);
|
||||
// Only clear tokens if it's an actual auth error (401, 403)
|
||||
if (error?.response?.status === 401 || error?.response?.status === 403) {
|
||||
tokenStorage.clearTokens();
|
||||
setUser(null);
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const login = async (credentials: LoginCredentials) => {
|
||||
try {
|
||||
const deviceInfo = {
|
||||
deviceId: generateDeviceFingerprint(),
|
||||
platform: 'web',
|
||||
model: navigator.userAgent,
|
||||
osVersion: navigator.platform,
|
||||
};
|
||||
|
||||
const response = await apiClient.post('/api/v1/auth/login', {
|
||||
email: credentials.email,
|
||||
password: credentials.password,
|
||||
deviceInfo,
|
||||
});
|
||||
|
||||
// Backend returns { success, data: { user, tokens } }
|
||||
const { data: responseData } = response.data;
|
||||
const { tokens, user: userData } = responseData;
|
||||
|
||||
tokenStorage.setTokens(tokens.accessToken, tokens.refreshToken);
|
||||
setUser(userData);
|
||||
|
||||
router.push('/');
|
||||
} catch (error: any) {
|
||||
console.error('Login failed:', error);
|
||||
throw new Error(error.response?.data?.message || 'Login failed');
|
||||
}
|
||||
};
|
||||
|
||||
const register = async (data: RegisterData) => {
|
||||
try {
|
||||
const deviceInfo = {
|
||||
deviceId: generateDeviceFingerprint(),
|
||||
platform: 'web',
|
||||
model: navigator.userAgent,
|
||||
osVersion: navigator.platform,
|
||||
};
|
||||
|
||||
const response = await apiClient.post('/api/v1/auth/register', {
|
||||
email: data.email,
|
||||
password: data.password,
|
||||
name: data.name,
|
||||
deviceInfo,
|
||||
});
|
||||
|
||||
// Backend returns { success, data: { user, family, tokens } }
|
||||
const { data: responseData } = response.data;
|
||||
const { tokens, user: userData } = responseData;
|
||||
|
||||
if (!tokens?.accessToken || !tokens?.refreshToken) {
|
||||
throw new Error('Invalid response from server');
|
||||
}
|
||||
|
||||
const { accessToken, refreshToken } = tokens;
|
||||
|
||||
tokenStorage.setTokens(accessToken, refreshToken);
|
||||
setUser(userData);
|
||||
|
||||
// Redirect to onboarding
|
||||
router.push('/onboarding');
|
||||
} catch (error: any) {
|
||||
console.error('Registration failed:', error);
|
||||
throw new Error(error.response?.data?.message || error.message || 'Registration failed');
|
||||
}
|
||||
};
|
||||
|
||||
const logout = async () => {
|
||||
try {
|
||||
await apiClient.post('/api/v1/auth/logout');
|
||||
} catch (error) {
|
||||
console.error('Logout failed:', error);
|
||||
} finally {
|
||||
tokenStorage.clearTokens();
|
||||
setUser(null);
|
||||
router.push('/login');
|
||||
}
|
||||
};
|
||||
|
||||
const refreshUser = async () => {
|
||||
try {
|
||||
const response = await apiClient.get('/api/v1/auth/me');
|
||||
setUser(response.data.data);
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh user:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider
|
||||
value={{
|
||||
user,
|
||||
isLoading,
|
||||
isAuthenticated,
|
||||
login,
|
||||
register,
|
||||
logout,
|
||||
refreshUser,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useAuth = () => {
|
||||
const context = useContext(AuthContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useAuth must be used within an AuthProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
// Helper function to generate a simple device fingerprint
|
||||
function generateDeviceFingerprint(): string {
|
||||
const navigator = window.navigator;
|
||||
const screen = window.screen;
|
||||
|
||||
const data = [
|
||||
navigator.userAgent,
|
||||
navigator.language,
|
||||
screen.colorDepth,
|
||||
screen.width,
|
||||
screen.height,
|
||||
new Date().getTimezoneOffset(),
|
||||
].join('|');
|
||||
|
||||
// Simple hash function
|
||||
let hash = 0;
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const char = data.charCodeAt(i);
|
||||
hash = ((hash << 5) - hash) + char;
|
||||
hash = hash & hash;
|
||||
}
|
||||
|
||||
return hash.toString(36);
|
||||
}
|
||||
232
maternal-web/lib/performance/monitoring.ts
Normal file
232
maternal-web/lib/performance/monitoring.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
import { onCLS, onINP, onFCP, onLCP, onTTFB, type Metric } from 'web-vitals';
|
||||
|
||||
/**
|
||||
* Performance Monitoring Module
|
||||
*
|
||||
* Tracks Core Web Vitals metrics:
|
||||
* - CLS (Cumulative Layout Shift): Measures visual stability
|
||||
* - INP (Interaction to Next Paint): Measures interactivity (replaces FID in v5)
|
||||
* - FCP (First Contentful Paint): Measures perceived load speed
|
||||
* - LCP (Largest Contentful Paint): Measures loading performance
|
||||
* - TTFB (Time to First Byte): Measures server response time
|
||||
*
|
||||
* Sends metrics to analytics (Google Analytics if available)
|
||||
*/
|
||||
|
||||
interface PerformanceMetric {
|
||||
name: string;
|
||||
value: number;
|
||||
id: string;
|
||||
delta: number;
|
||||
rating: 'good' | 'needs-improvement' | 'poor';
|
||||
}
|
||||
|
||||
/**
|
||||
* Send metric to analytics service
|
||||
*/
|
||||
const sendToAnalytics = (metric: Metric) => {
|
||||
const body: PerformanceMetric = {
|
||||
name: metric.name,
|
||||
value: Math.round(metric.value),
|
||||
id: metric.id,
|
||||
delta: metric.delta,
|
||||
rating: metric.rating,
|
||||
};
|
||||
|
||||
// Send to Google Analytics if available
|
||||
if (typeof window !== 'undefined' && (window as any).gtag) {
|
||||
(window as any).gtag('event', metric.name, {
|
||||
event_category: 'Web Vitals',
|
||||
event_label: metric.id,
|
||||
value: Math.round(metric.value),
|
||||
metric_id: metric.id,
|
||||
metric_value: metric.value,
|
||||
metric_delta: metric.delta,
|
||||
metric_rating: metric.rating,
|
||||
non_interaction: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Log to console in development
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log('[Performance]', body);
|
||||
}
|
||||
|
||||
// Send to custom analytics endpoint if needed
|
||||
if (process.env.NEXT_PUBLIC_ANALYTICS_ENDPOINT) {
|
||||
const analyticsEndpoint = process.env.NEXT_PUBLIC_ANALYTICS_ENDPOINT;
|
||||
|
||||
// Use navigator.sendBeacon for reliable analytics even during page unload
|
||||
if (navigator.sendBeacon) {
|
||||
navigator.sendBeacon(
|
||||
analyticsEndpoint,
|
||||
JSON.stringify(body)
|
||||
);
|
||||
} else {
|
||||
// Fallback to fetch
|
||||
fetch(analyticsEndpoint, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
keepalive: true,
|
||||
}).catch((error) => {
|
||||
console.error('Failed to send analytics:', error);
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Initialize performance monitoring
|
||||
* Call this function once when the app loads
|
||||
*/
|
||||
export const initPerformanceMonitoring = () => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Track Cumulative Layout Shift (CLS)
|
||||
// Good: < 0.1, Needs Improvement: < 0.25, Poor: >= 0.25
|
||||
onCLS(sendToAnalytics);
|
||||
|
||||
// Track Interaction to Next Paint (INP) - replaces FID in web-vitals v5
|
||||
// Good: < 200ms, Needs Improvement: < 500ms, Poor: >= 500ms
|
||||
onINP(sendToAnalytics);
|
||||
|
||||
// Track First Contentful Paint (FCP)
|
||||
// Good: < 1.8s, Needs Improvement: < 3s, Poor: >= 3s
|
||||
onFCP(sendToAnalytics);
|
||||
|
||||
// Track Largest Contentful Paint (LCP)
|
||||
// Good: < 2.5s, Needs Improvement: < 4s, Poor: >= 4s
|
||||
onLCP(sendToAnalytics);
|
||||
|
||||
// Track Time to First Byte (TTFB)
|
||||
// Good: < 800ms, Needs Improvement: < 1800ms, Poor: >= 1800ms
|
||||
onTTFB(sendToAnalytics);
|
||||
|
||||
console.log('[Performance] Monitoring initialized');
|
||||
} catch (error) {
|
||||
console.error('[Performance] Failed to initialize monitoring:', error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Report custom performance metrics
|
||||
*/
|
||||
export const reportCustomMetric = (name: string, value: number, metadata?: Record<string, any>) => {
|
||||
if (typeof window !== 'undefined' && (window as any).gtag) {
|
||||
(window as any).gtag('event', name, {
|
||||
event_category: 'Custom Metrics',
|
||||
value: Math.round(value),
|
||||
...metadata,
|
||||
});
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log('[Performance Custom Metric]', { name, value, metadata });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Measure and report component render time
|
||||
*/
|
||||
export const measureComponentRender = (componentName: string) => {
|
||||
if (typeof window === 'undefined' || !window.performance) {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const startMark = `${componentName}-render-start`;
|
||||
const endMark = `${componentName}-render-end`;
|
||||
const measureName = `${componentName}-render`;
|
||||
|
||||
performance.mark(startMark);
|
||||
|
||||
return () => {
|
||||
performance.mark(endMark);
|
||||
performance.measure(measureName, startMark, endMark);
|
||||
|
||||
const measure = performance.getEntriesByName(measureName)[0];
|
||||
if (measure) {
|
||||
reportCustomMetric(`component_render_${componentName}`, measure.duration, {
|
||||
component: componentName,
|
||||
});
|
||||
}
|
||||
|
||||
// Clean up marks and measures
|
||||
performance.clearMarks(startMark);
|
||||
performance.clearMarks(endMark);
|
||||
performance.clearMeasures(measureName);
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Track page load time
|
||||
*/
|
||||
export const trackPageLoad = (pageName: string) => {
|
||||
if (typeof window === 'undefined' || !window.performance) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Wait for load event
|
||||
window.addEventListener('load', () => {
|
||||
setTimeout(() => {
|
||||
const navigation = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming;
|
||||
if (navigation) {
|
||||
reportCustomMetric(`page_load_${pageName}`, navigation.loadEventEnd - navigation.fetchStart, {
|
||||
page: pageName,
|
||||
dom_content_loaded: navigation.domContentLoadedEventEnd - navigation.fetchStart,
|
||||
dom_interactive: navigation.domInteractive - navigation.fetchStart,
|
||||
});
|
||||
}
|
||||
}, 0);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Monitor long tasks (tasks that block the main thread for > 50ms)
|
||||
*/
|
||||
export const monitorLongTasks = () => {
|
||||
if (typeof window === 'undefined' || !(window as any).PerformanceObserver) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const observer = new PerformanceObserver((list) => {
|
||||
for (const entry of list.getEntries()) {
|
||||
reportCustomMetric('long_task', entry.duration, {
|
||||
start_time: entry.startTime,
|
||||
duration: entry.duration,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe({ entryTypes: ['longtask'] });
|
||||
} catch (error) {
|
||||
console.error('[Performance] Failed to monitor long tasks:', error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Track resource loading times
|
||||
*/
|
||||
export const trackResourceTiming = () => {
|
||||
if (typeof window === 'undefined' || !window.performance) {
|
||||
return;
|
||||
}
|
||||
|
||||
const resources = performance.getEntriesByType('resource') as PerformanceResourceTiming[];
|
||||
|
||||
const slowResources = resources.filter((resource) => resource.duration > 1000);
|
||||
|
||||
slowResources.forEach((resource) => {
|
||||
reportCustomMetric('slow_resource', resource.duration, {
|
||||
url: resource.name,
|
||||
type: resource.initiatorType,
|
||||
size: resource.transferSize,
|
||||
});
|
||||
});
|
||||
};
|
||||
140
maternal-web/lib/services/trackingService.ts
Normal file
140
maternal-web/lib/services/trackingService.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import apiClient from '@/lib/api/client';
|
||||
|
||||
export interface FeedingData {
|
||||
childId: string;
|
||||
type: 'breast_left' | 'breast_right' | 'breast_both' | 'bottle' | 'solid';
|
||||
duration?: number;
|
||||
amount?: number;
|
||||
unit?: 'ml' | 'oz';
|
||||
notes?: string;
|
||||
timestamp?: string;
|
||||
}
|
||||
|
||||
export interface SleepData {
|
||||
childId: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
quality: 'excellent' | 'good' | 'fair' | 'poor';
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface DiaperData {
|
||||
childId: string;
|
||||
type: 'wet' | 'dirty' | 'both' | 'clean';
|
||||
timestamp: string;
|
||||
rash: boolean;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface Activity {
|
||||
id: string;
|
||||
childId: string;
|
||||
type: 'feeding' | 'sleep' | 'diaper';
|
||||
timestamp: string;
|
||||
data: Record<string, any>;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface DailySummary {
|
||||
date: string;
|
||||
feedingCount: number;
|
||||
sleepHours: number;
|
||||
diaperCount: number;
|
||||
activities: Activity[];
|
||||
}
|
||||
|
||||
class TrackingService {
|
||||
async logFeeding(data: FeedingData): Promise<Activity> {
|
||||
const response = await apiClient.post('/api/v1/activities', {
|
||||
childId: data.childId,
|
||||
type: 'feeding',
|
||||
timestamp: data.timestamp || new Date().toISOString(),
|
||||
data: {
|
||||
feedingType: data.type,
|
||||
duration: data.duration,
|
||||
amount: data.amount,
|
||||
unit: data.unit,
|
||||
notes: data.notes,
|
||||
},
|
||||
});
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
async logSleep(data: SleepData): Promise<Activity> {
|
||||
const response = await apiClient.post('/api/v1/activities', {
|
||||
childId: data.childId,
|
||||
type: 'sleep',
|
||||
timestamp: data.startTime,
|
||||
data: {
|
||||
startTime: data.startTime,
|
||||
endTime: data.endTime,
|
||||
quality: data.quality,
|
||||
duration: this.calculateDuration(data.startTime, data.endTime),
|
||||
notes: data.notes,
|
||||
},
|
||||
});
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
async logDiaper(data: DiaperData): Promise<Activity> {
|
||||
const response = await apiClient.post('/api/v1/activities', {
|
||||
childId: data.childId,
|
||||
type: 'diaper',
|
||||
timestamp: data.timestamp,
|
||||
data: {
|
||||
diaperType: data.type,
|
||||
rash: data.rash,
|
||||
notes: data.notes,
|
||||
},
|
||||
});
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
async getActivities(childId: string, filters?: {
|
||||
type?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
limit?: number;
|
||||
}): Promise<Activity[]> {
|
||||
const params = new URLSearchParams({
|
||||
childId,
|
||||
...filters,
|
||||
} as Record<string, string>);
|
||||
|
||||
const response = await apiClient.get(`/api/v1/activities?${params.toString()}`);
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
async getActivityById(activityId: string): Promise<Activity> {
|
||||
const response = await apiClient.get(`/api/v1/activities/${activityId}`);
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
async updateActivity(activityId: string, data: Partial<Activity>): Promise<Activity> {
|
||||
const response = await apiClient.patch(`/api/v1/activities/${activityId}`, data);
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
async deleteActivity(activityId: string): Promise<void> {
|
||||
await apiClient.delete(`/api/v1/activities/${activityId}`);
|
||||
}
|
||||
|
||||
async getDailySummary(childId: string, date?: string): Promise<DailySummary> {
|
||||
const params = new URLSearchParams({
|
||||
childId,
|
||||
date: date || new Date().toISOString().split('T')[0],
|
||||
});
|
||||
|
||||
const response = await apiClient.get(`/api/v1/activities/daily-summary?${params.toString()}`);
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
private calculateDuration(startTime: string, endTime: string): number {
|
||||
const start = new Date(startTime);
|
||||
const end = new Date(endTime);
|
||||
return Math.floor((end.getTime() - start.getTime()) / 1000 / 60); // duration in minutes
|
||||
}
|
||||
}
|
||||
|
||||
export const trackingService = new TrackingService();
|
||||
93
maternal-web/lib/utils/tokenStorage.ts
Normal file
93
maternal-web/lib/utils/tokenStorage.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Safe token storage utilities that work with both SSR and client-side rendering
|
||||
*/
|
||||
|
||||
export const tokenStorage = {
|
||||
/**
|
||||
* Get access token from storage
|
||||
*/
|
||||
getAccessToken: (): string | null => {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return localStorage.getItem('accessToken');
|
||||
} catch (error) {
|
||||
console.error('Error reading accessToken:', error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get refresh token from storage
|
||||
*/
|
||||
getRefreshToken: (): string | null => {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return localStorage.getItem('refreshToken');
|
||||
} catch (error) {
|
||||
console.error('Error reading refreshToken:', error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Set access token in storage
|
||||
*/
|
||||
setAccessToken: (token: string): void => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
localStorage.setItem('accessToken', token);
|
||||
} catch (error) {
|
||||
console.error('Error setting accessToken:', error);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Set refresh token in storage
|
||||
*/
|
||||
setRefreshToken: (token: string): void => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
localStorage.setItem('refreshToken', token);
|
||||
} catch (error) {
|
||||
console.error('Error setting refreshToken:', error);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Set both tokens at once
|
||||
*/
|
||||
setTokens: (accessToken: string, refreshToken: string): void => {
|
||||
tokenStorage.setAccessToken(accessToken);
|
||||
tokenStorage.setRefreshToken(refreshToken);
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear all tokens from storage
|
||||
*/
|
||||
clearTokens: (): void => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
localStorage.removeItem('accessToken');
|
||||
localStorage.removeItem('refreshToken');
|
||||
} catch (error) {
|
||||
console.error('Error clearing tokens:', error);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if user has valid tokens
|
||||
*/
|
||||
hasTokens: (): boolean => {
|
||||
return !!(tokenStorage.getAccessToken() && tokenStorage.getRefreshToken());
|
||||
},
|
||||
};
|
||||
175
maternal-web/next.config.js
Normal file
175
maternal-web/next.config.js
Normal file
@@ -0,0 +1,175 @@
|
||||
const withPWA = require('next-pwa')({
|
||||
dest: 'public',
|
||||
register: true,
|
||||
skipWaiting: true,
|
||||
disable: process.env.NODE_ENV === 'development',
|
||||
runtimeCaching: [
|
||||
{
|
||||
urlPattern: /^https:\/\/fonts\.(?:gstatic)\.com\/.*/i,
|
||||
handler: 'CacheFirst',
|
||||
options: {
|
||||
cacheName: 'google-fonts-webfonts',
|
||||
expiration: {
|
||||
maxEntries: 4,
|
||||
maxAgeSeconds: 365 * 24 * 60 * 60, // 365 days
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
urlPattern: /^https:\/\/fonts\.(?:googleapis)\.com\/.*/i,
|
||||
handler: 'StaleWhileRevalidate',
|
||||
options: {
|
||||
cacheName: 'google-fonts-stylesheets',
|
||||
expiration: {
|
||||
maxEntries: 4,
|
||||
maxAgeSeconds: 7 * 24 * 60 * 60, // 7 days
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
urlPattern: /\.(?:eot|otf|ttc|ttf|woff|woff2|font.css)$/i,
|
||||
handler: 'StaleWhileRevalidate',
|
||||
options: {
|
||||
cacheName: 'static-font-assets',
|
||||
expiration: {
|
||||
maxEntries: 4,
|
||||
maxAgeSeconds: 7 * 24 * 60 * 60, // 7 days
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
urlPattern: /\.(?:jpg|jpeg|gif|png|svg|ico|webp)$/i,
|
||||
handler: 'StaleWhileRevalidate',
|
||||
options: {
|
||||
cacheName: 'static-image-assets',
|
||||
expiration: {
|
||||
maxEntries: 64,
|
||||
maxAgeSeconds: 24 * 60 * 60, // 24 hours
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
urlPattern: /\/_next\/image\?url=.+$/i,
|
||||
handler: 'StaleWhileRevalidate',
|
||||
options: {
|
||||
cacheName: 'next-image',
|
||||
expiration: {
|
||||
maxEntries: 64,
|
||||
maxAgeSeconds: 24 * 60 * 60, // 24 hours
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
urlPattern: /\.(?:mp3|wav|ogg)$/i,
|
||||
handler: 'CacheFirst',
|
||||
options: {
|
||||
rangeRequests: true,
|
||||
cacheName: 'static-audio-assets',
|
||||
expiration: {
|
||||
maxEntries: 32,
|
||||
maxAgeSeconds: 24 * 60 * 60, // 24 hours
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
urlPattern: /\.(?:mp4)$/i,
|
||||
handler: 'CacheFirst',
|
||||
options: {
|
||||
rangeRequests: true,
|
||||
cacheName: 'static-video-assets',
|
||||
expiration: {
|
||||
maxEntries: 32,
|
||||
maxAgeSeconds: 24 * 60 * 60, // 24 hours
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
urlPattern: /\.(?:js)$/i,
|
||||
handler: 'StaleWhileRevalidate',
|
||||
options: {
|
||||
cacheName: 'static-js-assets',
|
||||
expiration: {
|
||||
maxEntries: 32,
|
||||
maxAgeSeconds: 24 * 60 * 60, // 24 hours
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
urlPattern: /\.(?:css|less)$/i,
|
||||
handler: 'StaleWhileRevalidate',
|
||||
options: {
|
||||
cacheName: 'static-style-assets',
|
||||
expiration: {
|
||||
maxEntries: 32,
|
||||
maxAgeSeconds: 24 * 60 * 60, // 24 hours
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
urlPattern: /\/_next\/data\/.+\/.+\.json$/i,
|
||||
handler: 'StaleWhileRevalidate',
|
||||
options: {
|
||||
cacheName: 'next-data',
|
||||
expiration: {
|
||||
maxEntries: 32,
|
||||
maxAgeSeconds: 24 * 60 * 60, // 24 hours
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
urlPattern: /\/api\/.*$/i,
|
||||
handler: 'NetworkFirst',
|
||||
method: 'GET',
|
||||
options: {
|
||||
cacheName: 'apis',
|
||||
expiration: {
|
||||
maxEntries: 16,
|
||||
maxAgeSeconds: 24 * 60 * 60, // 24 hours
|
||||
},
|
||||
networkTimeoutSeconds: 10, // fall back to cache if API doesn't respond within 10s
|
||||
},
|
||||
},
|
||||
{
|
||||
urlPattern: /.*/i,
|
||||
handler: 'NetworkFirst',
|
||||
options: {
|
||||
cacheName: 'others',
|
||||
expiration: {
|
||||
maxEntries: 32,
|
||||
maxAgeSeconds: 24 * 60 * 60, // 24 hours
|
||||
},
|
||||
networkTimeoutSeconds: 10,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
|
||||
// Performance optimizations
|
||||
compiler: {
|
||||
removeConsole: process.env.NODE_ENV === 'production',
|
||||
},
|
||||
|
||||
// Image optimization
|
||||
images: {
|
||||
formats: ['image/avif', 'image/webp'],
|
||||
deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
|
||||
imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
|
||||
},
|
||||
|
||||
// Production optimizations
|
||||
swcMinify: true,
|
||||
|
||||
// Compression
|
||||
compress: true,
|
||||
|
||||
// Enable experimental features for better performance
|
||||
experimental: {
|
||||
optimizePackageImports: ['@mui/material', '@mui/icons-material'],
|
||||
},
|
||||
}
|
||||
|
||||
module.exports = withPWA(nextConfig)
|
||||
30
maternal-web/next.config.mjs
Normal file
30
maternal-web/next.config.mjs
Normal file
@@ -0,0 +1,30 @@
|
||||
import withPWA from 'next-pwa';
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
images: {
|
||||
domains: ['api.maternalapp.com', 'localhost'],
|
||||
},
|
||||
};
|
||||
|
||||
const pwaConfig = withPWA({
|
||||
dest: 'public',
|
||||
register: true,
|
||||
skipWaiting: true,
|
||||
disable: process.env.NODE_ENV === 'development',
|
||||
runtimeCaching: [
|
||||
{
|
||||
urlPattern: /^https?.*/,
|
||||
handler: 'NetworkFirst',
|
||||
options: {
|
||||
cacheName: 'offlineCache',
|
||||
expiration: {
|
||||
maxEntries: 200,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
export default pwaConfig(nextConfig);
|
||||
13986
maternal-web/package-lock.json
generated
Normal file
13986
maternal-web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
62
maternal-web/package.json
Normal file
62
maternal-web/package.json
Normal file
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"name": "maternal-web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -p 3030 -H 0.0.0.0",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:coverage": "jest --coverage",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:ui": "playwright test --ui",
|
||||
"test:e2e:headed": "playwright test --headed"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@mui/icons-material": "^5.18.0",
|
||||
"@mui/material": "^5.18.0",
|
||||
"@mui/material-nextjs": "^7.3.2",
|
||||
"@reduxjs/toolkit": "^2.9.0",
|
||||
"@tanstack/react-query": "^5.90.2",
|
||||
"axios": "^1.12.2",
|
||||
"date-fns": "^4.1.0",
|
||||
"framer-motion": "^11.18.2",
|
||||
"next": "14.2.0",
|
||||
"next-pwa": "^5.6.0",
|
||||
"react": "^18",
|
||||
"react-dom": "^18",
|
||||
"react-hook-form": "^7.63.0",
|
||||
"react-redux": "^9.2.0",
|
||||
"recharts": "^3.2.1",
|
||||
"redux-persist": "^6.0.0",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"web-vitals": "^5.1.0",
|
||||
"workbox-webpack-plugin": "^7.3.0",
|
||||
"workbox-window": "^7.3.0",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@axe-core/react": "^4.10.2",
|
||||
"@playwright/test": "^1.55.1",
|
||||
"@testing-library/jest-dom": "^6.9.0",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^18",
|
||||
"@types/react-dom": "^18",
|
||||
"identity-obj-proxy": "^3.0.0",
|
||||
"jest": "^30.2.0",
|
||||
"jest-axe": "^10.0.0",
|
||||
"jest-environment-jsdom": "^30.2.0",
|
||||
"postcss": "^8",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"ts-jest": "^29.4.4",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
46
maternal-web/playwright.config.ts
Normal file
46
maternal-web/playwright.config.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests/e2e',
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
reporter: 'html',
|
||||
use: {
|
||||
baseURL: 'http://localhost:3030',
|
||||
trace: 'on-first-retry',
|
||||
screenshot: 'only-on-failure',
|
||||
},
|
||||
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
{
|
||||
name: 'firefox',
|
||||
use: { ...devices['Desktop Firefox'] },
|
||||
},
|
||||
{
|
||||
name: 'webkit',
|
||||
use: { ...devices['Desktop Safari'] },
|
||||
},
|
||||
// Mobile viewports
|
||||
{
|
||||
name: 'Mobile Chrome',
|
||||
use: { ...devices['Pixel 5'] },
|
||||
},
|
||||
{
|
||||
name: 'Mobile Safari',
|
||||
use: { ...devices['iPhone 12'] },
|
||||
},
|
||||
],
|
||||
|
||||
webServer: {
|
||||
command: 'npm run dev',
|
||||
url: 'http://localhost:3030',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 120000,
|
||||
},
|
||||
});
|
||||
8
maternal-web/postcss.config.mjs
Normal file
8
maternal-web/postcss.config.mjs
Normal file
@@ -0,0 +1,8 @@
|
||||
/** @type {import('postcss-load-config').Config} */
|
||||
const config = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
0
maternal-web/public/icons/icon-128x128.png
Normal file
0
maternal-web/public/icons/icon-128x128.png
Normal file
0
maternal-web/public/icons/icon-144x144.png
Normal file
0
maternal-web/public/icons/icon-144x144.png
Normal file
0
maternal-web/public/icons/icon-152x152.png
Normal file
0
maternal-web/public/icons/icon-152x152.png
Normal file
0
maternal-web/public/icons/icon-192x192.png
Normal file
0
maternal-web/public/icons/icon-192x192.png
Normal file
0
maternal-web/public/icons/icon-384x384.png
Normal file
0
maternal-web/public/icons/icon-384x384.png
Normal file
0
maternal-web/public/icons/icon-512x512.png
Normal file
0
maternal-web/public/icons/icon-512x512.png
Normal file
0
maternal-web/public/icons/icon-72x72.png
Normal file
0
maternal-web/public/icons/icon-72x72.png
Normal file
0
maternal-web/public/icons/icon-96x96.png
Normal file
0
maternal-web/public/icons/icon-96x96.png
Normal file
62
maternal-web/public/manifest.json
Normal file
62
maternal-web/public/manifest.json
Normal file
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"name": "Maternal Organization App",
|
||||
"short_name": "Maternal App",
|
||||
"description": "Track your child's activities, get AI-powered insights, and stay organized with your family.",
|
||||
"theme_color": "#FFB6C1",
|
||||
"background_color": "#FFFFFF",
|
||||
"display": "standalone",
|
||||
"orientation": "portrait",
|
||||
"scope": "/",
|
||||
"start_url": "/",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icons/icon-72x72.png",
|
||||
"sizes": "72x72",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable any"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-96x96.png",
|
||||
"sizes": "96x96",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable any"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-128x128.png",
|
||||
"sizes": "128x128",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable any"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-144x144.png",
|
||||
"sizes": "144x144",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable any"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-152x152.png",
|
||||
"sizes": "152x152",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable any"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable any"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-384x384.png",
|
||||
"sizes": "384x384",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable any"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable any"
|
||||
}
|
||||
],
|
||||
"splash_pages": null
|
||||
}
|
||||
1
maternal-web/public/next.svg
Normal file
1
maternal-web/public/next.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
1
maternal-web/public/sw.js
Normal file
1
maternal-web/public/sw.js
Normal file
File diff suppressed because one or more lines are too long
1
maternal-web/public/vercel.svg
Normal file
1
maternal-web/public/vercel.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 283 64"><path fill="black" d="M141 16c-11 0-19 7-19 18s9 18 20 18c7 0 13-3 16-7l-7-5c-2 3-6 4-9 4-5 0-9-3-10-7h28v-3c0-11-8-18-19-18zm-9 15c1-4 4-7 9-7s8 3 9 7h-18zm117-15c-11 0-19 7-19 18s9 18 20 18c6 0 12-3 16-7l-8-5c-2 3-5 4-8 4-5 0-9-3-11-7h28l1-3c0-11-8-18-19-18zm-10 15c2-4 5-7 10-7s8 3 9 7h-19zm-39 3c0 6 4 10 10 10 4 0 7-2 9-5l8 5c-3 5-9 8-17 8-11 0-19-7-19-18s8-18 19-18c8 0 14 3 17 8l-8 5c-2-3-5-5-9-5-6 0-10 4-10 10zm83-29v46h-9V5h9zM37 0l37 64H0L37 0zm92 5-27 48L74 5h10l18 30 17-30h10zm59 12v10l-3-1c-6 0-10 4-10 10v15h-9V17h9v9c0-5 6-9 13-9z"/></svg>
|
||||
|
After Width: | Height: | Size: 629 B |
1
maternal-web/public/workbox-4d767a27.js
Normal file
1
maternal-web/public/workbox-4d767a27.js
Normal file
File diff suppressed because one or more lines are too long
74
maternal-web/store/slices/offlineSlice.ts
Normal file
74
maternal-web/store/slices/offlineSlice.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||
|
||||
export interface PendingAction {
|
||||
id: string;
|
||||
type: string;
|
||||
payload: any;
|
||||
timestamp: string;
|
||||
retryCount: number;
|
||||
}
|
||||
|
||||
interface OfflineState {
|
||||
isOnline: boolean;
|
||||
pendingActions: PendingAction[];
|
||||
lastSyncTime: string | null;
|
||||
syncInProgress: boolean;
|
||||
}
|
||||
|
||||
const initialState: OfflineState = {
|
||||
isOnline: typeof navigator !== 'undefined' ? navigator.onLine : true,
|
||||
pendingActions: [],
|
||||
lastSyncTime: null,
|
||||
syncInProgress: false,
|
||||
};
|
||||
|
||||
const offlineSlice = createSlice({
|
||||
name: 'offline',
|
||||
initialState,
|
||||
reducers: {
|
||||
setOnlineStatus: (state, action: PayloadAction<boolean>) => {
|
||||
state.isOnline = action.payload;
|
||||
if (action.payload && state.pendingActions.length > 0) {
|
||||
state.syncInProgress = true;
|
||||
}
|
||||
},
|
||||
addPendingAction: (state, action: PayloadAction<Omit<PendingAction, 'id' | 'timestamp' | 'retryCount'>>) => {
|
||||
state.pendingActions.push({
|
||||
...action.payload,
|
||||
id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
timestamp: new Date().toISOString(),
|
||||
retryCount: 0,
|
||||
});
|
||||
},
|
||||
removePendingAction: (state, action: PayloadAction<string>) => {
|
||||
state.pendingActions = state.pendingActions.filter(a => a.id !== action.payload);
|
||||
},
|
||||
incrementRetryCount: (state, action: PayloadAction<string>) => {
|
||||
const action_ = state.pendingActions.find(a => a.id === action.payload);
|
||||
if (action_) {
|
||||
action_.retryCount += 1;
|
||||
}
|
||||
},
|
||||
clearPendingActions: (state) => {
|
||||
state.pendingActions = [];
|
||||
},
|
||||
setSyncInProgress: (state, action: PayloadAction<boolean>) => {
|
||||
state.syncInProgress = action.payload;
|
||||
},
|
||||
updateLastSyncTime: (state) => {
|
||||
state.lastSyncTime = new Date().toISOString();
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const {
|
||||
setOnlineStatus,
|
||||
addPendingAction,
|
||||
removePendingAction,
|
||||
incrementRetryCount,
|
||||
clearPendingActions,
|
||||
setSyncInProgress,
|
||||
updateLastSyncTime,
|
||||
} = offlineSlice.actions;
|
||||
|
||||
export default offlineSlice.reducer;
|
||||
123
maternal-web/styles/themes/maternalTheme.ts
Normal file
123
maternal-web/styles/themes/maternalTheme.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
'use client';
|
||||
|
||||
import { createTheme } from '@mui/material/styles';
|
||||
|
||||
export const maternalTheme = createTheme({
|
||||
palette: {
|
||||
primary: {
|
||||
main: '#FFB6C1', // Light pink/rose
|
||||
light: '#FFE4E1', // Misty rose
|
||||
dark: '#DB7093', // Pale violet red
|
||||
},
|
||||
secondary: {
|
||||
main: '#FFDAB9', // Peach puff
|
||||
light: '#FFE5CC',
|
||||
dark: '#FFB347', // Deep peach
|
||||
},
|
||||
background: {
|
||||
default: '#FFF9F5', // Warm white
|
||||
paper: '#FFFFFF',
|
||||
},
|
||||
text: {
|
||||
primary: '#2D3748',
|
||||
secondary: '#718096',
|
||||
},
|
||||
},
|
||||
typography: {
|
||||
fontFamily: '"Inter", "Roboto", "Helvetica", "Arial", sans-serif',
|
||||
h1: {
|
||||
fontSize: '2rem',
|
||||
fontWeight: 600,
|
||||
},
|
||||
h2: {
|
||||
fontSize: '1.75rem',
|
||||
fontWeight: 600,
|
||||
},
|
||||
h3: {
|
||||
fontSize: '1.5rem',
|
||||
fontWeight: 600,
|
||||
},
|
||||
h4: {
|
||||
fontSize: '1.25rem',
|
||||
fontWeight: 600,
|
||||
},
|
||||
h5: {
|
||||
fontSize: '1.125rem',
|
||||
fontWeight: 600,
|
||||
},
|
||||
h6: {
|
||||
fontSize: '1rem',
|
||||
fontWeight: 600,
|
||||
},
|
||||
body1: {
|
||||
fontSize: '1rem',
|
||||
},
|
||||
body2: {
|
||||
fontSize: '0.875rem',
|
||||
},
|
||||
},
|
||||
shape: {
|
||||
borderRadius: 16,
|
||||
},
|
||||
components: {
|
||||
MuiButton: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
borderRadius: 24,
|
||||
textTransform: 'none',
|
||||
minHeight: 48, // Touch target size
|
||||
fontSize: '1rem',
|
||||
fontWeight: 500,
|
||||
paddingLeft: 24,
|
||||
paddingRight: 24,
|
||||
},
|
||||
sizeLarge: {
|
||||
minHeight: 56,
|
||||
fontSize: '1.125rem',
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiTextField: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
'& .MuiInputBase-root': {
|
||||
minHeight: 48,
|
||||
borderRadius: 16,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiCard: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
borderRadius: 20,
|
||||
boxShadow: '0 4px 20px rgba(0, 0, 0, 0.08)',
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiPaper: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
borderRadius: 16,
|
||||
},
|
||||
elevation1: {
|
||||
boxShadow: '0 2px 10px rgba(0, 0, 0, 0.06)',
|
||||
},
|
||||
elevation2: {
|
||||
boxShadow: '0 4px 20px rgba(0, 0, 0, 0.08)',
|
||||
},
|
||||
elevation3: {
|
||||
boxShadow: '0 6px 30px rgba(0, 0, 0, 0.1)',
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiChip: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
borderRadius: 12,
|
||||
fontWeight: 500,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
20
maternal-web/tailwind.config.ts
Normal file
20
maternal-web/tailwind.config.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { Config } from "tailwindcss";
|
||||
|
||||
const config: Config = {
|
||||
content: [
|
||||
"./pages/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./components/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./app/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
backgroundImage: {
|
||||
"gradient-radial": "radial-gradient(var(--tw-gradient-stops))",
|
||||
"gradient-conic":
|
||||
"conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
export default config;
|
||||
145
maternal-web/tests/README.md
Normal file
145
maternal-web/tests/README.md
Normal file
@@ -0,0 +1,145 @@
|
||||
# Testing Guide
|
||||
|
||||
This document describes the testing setup and best practices for the Maternal Web application.
|
||||
|
||||
## Test Structure
|
||||
|
||||
```
|
||||
maternal-web/
|
||||
├── components/
|
||||
│ └── **/__tests__/ # Component unit tests
|
||||
├── lib/
|
||||
│ └── **/__tests__/ # Library/utility unit tests
|
||||
├── tests/
|
||||
│ └── e2e/ # End-to-end tests
|
||||
├── jest.config.js # Jest configuration
|
||||
├── jest.setup.ts # Jest setup file
|
||||
└── playwright.config.ts # Playwright configuration
|
||||
```
|
||||
|
||||
## Running Tests
|
||||
|
||||
### Unit Tests (Jest + React Testing Library)
|
||||
|
||||
```bash
|
||||
# Run all unit tests
|
||||
npm test
|
||||
|
||||
# Run tests in watch mode
|
||||
npm run test:watch
|
||||
|
||||
# Run tests with coverage
|
||||
npm run test:coverage
|
||||
```
|
||||
|
||||
### E2E Tests (Playwright)
|
||||
|
||||
```bash
|
||||
# Run all E2E tests
|
||||
npm run test:e2e
|
||||
|
||||
# Run E2E tests with UI
|
||||
npm run test:e2e:ui
|
||||
|
||||
# Run E2E tests in headed mode (see browser)
|
||||
npm run test:e2e:headed
|
||||
```
|
||||
|
||||
## Writing Tests
|
||||
|
||||
### Unit Tests
|
||||
|
||||
Unit tests should be placed in `__tests__` directories next to the code they test.
|
||||
|
||||
Example component test:
|
||||
|
||||
```typescript
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { MyComponent } from '../MyComponent'
|
||||
|
||||
describe('MyComponent', () => {
|
||||
it('renders correctly', () => {
|
||||
render(<MyComponent title="Test" />)
|
||||
expect(screen.getByText('Test')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
### E2E Tests
|
||||
|
||||
E2E tests should be placed in `tests/e2e/` directory.
|
||||
|
||||
Example E2E test:
|
||||
|
||||
```typescript
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('should navigate to page', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await expect(page.locator('h1')).toContainText('Welcome');
|
||||
});
|
||||
```
|
||||
|
||||
## Coverage Thresholds
|
||||
|
||||
The project maintains the following coverage thresholds:
|
||||
|
||||
- Branches: 70%
|
||||
- Functions: 70%
|
||||
- Lines: 70%
|
||||
- Statements: 70%
|
||||
|
||||
## CI/CD Integration
|
||||
|
||||
Tests run automatically on:
|
||||
- Every push to `master` or `main` branches
|
||||
- Every pull request
|
||||
|
||||
The CI pipeline:
|
||||
1. Runs linting
|
||||
2. Runs unit tests with coverage
|
||||
3. Runs E2E tests (Chromium only in CI)
|
||||
4. Builds the application
|
||||
5. Uploads test artifacts
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Write tests for new features** - All new features should include tests
|
||||
2. **Test user interactions** - Focus on testing what users see and do
|
||||
3. **Keep tests simple** - Each test should test one thing
|
||||
4. **Use descriptive test names** - Test names should describe what they test
|
||||
5. **Avoid implementation details** - Test behavior, not implementation
|
||||
6. **Mock external dependencies** - Use mocks for API calls and external services
|
||||
|
||||
## Useful Commands
|
||||
|
||||
```bash
|
||||
# Run specific test file
|
||||
npm test -- MyComponent.test.tsx
|
||||
|
||||
# Run tests matching pattern
|
||||
npm test -- --testNamePattern="should render"
|
||||
|
||||
# Update snapshots
|
||||
npm test -- -u
|
||||
|
||||
# Debug tests
|
||||
node --inspect-brk node_modules/.bin/jest --runInBand
|
||||
|
||||
# Generate Playwright test code
|
||||
npx playwright codegen http://localhost:3030
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Jest
|
||||
|
||||
- **Tests timing out**: Increase timeout with `jest.setTimeout(10000)` in test file
|
||||
- **Module not found**: Check `moduleNameMapper` in `jest.config.js`
|
||||
- **Async tests failing**: Make sure to `await` async operations and use `async/await` in tests
|
||||
|
||||
### Playwright
|
||||
|
||||
- **Browser not launching**: Run `npx playwright install` to install browsers
|
||||
- **Tests flaky**: Add `await page.waitForLoadState('networkidle')` or explicit waits
|
||||
- **Selectors not working**: Use Playwright Inspector with `npx playwright test --debug`
|
||||
139
maternal-web/tests/e2e/tracking.spec.ts
Normal file
139
maternal-web/tests/e2e/tracking.spec.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('Activity Tracking Flow', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Login before each test
|
||||
await page.goto('/login');
|
||||
await page.fill('input[name="email"]', 'andrei@cloudz.ro');
|
||||
await page.fill('input[name="password"]', 'password');
|
||||
await page.click('button[type="submit"]');
|
||||
|
||||
// Wait for redirect to homepage
|
||||
await page.waitForURL('/');
|
||||
});
|
||||
|
||||
test('should navigate to feeding tracker', async ({ page }) => {
|
||||
// Click on feeding quick action
|
||||
await page.click('text=Feeding');
|
||||
|
||||
// Verify we're on the feeding page
|
||||
await expect(page).toHaveURL('/track/feeding');
|
||||
await expect(page.locator('text=Track Feeding')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should navigate to sleep tracker', async ({ page }) => {
|
||||
// Click on sleep quick action
|
||||
await page.click('text=Sleep');
|
||||
|
||||
// Verify we're on the sleep page
|
||||
await expect(page).toHaveURL('/track/sleep');
|
||||
await expect(page.locator('text=Track Sleep')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should navigate to diaper tracker', async ({ page }) => {
|
||||
// Click on diaper quick action
|
||||
await page.click('text=Diaper');
|
||||
|
||||
// Verify we're on the diaper page
|
||||
await expect(page).toHaveURL('/track/diaper');
|
||||
await expect(page.locator('text=Track Diaper Change')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should display today summary on homepage', async ({ page }) => {
|
||||
// Check that Today's Summary section exists
|
||||
await expect(page.locator('text=Today\'s Summary')).toBeVisible();
|
||||
|
||||
// Check that the three metrics are displayed
|
||||
await expect(page.locator('text=Feedings')).toBeVisible();
|
||||
await expect(page.locator('text=Sleep')).toBeVisible();
|
||||
await expect(page.locator('text=Diapers')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should navigate to AI Assistant', async ({ page }) => {
|
||||
// Click on AI Assistant quick action
|
||||
await page.click('text=AI Assistant');
|
||||
|
||||
// Verify we're on the AI Assistant page
|
||||
await expect(page).toHaveURL('/ai-assistant');
|
||||
await expect(page.locator('text=AI Assistant')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should navigate to Analytics', async ({ page }) => {
|
||||
// Click on Analytics quick action
|
||||
await page.click('text=Analytics');
|
||||
|
||||
// Verify we're on the Analytics page
|
||||
await expect(page).toHaveURL('/analytics');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Feeding Tracker', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/login');
|
||||
await page.fill('input[name="email"]', 'andrei@cloudz.ro');
|
||||
await page.fill('input[name="password"]', 'password');
|
||||
await page.click('button[type="submit"]');
|
||||
await page.waitForURL('/');
|
||||
|
||||
// Navigate to feeding tracker
|
||||
await page.goto('/track/feeding');
|
||||
});
|
||||
|
||||
test('should have feeding type options', async ({ page }) => {
|
||||
// Check that feeding type buttons are visible
|
||||
await expect(page.locator('text=Bottle')).toBeVisible();
|
||||
await expect(page.locator('text=Breast')).toBeVisible();
|
||||
await expect(page.locator('text=Solid')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should have save button', async ({ page }) => {
|
||||
await expect(page.locator('button:has-text("Save")')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Sleep Tracker', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/login');
|
||||
await page.fill('input[name="email"]', 'andrei@cloudz.ro');
|
||||
await page.fill('input[name="password"]', 'password');
|
||||
await page.click('button[type="submit"]');
|
||||
await page.waitForURL('/');
|
||||
|
||||
// Navigate to sleep tracker
|
||||
await page.goto('/track/sleep');
|
||||
});
|
||||
|
||||
test('should have sleep type options', async ({ page }) => {
|
||||
// Check that sleep type buttons are visible
|
||||
await expect(page.locator('text=Nap')).toBeVisible();
|
||||
await expect(page.locator('text=Night')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should have save button', async ({ page }) => {
|
||||
await expect(page.locator('button:has-text("Save")')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Diaper Tracker', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/login');
|
||||
await page.fill('input[name="email"]', 'andrei@cloudz.ro');
|
||||
await page.fill('input[name="password"]', 'password');
|
||||
await page.click('button[type="submit"]');
|
||||
await page.waitForURL('/');
|
||||
|
||||
// Navigate to diaper tracker
|
||||
await page.goto('/track/diaper');
|
||||
});
|
||||
|
||||
test('should have diaper type options', async ({ page }) => {
|
||||
// Check that diaper type buttons are visible
|
||||
await expect(page.locator('text=Wet')).toBeVisible();
|
||||
await expect(page.locator('text=Dirty')).toBeVisible();
|
||||
await expect(page.locator('text=Both')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should have save button', async ({ page }) => {
|
||||
await expect(page.locator('button:has-text("Save")')).toBeVisible();
|
||||
});
|
||||
});
|
||||
26
maternal-web/tsconfig.json
Normal file
26
maternal-web/tsconfig.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Reference in New Issue
Block a user