Compare commits
No commits in common. "4a596abedfabd3c5a081c0c1c4c275a189912159" and "7d2b7fc90c932989611a9a3d1815c6e216aab792" have entirely different histories.
4a596abedf
...
7d2b7fc90c
23 changed files with 237 additions and 3106 deletions
35
CLAUDE.md
35
CLAUDE.md
|
|
@ -74,11 +74,38 @@ tasks (id, title, description, status, created_at, updated_at, completed_at)
|
||||||
|
|
||||||
## Development Commands
|
## Development Commands
|
||||||
|
|
||||||
**Primary command**: Use `just` for all development tasks. Run `just --list` to see all available commands.
|
### Testing
|
||||||
|
```bash
|
||||||
|
# Backend tests
|
||||||
|
just test-unit # Unit tests (cargo test)
|
||||||
|
just test-coverage # Coverage report (tarpaulin HTML)
|
||||||
|
just test-integration # API tests (Hurl)
|
||||||
|
|
||||||
**Key commands**:
|
# Individual commands
|
||||||
- `just check` - Validate all changes (primary validation command)
|
cargo test # Direct unit test execution
|
||||||
- `just fmt` - Format code (resolve formatting errors)
|
hurl --test tests/api/*.hurl # Direct API test execution
|
||||||
|
|
||||||
|
# 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,8 +1,59 @@
|
||||||
/* Captain's Log - Global Styles */
|
@import 'tailwindcss';
|
||||||
|
|
||||||
|
@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 {
|
||||||
margin: 0;
|
@apply bg-gray-50 dark:bg-gray-950 text-gray-900 dark:text-gray-100;
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,64 +0,0 @@
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,216 +0,0 @@
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,53 +0,0 @@
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,359 +0,0 @@
|
||||||
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)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,227 +0,0 @@
|
||||||
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()
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,211 +0,0 @@
|
||||||
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()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,114 +0,0 @@
|
||||||
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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,318 +0,0 @@
|
||||||
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)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,161 +0,0 @@
|
||||||
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,12 +6,8 @@ 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 = () => [
|
||||||
|
|
@ -46,14 +42,7 @@ export function Layout({ children }: { children: React.ReactNode }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return <Outlet />
|
||||||
<ThemeProvider theme={theme}>
|
|
||||||
<CssBaseline />
|
|
||||||
<AppLayout>
|
|
||||||
<Outlet />
|
|
||||||
</AppLayout>
|
|
||||||
</ThemeProvider>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
|
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
|
||||||
|
|
@ -73,19 +62,14 @@ export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ThemeProvider theme={theme}>
|
<main className="pt-16 p-4 container mx-auto">
|
||||||
<CssBaseline />
|
<h1>{message}</h1>
|
||||||
<AppLayout>
|
<p>{details}</p>
|
||||||
<main className="pt-16 p-4 container mx-auto">
|
{stack && (
|
||||||
<h1>{message}</h1>
|
<pre className="w-full p-4 overflow-x-auto">
|
||||||
<p>{details}</p>
|
<code>{stack}</code>
|
||||||
{stack && (
|
</pre>
|
||||||
<pre className="w-full p-4 overflow-x-auto">
|
)}
|
||||||
<code>{stack}</code>
|
</main>
|
||||||
</pre>
|
|
||||||
)}
|
|
||||||
</main>
|
|
||||||
</AppLayout>
|
|
||||||
</ThemeProvider>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,14 +3,8 @@ import { describe, it, expect } from 'vitest'
|
||||||
import Home from './home'
|
import Home from './home'
|
||||||
|
|
||||||
describe('Home component', () => {
|
describe('Home component', () => {
|
||||||
it('should render task management interface', () => {
|
it('should render welcome component', () => {
|
||||||
render(<Home />)
|
render(<Home />)
|
||||||
expect(screen.getByText(/Tasks/i)).toBeInTheDocument()
|
expect(screen.getByText(/React Router/i)).toBeInTheDocument()
|
||||||
expect(
|
|
||||||
screen.getByText(/GTD-inspired task management system/i)
|
|
||||||
).toBeInTheDocument()
|
|
||||||
expect(
|
|
||||||
screen.getByText(/Task Management Interface Coming Soon/i)
|
|
||||||
).toBeInTheDocument()
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,44 +1,13 @@
|
||||||
import type { Route } from './+types/home'
|
import type { Route } from './+types/home'
|
||||||
import { Box, Typography, Container } from '@mui/material'
|
import { Welcome } from '../welcome/welcome'
|
||||||
|
|
||||||
export function meta(_: Route.MetaArgs) {
|
export function meta(_: Route.MetaArgs) {
|
||||||
return [
|
return [
|
||||||
{ title: "Captain's Log - Tasks" },
|
{ title: 'New React Router App' },
|
||||||
{ name: 'description', content: 'GTD-inspired task management system' },
|
{ name: 'description', content: 'Welcome to React Router!' },
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
return (
|
return <Welcome />
|
||||||
<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>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,267 +0,0 @@
|
||||||
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'
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,113 +0,0 @@
|
||||||
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()
|
|
||||||
|
|
@ -1,178 +0,0 @@
|
||||||
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,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
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,10 +16,6 @@
|
||||||
"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",
|
||||||
|
|
@ -43,6 +39,7 @@
|
||||||
"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",
|
||||||
|
|
|
||||||
76
frontend/tailwind.config.js
Normal file
76
frontend/tailwind.config.js
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
/** @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,7 +25,5 @@ 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
|
||||||
- [x] **File**: `frontend/src/types/task.ts`
|
- [ ] **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
|
||||||
- [x] **File**: `frontend/src/services/api.ts`
|
- [ ] **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
|
||||||
- [x] **Files**: `frontend/src/hooks/useTask.ts`, `frontend/src/hooks/useTasks.ts`
|
- [ ] **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
|
||||||
- [x] **File**: `frontend/tests/api.test.ts`
|
- [ ] **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,26 +107,7 @@ captains-log/
|
||||||
|
|
||||||
## Phase 3: Core Components (Days 5-6)
|
## Phase 3: Core Components (Days 5-6)
|
||||||
|
|
||||||
**UI Framework**: Use Material-UI (MUI) for consistent design system and components alongside Tailwind CSS for custom styling.
|
### Task 3.1: Task Card Component
|
||||||
|
|
||||||
### 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
|
||||||
|
|
@ -135,16 +116,15 @@ 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.4: Task Form Component
|
### Task 3.2: Task Form Component
|
||||||
- [ ] **File**: `frontend/src/components/TaskForm.tsx`
|
- [ ] **File**: `frontend/src/components/TaskForm.tsx`
|
||||||
- Create/edit form using Formik for form state management
|
- Create/edit form with all task properties
|
||||||
- Use MUI TextField, Select, and Button components
|
- Form validation and error display
|
||||||
- Form validation with Yup schema and error display
|
- Handle form submission with API calls
|
||||||
- Handle form submission with API calls through Formik onSubmit
|
- Support both modal and inline modes
|
||||||
- Support both MUI Dialog modal and inline modes
|
- **Expected outcome**: TaskForm creates and updates tasks
|
||||||
- **Expected outcome**: TaskForm creates and updates tasks with robust form handling
|
|
||||||
|
|
||||||
### Task 3.5: Quick Capture Component
|
### Task 3.3: 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
|
||||||
|
|
@ -152,14 +132,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.6: Status Badge Component
|
### Task 3.4: 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.7: Component Unit Tests
|
### Task 3.5: 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
|
||||||
|
|
@ -169,6 +149,22 @@ 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