Compare commits
5 commits
7d2b7fc90c
...
4a596abedf
| Author | SHA1 | Date | |
|---|---|---|---|
| 4a596abedf | |||
| 6ef9843835 | |||
| 80539fd199 | |||
| 66e0a1d3f3 | |||
| c443a13a14 |
23 changed files with 3106 additions and 237 deletions
35
CLAUDE.md
35
CLAUDE.md
|
|
@ -74,38 +74,11 @@ tasks (id, title, description, status, created_at, updated_at, completed_at)
|
||||||
|
|
||||||
## Development Commands
|
## Development Commands
|
||||||
|
|
||||||
### Testing
|
**Primary command**: Use `just` for all development tasks. Run `just --list` to see all available commands.
|
||||||
```bash
|
|
||||||
# Backend tests
|
|
||||||
just test-unit # Unit tests (cargo test)
|
|
||||||
just test-coverage # Coverage report (tarpaulin HTML)
|
|
||||||
just test-integration # API tests (Hurl)
|
|
||||||
|
|
||||||
# Individual commands
|
**Key commands**:
|
||||||
cargo test # Direct unit test execution
|
- `just check` - Validate all changes (primary validation command)
|
||||||
hurl --test tests/api/*.hurl # Direct API test execution
|
- `just fmt` - Format code (resolve formatting errors)
|
||||||
|
|
||||||
# Frontend tests (when implemented)
|
|
||||||
npm test # Unit tests
|
|
||||||
npm run test:e2e # End-to-end tests
|
|
||||||
npm run test:coverage # Coverage report
|
|
||||||
```
|
|
||||||
|
|
||||||
### Development Server
|
|
||||||
```bash
|
|
||||||
# Backend (Rust server)
|
|
||||||
just dev # Run backend server (cargo run)
|
|
||||||
|
|
||||||
# Other backend commands
|
|
||||||
just build # Build project
|
|
||||||
just migrate # Run database migrations
|
|
||||||
just reset-db # Reset database
|
|
||||||
just fmt # Format code
|
|
||||||
just lint # Run clippy
|
|
||||||
|
|
||||||
# Frontend (when implemented)
|
|
||||||
npm run dev # Vite dev server
|
|
||||||
```
|
|
||||||
|
|
||||||
## Current Phase: Core MVP Backend ✅
|
## Current Phase: Core MVP Backend ✅
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,59 +1,8 @@
|
||||||
@import 'tailwindcss';
|
/* Captain's Log - Global Styles */
|
||||||
|
|
||||||
@theme {
|
|
||||||
--font-sans:
|
|
||||||
'Inter', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji',
|
|
||||||
'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
|
|
||||||
|
|
||||||
/* Captain's Log Design Tokens */
|
|
||||||
--color-primary-50: #eff6ff;
|
|
||||||
--color-primary-100: #dbeafe;
|
|
||||||
--color-primary-500: #3b82f6;
|
|
||||||
--color-primary-950: #172554;
|
|
||||||
|
|
||||||
--color-task-todo: #3b82f6;
|
|
||||||
--color-task-done: #22c55e;
|
|
||||||
--color-task-backlog: #6b7280;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
/* Base font family is handled by Material-UI theme */
|
||||||
html,
|
html,
|
||||||
body {
|
body {
|
||||||
@apply bg-gray-50 dark:bg-gray-950 text-gray-900 dark:text-gray-100;
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
color-scheme: dark;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Captain's Log Component Styles */
|
|
||||||
.task-card {
|
|
||||||
@apply bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-4 transition-all duration-200;
|
|
||||||
}
|
|
||||||
|
|
||||||
.task-card:hover {
|
|
||||||
@apply shadow-md border-gray-300 dark:border-gray-600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quick-capture {
|
|
||||||
@apply bg-white dark:bg-gray-800 border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-xl p-4 transition-colors duration-200;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quick-capture:focus-within {
|
|
||||||
@apply border-blue-500 bg-blue-50 dark:bg-blue-950;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-badge {
|
|
||||||
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-todo {
|
|
||||||
@apply bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-done {
|
|
||||||
@apply bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-backlog {
|
|
||||||
@apply bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200;
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
64
frontend/app/components/ErrorFallback.tsx
Normal file
64
frontend/app/components/ErrorFallback.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
import React from 'react'
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
AlertTitle,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Container,
|
||||||
|
Typography,
|
||||||
|
Paper,
|
||||||
|
} from '@mui/material'
|
||||||
|
import {
|
||||||
|
Refresh as RefreshIcon,
|
||||||
|
BugReport as BugReportIcon,
|
||||||
|
} from '@mui/icons-material'
|
||||||
|
|
||||||
|
interface ErrorFallbackProps {
|
||||||
|
error: Error
|
||||||
|
resetError: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ErrorFallback({
|
||||||
|
error,
|
||||||
|
resetError,
|
||||||
|
}: ErrorFallbackProps) {
|
||||||
|
return (
|
||||||
|
<Container maxWidth="md" sx={{ py: 8 }}>
|
||||||
|
<Paper sx={{ p: 4, textAlign: 'center' }}>
|
||||||
|
<Box sx={{ mb: 3 }}>
|
||||||
|
<BugReportIcon sx={{ fontSize: 64, color: 'error.main', mb: 2 }} />
|
||||||
|
<Typography variant="h4" gutterBottom>
|
||||||
|
Something went wrong
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body1" color="text.secondary">
|
||||||
|
An unexpected error occurred. Please try refreshing the page or
|
||||||
|
contact support if the problem persists.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Alert severity="error" sx={{ mb: 3, textAlign: 'left' }}>
|
||||||
|
<AlertTitle>Error Details</AlertTitle>
|
||||||
|
{error.message}
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', gap: 2, justifyContent: 'center' }}>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
startIcon={<RefreshIcon />}
|
||||||
|
onClick={resetError}
|
||||||
|
size="large"
|
||||||
|
>
|
||||||
|
Try Again
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
onClick={() => window.location.reload()}
|
||||||
|
size="large"
|
||||||
|
>
|
||||||
|
Reload Page
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
</Container>
|
||||||
|
)
|
||||||
|
}
|
||||||
216
frontend/app/components/Layout.tsx
Normal file
216
frontend/app/components/Layout.tsx
Normal file
|
|
@ -0,0 +1,216 @@
|
||||||
|
import React, { useState } from 'react'
|
||||||
|
import {
|
||||||
|
AppBar,
|
||||||
|
Box,
|
||||||
|
CssBaseline,
|
||||||
|
Drawer,
|
||||||
|
IconButton,
|
||||||
|
List,
|
||||||
|
ListItem,
|
||||||
|
ListItemButton,
|
||||||
|
ListItemIcon,
|
||||||
|
ListItemText,
|
||||||
|
Toolbar,
|
||||||
|
Typography,
|
||||||
|
useTheme,
|
||||||
|
Fab,
|
||||||
|
LinearProgress,
|
||||||
|
} from '@mui/material'
|
||||||
|
import {
|
||||||
|
Menu as MenuIcon,
|
||||||
|
Dashboard as DashboardIcon,
|
||||||
|
Add as AddIcon,
|
||||||
|
Settings as SettingsIcon,
|
||||||
|
} from '@mui/icons-material'
|
||||||
|
|
||||||
|
const drawerWidth = 240
|
||||||
|
|
||||||
|
interface LayoutProps {
|
||||||
|
children: React.ReactNode
|
||||||
|
loading?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Layout({ children, loading = false }: LayoutProps) {
|
||||||
|
const theme = useTheme()
|
||||||
|
const [mobileOpen, setMobileOpen] = useState(false)
|
||||||
|
|
||||||
|
const handleDrawerToggle = () => {
|
||||||
|
setMobileOpen(!mobileOpen)
|
||||||
|
}
|
||||||
|
|
||||||
|
const menuItems = [
|
||||||
|
{
|
||||||
|
text: 'Tasks',
|
||||||
|
icon: <DashboardIcon />,
|
||||||
|
path: '/',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: 'Settings',
|
||||||
|
icon: <SettingsIcon />,
|
||||||
|
path: '/settings',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const drawer = (
|
||||||
|
<div>
|
||||||
|
<List sx={{ pt: 2 }}>
|
||||||
|
{menuItems.map(item => (
|
||||||
|
<ListItem key={item.text} disablePadding>
|
||||||
|
<ListItemButton
|
||||||
|
component="a"
|
||||||
|
href={item.path}
|
||||||
|
sx={{
|
||||||
|
borderRadius: 2,
|
||||||
|
mx: 1,
|
||||||
|
my: 0.5,
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: theme.palette.primary.main + '10',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ListItemIcon sx={{ color: theme.palette.primary.main }}>
|
||||||
|
{item.icon}
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText
|
||||||
|
primary={item.text}
|
||||||
|
primaryTypographyProps={{
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</ListItemButton>
|
||||||
|
</ListItem>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex' }}>
|
||||||
|
<CssBaseline />
|
||||||
|
|
||||||
|
{/* Loading indicator */}
|
||||||
|
{loading && (
|
||||||
|
<LinearProgress
|
||||||
|
sx={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
zIndex: theme.zIndex.appBar + 1,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* App Bar */}
|
||||||
|
<AppBar
|
||||||
|
position="fixed"
|
||||||
|
sx={{
|
||||||
|
width: '100%',
|
||||||
|
backgroundColor: theme.palette.primary.main,
|
||||||
|
color: theme.palette.primary.contrastText,
|
||||||
|
zIndex: theme.zIndex.drawer + 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Toolbar>
|
||||||
|
<IconButton
|
||||||
|
color="inherit"
|
||||||
|
aria-label="open drawer"
|
||||||
|
edge="start"
|
||||||
|
onClick={handleDrawerToggle}
|
||||||
|
sx={{ mr: 2, display: { md: 'none' } }}
|
||||||
|
>
|
||||||
|
<MenuIcon />
|
||||||
|
</IconButton>
|
||||||
|
|
||||||
|
<Typography
|
||||||
|
variant="h6"
|
||||||
|
noWrap
|
||||||
|
component="div"
|
||||||
|
sx={{
|
||||||
|
flexGrow: 1,
|
||||||
|
color: '#ffffff',
|
||||||
|
fontWeight: 700,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
⚓ Captain's Log
|
||||||
|
</Typography>
|
||||||
|
</Toolbar>
|
||||||
|
</AppBar>
|
||||||
|
|
||||||
|
{/* Drawer */}
|
||||||
|
<Box
|
||||||
|
component="nav"
|
||||||
|
sx={{ width: { md: drawerWidth }, flexShrink: { md: 0 } }}
|
||||||
|
aria-label="navigation menu"
|
||||||
|
>
|
||||||
|
{/* Mobile drawer */}
|
||||||
|
<Drawer
|
||||||
|
variant="temporary"
|
||||||
|
open={mobileOpen}
|
||||||
|
onClose={handleDrawerToggle}
|
||||||
|
ModalProps={{
|
||||||
|
keepMounted: true, // Better open performance on mobile.
|
||||||
|
}}
|
||||||
|
sx={{
|
||||||
|
display: { xs: 'block', md: 'none' },
|
||||||
|
'& .MuiDrawer-paper': {
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
width: drawerWidth,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{drawer}
|
||||||
|
</Drawer>
|
||||||
|
|
||||||
|
{/* Desktop drawer */}
|
||||||
|
<Drawer
|
||||||
|
variant="permanent"
|
||||||
|
sx={{
|
||||||
|
display: { xs: 'none', md: 'block' },
|
||||||
|
'& .MuiDrawer-paper': {
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
width: drawerWidth,
|
||||||
|
top: '64px', // Position below AppBar
|
||||||
|
height: 'calc(100% - 64px)', // Adjust height to account for AppBar
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
open
|
||||||
|
>
|
||||||
|
{drawer}
|
||||||
|
</Drawer>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Main content */}
|
||||||
|
<Box
|
||||||
|
component="main"
|
||||||
|
sx={{
|
||||||
|
flexGrow: 1,
|
||||||
|
p: 3,
|
||||||
|
width: { md: `calc(100% - ${drawerWidth}px)` },
|
||||||
|
minHeight: '100vh',
|
||||||
|
backgroundColor: theme.palette.background.default,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Toolbar />
|
||||||
|
{children}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Quick capture FAB */}
|
||||||
|
<Fab
|
||||||
|
color="primary"
|
||||||
|
aria-label="add task"
|
||||||
|
sx={{
|
||||||
|
position: 'fixed',
|
||||||
|
bottom: 16,
|
||||||
|
right: 16,
|
||||||
|
zIndex: theme.zIndex.fab,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AddIcon />
|
||||||
|
</Fab>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
53
frontend/app/components/LoadingSpinner.tsx
Normal file
53
frontend/app/components/LoadingSpinner.tsx
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
import React from 'react'
|
||||||
|
import { Box, CircularProgress, Typography, Skeleton } from '@mui/material'
|
||||||
|
|
||||||
|
interface LoadingSpinnerProps {
|
||||||
|
size?: number
|
||||||
|
message?: string
|
||||||
|
variant?: 'spinner' | 'skeleton'
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LoadingSpinner({
|
||||||
|
size = 40,
|
||||||
|
message = 'Loading...',
|
||||||
|
variant = 'spinner',
|
||||||
|
}: LoadingSpinnerProps) {
|
||||||
|
if (variant === 'skeleton') {
|
||||||
|
return (
|
||||||
|
<Box sx={{ p: 2 }}>
|
||||||
|
<Skeleton variant="text" width="60%" height={32} sx={{ mb: 2 }} />
|
||||||
|
<Skeleton
|
||||||
|
variant="rectangular"
|
||||||
|
width="100%"
|
||||||
|
height={120}
|
||||||
|
sx={{ mb: 1 }}
|
||||||
|
/>
|
||||||
|
<Skeleton
|
||||||
|
variant="rectangular"
|
||||||
|
width="100%"
|
||||||
|
height={120}
|
||||||
|
sx={{ mb: 1 }}
|
||||||
|
/>
|
||||||
|
<Skeleton variant="rectangular" width="100%" height={120} />
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
p: 4,
|
||||||
|
gap: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CircularProgress size={size} thickness={4} />
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{message}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
359
frontend/app/hooks/useApi.test.ts
Normal file
359
frontend/app/hooks/useApi.test.ts
Normal file
|
|
@ -0,0 +1,359 @@
|
||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||||
|
import { renderHook, act, waitFor } from '@testing-library/react'
|
||||||
|
import { useApi, useApiForm, useApiCache } from './useApi'
|
||||||
|
|
||||||
|
// Mock API function for testing
|
||||||
|
const mockApiFunction = vi.fn()
|
||||||
|
const mockFormSubmitFunction = vi.fn()
|
||||||
|
const mockCacheFunction = vi.fn()
|
||||||
|
|
||||||
|
describe('useApi', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should initialize with default state', () => {
|
||||||
|
const { result } = renderHook(() => useApi(mockApiFunction))
|
||||||
|
|
||||||
|
expect(result.current.data).toBeNull()
|
||||||
|
expect(result.current.loading).toBe(false)
|
||||||
|
expect(result.current.error).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should execute API function successfully', async () => {
|
||||||
|
const mockData = { id: 1, name: 'Test' }
|
||||||
|
mockApiFunction.mockResolvedValueOnce(mockData)
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useApi(mockApiFunction))
|
||||||
|
|
||||||
|
let executeResult: unknown
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
executeResult = await result.current.execute('test-arg')
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.current.data).toEqual(mockData)
|
||||||
|
expect(result.current.loading).toBe(false)
|
||||||
|
expect(result.current.error).toBeNull()
|
||||||
|
expect(executeResult).toEqual(mockData)
|
||||||
|
expect(mockApiFunction).toHaveBeenCalledWith('test-arg')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle API function errors', async () => {
|
||||||
|
const errorMessage = 'API Error'
|
||||||
|
mockApiFunction.mockRejectedValueOnce({ message: errorMessage })
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useApi(mockApiFunction))
|
||||||
|
|
||||||
|
let executeResult: unknown
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
executeResult = await result.current.execute()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.current.data).toBeNull()
|
||||||
|
expect(result.current.loading).toBe(false)
|
||||||
|
expect(result.current.error).toBe(errorMessage)
|
||||||
|
expect(executeResult).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should call onSuccess callback', async () => {
|
||||||
|
const mockData = { id: 1 }
|
||||||
|
const onSuccess = vi.fn()
|
||||||
|
mockApiFunction.mockResolvedValueOnce(mockData)
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useApi(mockApiFunction, { onSuccess }))
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.execute()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(onSuccess).toHaveBeenCalledWith(mockData)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should call onError callback', async () => {
|
||||||
|
const errorMessage = 'Test Error'
|
||||||
|
const onError = vi.fn()
|
||||||
|
mockApiFunction.mockRejectedValueOnce({ message: errorMessage })
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useApi(mockApiFunction, { onError }))
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.execute()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(onError).toHaveBeenCalledWith(errorMessage)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should execute immediately when immediate option is true', async () => {
|
||||||
|
const mockData = { id: 1 }
|
||||||
|
mockApiFunction.mockResolvedValueOnce(mockData)
|
||||||
|
|
||||||
|
renderHook(() => useApi(mockApiFunction, { immediate: true }))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockApiFunction).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should reset state', async () => {
|
||||||
|
const mockData = { id: 1 }
|
||||||
|
mockApiFunction.mockResolvedValueOnce(mockData)
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useApi(mockApiFunction))
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.execute()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.current.data).toEqual(mockData)
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.reset()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.current.data).toBeNull()
|
||||||
|
expect(result.current.loading).toBe(false)
|
||||||
|
expect(result.current.error).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should clear error', async () => {
|
||||||
|
mockApiFunction.mockRejectedValueOnce({ message: 'Test Error' })
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useApi(mockApiFunction))
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.execute()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.current.error).toBeTruthy()
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.clearError()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.current.error).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should cancel previous requests', async () => {
|
||||||
|
let resolveFirst: (value: unknown) => void
|
||||||
|
let resolveSecond: (value: unknown) => void
|
||||||
|
|
||||||
|
const firstPromise = new Promise(resolve => {
|
||||||
|
resolveFirst = resolve
|
||||||
|
})
|
||||||
|
const secondPromise = new Promise(resolve => {
|
||||||
|
resolveSecond = resolve
|
||||||
|
})
|
||||||
|
|
||||||
|
mockApiFunction
|
||||||
|
.mockReturnValueOnce(firstPromise)
|
||||||
|
.mockReturnValueOnce(secondPromise)
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useApi(mockApiFunction))
|
||||||
|
|
||||||
|
// Start first request
|
||||||
|
act(() => {
|
||||||
|
result.current.execute('first')
|
||||||
|
})
|
||||||
|
|
||||||
|
// Start second request before first completes
|
||||||
|
act(() => {
|
||||||
|
result.current.execute('second')
|
||||||
|
})
|
||||||
|
|
||||||
|
// Resolve first request (should be cancelled)
|
||||||
|
await act(async () => {
|
||||||
|
resolveFirst({ data: 'first' })
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 0))
|
||||||
|
})
|
||||||
|
|
||||||
|
// Resolve second request
|
||||||
|
await act(async () => {
|
||||||
|
resolveSecond({ data: 'second' })
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 0))
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.current.data).toEqual({ data: 'second' })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('useApiForm', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle form submission', async () => {
|
||||||
|
const formData = { name: 'Test' }
|
||||||
|
const responseData = { id: 1, name: 'Test' }
|
||||||
|
mockFormSubmitFunction.mockResolvedValueOnce(responseData)
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useApiForm(mockFormSubmitFunction))
|
||||||
|
|
||||||
|
let submitResult: unknown
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
submitResult = await result.current.handleSubmit(formData)
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(submitResult).toEqual(responseData)
|
||||||
|
expect(mockFormSubmitFunction).toHaveBeenCalledWith(formData)
|
||||||
|
expect(result.current.data).toEqual(responseData)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should reset on success when option is enabled', async () => {
|
||||||
|
const formData = { name: 'Test' }
|
||||||
|
const responseData = { id: 1, name: 'Test' }
|
||||||
|
mockFormSubmitFunction.mockResolvedValueOnce(responseData)
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useApiForm(mockFormSubmitFunction, { resetOnSuccess: true })
|
||||||
|
)
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.handleSubmit(formData)
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.current.data).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('useApiCache', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
vi.useFakeTimers()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should fetch and cache data', async () => {
|
||||||
|
const mockData = { id: 1, name: 'Test' }
|
||||||
|
mockCacheFunction.mockResolvedValueOnce(mockData)
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useApiCache('test-key', mockCacheFunction)
|
||||||
|
)
|
||||||
|
|
||||||
|
let fetchResult: unknown
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fetchResult = await result.current.fetchData()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(fetchResult).toEqual(mockData)
|
||||||
|
expect(result.current.data).toEqual(mockData)
|
||||||
|
expect(mockCacheFunction).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return cached data when fresh', async () => {
|
||||||
|
const mockData = { id: 1, name: 'Test' }
|
||||||
|
mockCacheFunction.mockResolvedValueOnce(mockData)
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useApiCache('test-key', mockCacheFunction, { staleTime: 60000 })
|
||||||
|
)
|
||||||
|
|
||||||
|
// First fetch
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.fetchData()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Second fetch should return cached data
|
||||||
|
let secondResult: unknown
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
secondResult = await result.current.fetchData()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(secondResult).toEqual(mockData)
|
||||||
|
expect(mockCacheFunction).toHaveBeenCalledTimes(1) // Should not call API again
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should fetch fresh data when stale', async () => {
|
||||||
|
const mockData1 = { id: 1, name: 'Test 1' }
|
||||||
|
const mockData2 = { id: 2, name: 'Test 2' }
|
||||||
|
|
||||||
|
mockCacheFunction
|
||||||
|
.mockResolvedValueOnce(mockData1)
|
||||||
|
.mockResolvedValueOnce(mockData2)
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useApiCache('test-key', mockCacheFunction, { staleTime: 1000 })
|
||||||
|
)
|
||||||
|
|
||||||
|
// First fetch
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.fetchData()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.current.data).toEqual(mockData1)
|
||||||
|
|
||||||
|
// Advance time past stale time
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(2000)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Second fetch should get fresh data
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.fetchData()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.current.data).toEqual(mockData2)
|
||||||
|
expect(mockCacheFunction).toHaveBeenCalledTimes(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should clear cache', async () => {
|
||||||
|
const mockData = { id: 1, name: 'Test' }
|
||||||
|
mockCacheFunction.mockResolvedValueOnce(mockData)
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useApiCache('test-key', mockCacheFunction)
|
||||||
|
)
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.fetchData()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.current.data).toEqual(mockData)
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
result.current.clearCache()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.current.data).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should indicate stale status', async () => {
|
||||||
|
const mockData = { id: 1, name: 'Test' }
|
||||||
|
mockCacheFunction.mockResolvedValueOnce(mockData)
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useApiCache('test-key', mockCacheFunction, { staleTime: 1000 })
|
||||||
|
)
|
||||||
|
|
||||||
|
// Initially stale (no data)
|
||||||
|
expect(result.current.isStale).toBe(true)
|
||||||
|
|
||||||
|
// Fetch data
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.fetchData()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Should be fresh
|
||||||
|
expect(result.current.isStale).toBe(false)
|
||||||
|
|
||||||
|
// Advance time past stale time
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(2000)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Should be stale again
|
||||||
|
expect(result.current.isStale).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
227
frontend/app/hooks/useApi.ts
Normal file
227
frontend/app/hooks/useApi.ts
Normal file
|
|
@ -0,0 +1,227 @@
|
||||||
|
import { useState, useCallback, useRef, useEffect } from 'react'
|
||||||
|
import type { ApiError } from '~/types/task'
|
||||||
|
|
||||||
|
interface UseApiState<T> {
|
||||||
|
data: T | null
|
||||||
|
loading: boolean
|
||||||
|
error: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseApiActions<T> {
|
||||||
|
execute: (...args: unknown[]) => Promise<T | null>
|
||||||
|
reset: () => void
|
||||||
|
clearError: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseApiOptions {
|
||||||
|
immediate?: boolean
|
||||||
|
onSuccess?: (data: unknown) => void
|
||||||
|
onError?: (error: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useApi<T>(
|
||||||
|
apiFunction: (...args: unknown[]) => Promise<T>,
|
||||||
|
options: UseApiOptions = {}
|
||||||
|
): UseApiState<T> & UseApiActions<T> {
|
||||||
|
const { immediate = false, onSuccess, onError } = options
|
||||||
|
|
||||||
|
const [state, setState] = useState<UseApiState<T>>({
|
||||||
|
data: null,
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const mountedRef = useRef(true)
|
||||||
|
const abortControllerRef = useRef<AbortController | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
mountedRef.current = false
|
||||||
|
if (abortControllerRef.current) {
|
||||||
|
abortControllerRef.current.abort()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const reset = useCallback(() => {
|
||||||
|
setState({
|
||||||
|
data: null,
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const clearError = useCallback(() => {
|
||||||
|
setState(prev => ({ ...prev, error: null }))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const execute = useCallback(
|
||||||
|
async (...args: unknown[]): Promise<T | null> => {
|
||||||
|
// Cancel previous request if still pending
|
||||||
|
if (abortControllerRef.current) {
|
||||||
|
abortControllerRef.current.abort()
|
||||||
|
}
|
||||||
|
|
||||||
|
abortControllerRef.current = new AbortController()
|
||||||
|
|
||||||
|
setState(prev => ({ ...prev, loading: true, error: null }))
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await apiFunction(...args)
|
||||||
|
|
||||||
|
if (!mountedRef.current) return null
|
||||||
|
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
data: result,
|
||||||
|
loading: false,
|
||||||
|
}))
|
||||||
|
|
||||||
|
if (onSuccess) {
|
||||||
|
onSuccess(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
} catch (error) {
|
||||||
|
if (!mountedRef.current) return null
|
||||||
|
|
||||||
|
const apiError = error as ApiError
|
||||||
|
const errorMessage = apiError.message || 'An unknown error occurred'
|
||||||
|
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
loading: false,
|
||||||
|
error: errorMessage,
|
||||||
|
}))
|
||||||
|
|
||||||
|
if (onError) {
|
||||||
|
onError(errorMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
} finally {
|
||||||
|
abortControllerRef.current = null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[apiFunction, onSuccess, onError]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Execute immediately if requested
|
||||||
|
useEffect(() => {
|
||||||
|
if (immediate) {
|
||||||
|
execute()
|
||||||
|
}
|
||||||
|
}, [immediate, execute])
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
execute,
|
||||||
|
reset,
|
||||||
|
clearError,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Utility hook for handling form submissions
|
||||||
|
export function useApiForm<T>(
|
||||||
|
submitFunction: (data: unknown) => Promise<T>,
|
||||||
|
options: UseApiOptions & { resetOnSuccess?: boolean } = {}
|
||||||
|
) {
|
||||||
|
const { resetOnSuccess = false, ...apiOptions } = options
|
||||||
|
|
||||||
|
const api = useApi(submitFunction, apiOptions)
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(
|
||||||
|
async (data: unknown) => {
|
||||||
|
const result = await api.execute(data)
|
||||||
|
|
||||||
|
if (result && resetOnSuccess) {
|
||||||
|
api.reset()
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
},
|
||||||
|
[api, resetOnSuccess]
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
...api,
|
||||||
|
handleSubmit,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Utility hook for data caching and synchronization
|
||||||
|
export function useApiCache<T>(
|
||||||
|
key: string,
|
||||||
|
fetchFunction: () => Promise<T>,
|
||||||
|
options: { cacheTime?: number; staleTime?: number } = {}
|
||||||
|
) {
|
||||||
|
const { cacheTime = 5 * 60 * 1000, staleTime = 30 * 1000 } = options // 5min cache, 30s stale
|
||||||
|
|
||||||
|
const [cacheData, setCacheData] = useState<{
|
||||||
|
data: T | null
|
||||||
|
timestamp: number
|
||||||
|
} | null>(null)
|
||||||
|
|
||||||
|
const api = useApi(fetchFunction)
|
||||||
|
|
||||||
|
const getCachedData = useCallback((): T | null => {
|
||||||
|
if (!cacheData) return null
|
||||||
|
|
||||||
|
const now = Date.now()
|
||||||
|
const age = now - cacheData.timestamp
|
||||||
|
|
||||||
|
if (age > cacheTime) {
|
||||||
|
setCacheData(null)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return cacheData.data
|
||||||
|
}, [cacheData, cacheTime])
|
||||||
|
|
||||||
|
const isStale = useCallback((): boolean => {
|
||||||
|
if (!cacheData) return true
|
||||||
|
|
||||||
|
const now = Date.now()
|
||||||
|
const age = now - cacheData.timestamp
|
||||||
|
|
||||||
|
return age > staleTime
|
||||||
|
}, [cacheData, staleTime])
|
||||||
|
|
||||||
|
const fetchData = useCallback(
|
||||||
|
async (force = false): Promise<T | null> => {
|
||||||
|
// Return cached data if fresh and not forced
|
||||||
|
if (!force && !isStale()) {
|
||||||
|
const cached = getCachedData()
|
||||||
|
if (cached) return cached
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await api.execute()
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
setCacheData({
|
||||||
|
data: result,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
},
|
||||||
|
[api, isStale, getCachedData]
|
||||||
|
)
|
||||||
|
|
||||||
|
const clearCache = useCallback(() => {
|
||||||
|
setCacheData(null)
|
||||||
|
api.reset()
|
||||||
|
}, [api])
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: api.data || getCachedData(),
|
||||||
|
loading: api.loading,
|
||||||
|
error: api.error,
|
||||||
|
fetchData,
|
||||||
|
clearCache,
|
||||||
|
get isStale() {
|
||||||
|
return isStale()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
211
frontend/app/hooks/useTask.test.ts
Normal file
211
frontend/app/hooks/useTask.test.ts
Normal file
|
|
@ -0,0 +1,211 @@
|
||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||||
|
import { renderHook, act, waitFor } from '@testing-library/react'
|
||||||
|
import { useTask } from './useTask'
|
||||||
|
import type { Task, UpdateTaskRequest } from '~/types/task'
|
||||||
|
import { TaskStatus } from '~/types/task'
|
||||||
|
import { apiClient } from '~/services/api'
|
||||||
|
|
||||||
|
// Mock the API client
|
||||||
|
vi.mock('~/services/api', () => ({
|
||||||
|
apiClient: {
|
||||||
|
getTask: vi.fn(),
|
||||||
|
updateTask: vi.fn(),
|
||||||
|
deleteTask: vi.fn(),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
const mockApiClient = apiClient as unknown as {
|
||||||
|
getTask: ReturnType<typeof vi.fn>
|
||||||
|
updateTask: ReturnType<typeof vi.fn>
|
||||||
|
deleteTask: ReturnType<typeof vi.fn>
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sample task data
|
||||||
|
const mockTask: Task = {
|
||||||
|
id: '550e8400-e29b-41d4-a716-446655440000',
|
||||||
|
title: 'Test Task',
|
||||||
|
description: 'Test Description',
|
||||||
|
status: TaskStatus.Todo,
|
||||||
|
created_at: '2023-01-01T00:00:00Z',
|
||||||
|
updated_at: '2023-01-01T00:00:00Z',
|
||||||
|
completed_at: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('useTask', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should initialize with default state', () => {
|
||||||
|
const { result } = renderHook(() => useTask())
|
||||||
|
|
||||||
|
expect(result.current.task).toBeNull()
|
||||||
|
expect(result.current.loading).toBe(false)
|
||||||
|
expect(result.current.error).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getTask', () => {
|
||||||
|
it('should fetch task successfully', async () => {
|
||||||
|
mockApiClient.getTask.mockResolvedValueOnce(mockTask)
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useTask())
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.getTask(mockTask.id)
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.current.loading).toBe(true)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.loading).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.current.task).toEqual(mockTask)
|
||||||
|
expect(result.current.error).toBeNull()
|
||||||
|
expect(mockApiClient.getTask).toHaveBeenCalledWith(mockTask.id)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle fetch errors', async () => {
|
||||||
|
const errorMessage = 'Task not found'
|
||||||
|
mockApiClient.getTask.mockRejectedValueOnce({ message: errorMessage })
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useTask())
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.getTask(mockTask.id)
|
||||||
|
})
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.loading).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.current.task).toBeNull()
|
||||||
|
expect(result.current.error).toBe(errorMessage)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('updateTask', () => {
|
||||||
|
it('should update task with optimistic update', async () => {
|
||||||
|
const updateData: UpdateTaskRequest = {
|
||||||
|
title: 'Updated Task',
|
||||||
|
status: TaskStatus.Done,
|
||||||
|
}
|
||||||
|
const updatedTask = { ...mockTask, ...updateData }
|
||||||
|
|
||||||
|
mockApiClient.getTask.mockResolvedValueOnce(mockTask)
|
||||||
|
mockApiClient.updateTask.mockResolvedValueOnce(updatedTask)
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useTask())
|
||||||
|
|
||||||
|
// Set initial task
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.getTask(mockTask.id)
|
||||||
|
})
|
||||||
|
|
||||||
|
let updateResult: Task | null = null
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
updateResult = await result.current.updateTask(mockTask.id, updateData)
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(updateResult).toEqual(updatedTask)
|
||||||
|
expect(mockApiClient.updateTask).toHaveBeenCalledWith(
|
||||||
|
mockTask.id,
|
||||||
|
updateData
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle update errors and revert optimistic update', async () => {
|
||||||
|
const updateData: UpdateTaskRequest = { status: TaskStatus.Done }
|
||||||
|
const errorMessage = 'Update failed'
|
||||||
|
|
||||||
|
// Setup initial task
|
||||||
|
mockApiClient.getTask.mockResolvedValueOnce(mockTask)
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useTask())
|
||||||
|
|
||||||
|
// Set initial task state
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.getTask(mockTask.id)
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.current.task).toEqual(mockTask)
|
||||||
|
|
||||||
|
// Mock update failure and revert call
|
||||||
|
mockApiClient.updateTask.mockRejectedValueOnce({ message: errorMessage })
|
||||||
|
mockApiClient.getTask.mockResolvedValueOnce(mockTask)
|
||||||
|
|
||||||
|
let updateResult: Task | null = null
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
updateResult = await result.current.updateTask(mockTask.id, updateData)
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(updateResult).toBeNull()
|
||||||
|
expect(result.current.error).toBe(errorMessage)
|
||||||
|
expect(result.current.loading).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('deleteTask', () => {
|
||||||
|
it('should delete task successfully', async () => {
|
||||||
|
mockApiClient.deleteTask.mockResolvedValueOnce(undefined)
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useTask())
|
||||||
|
|
||||||
|
// Set initial task
|
||||||
|
await act(async () => {
|
||||||
|
mockApiClient.getTask.mockResolvedValueOnce(mockTask)
|
||||||
|
await result.current.getTask(mockTask.id)
|
||||||
|
})
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.deleteTask(mockTask.id)
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.current.task).toBeNull()
|
||||||
|
expect(result.current.error).toBeNull()
|
||||||
|
expect(mockApiClient.deleteTask).toHaveBeenCalledWith(mockTask.id)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle delete errors', async () => {
|
||||||
|
const errorMessage = 'Delete failed'
|
||||||
|
mockApiClient.deleteTask.mockRejectedValueOnce({ message: errorMessage })
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useTask())
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.deleteTask(mockTask.id)
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.current.error).toBe(errorMessage)
|
||||||
|
expect(result.current.loading).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('clearError', () => {
|
||||||
|
it('should clear error state', async () => {
|
||||||
|
mockApiClient.getTask.mockRejectedValueOnce({ message: 'Test error' })
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useTask())
|
||||||
|
|
||||||
|
// Trigger an error
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.getTask(mockTask.id)
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.current.error).toBeTruthy()
|
||||||
|
|
||||||
|
// Clear the error
|
||||||
|
act(() => {
|
||||||
|
result.current.clearError()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.current.error).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
114
frontend/app/hooks/useTask.ts
Normal file
114
frontend/app/hooks/useTask.ts
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
import { useState, useCallback } from 'react'
|
||||||
|
import type { Task, UpdateTaskRequest, ApiError } from '~/types/task'
|
||||||
|
import { apiClient } from '~/services/api'
|
||||||
|
|
||||||
|
interface UseTaskState {
|
||||||
|
task: Task | null
|
||||||
|
loading: boolean
|
||||||
|
error: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseTaskActions {
|
||||||
|
getTask: (id: string) => Promise<void>
|
||||||
|
updateTask: (id: string, data: UpdateTaskRequest) => Promise<Task | null>
|
||||||
|
deleteTask: (id: string) => Promise<void>
|
||||||
|
clearError: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTask(): UseTaskState & UseTaskActions {
|
||||||
|
const [state, setState] = useState<UseTaskState>({
|
||||||
|
task: null,
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const clearError = useCallback(() => {
|
||||||
|
setState(prev => ({ ...prev, error: null }))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const getTask = useCallback(async (id: string) => {
|
||||||
|
setState(prev => ({ ...prev, loading: true, error: null }))
|
||||||
|
|
||||||
|
try {
|
||||||
|
const task = await apiClient.getTask(id)
|
||||||
|
setState(prev => ({ ...prev, task, loading: false }))
|
||||||
|
} catch (error) {
|
||||||
|
const apiError = error as ApiError
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
loading: false,
|
||||||
|
error: apiError.message,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const updateTask = useCallback(
|
||||||
|
async (id: string, data: UpdateTaskRequest): Promise<Task | null> => {
|
||||||
|
setState(prev => ({ ...prev, loading: true, error: null }))
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Optimistic update
|
||||||
|
if (state.task && state.task.id === id) {
|
||||||
|
const optimisticTask: Task = {
|
||||||
|
...state.task,
|
||||||
|
...data,
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
}
|
||||||
|
setState(prev => ({ ...prev, task: optimisticTask }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedTask = await apiClient.updateTask(id, data)
|
||||||
|
setState(prev => ({ ...prev, task: updatedTask, loading: false }))
|
||||||
|
return updatedTask
|
||||||
|
} catch (error) {
|
||||||
|
const apiError = error as ApiError
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
loading: false,
|
||||||
|
error: apiError.message,
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Revert optimistic update on error
|
||||||
|
if (state.task && state.task.id === id) {
|
||||||
|
try {
|
||||||
|
const originalTask = await apiClient.getTask(id)
|
||||||
|
setState(prev => ({ ...prev, task: originalTask }))
|
||||||
|
} catch {
|
||||||
|
// If we can't revert, just keep the optimistic state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[state.task]
|
||||||
|
)
|
||||||
|
|
||||||
|
const deleteTask = useCallback(async (id: string) => {
|
||||||
|
setState(prev => ({ ...prev, loading: true, error: null }))
|
||||||
|
|
||||||
|
try {
|
||||||
|
await apiClient.deleteTask(id)
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
task: prev.task?.id === id ? null : prev.task,
|
||||||
|
loading: false,
|
||||||
|
}))
|
||||||
|
} catch (error) {
|
||||||
|
const apiError = error as ApiError
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
loading: false,
|
||||||
|
error: apiError.message,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
getTask,
|
||||||
|
updateTask,
|
||||||
|
deleteTask,
|
||||||
|
clearError,
|
||||||
|
}
|
||||||
|
}
|
||||||
318
frontend/app/hooks/useTasks.test.ts
Normal file
318
frontend/app/hooks/useTasks.test.ts
Normal file
|
|
@ -0,0 +1,318 @@
|
||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||||
|
import { renderHook, act, waitFor } from '@testing-library/react'
|
||||||
|
import { useTasks } from './useTasks'
|
||||||
|
import type { Task, CreateTaskRequest } from '~/types/task'
|
||||||
|
import { TaskStatus } from '~/types/task'
|
||||||
|
import { apiClient } from '~/services/api'
|
||||||
|
|
||||||
|
// Mock the API client
|
||||||
|
vi.mock('~/services/api', () => ({
|
||||||
|
apiClient: {
|
||||||
|
listTasks: vi.fn(),
|
||||||
|
createTask: vi.fn(),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
const mockApiClient = apiClient as unknown as {
|
||||||
|
listTasks: ReturnType<typeof vi.fn>
|
||||||
|
createTask: ReturnType<typeof vi.fn>
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sample task data
|
||||||
|
const mockTasks: Task[] = [
|
||||||
|
{
|
||||||
|
id: '550e8400-e29b-41d4-a716-446655440000',
|
||||||
|
title: 'Test Task 1',
|
||||||
|
description: 'Test Description 1',
|
||||||
|
status: TaskStatus.Todo,
|
||||||
|
created_at: '2023-01-01T00:00:00Z',
|
||||||
|
updated_at: '2023-01-01T00:00:00Z',
|
||||||
|
completed_at: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '550e8400-e29b-41d4-a716-446655440001',
|
||||||
|
title: 'Test Task 2',
|
||||||
|
description: 'Test Description 2',
|
||||||
|
status: TaskStatus.Done,
|
||||||
|
created_at: '2023-01-01T01:00:00Z',
|
||||||
|
updated_at: '2023-01-01T01:00:00Z',
|
||||||
|
completed_at: '2023-01-01T02:00:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '550e8400-e29b-41d4-a716-446655440002',
|
||||||
|
title: 'Test Task 3',
|
||||||
|
description: null,
|
||||||
|
status: TaskStatus.Backlog,
|
||||||
|
created_at: '2023-01-01T02:00:00Z',
|
||||||
|
updated_at: '2023-01-01T02:00:00Z',
|
||||||
|
completed_at: null,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
describe('useTasks', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should initialize with default state', () => {
|
||||||
|
const { result } = renderHook(() => useTasks({ autoFetch: false }))
|
||||||
|
|
||||||
|
expect(result.current.tasks).toEqual([])
|
||||||
|
expect(result.current.loading).toBe(false)
|
||||||
|
expect(result.current.error).toBeNull()
|
||||||
|
expect(result.current.lastFetch).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should auto-fetch tasks on mount by default', async () => {
|
||||||
|
mockApiClient.listTasks.mockResolvedValueOnce(mockTasks)
|
||||||
|
|
||||||
|
renderHook(() => useTasks())
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockApiClient.listTasks).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not auto-fetch when disabled', () => {
|
||||||
|
renderHook(() => useTasks({ autoFetch: false }))
|
||||||
|
|
||||||
|
expect(mockApiClient.listTasks).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('fetchTasks', () => {
|
||||||
|
it('should fetch tasks successfully', async () => {
|
||||||
|
mockApiClient.listTasks.mockResolvedValueOnce(mockTasks)
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useTasks({ autoFetch: false }))
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.fetchTasks()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.current.tasks).toEqual(mockTasks)
|
||||||
|
expect(result.current.loading).toBe(false)
|
||||||
|
expect(result.current.error).toBeNull()
|
||||||
|
expect(result.current.lastFetch).toBeInstanceOf(Date)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle fetch errors', async () => {
|
||||||
|
const errorMessage = 'Fetch failed'
|
||||||
|
mockApiClient.listTasks.mockRejectedValueOnce({ message: errorMessage })
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useTasks({ autoFetch: false }))
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.fetchTasks()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.current.tasks).toEqual([])
|
||||||
|
expect(result.current.loading).toBe(false)
|
||||||
|
expect(result.current.error).toBe(errorMessage)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('createTask', () => {
|
||||||
|
it('should create task successfully', async () => {
|
||||||
|
const newTaskData: CreateTaskRequest = {
|
||||||
|
title: 'New Task',
|
||||||
|
description: 'New Description',
|
||||||
|
}
|
||||||
|
const newTask: Task = {
|
||||||
|
id: 'new-task-id',
|
||||||
|
title: newTaskData.title,
|
||||||
|
description: newTaskData.description || null,
|
||||||
|
status: TaskStatus.Todo,
|
||||||
|
created_at: '2023-01-01T03:00:00Z',
|
||||||
|
updated_at: '2023-01-01T03:00:00Z',
|
||||||
|
completed_at: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
mockApiClient.createTask.mockResolvedValueOnce(newTask)
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useTasks({ autoFetch: false }))
|
||||||
|
|
||||||
|
// Set initial tasks
|
||||||
|
await act(async () => {
|
||||||
|
mockApiClient.listTasks.mockResolvedValueOnce(mockTasks)
|
||||||
|
await result.current.fetchTasks()
|
||||||
|
})
|
||||||
|
|
||||||
|
let createResult: Task | null = null
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
createResult = await result.current.createTask(newTaskData)
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(createResult).toEqual(newTask)
|
||||||
|
expect(result.current.tasks[0]).toEqual(newTask) // Should be first in list
|
||||||
|
expect(result.current.tasks).toHaveLength(mockTasks.length + 1)
|
||||||
|
expect(mockApiClient.createTask).toHaveBeenCalledWith(newTaskData)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle create errors', async () => {
|
||||||
|
const newTaskData: CreateTaskRequest = { title: '' }
|
||||||
|
const errorMessage = 'Title must not be empty'
|
||||||
|
|
||||||
|
mockApiClient.createTask.mockRejectedValueOnce({ message: errorMessage })
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useTasks({ autoFetch: false }))
|
||||||
|
|
||||||
|
let createResult: Task | null = null
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
createResult = await result.current.createTask(newTaskData)
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(createResult).toBeNull()
|
||||||
|
expect(result.current.error).toBe(errorMessage)
|
||||||
|
expect(result.current.loading).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('refreshTasks', () => {
|
||||||
|
it('should refresh tasks without loading state when tasks exist', async () => {
|
||||||
|
const { result } = renderHook(() => useTasks({ autoFetch: false }))
|
||||||
|
|
||||||
|
// Set initial tasks
|
||||||
|
await act(async () => {
|
||||||
|
mockApiClient.listTasks.mockResolvedValueOnce(mockTasks)
|
||||||
|
await result.current.fetchTasks()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Refresh with updated tasks
|
||||||
|
const updatedTasks = [...mockTasks, { ...mockTasks[0], id: 'new-id' }]
|
||||||
|
mockApiClient.listTasks.mockResolvedValueOnce(updatedTasks)
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.refreshTasks()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.current.tasks).toEqual(updatedTasks)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should show loading state when no tasks exist', async () => {
|
||||||
|
mockApiClient.listTasks.mockResolvedValueOnce(mockTasks)
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useTasks({ autoFetch: false }))
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.refreshTasks()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.current.loading).toBe(true)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.loading).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('utility functions', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
mockApiClient.listTasks.mockResolvedValueOnce(mockTasks)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should get task by ID', async () => {
|
||||||
|
const { result } = renderHook(() => useTasks())
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.tasks).toHaveLength(mockTasks.length)
|
||||||
|
})
|
||||||
|
|
||||||
|
const foundTask = result.current.getTaskById(mockTasks[0].id)
|
||||||
|
expect(foundTask).toEqual(mockTasks[0])
|
||||||
|
|
||||||
|
const notFoundTask = result.current.getTaskById('nonexistent-id')
|
||||||
|
expect(notFoundTask).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should filter tasks by status', async () => {
|
||||||
|
const { result } = renderHook(() => useTasks())
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.tasks).toHaveLength(mockTasks.length)
|
||||||
|
})
|
||||||
|
|
||||||
|
const todoTasks = result.current.filterTasksByStatus(TaskStatus.Todo)
|
||||||
|
expect(todoTasks).toHaveLength(1)
|
||||||
|
expect(todoTasks[0].status).toBe(TaskStatus.Todo)
|
||||||
|
|
||||||
|
const doneTasks = result.current.filterTasksByStatus(TaskStatus.Done)
|
||||||
|
expect(doneTasks).toHaveLength(1)
|
||||||
|
expect(doneTasks[0].status).toBe(TaskStatus.Done)
|
||||||
|
|
||||||
|
const backlogTasks = result.current.filterTasksByStatus(
|
||||||
|
TaskStatus.Backlog
|
||||||
|
)
|
||||||
|
expect(backlogTasks).toHaveLength(1)
|
||||||
|
expect(backlogTasks[0].status).toBe(TaskStatus.Backlog)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('clearError', () => {
|
||||||
|
it('should clear error state', async () => {
|
||||||
|
mockApiClient.listTasks.mockRejectedValueOnce({ message: 'Test error' })
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useTasks({ autoFetch: false }))
|
||||||
|
|
||||||
|
// Trigger an error
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.fetchTasks()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.current.error).toBeTruthy()
|
||||||
|
|
||||||
|
// Clear the error
|
||||||
|
act(() => {
|
||||||
|
result.current.clearError()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.current.error).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('refresh interval', () => {
|
||||||
|
it('should set up refresh interval', async () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
|
||||||
|
const updatedTasks = [...mockTasks, { ...mockTasks[0], id: 'new-id' }]
|
||||||
|
|
||||||
|
mockApiClient.listTasks
|
||||||
|
.mockResolvedValueOnce(mockTasks)
|
||||||
|
.mockResolvedValueOnce(updatedTasks)
|
||||||
|
.mockResolvedValue(updatedTasks) // Handle any extra calls
|
||||||
|
|
||||||
|
const { result, unmount } = renderHook(() =>
|
||||||
|
useTasks({ refreshInterval: 1000, autoFetch: false })
|
||||||
|
)
|
||||||
|
|
||||||
|
// Manual initial fetch
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.fetchTasks()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.current.tasks).toHaveLength(mockTasks.length)
|
||||||
|
|
||||||
|
const initialCallCount = mockApiClient.listTasks.mock.calls.length
|
||||||
|
|
||||||
|
// Advance timer to trigger refresh
|
||||||
|
await act(async () => {
|
||||||
|
vi.advanceTimersByTime(1000)
|
||||||
|
await vi.runOnlyPendingTimersAsync()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Should have made at least one more call
|
||||||
|
expect(mockApiClient.listTasks.mock.calls.length).toBeGreaterThan(
|
||||||
|
initialCallCount
|
||||||
|
)
|
||||||
|
expect(result.current.tasks).toHaveLength(updatedTasks.length)
|
||||||
|
|
||||||
|
unmount()
|
||||||
|
vi.useRealTimers()
|
||||||
|
}, 10000)
|
||||||
|
})
|
||||||
|
})
|
||||||
161
frontend/app/hooks/useTasks.ts
Normal file
161
frontend/app/hooks/useTasks.ts
Normal file
|
|
@ -0,0 +1,161 @@
|
||||||
|
import { useState, useCallback, useEffect } from 'react'
|
||||||
|
import type { Task, CreateTaskRequest, ApiError } from '~/types/task'
|
||||||
|
import { TaskStatus } from '~/types/task'
|
||||||
|
import { apiClient } from '~/services/api'
|
||||||
|
|
||||||
|
interface UseTasksState {
|
||||||
|
tasks: Task[]
|
||||||
|
loading: boolean
|
||||||
|
error: string | null
|
||||||
|
lastFetch: Date | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseTasksActions {
|
||||||
|
fetchTasks: () => Promise<void>
|
||||||
|
createTask: (data: CreateTaskRequest) => Promise<Task | null>
|
||||||
|
refreshTasks: () => Promise<void>
|
||||||
|
clearError: () => void
|
||||||
|
getTaskById: (id: string) => Task | undefined
|
||||||
|
filterTasksByStatus: (status: TaskStatus) => Task[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseTasksOptions {
|
||||||
|
autoFetch?: boolean
|
||||||
|
refreshInterval?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTasks(
|
||||||
|
options: UseTasksOptions = {}
|
||||||
|
): UseTasksState & UseTasksActions {
|
||||||
|
const { autoFetch = true, refreshInterval } = options
|
||||||
|
|
||||||
|
const [state, setState] = useState<UseTasksState>({
|
||||||
|
tasks: [],
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
lastFetch: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const clearError = useCallback(() => {
|
||||||
|
setState(prev => ({ ...prev, error: null }))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const fetchTasks = useCallback(async () => {
|
||||||
|
setState(prev => ({ ...prev, loading: true, error: null }))
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tasks = await apiClient.listTasks()
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
tasks,
|
||||||
|
loading: false,
|
||||||
|
lastFetch: new Date(),
|
||||||
|
}))
|
||||||
|
} catch (error) {
|
||||||
|
const apiError = error as ApiError
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
loading: false,
|
||||||
|
error: apiError.message,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const createTask = useCallback(
|
||||||
|
async (data: CreateTaskRequest): Promise<Task | null> => {
|
||||||
|
setState(prev => ({ ...prev, loading: true, error: null }))
|
||||||
|
|
||||||
|
try {
|
||||||
|
const newTask = await apiClient.createTask(data)
|
||||||
|
|
||||||
|
// Add the new task to the beginning of the list (most recent first)
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
tasks: [newTask, ...prev.tasks],
|
||||||
|
loading: false,
|
||||||
|
}))
|
||||||
|
|
||||||
|
return newTask
|
||||||
|
} catch (error) {
|
||||||
|
const apiError = error as ApiError
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
loading: false,
|
||||||
|
error: apiError.message,
|
||||||
|
}))
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
const refreshTasks = useCallback(async () => {
|
||||||
|
// Force refresh without showing loading state if tasks already exist
|
||||||
|
const showLoading = state.tasks.length === 0
|
||||||
|
|
||||||
|
if (showLoading) {
|
||||||
|
setState(prev => ({ ...prev, loading: true, error: null }))
|
||||||
|
} else {
|
||||||
|
setState(prev => ({ ...prev, error: null }))
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tasks = await apiClient.listTasks()
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
tasks,
|
||||||
|
loading: false,
|
||||||
|
lastFetch: new Date(),
|
||||||
|
}))
|
||||||
|
} catch (error) {
|
||||||
|
const apiError = error as ApiError
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
loading: false,
|
||||||
|
error: apiError.message,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}, [state.tasks])
|
||||||
|
|
||||||
|
const getTaskById = useCallback(
|
||||||
|
(id: string): Task | undefined => {
|
||||||
|
return state.tasks.find(task => task.id === id)
|
||||||
|
},
|
||||||
|
[state.tasks]
|
||||||
|
)
|
||||||
|
|
||||||
|
const filterTasksByStatus = useCallback(
|
||||||
|
(status: TaskStatus): Task[] => {
|
||||||
|
return state.tasks.filter(task => task.status === status)
|
||||||
|
},
|
||||||
|
[state.tasks]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Auto-fetch tasks on mount
|
||||||
|
useEffect(() => {
|
||||||
|
if (autoFetch) {
|
||||||
|
fetchTasks()
|
||||||
|
}
|
||||||
|
}, [autoFetch, fetchTasks])
|
||||||
|
|
||||||
|
// Set up refresh interval if specified
|
||||||
|
useEffect(() => {
|
||||||
|
if (!refreshInterval) return
|
||||||
|
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
refreshTasks()
|
||||||
|
}, refreshInterval)
|
||||||
|
|
||||||
|
return () => clearInterval(interval)
|
||||||
|
}, [refreshInterval, refreshTasks])
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
fetchTasks,
|
||||||
|
createTask,
|
||||||
|
refreshTasks,
|
||||||
|
clearError,
|
||||||
|
getTaskById,
|
||||||
|
filterTasksByStatus,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,8 +6,12 @@ import {
|
||||||
Scripts,
|
Scripts,
|
||||||
ScrollRestoration,
|
ScrollRestoration,
|
||||||
} from 'react-router'
|
} from 'react-router'
|
||||||
|
import { ThemeProvider } from '@mui/material/styles'
|
||||||
|
import { CssBaseline } from '@mui/material'
|
||||||
|
|
||||||
import type { Route } from './+types/root'
|
import type { Route } from './+types/root'
|
||||||
|
import { theme } from './theme'
|
||||||
|
import AppLayout from './components/Layout'
|
||||||
import './app.css'
|
import './app.css'
|
||||||
|
|
||||||
export const links: Route.LinksFunction = () => [
|
export const links: Route.LinksFunction = () => [
|
||||||
|
|
@ -42,7 +46,14 @@ export function Layout({ children }: { children: React.ReactNode }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return <Outlet />
|
return (
|
||||||
|
<ThemeProvider theme={theme}>
|
||||||
|
<CssBaseline />
|
||||||
|
<AppLayout>
|
||||||
|
<Outlet />
|
||||||
|
</AppLayout>
|
||||||
|
</ThemeProvider>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
|
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
|
||||||
|
|
@ -62,14 +73,19 @@ export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="pt-16 p-4 container mx-auto">
|
<ThemeProvider theme={theme}>
|
||||||
<h1>{message}</h1>
|
<CssBaseline />
|
||||||
<p>{details}</p>
|
<AppLayout>
|
||||||
{stack && (
|
<main className="pt-16 p-4 container mx-auto">
|
||||||
<pre className="w-full p-4 overflow-x-auto">
|
<h1>{message}</h1>
|
||||||
<code>{stack}</code>
|
<p>{details}</p>
|
||||||
</pre>
|
{stack && (
|
||||||
)}
|
<pre className="w-full p-4 overflow-x-auto">
|
||||||
</main>
|
<code>{stack}</code>
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</AppLayout>
|
||||||
|
</ThemeProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,14 @@ import { describe, it, expect } from 'vitest'
|
||||||
import Home from './home'
|
import Home from './home'
|
||||||
|
|
||||||
describe('Home component', () => {
|
describe('Home component', () => {
|
||||||
it('should render welcome component', () => {
|
it('should render task management interface', () => {
|
||||||
render(<Home />)
|
render(<Home />)
|
||||||
expect(screen.getByText(/React Router/i)).toBeInTheDocument()
|
expect(screen.getByText(/Tasks/i)).toBeInTheDocument()
|
||||||
|
expect(
|
||||||
|
screen.getByText(/GTD-inspired task management system/i)
|
||||||
|
).toBeInTheDocument()
|
||||||
|
expect(
|
||||||
|
screen.getByText(/Task Management Interface Coming Soon/i)
|
||||||
|
).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,44 @@
|
||||||
import type { Route } from './+types/home'
|
import type { Route } from './+types/home'
|
||||||
import { Welcome } from '../welcome/welcome'
|
import { Box, Typography, Container } from '@mui/material'
|
||||||
|
|
||||||
export function meta(_: Route.MetaArgs) {
|
export function meta(_: Route.MetaArgs) {
|
||||||
return [
|
return [
|
||||||
{ title: 'New React Router App' },
|
{ title: "Captain's Log - Tasks" },
|
||||||
{ name: 'description', content: 'Welcome to React Router!' },
|
{ name: 'description', content: 'GTD-inspired task management system' },
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
return <Welcome />
|
return (
|
||||||
|
<Container maxWidth="lg">
|
||||||
|
<Box sx={{ py: 4 }}>
|
||||||
|
<Typography variant="h1" component="h1" gutterBottom>
|
||||||
|
Tasks
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body1" color="text.secondary" sx={{ mb: 4 }}>
|
||||||
|
Your GTD-inspired task management system. Capture everything, see only
|
||||||
|
what matters.
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
p: 4,
|
||||||
|
textAlign: 'center',
|
||||||
|
color: 'text.secondary',
|
||||||
|
border: '2px dashed',
|
||||||
|
borderColor: 'grey.300',
|
||||||
|
borderRadius: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
Task Management Interface Coming Soon
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2">
|
||||||
|
The task list, task cards, and quick capture components will be
|
||||||
|
implemented in the next phase.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Container>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
267
frontend/app/services/api.test.ts
Normal file
267
frontend/app/services/api.test.ts
Normal file
|
|
@ -0,0 +1,267 @@
|
||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||||
|
import { apiClient } from './api'
|
||||||
|
import type { Task, CreateTaskRequest, UpdateTaskRequest } from '~/types/task'
|
||||||
|
import { TaskStatus } from '~/types/task'
|
||||||
|
|
||||||
|
// Mock fetch globally
|
||||||
|
const mockFetch = vi.fn()
|
||||||
|
global.fetch = mockFetch
|
||||||
|
|
||||||
|
// Sample task data for testing
|
||||||
|
const mockTask: Task = {
|
||||||
|
id: '550e8400-e29b-41d4-a716-446655440000',
|
||||||
|
title: 'Test Task',
|
||||||
|
description: 'Test Description',
|
||||||
|
status: TaskStatus.Todo,
|
||||||
|
created_at: '2023-01-01T00:00:00Z',
|
||||||
|
updated_at: '2023-01-01T00:00:00Z',
|
||||||
|
completed_at: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockTasks: Task[] = [
|
||||||
|
mockTask,
|
||||||
|
{
|
||||||
|
...mockTask,
|
||||||
|
id: '550e8400-e29b-41d4-a716-446655440001',
|
||||||
|
title: 'Another Task',
|
||||||
|
status: TaskStatus.Done,
|
||||||
|
completed_at: '2023-01-01T01:00:00Z',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
describe('API Client', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockFetch.mockClear()
|
||||||
|
// Silence console.log during tests
|
||||||
|
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||||
|
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('listTasks', () => {
|
||||||
|
it('should fetch all tasks successfully', async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
statusText: 'OK',
|
||||||
|
json: () => Promise.resolve(mockTasks),
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await apiClient.listTasks()
|
||||||
|
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith('/api/tasks', {
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
})
|
||||||
|
expect(result).toEqual(mockTasks)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle empty task list', async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
statusText: 'OK',
|
||||||
|
json: () => Promise.resolve([]),
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await apiClient.listTasks()
|
||||||
|
|
||||||
|
expect(result).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should throw error on failed request', async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
status: 500,
|
||||||
|
statusText: 'Internal Server Error',
|
||||||
|
json: () => Promise.resolve({ message: 'Server error' }),
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(apiClient.listTasks()).rejects.toThrow('Server error')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getTask', () => {
|
||||||
|
it('should fetch single task successfully', async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
statusText: 'OK',
|
||||||
|
json: () => Promise.resolve(mockTask),
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await apiClient.getTask(mockTask.id)
|
||||||
|
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith(`/api/tasks/${mockTask.id}`, {
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
})
|
||||||
|
expect(result).toEqual(mockTask)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle task not found', async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
status: 404,
|
||||||
|
statusText: 'Not Found',
|
||||||
|
json: () => Promise.resolve({ message: 'Task not found' }),
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(apiClient.getTask('nonexistent-id')).rejects.toThrow(
|
||||||
|
'Task not found'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('createTask', () => {
|
||||||
|
it('should create task successfully', async () => {
|
||||||
|
const newTaskData: CreateTaskRequest = {
|
||||||
|
title: 'New Task',
|
||||||
|
description: 'New Description',
|
||||||
|
}
|
||||||
|
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
status: 201,
|
||||||
|
statusText: 'Created',
|
||||||
|
json: () => Promise.resolve(mockTask),
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await apiClient.createTask(newTaskData)
|
||||||
|
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith('/api/tasks', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(newTaskData),
|
||||||
|
})
|
||||||
|
expect(result).toEqual(mockTask)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle validation errors', async () => {
|
||||||
|
const invalidData: CreateTaskRequest = {
|
||||||
|
title: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
status: 422,
|
||||||
|
statusText: 'Unprocessable Entity',
|
||||||
|
json: () => Promise.resolve({ message: 'Title must not be empty' }),
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(apiClient.createTask(invalidData)).rejects.toThrow(
|
||||||
|
'Title must not be empty'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('updateTask', () => {
|
||||||
|
it('should update task successfully', async () => {
|
||||||
|
const updateData: UpdateTaskRequest = {
|
||||||
|
title: 'Updated Task',
|
||||||
|
status: TaskStatus.Done,
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedTask = { ...mockTask, ...updateData }
|
||||||
|
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
statusText: 'OK',
|
||||||
|
json: () => Promise.resolve(updatedTask),
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await apiClient.updateTask(mockTask.id, updateData)
|
||||||
|
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith(`/api/tasks/${mockTask.id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(updateData),
|
||||||
|
})
|
||||||
|
expect(result).toEqual(updatedTask)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle partial updates', async () => {
|
||||||
|
const updateData: UpdateTaskRequest = {
|
||||||
|
status: TaskStatus.Done,
|
||||||
|
}
|
||||||
|
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
statusText: 'OK',
|
||||||
|
json: () => Promise.resolve({ ...mockTask, status: TaskStatus.Done }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await apiClient.updateTask(mockTask.id, updateData)
|
||||||
|
|
||||||
|
expect(result.status).toBe(TaskStatus.Done)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('deleteTask', () => {
|
||||||
|
it('should delete task successfully', async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
status: 204,
|
||||||
|
statusText: 'No Content',
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await apiClient.deleteTask(mockTask.id)
|
||||||
|
expect(result).toBeNull()
|
||||||
|
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith(`/api/tasks/${mockTask.id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle delete errors', async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
status: 404,
|
||||||
|
statusText: 'Not Found',
|
||||||
|
json: () => Promise.resolve({ message: 'Task not found' }),
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(apiClient.deleteTask('nonexistent-id')).rejects.toThrow(
|
||||||
|
'Task not found'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Error Handling', () => {
|
||||||
|
it('should handle network errors', async () => {
|
||||||
|
mockFetch.mockRejectedValueOnce(new Error('Network error'))
|
||||||
|
|
||||||
|
await expect(apiClient.listTasks()).rejects.toThrow('Network error')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle malformed JSON responses', async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
status: 500,
|
||||||
|
statusText: 'Internal Server Error',
|
||||||
|
json: () => Promise.reject(new Error('Invalid JSON')),
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(apiClient.listTasks()).rejects.toThrow(
|
||||||
|
'HTTP 500: Internal Server Error'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle responses with no error message', async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
status: 400,
|
||||||
|
statusText: 'Bad Request',
|
||||||
|
json: () => Promise.resolve({}),
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(apiClient.listTasks()).rejects.toThrow(
|
||||||
|
'HTTP 400: Bad Request'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
113
frontend/app/services/api.ts
Normal file
113
frontend/app/services/api.ts
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
import type {
|
||||||
|
Task,
|
||||||
|
CreateTaskRequest,
|
||||||
|
UpdateTaskRequest,
|
||||||
|
ApiError,
|
||||||
|
} from '~/types/task'
|
||||||
|
|
||||||
|
const API_BASE_URL = '/api'
|
||||||
|
|
||||||
|
class ApiClient {
|
||||||
|
private async fetchWrapper<T>(
|
||||||
|
endpoint: string,
|
||||||
|
options: RequestInit = {}
|
||||||
|
): Promise<T> {
|
||||||
|
const url = `${API_BASE_URL}${endpoint}`
|
||||||
|
|
||||||
|
const config: RequestInit = {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...options.headers,
|
||||||
|
},
|
||||||
|
...options,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
console.log(`API Request: ${config.method || 'GET'} ${url}`, {
|
||||||
|
body: config.body,
|
||||||
|
headers: config.headers,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, config)
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
console.log(`API Response: ${response.status} ${response.statusText}`, {
|
||||||
|
url,
|
||||||
|
status: response.status,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
let errorMessage = `HTTP ${response.status}: ${response.statusText}`
|
||||||
|
|
||||||
|
try {
|
||||||
|
const errorData = await response.json()
|
||||||
|
errorMessage = errorData.message || errorMessage
|
||||||
|
} catch {
|
||||||
|
// If JSON parsing fails, use the default error message
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(errorMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status === 204) {
|
||||||
|
return null as T
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
console.log('API Response Data:', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
return data
|
||||||
|
} catch (error) {
|
||||||
|
const apiError: ApiError = {
|
||||||
|
message:
|
||||||
|
error instanceof Error ? error.message : 'Unknown error occurred',
|
||||||
|
status:
|
||||||
|
error instanceof Error && 'status' in error
|
||||||
|
? (error as { status?: number }).status
|
||||||
|
: undefined,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
console.error('API Error:', apiError)
|
||||||
|
}
|
||||||
|
|
||||||
|
throw apiError
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async listTasks(): Promise<Task[]> {
|
||||||
|
return this.fetchWrapper<Task[]>('/tasks')
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTask(id: string): Promise<Task> {
|
||||||
|
return this.fetchWrapper<Task>(`/tasks/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async createTask(data: CreateTaskRequest): Promise<Task> {
|
||||||
|
return this.fetchWrapper<Task>('/tasks', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateTask(id: string, data: UpdateTaskRequest): Promise<Task> {
|
||||||
|
return this.fetchWrapper<Task>(`/tasks/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteTask(id: string): Promise<void> {
|
||||||
|
return this.fetchWrapper<void>(`/tasks/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const apiClient = new ApiClient()
|
||||||
178
frontend/app/theme.ts
Normal file
178
frontend/app/theme.ts
Normal file
|
|
@ -0,0 +1,178 @@
|
||||||
|
import { createTheme } from '@mui/material/styles'
|
||||||
|
|
||||||
|
// Color palette constants
|
||||||
|
const colors = {
|
||||||
|
// Primary Colors
|
||||||
|
deepNavy: '#1e3a5f', // Main brand color for headers and primary actions
|
||||||
|
oceanBlue: '#2c5282', // Secondary blue for links and active states
|
||||||
|
compassGold: '#d69e2e', // Accent color for highlights and call-to-actions
|
||||||
|
|
||||||
|
// Status Colors
|
||||||
|
chartGreen: '#48bb78', // Completed tasks and success states
|
||||||
|
sunsetCoral: '#f56565', // Urgent tasks and error states
|
||||||
|
seaFoam: '#4fd1c7', // Information and notification states
|
||||||
|
|
||||||
|
// Neutrals
|
||||||
|
parchment: '#f7fafc', // Clean background color
|
||||||
|
fogGray: '#e2e8f0', // Subtle borders and dividers
|
||||||
|
stormGray: '#718096', // Secondary text
|
||||||
|
anchorDark: '#2d3748', // Primary text and headings
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '@mui/material/styles' {
|
||||||
|
interface Theme {
|
||||||
|
custom: {
|
||||||
|
task: {
|
||||||
|
todo: string
|
||||||
|
done: string
|
||||||
|
backlog: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ThemeOptions {
|
||||||
|
custom?: {
|
||||||
|
task?: {
|
||||||
|
todo?: string
|
||||||
|
done?: string
|
||||||
|
backlog?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const theme = createTheme({
|
||||||
|
palette: {
|
||||||
|
mode: 'light',
|
||||||
|
primary: {
|
||||||
|
main: colors.deepNavy,
|
||||||
|
light: colors.oceanBlue,
|
||||||
|
contrastText: '#ffffff',
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
main: colors.compassGold,
|
||||||
|
contrastText: '#ffffff',
|
||||||
|
},
|
||||||
|
success: {
|
||||||
|
main: colors.chartGreen,
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
main: colors.sunsetCoral,
|
||||||
|
},
|
||||||
|
info: {
|
||||||
|
main: colors.seaFoam,
|
||||||
|
},
|
||||||
|
background: {
|
||||||
|
default: colors.parchment,
|
||||||
|
paper: '#ffffff',
|
||||||
|
},
|
||||||
|
text: {
|
||||||
|
primary: colors.anchorDark,
|
||||||
|
secondary: colors.stormGray,
|
||||||
|
},
|
||||||
|
grey: {
|
||||||
|
100: '#edf2f7',
|
||||||
|
200: colors.fogGray,
|
||||||
|
300: '#cbd5e0',
|
||||||
|
500: colors.stormGray,
|
||||||
|
700: colors.anchorDark,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
typography: {
|
||||||
|
fontFamily: '"Inter", ui-sans-serif, system-ui, sans-serif',
|
||||||
|
h1: {
|
||||||
|
fontSize: '2rem',
|
||||||
|
fontWeight: 700,
|
||||||
|
lineHeight: 1.2,
|
||||||
|
},
|
||||||
|
h2: {
|
||||||
|
fontSize: '1.5rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
lineHeight: 1.3,
|
||||||
|
},
|
||||||
|
h3: {
|
||||||
|
fontSize: '1.25rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
lineHeight: 1.4,
|
||||||
|
},
|
||||||
|
body1: {
|
||||||
|
fontSize: '1rem',
|
||||||
|
lineHeight: 1.6,
|
||||||
|
},
|
||||||
|
body2: {
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
lineHeight: 1.5,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
shape: {
|
||||||
|
borderRadius: 12,
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
MuiAppBar: {
|
||||||
|
styleOverrides: {
|
||||||
|
root: {
|
||||||
|
backgroundColor: colors.deepNavy,
|
||||||
|
color: '#ffffff',
|
||||||
|
boxShadow:
|
||||||
|
'0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)',
|
||||||
|
borderBottom: 'none',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
MuiDrawer: {
|
||||||
|
styleOverrides: {
|
||||||
|
paper: {
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
borderRight: `1px solid ${colors.fogGray}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
MuiButton: {
|
||||||
|
styleOverrides: {
|
||||||
|
root: {
|
||||||
|
textTransform: 'none',
|
||||||
|
borderRadius: '0.75rem',
|
||||||
|
fontWeight: 500,
|
||||||
|
},
|
||||||
|
contained: {
|
||||||
|
boxShadow: '0 1px 2px 0 rgb(0 0 0 / 0.05)',
|
||||||
|
'&:hover': {
|
||||||
|
boxShadow:
|
||||||
|
'0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
MuiCard: {
|
||||||
|
styleOverrides: {
|
||||||
|
root: {
|
||||||
|
borderRadius: '0.75rem',
|
||||||
|
boxShadow:
|
||||||
|
'0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)',
|
||||||
|
border: `1px solid ${colors.fogGray}`,
|
||||||
|
'&:hover': {
|
||||||
|
boxShadow:
|
||||||
|
'0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)',
|
||||||
|
borderColor: '#cbd5e0',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
MuiTextField: {
|
||||||
|
styleOverrides: {
|
||||||
|
root: {
|
||||||
|
'& .MuiOutlinedInput-root': {
|
||||||
|
borderRadius: '0.75rem',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
custom: {
|
||||||
|
task: {
|
||||||
|
todo: colors.oceanBlue,
|
||||||
|
done: colors.chartGreen,
|
||||||
|
backlog: colors.stormGray,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
35
frontend/app/types/task.ts
Normal file
35
frontend/app/types/task.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
export enum TaskStatus {
|
||||||
|
Todo = 'todo',
|
||||||
|
Done = 'done',
|
||||||
|
Backlog = 'backlog',
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Task {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
description: string | null
|
||||||
|
status: TaskStatus
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
completed_at: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateTaskRequest {
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateTaskRequest {
|
||||||
|
title?: string
|
||||||
|
description?: string
|
||||||
|
status?: TaskStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiError {
|
||||||
|
message: string
|
||||||
|
status?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaskListResponse {
|
||||||
|
tasks: Task[]
|
||||||
|
}
|
||||||
701
frontend/package-lock.json
generated
701
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -16,6 +16,10 @@
|
||||||
"typecheck": "react-router typegen && tsc"
|
"typecheck": "react-router typegen && tsc"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@emotion/react": "^11.14.0",
|
||||||
|
"@emotion/styled": "^11.14.1",
|
||||||
|
"@mui/icons-material": "^7.3.2",
|
||||||
|
"@mui/material": "^7.3.2",
|
||||||
"@react-router/node": "^7.7.1",
|
"@react-router/node": "^7.7.1",
|
||||||
"@react-router/serve": "^7.7.1",
|
"@react-router/serve": "^7.7.1",
|
||||||
"isbot": "^5.1.27",
|
"isbot": "^5.1.27",
|
||||||
|
|
@ -39,7 +43,6 @@
|
||||||
"eslint-plugin-react-refresh": "^0.4.20",
|
"eslint-plugin-react-refresh": "^0.4.20",
|
||||||
"jsdom": "^27.0.0",
|
"jsdom": "^27.0.0",
|
||||||
"prettier": "^3.6.2",
|
"prettier": "^3.6.2",
|
||||||
"tailwindcss": "^4.1.4",
|
|
||||||
"typescript": "^5.8.3",
|
"typescript": "^5.8.3",
|
||||||
"typescript-eslint": "^8.44.0",
|
"typescript-eslint": "^8.44.0",
|
||||||
"vite": "^6.3.3",
|
"vite": "^6.3.3",
|
||||||
|
|
|
||||||
|
|
@ -1,76 +0,0 @@
|
||||||
/** @type {import('tailwindcss').Config} */
|
|
||||||
export default {
|
|
||||||
content: [
|
|
||||||
"./app/**/*.{js,jsx,ts,tsx}",
|
|
||||||
],
|
|
||||||
theme: {
|
|
||||||
extend: {
|
|
||||||
fontFamily: {
|
|
||||||
sans: ["Inter", "ui-sans-serif", "system-ui", "sans-serif"],
|
|
||||||
},
|
|
||||||
colors: {
|
|
||||||
primary: {
|
|
||||||
50: '#eff6ff',
|
|
||||||
100: '#dbeafe',
|
|
||||||
200: '#bfdbfe',
|
|
||||||
300: '#93c5fd',
|
|
||||||
400: '#60a5fa',
|
|
||||||
500: '#3b82f6',
|
|
||||||
600: '#2563eb',
|
|
||||||
700: '#1d4ed8',
|
|
||||||
800: '#1e40af',
|
|
||||||
900: '#1e3a8a',
|
|
||||||
},
|
|
||||||
success: {
|
|
||||||
50: '#f0fdf4',
|
|
||||||
100: '#dcfce7',
|
|
||||||
200: '#bbf7d0',
|
|
||||||
300: '#86efac',
|
|
||||||
400: '#4ade80',
|
|
||||||
500: '#22c55e',
|
|
||||||
600: '#16a34a',
|
|
||||||
700: '#15803d',
|
|
||||||
800: '#166534',
|
|
||||||
900: '#14532d',
|
|
||||||
},
|
|
||||||
warning: {
|
|
||||||
50: '#fffbeb',
|
|
||||||
100: '#fef3c7',
|
|
||||||
200: '#fde68a',
|
|
||||||
300: '#fcd34d',
|
|
||||||
400: '#fbbf24',
|
|
||||||
500: '#f59e0b',
|
|
||||||
600: '#d97706',
|
|
||||||
700: '#b45309',
|
|
||||||
800: '#92400e',
|
|
||||||
900: '#78350f',
|
|
||||||
},
|
|
||||||
danger: {
|
|
||||||
50: '#fef2f2',
|
|
||||||
100: '#fee2e2',
|
|
||||||
200: '#fecaca',
|
|
||||||
300: '#fca5a5',
|
|
||||||
400: '#f87171',
|
|
||||||
500: '#ef4444',
|
|
||||||
600: '#dc2626',
|
|
||||||
700: '#b91c1c',
|
|
||||||
800: '#991b1b',
|
|
||||||
900: '#7f1d1d',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
spacing: {
|
|
||||||
'18': '4.5rem',
|
|
||||||
'88': '22rem',
|
|
||||||
},
|
|
||||||
borderRadius: {
|
|
||||||
'xl': '0.75rem',
|
|
||||||
'2xl': '1rem',
|
|
||||||
},
|
|
||||||
boxShadow: {
|
|
||||||
'card': '0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06)',
|
|
||||||
'card-hover': '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
plugins: [],
|
|
||||||
}
|
|
||||||
2
justfile
2
justfile
|
|
@ -25,5 +25,7 @@ fmt-check: fmt-check-backend fmt-check-frontend
|
||||||
|
|
||||||
lint: lint-backend lint-frontend
|
lint: lint-backend lint-frontend
|
||||||
|
|
||||||
|
check: fmt-check lint test
|
||||||
|
|
||||||
clean: clean-backend clean-frontend
|
clean: clean-backend clean-frontend
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -75,14 +75,14 @@ captains-log/
|
||||||
## Phase 2: Core API Integration (Days 3-4)
|
## Phase 2: Core API Integration (Days 3-4)
|
||||||
|
|
||||||
### Task 2.1: Define TypeScript Types
|
### Task 2.1: Define TypeScript Types
|
||||||
- [ ] **File**: `frontend/src/types/task.ts`
|
- [x] **File**: `frontend/src/types/task.ts`
|
||||||
- Create Task interface matching backend TaskModel
|
- Create Task interface matching backend TaskModel
|
||||||
- Add TaskStatus enum (Todo, Done, Backlog)
|
- Add TaskStatus enum (Todo, Done, Backlog)
|
||||||
- Include API response types and error types
|
- Include API response types and error types
|
||||||
- **Expected outcome**: Type definitions compile without errors
|
- **Expected outcome**: Type definitions compile without errors
|
||||||
|
|
||||||
### Task 2.2: Backend API Client
|
### Task 2.2: Backend API Client
|
||||||
- [ ] **File**: `frontend/src/services/api.ts`
|
- [x] **File**: `frontend/src/services/api.ts`
|
||||||
- Implement API client with fetch wrapper
|
- Implement API client with fetch wrapper
|
||||||
- Add all task endpoints: GET, POST, PUT, DELETE /api/tasks
|
- Add all task endpoints: GET, POST, PUT, DELETE /api/tasks
|
||||||
- Include error handling and response parsing
|
- Include error handling and response parsing
|
||||||
|
|
@ -90,7 +90,7 @@ captains-log/
|
||||||
- **Expected outcome**: API client can communicate with backend
|
- **Expected outcome**: API client can communicate with backend
|
||||||
|
|
||||||
### Task 2.3: Custom React Hooks for API
|
### Task 2.3: Custom React Hooks for API
|
||||||
- [ ] **Files**: `frontend/src/hooks/useTask.ts`, `frontend/src/hooks/useTasks.ts`
|
- [x] **Files**: `frontend/src/hooks/useTask.ts`, `frontend/src/hooks/useTasks.ts`
|
||||||
- Create useTask hook for single task operations (get, update, delete)
|
- Create useTask hook for single task operations (get, update, delete)
|
||||||
- Create useTasks hook for task list operations (list, create)
|
- Create useTasks hook for task list operations (list, create)
|
||||||
- Include loading states, error handling, and optimistic updates
|
- Include loading states, error handling, and optimistic updates
|
||||||
|
|
@ -98,7 +98,7 @@ captains-log/
|
||||||
- **Expected outcome**: Hooks provide clean API for components
|
- **Expected outcome**: Hooks provide clean API for components
|
||||||
|
|
||||||
### Task 2.4: API Integration Tests
|
### Task 2.4: API Integration Tests
|
||||||
- [ ] **File**: `frontend/tests/api.test.ts`
|
- [x] **File**: `frontend/tests/api.test.ts`
|
||||||
- Test API client with mock responses
|
- Test API client with mock responses
|
||||||
- Test custom hooks with mock API calls
|
- Test custom hooks with mock API calls
|
||||||
- Test error handling scenarios
|
- Test error handling scenarios
|
||||||
|
|
@ -107,7 +107,26 @@ captains-log/
|
||||||
|
|
||||||
## Phase 3: Core Components (Days 5-6)
|
## Phase 3: Core Components (Days 5-6)
|
||||||
|
|
||||||
### Task 3.1: Task Card Component
|
**UI Framework**: Use Material-UI (MUI) for consistent design system and components alongside Tailwind CSS for custom styling.
|
||||||
|
|
||||||
|
### Task 3.1: Main App Component and Routing
|
||||||
|
- [ ] **Files**: `frontend/src/App.tsx`, `frontend/src/main.tsx`
|
||||||
|
- Setup React Router with basic navigation
|
||||||
|
- Create main layout with MUI AppBar/Drawer and task area
|
||||||
|
- Implement responsive design with MUI breakpoints and Tailwind utilities
|
||||||
|
- Add MUI loading states (CircularProgress) and error boundaries
|
||||||
|
- Configure MUI theme with custom colors
|
||||||
|
- **Expected outcome**: Full application loads and navigates properly with Material Design
|
||||||
|
|
||||||
|
### Task 3.2: Task List Component
|
||||||
|
- [ ] **File**: `frontend/src/components/TaskList.tsx`
|
||||||
|
- Display tasks using MUI List/Grid components
|
||||||
|
- Filter tasks by status using MUI Chip/Select components
|
||||||
|
- Sort tasks with MUI Select dropdown
|
||||||
|
- Implement virtual scrolling with MUI virtualization
|
||||||
|
- **Expected outcome**: TaskList displays tasks efficiently with Material Design
|
||||||
|
|
||||||
|
### Task 3.3: Task Card Component
|
||||||
- [ ] **File**: `frontend/src/components/TaskCard.tsx`
|
- [ ] **File**: `frontend/src/components/TaskCard.tsx`
|
||||||
- Display task with title, description, status, dates
|
- Display task with title, description, status, dates
|
||||||
- Implement inline editing for title and description
|
- Implement inline editing for title and description
|
||||||
|
|
@ -116,15 +135,16 @@ captains-log/
|
||||||
- Mobile-friendly touch interactions
|
- Mobile-friendly touch interactions
|
||||||
- **Expected outcome**: TaskCard displays and edits tasks correctly
|
- **Expected outcome**: TaskCard displays and edits tasks correctly
|
||||||
|
|
||||||
### Task 3.2: Task Form Component
|
### Task 3.4: Task Form Component
|
||||||
- [ ] **File**: `frontend/src/components/TaskForm.tsx`
|
- [ ] **File**: `frontend/src/components/TaskForm.tsx`
|
||||||
- Create/edit form with all task properties
|
- Create/edit form using Formik for form state management
|
||||||
- Form validation and error display
|
- Use MUI TextField, Select, and Button components
|
||||||
- Handle form submission with API calls
|
- Form validation with Yup schema and error display
|
||||||
- Support both modal and inline modes
|
- Handle form submission with API calls through Formik onSubmit
|
||||||
- **Expected outcome**: TaskForm creates and updates tasks
|
- Support both MUI Dialog modal and inline modes
|
||||||
|
- **Expected outcome**: TaskForm creates and updates tasks with robust form handling
|
||||||
|
|
||||||
### Task 3.3: Quick Capture Component
|
### Task 3.5: Quick Capture Component
|
||||||
- [ ] **File**: `frontend/src/components/QuickCapture.tsx`
|
- [ ] **File**: `frontend/src/components/QuickCapture.tsx`
|
||||||
- Minimal input for rapid task creation
|
- Minimal input for rapid task creation
|
||||||
- Auto-focus and keyboard shortcuts
|
- Auto-focus and keyboard shortcuts
|
||||||
|
|
@ -132,14 +152,14 @@ captains-log/
|
||||||
- Smart defaults for new tasks
|
- Smart defaults for new tasks
|
||||||
- **Expected outcome**: QuickCapture enables fast task entry
|
- **Expected outcome**: QuickCapture enables fast task entry
|
||||||
|
|
||||||
### Task 3.4: Status Badge Component
|
### Task 3.6: Status Badge Component
|
||||||
- [ ] **File**: `frontend/src/components/StatusBadge.tsx`
|
- [ ] **File**: `frontend/src/components/StatusBadge.tsx`
|
||||||
- Visual status indicators with colors
|
- Visual status indicators with colors
|
||||||
- Consistent styling across components
|
- Consistent styling across components
|
||||||
- Accessible color schemes
|
- Accessible color schemes
|
||||||
- **Expected outcome**: StatusBadge provides clear status visualization
|
- **Expected outcome**: StatusBadge provides clear status visualization
|
||||||
|
|
||||||
### Task 3.5: Component Unit Tests
|
### Task 3.7: Component Unit Tests
|
||||||
- [ ] **Files**: `frontend/tests/components/*.test.tsx`
|
- [ ] **Files**: `frontend/tests/components/*.test.tsx`
|
||||||
- Test all components with React Testing Library
|
- Test all components with React Testing Library
|
||||||
- Test user interactions and state changes
|
- Test user interactions and state changes
|
||||||
|
|
@ -149,22 +169,6 @@ captains-log/
|
||||||
|
|
||||||
## Phase 4: Main Application (Days 7-8)
|
## Phase 4: Main Application (Days 7-8)
|
||||||
|
|
||||||
### Task 4.1: Task List Component
|
|
||||||
- [ ] **File**: `frontend/src/components/TaskList.tsx`
|
|
||||||
- Display tasks in organized list/grid layout
|
|
||||||
- Filter tasks by status (Todo, In Progress, Done, Someday)
|
|
||||||
- Sort tasks by created date, priority, due date
|
|
||||||
- Implement virtual scrolling for performance
|
|
||||||
- **Expected outcome**: TaskList displays tasks efficiently
|
|
||||||
|
|
||||||
### Task 4.2: Main App Component and Routing
|
|
||||||
- [ ] **Files**: `frontend/src/App.tsx`, `frontend/src/main.tsx`
|
|
||||||
- Setup React Router with basic navigation
|
|
||||||
- Create main layout with header and task area
|
|
||||||
- Implement responsive design with Tailwind
|
|
||||||
- Add loading states and error boundaries
|
|
||||||
- **Expected outcome**: Full application loads and navigates properly
|
|
||||||
|
|
||||||
### Task 4.3: State Management and Persistence
|
### Task 4.3: State Management and Persistence
|
||||||
- [ ] **File**: `frontend/src/hooks/useApi.ts`
|
- [ ] **File**: `frontend/src/hooks/useApi.ts`
|
||||||
- Implement localStorage for offline task caching
|
- Implement localStorage for offline task caching
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue