Create a frontend wireframe. (#7)
Sets up API methods and types. Sets up a colorscheme. Sets up a homepage. Removes tailwind in favor of mui for now. Reviewed-on: #7 Co-authored-by: Drew Galbraith <drew@tiramisu.one> Co-committed-by: Drew Galbraith <drew@tiramisu.one>
This commit is contained in:
parent
7d2b7fc90c
commit
d60d834f38
27 changed files with 3114 additions and 977 deletions
39
CLAUDE.md
39
CLAUDE.md
|
|
@ -50,11 +50,11 @@ backend/
|
|||
└── migrations/ # SQLx database migrations
|
||||
```
|
||||
|
||||
### Frontend: Vite + React + Tailwind CSS
|
||||
### Frontend: Vite + React
|
||||
- **Build Tool**: Vite for fast development and optimized builds
|
||||
- **Framework**: React with functional components and hooks
|
||||
- **Routing**: React Router for client-side navigation
|
||||
- **Styling**: Tailwind CSS for rapid UI development
|
||||
- **Styling**: CSS for styling
|
||||
- **State Management**: React state with localStorage persistence
|
||||
- **PWA Features**: Service worker for offline functionality
|
||||
- **Mobile Optimization**: Touch gestures, responsive design
|
||||
|
|
@ -74,38 +74,11 @@ tasks (id, title, description, status, created_at, updated_at, completed_at)
|
|||
|
||||
## Development 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)
|
||||
**Primary command**: Use `just` for all development tasks. Run `just --list` to see all available commands.
|
||||
|
||||
# Individual commands
|
||||
cargo test # Direct unit test execution
|
||||
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
|
||||
```
|
||||
**Key commands**:
|
||||
- `just check` - Validate all changes (primary validation command)
|
||||
- `just fmt` - Format code (resolve formatting errors)
|
||||
|
||||
## Current Phase: Core MVP Backend ✅
|
||||
|
||||
|
|
|
|||
2
PRD.md
2
PRD.md
|
|
@ -89,7 +89,7 @@
|
|||
- **Build Tool**: Vite for fast development and optimized builds
|
||||
- **Framework**: React with functional components and hooks
|
||||
- **Routing**: React Router for client-side navigation
|
||||
- **Styling**: Tailwind CSS for rapid UI development
|
||||
- **Styling**: CSS for styling
|
||||
- **State Management**: React state with localStorage persistence
|
||||
- **PWA Features**: Service worker for offline functionality
|
||||
- **Mobile Optimization**: Touch gestures, responsive design
|
||||
|
|
|
|||
|
|
@ -1,87 +0,0 @@
|
|||
# Welcome to React Router!
|
||||
|
||||
A modern, production-ready template for building full-stack React applications using React Router.
|
||||
|
||||
[](https://stackblitz.com/github/remix-run/react-router-templates/tree/main/default)
|
||||
|
||||
## Features
|
||||
|
||||
- 🚀 Server-side rendering
|
||||
- ⚡️ Hot Module Replacement (HMR)
|
||||
- 📦 Asset bundling and optimization
|
||||
- 🔄 Data loading and mutations
|
||||
- 🔒 TypeScript by default
|
||||
- 🎉 TailwindCSS for styling
|
||||
- 📖 [React Router docs](https://reactrouter.com/)
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Installation
|
||||
|
||||
Install the dependencies:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
### Development
|
||||
|
||||
Start the development server with HMR:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Your application will be available at `http://localhost:5173`.
|
||||
|
||||
## Building for Production
|
||||
|
||||
Create a production build:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
## Deployment
|
||||
|
||||
### Docker Deployment
|
||||
|
||||
To build and run using Docker:
|
||||
|
||||
```bash
|
||||
docker build -t my-app .
|
||||
|
||||
# Run the container
|
||||
docker run -p 3000:3000 my-app
|
||||
```
|
||||
|
||||
The containerized application can be deployed to any platform that supports Docker, including:
|
||||
|
||||
- AWS ECS
|
||||
- Google Cloud Run
|
||||
- Azure Container Apps
|
||||
- Digital Ocean App Platform
|
||||
- Fly.io
|
||||
- Railway
|
||||
|
||||
### DIY Deployment
|
||||
|
||||
If you're familiar with deploying Node applications, the built-in app server is production-ready.
|
||||
|
||||
Make sure to deploy the output of `npm run build`
|
||||
|
||||
```
|
||||
├── package.json
|
||||
├── package-lock.json (or pnpm-lock.yaml, or bun.lockb)
|
||||
├── build/
|
||||
│ ├── client/ # Static assets
|
||||
│ └── server/ # Server-side code
|
||||
```
|
||||
|
||||
## Styling
|
||||
|
||||
This template comes with [Tailwind CSS](https://tailwindcss.com/) already configured for a simple default starting experience. You can use whatever CSS framework you prefer.
|
||||
|
||||
---
|
||||
|
||||
Built with ❤️ using React Router.
|
||||
|
|
@ -1,59 +1,8 @@
|
|||
@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;
|
||||
}
|
||||
/* Captain's Log - Global Styles */
|
||||
|
||||
/* Base font family is handled by Material-UI theme */
|
||||
html,
|
||||
body {
|
||||
@apply bg-gray-50 dark:bg-gray-950 text-gray-900 dark:text-gray-100;
|
||||
|
||||
@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;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
|
|
|||
64
frontend/app/components/ErrorFallback.tsx
Normal file
64
frontend/app/components/ErrorFallback.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import React from 'react'
|
||||
import {
|
||||
Alert,
|
||||
AlertTitle,
|
||||
Box,
|
||||
Button,
|
||||
Container,
|
||||
Typography,
|
||||
Paper,
|
||||
} from '@mui/material'
|
||||
import {
|
||||
Refresh as RefreshIcon,
|
||||
BugReport as BugReportIcon,
|
||||
} from '@mui/icons-material'
|
||||
|
||||
interface ErrorFallbackProps {
|
||||
error: Error
|
||||
resetError: () => void
|
||||
}
|
||||
|
||||
export default function ErrorFallback({
|
||||
error,
|
||||
resetError,
|
||||
}: ErrorFallbackProps) {
|
||||
return (
|
||||
<Container maxWidth="md" sx={{ py: 8 }}>
|
||||
<Paper sx={{ p: 4, textAlign: 'center' }}>
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<BugReportIcon sx={{ fontSize: 64, color: 'error.main', mb: 2 }} />
|
||||
<Typography variant="h4" gutterBottom>
|
||||
Something went wrong
|
||||
</Typography>
|
||||
<Typography variant="body1" color="text.secondary">
|
||||
An unexpected error occurred. Please try refreshing the page or
|
||||
contact support if the problem persists.
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Alert severity="error" sx={{ mb: 3, textAlign: 'left' }}>
|
||||
<AlertTitle>Error Details</AlertTitle>
|
||||
{error.message}
|
||||
</Alert>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 2, justifyContent: 'center' }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<RefreshIcon />}
|
||||
onClick={resetError}
|
||||
size="large"
|
||||
>
|
||||
Try Again
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => window.location.reload()}
|
||||
size="large"
|
||||
>
|
||||
Reload Page
|
||||
</Button>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
216
frontend/app/components/Layout.tsx
Normal file
216
frontend/app/components/Layout.tsx
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
import React, { useState } from 'react'
|
||||
import {
|
||||
AppBar,
|
||||
Box,
|
||||
CssBaseline,
|
||||
Drawer,
|
||||
IconButton,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemButton,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Toolbar,
|
||||
Typography,
|
||||
useTheme,
|
||||
Fab,
|
||||
LinearProgress,
|
||||
} from '@mui/material'
|
||||
import {
|
||||
Menu as MenuIcon,
|
||||
Dashboard as DashboardIcon,
|
||||
Add as AddIcon,
|
||||
Settings as SettingsIcon,
|
||||
} from '@mui/icons-material'
|
||||
|
||||
const drawerWidth = 240
|
||||
|
||||
interface LayoutProps {
|
||||
children: React.ReactNode
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
export default function Layout({ children, loading = false }: LayoutProps) {
|
||||
const theme = useTheme()
|
||||
const [mobileOpen, setMobileOpen] = useState(false)
|
||||
|
||||
const handleDrawerToggle = () => {
|
||||
setMobileOpen(!mobileOpen)
|
||||
}
|
||||
|
||||
const menuItems = [
|
||||
{
|
||||
text: 'Tasks',
|
||||
icon: <DashboardIcon />,
|
||||
path: '/',
|
||||
},
|
||||
{
|
||||
text: 'Settings',
|
||||
icon: <SettingsIcon />,
|
||||
path: '/settings',
|
||||
},
|
||||
]
|
||||
|
||||
const drawer = (
|
||||
<div>
|
||||
<List sx={{ pt: 2 }}>
|
||||
{menuItems.map(item => (
|
||||
<ListItem key={item.text} disablePadding>
|
||||
<ListItemButton
|
||||
component="a"
|
||||
href={item.path}
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
mx: 1,
|
||||
my: 0.5,
|
||||
'&:hover': {
|
||||
backgroundColor: theme.palette.primary.main + '10',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ListItemIcon sx={{ color: theme.palette.primary.main }}>
|
||||
{item.icon}
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={item.text}
|
||||
primaryTypographyProps={{
|
||||
fontWeight: 500,
|
||||
}}
|
||||
/>
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex' }}>
|
||||
<CssBaseline />
|
||||
|
||||
{/* Loading indicator */}
|
||||
{loading && (
|
||||
<LinearProgress
|
||||
sx={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: theme.zIndex.appBar + 1,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* App Bar */}
|
||||
<AppBar
|
||||
position="fixed"
|
||||
sx={{
|
||||
width: '100%',
|
||||
backgroundColor: theme.palette.primary.main,
|
||||
color: theme.palette.primary.contrastText,
|
||||
zIndex: theme.zIndex.drawer + 1,
|
||||
}}
|
||||
>
|
||||
<Toolbar>
|
||||
<IconButton
|
||||
color="inherit"
|
||||
aria-label="open drawer"
|
||||
edge="start"
|
||||
onClick={handleDrawerToggle}
|
||||
sx={{ mr: 2, display: { md: 'none' } }}
|
||||
>
|
||||
<MenuIcon />
|
||||
</IconButton>
|
||||
|
||||
<Typography
|
||||
variant="h6"
|
||||
noWrap
|
||||
component="div"
|
||||
sx={{
|
||||
flexGrow: 1,
|
||||
color: '#ffffff',
|
||||
fontWeight: 700,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
⚓ Captain's Log
|
||||
</Typography>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
|
||||
{/* Drawer */}
|
||||
<Box
|
||||
component="nav"
|
||||
sx={{ width: { md: drawerWidth }, flexShrink: { md: 0 } }}
|
||||
aria-label="navigation menu"
|
||||
>
|
||||
{/* Mobile drawer */}
|
||||
<Drawer
|
||||
variant="temporary"
|
||||
open={mobileOpen}
|
||||
onClose={handleDrawerToggle}
|
||||
ModalProps={{
|
||||
keepMounted: true, // Better open performance on mobile.
|
||||
}}
|
||||
sx={{
|
||||
display: { xs: 'block', md: 'none' },
|
||||
'& .MuiDrawer-paper': {
|
||||
boxSizing: 'border-box',
|
||||
width: drawerWidth,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{drawer}
|
||||
</Drawer>
|
||||
|
||||
{/* Desktop drawer */}
|
||||
<Drawer
|
||||
variant="permanent"
|
||||
sx={{
|
||||
display: { xs: 'none', md: 'block' },
|
||||
'& .MuiDrawer-paper': {
|
||||
boxSizing: 'border-box',
|
||||
width: drawerWidth,
|
||||
top: '64px', // Position below AppBar
|
||||
height: 'calc(100% - 64px)', // Adjust height to account for AppBar
|
||||
},
|
||||
}}
|
||||
open
|
||||
>
|
||||
{drawer}
|
||||
</Drawer>
|
||||
</Box>
|
||||
|
||||
{/* Main content */}
|
||||
<Box
|
||||
component="main"
|
||||
sx={{
|
||||
flexGrow: 1,
|
||||
p: 3,
|
||||
width: { md: `calc(100% - ${drawerWidth}px)` },
|
||||
minHeight: '100vh',
|
||||
backgroundColor: theme.palette.background.default,
|
||||
}}
|
||||
>
|
||||
<Toolbar />
|
||||
{children}
|
||||
</Box>
|
||||
|
||||
{/* Quick capture FAB */}
|
||||
<Fab
|
||||
color="primary"
|
||||
aria-label="add task"
|
||||
sx={{
|
||||
position: 'fixed',
|
||||
bottom: 16,
|
||||
right: 16,
|
||||
zIndex: theme.zIndex.fab,
|
||||
}}
|
||||
>
|
||||
<AddIcon />
|
||||
</Fab>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
53
frontend/app/components/LoadingSpinner.tsx
Normal file
53
frontend/app/components/LoadingSpinner.tsx
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import React from 'react'
|
||||
import { Box, CircularProgress, Typography, Skeleton } from '@mui/material'
|
||||
|
||||
interface LoadingSpinnerProps {
|
||||
size?: number
|
||||
message?: string
|
||||
variant?: 'spinner' | 'skeleton'
|
||||
}
|
||||
|
||||
export default function LoadingSpinner({
|
||||
size = 40,
|
||||
message = 'Loading...',
|
||||
variant = 'spinner',
|
||||
}: LoadingSpinnerProps) {
|
||||
if (variant === 'skeleton') {
|
||||
return (
|
||||
<Box sx={{ p: 2 }}>
|
||||
<Skeleton variant="text" width="60%" height={32} sx={{ mb: 2 }} />
|
||||
<Skeleton
|
||||
variant="rectangular"
|
||||
width="100%"
|
||||
height={120}
|
||||
sx={{ mb: 1 }}
|
||||
/>
|
||||
<Skeleton
|
||||
variant="rectangular"
|
||||
width="100%"
|
||||
height={120}
|
||||
sx={{ mb: 1 }}
|
||||
/>
|
||||
<Skeleton variant="rectangular" width="100%" height={120} />
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
p: 4,
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<CircularProgress size={size} thickness={4} />
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{message}
|
||||
</Typography>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
359
frontend/app/hooks/useApi.test.ts
Normal file
359
frontend/app/hooks/useApi.test.ts
Normal file
|
|
@ -0,0 +1,359 @@
|
|||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { renderHook, act, waitFor } from '@testing-library/react'
|
||||
import { useApi, useApiForm, useApiCache } from './useApi'
|
||||
|
||||
// Mock API function for testing
|
||||
const mockApiFunction = vi.fn()
|
||||
const mockFormSubmitFunction = vi.fn()
|
||||
const mockCacheFunction = vi.fn()
|
||||
|
||||
describe('useApi', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('should initialize with default state', () => {
|
||||
const { result } = renderHook(() => useApi(mockApiFunction))
|
||||
|
||||
expect(result.current.data).toBeNull()
|
||||
expect(result.current.loading).toBe(false)
|
||||
expect(result.current.error).toBeNull()
|
||||
})
|
||||
|
||||
it('should execute API function successfully', async () => {
|
||||
const mockData = { id: 1, name: 'Test' }
|
||||
mockApiFunction.mockResolvedValueOnce(mockData)
|
||||
|
||||
const { result } = renderHook(() => useApi(mockApiFunction))
|
||||
|
||||
let executeResult: unknown
|
||||
|
||||
await act(async () => {
|
||||
executeResult = await result.current.execute('test-arg')
|
||||
})
|
||||
|
||||
expect(result.current.data).toEqual(mockData)
|
||||
expect(result.current.loading).toBe(false)
|
||||
expect(result.current.error).toBeNull()
|
||||
expect(executeResult).toEqual(mockData)
|
||||
expect(mockApiFunction).toHaveBeenCalledWith('test-arg')
|
||||
})
|
||||
|
||||
it('should handle API function errors', async () => {
|
||||
const errorMessage = 'API Error'
|
||||
mockApiFunction.mockRejectedValueOnce({ message: errorMessage })
|
||||
|
||||
const { result } = renderHook(() => useApi(mockApiFunction))
|
||||
|
||||
let executeResult: unknown
|
||||
|
||||
await act(async () => {
|
||||
executeResult = await result.current.execute()
|
||||
})
|
||||
|
||||
expect(result.current.data).toBeNull()
|
||||
expect(result.current.loading).toBe(false)
|
||||
expect(result.current.error).toBe(errorMessage)
|
||||
expect(executeResult).toBeNull()
|
||||
})
|
||||
|
||||
it('should call onSuccess callback', async () => {
|
||||
const mockData = { id: 1 }
|
||||
const onSuccess = vi.fn()
|
||||
mockApiFunction.mockResolvedValueOnce(mockData)
|
||||
|
||||
const { result } = renderHook(() => useApi(mockApiFunction, { onSuccess }))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.execute()
|
||||
})
|
||||
|
||||
expect(onSuccess).toHaveBeenCalledWith(mockData)
|
||||
})
|
||||
|
||||
it('should call onError callback', async () => {
|
||||
const errorMessage = 'Test Error'
|
||||
const onError = vi.fn()
|
||||
mockApiFunction.mockRejectedValueOnce({ message: errorMessage })
|
||||
|
||||
const { result } = renderHook(() => useApi(mockApiFunction, { onError }))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.execute()
|
||||
})
|
||||
|
||||
expect(onError).toHaveBeenCalledWith(errorMessage)
|
||||
})
|
||||
|
||||
it('should execute immediately when immediate option is true', async () => {
|
||||
const mockData = { id: 1 }
|
||||
mockApiFunction.mockResolvedValueOnce(mockData)
|
||||
|
||||
renderHook(() => useApi(mockApiFunction, { immediate: true }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockApiFunction).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should reset state', async () => {
|
||||
const mockData = { id: 1 }
|
||||
mockApiFunction.mockResolvedValueOnce(mockData)
|
||||
|
||||
const { result } = renderHook(() => useApi(mockApiFunction))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.execute()
|
||||
})
|
||||
|
||||
expect(result.current.data).toEqual(mockData)
|
||||
|
||||
act(() => {
|
||||
result.current.reset()
|
||||
})
|
||||
|
||||
expect(result.current.data).toBeNull()
|
||||
expect(result.current.loading).toBe(false)
|
||||
expect(result.current.error).toBeNull()
|
||||
})
|
||||
|
||||
it('should clear error', async () => {
|
||||
mockApiFunction.mockRejectedValueOnce({ message: 'Test Error' })
|
||||
|
||||
const { result } = renderHook(() => useApi(mockApiFunction))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.execute()
|
||||
})
|
||||
|
||||
expect(result.current.error).toBeTruthy()
|
||||
|
||||
act(() => {
|
||||
result.current.clearError()
|
||||
})
|
||||
|
||||
expect(result.current.error).toBeNull()
|
||||
})
|
||||
|
||||
it('should cancel previous requests', async () => {
|
||||
let resolveFirst: (value: unknown) => void
|
||||
let resolveSecond: (value: unknown) => void
|
||||
|
||||
const firstPromise = new Promise(resolve => {
|
||||
resolveFirst = resolve
|
||||
})
|
||||
const secondPromise = new Promise(resolve => {
|
||||
resolveSecond = resolve
|
||||
})
|
||||
|
||||
mockApiFunction
|
||||
.mockReturnValueOnce(firstPromise)
|
||||
.mockReturnValueOnce(secondPromise)
|
||||
|
||||
const { result } = renderHook(() => useApi(mockApiFunction))
|
||||
|
||||
// Start first request
|
||||
act(() => {
|
||||
result.current.execute('first')
|
||||
})
|
||||
|
||||
// Start second request before first completes
|
||||
act(() => {
|
||||
result.current.execute('second')
|
||||
})
|
||||
|
||||
// Resolve first request (should be cancelled)
|
||||
await act(async () => {
|
||||
resolveFirst({ data: 'first' })
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
})
|
||||
|
||||
// Resolve second request
|
||||
await act(async () => {
|
||||
resolveSecond({ data: 'second' })
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
})
|
||||
|
||||
expect(result.current.data).toEqual({ data: 'second' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('useApiForm', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should handle form submission', async () => {
|
||||
const formData = { name: 'Test' }
|
||||
const responseData = { id: 1, name: 'Test' }
|
||||
mockFormSubmitFunction.mockResolvedValueOnce(responseData)
|
||||
|
||||
const { result } = renderHook(() => useApiForm(mockFormSubmitFunction))
|
||||
|
||||
let submitResult: unknown
|
||||
|
||||
await act(async () => {
|
||||
submitResult = await result.current.handleSubmit(formData)
|
||||
})
|
||||
|
||||
expect(submitResult).toEqual(responseData)
|
||||
expect(mockFormSubmitFunction).toHaveBeenCalledWith(formData)
|
||||
expect(result.current.data).toEqual(responseData)
|
||||
})
|
||||
|
||||
it('should reset on success when option is enabled', async () => {
|
||||
const formData = { name: 'Test' }
|
||||
const responseData = { id: 1, name: 'Test' }
|
||||
mockFormSubmitFunction.mockResolvedValueOnce(responseData)
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useApiForm(mockFormSubmitFunction, { resetOnSuccess: true })
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSubmit(formData)
|
||||
})
|
||||
|
||||
expect(result.current.data).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('useApiCache', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('should fetch and cache data', async () => {
|
||||
const mockData = { id: 1, name: 'Test' }
|
||||
mockCacheFunction.mockResolvedValueOnce(mockData)
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useApiCache('test-key', mockCacheFunction)
|
||||
)
|
||||
|
||||
let fetchResult: unknown
|
||||
|
||||
await act(async () => {
|
||||
fetchResult = await result.current.fetchData()
|
||||
})
|
||||
|
||||
expect(fetchResult).toEqual(mockData)
|
||||
expect(result.current.data).toEqual(mockData)
|
||||
expect(mockCacheFunction).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should return cached data when fresh', async () => {
|
||||
const mockData = { id: 1, name: 'Test' }
|
||||
mockCacheFunction.mockResolvedValueOnce(mockData)
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useApiCache('test-key', mockCacheFunction, { staleTime: 60000 })
|
||||
)
|
||||
|
||||
// First fetch
|
||||
await act(async () => {
|
||||
await result.current.fetchData()
|
||||
})
|
||||
|
||||
// Second fetch should return cached data
|
||||
let secondResult: unknown
|
||||
|
||||
await act(async () => {
|
||||
secondResult = await result.current.fetchData()
|
||||
})
|
||||
|
||||
expect(secondResult).toEqual(mockData)
|
||||
expect(mockCacheFunction).toHaveBeenCalledTimes(1) // Should not call API again
|
||||
})
|
||||
|
||||
it('should fetch fresh data when stale', async () => {
|
||||
const mockData1 = { id: 1, name: 'Test 1' }
|
||||
const mockData2 = { id: 2, name: 'Test 2' }
|
||||
|
||||
mockCacheFunction
|
||||
.mockResolvedValueOnce(mockData1)
|
||||
.mockResolvedValueOnce(mockData2)
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useApiCache('test-key', mockCacheFunction, { staleTime: 1000 })
|
||||
)
|
||||
|
||||
// First fetch
|
||||
await act(async () => {
|
||||
await result.current.fetchData()
|
||||
})
|
||||
|
||||
expect(result.current.data).toEqual(mockData1)
|
||||
|
||||
// Advance time past stale time
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(2000)
|
||||
})
|
||||
|
||||
// Second fetch should get fresh data
|
||||
await act(async () => {
|
||||
await result.current.fetchData()
|
||||
})
|
||||
|
||||
expect(result.current.data).toEqual(mockData2)
|
||||
expect(mockCacheFunction).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('should clear cache', async () => {
|
||||
const mockData = { id: 1, name: 'Test' }
|
||||
mockCacheFunction.mockResolvedValueOnce(mockData)
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useApiCache('test-key', mockCacheFunction)
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.fetchData()
|
||||
})
|
||||
|
||||
expect(result.current.data).toEqual(mockData)
|
||||
|
||||
await act(async () => {
|
||||
result.current.clearCache()
|
||||
})
|
||||
|
||||
expect(result.current.data).toBeNull()
|
||||
})
|
||||
|
||||
it('should indicate stale status', async () => {
|
||||
const mockData = { id: 1, name: 'Test' }
|
||||
mockCacheFunction.mockResolvedValueOnce(mockData)
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useApiCache('test-key', mockCacheFunction, { staleTime: 1000 })
|
||||
)
|
||||
|
||||
// Initially stale (no data)
|
||||
expect(result.current.isStale).toBe(true)
|
||||
|
||||
// Fetch data
|
||||
await act(async () => {
|
||||
await result.current.fetchData()
|
||||
})
|
||||
|
||||
// Should be fresh
|
||||
expect(result.current.isStale).toBe(false)
|
||||
|
||||
// Advance time past stale time
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(2000)
|
||||
})
|
||||
|
||||
// Should be stale again
|
||||
expect(result.current.isStale).toBe(true)
|
||||
})
|
||||
})
|
||||
227
frontend/app/hooks/useApi.ts
Normal file
227
frontend/app/hooks/useApi.ts
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
import { useState, useCallback, useRef, useEffect } from 'react'
|
||||
import type { ApiError } from '~/types/task'
|
||||
|
||||
interface UseApiState<T> {
|
||||
data: T | null
|
||||
loading: boolean
|
||||
error: string | null
|
||||
}
|
||||
|
||||
interface UseApiActions<T> {
|
||||
execute: (...args: unknown[]) => Promise<T | null>
|
||||
reset: () => void
|
||||
clearError: () => void
|
||||
}
|
||||
|
||||
interface UseApiOptions {
|
||||
immediate?: boolean
|
||||
onSuccess?: (data: unknown) => void
|
||||
onError?: (error: string) => void
|
||||
}
|
||||
|
||||
export function useApi<T>(
|
||||
apiFunction: (...args: unknown[]) => Promise<T>,
|
||||
options: UseApiOptions = {}
|
||||
): UseApiState<T> & UseApiActions<T> {
|
||||
const { immediate = false, onSuccess, onError } = options
|
||||
|
||||
const [state, setState] = useState<UseApiState<T>>({
|
||||
data: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
})
|
||||
|
||||
const mountedRef = useRef(true)
|
||||
const abortControllerRef = useRef<AbortController | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
mountedRef.current = false
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort()
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setState({
|
||||
data: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
})
|
||||
}, [])
|
||||
|
||||
const clearError = useCallback(() => {
|
||||
setState(prev => ({ ...prev, error: null }))
|
||||
}, [])
|
||||
|
||||
const execute = useCallback(
|
||||
async (...args: unknown[]): Promise<T | null> => {
|
||||
// Cancel previous request if still pending
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort()
|
||||
}
|
||||
|
||||
abortControllerRef.current = new AbortController()
|
||||
|
||||
setState(prev => ({ ...prev, loading: true, error: null }))
|
||||
|
||||
try {
|
||||
const result = await apiFunction(...args)
|
||||
|
||||
if (!mountedRef.current) return null
|
||||
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
data: result,
|
||||
loading: false,
|
||||
}))
|
||||
|
||||
if (onSuccess) {
|
||||
onSuccess(result)
|
||||
}
|
||||
|
||||
return result
|
||||
} catch (error) {
|
||||
if (!mountedRef.current) return null
|
||||
|
||||
const apiError = error as ApiError
|
||||
const errorMessage = apiError.message || 'An unknown error occurred'
|
||||
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
loading: false,
|
||||
error: errorMessage,
|
||||
}))
|
||||
|
||||
if (onError) {
|
||||
onError(errorMessage)
|
||||
}
|
||||
|
||||
return null
|
||||
} finally {
|
||||
abortControllerRef.current = null
|
||||
}
|
||||
},
|
||||
[apiFunction, onSuccess, onError]
|
||||
)
|
||||
|
||||
// Execute immediately if requested
|
||||
useEffect(() => {
|
||||
if (immediate) {
|
||||
execute()
|
||||
}
|
||||
}, [immediate, execute])
|
||||
|
||||
return {
|
||||
...state,
|
||||
execute,
|
||||
reset,
|
||||
clearError,
|
||||
}
|
||||
}
|
||||
|
||||
// Utility hook for handling form submissions
|
||||
export function useApiForm<T>(
|
||||
submitFunction: (data: unknown) => Promise<T>,
|
||||
options: UseApiOptions & { resetOnSuccess?: boolean } = {}
|
||||
) {
|
||||
const { resetOnSuccess = false, ...apiOptions } = options
|
||||
|
||||
const api = useApi(submitFunction, apiOptions)
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (data: unknown) => {
|
||||
const result = await api.execute(data)
|
||||
|
||||
if (result && resetOnSuccess) {
|
||||
api.reset()
|
||||
}
|
||||
|
||||
return result
|
||||
},
|
||||
[api, resetOnSuccess]
|
||||
)
|
||||
|
||||
return {
|
||||
...api,
|
||||
handleSubmit,
|
||||
}
|
||||
}
|
||||
|
||||
// Utility hook for data caching and synchronization
|
||||
export function useApiCache<T>(
|
||||
key: string,
|
||||
fetchFunction: () => Promise<T>,
|
||||
options: { cacheTime?: number; staleTime?: number } = {}
|
||||
) {
|
||||
const { cacheTime = 5 * 60 * 1000, staleTime = 30 * 1000 } = options // 5min cache, 30s stale
|
||||
|
||||
const [cacheData, setCacheData] = useState<{
|
||||
data: T | null
|
||||
timestamp: number
|
||||
} | null>(null)
|
||||
|
||||
const api = useApi(fetchFunction)
|
||||
|
||||
const getCachedData = useCallback((): T | null => {
|
||||
if (!cacheData) return null
|
||||
|
||||
const now = Date.now()
|
||||
const age = now - cacheData.timestamp
|
||||
|
||||
if (age > cacheTime) {
|
||||
setCacheData(null)
|
||||
return null
|
||||
}
|
||||
|
||||
return cacheData.data
|
||||
}, [cacheData, cacheTime])
|
||||
|
||||
const isStale = useCallback((): boolean => {
|
||||
if (!cacheData) return true
|
||||
|
||||
const now = Date.now()
|
||||
const age = now - cacheData.timestamp
|
||||
|
||||
return age > staleTime
|
||||
}, [cacheData, staleTime])
|
||||
|
||||
const fetchData = useCallback(
|
||||
async (force = false): Promise<T | null> => {
|
||||
// Return cached data if fresh and not forced
|
||||
if (!force && !isStale()) {
|
||||
const cached = getCachedData()
|
||||
if (cached) return cached
|
||||
}
|
||||
|
||||
const result = await api.execute()
|
||||
|
||||
if (result) {
|
||||
setCacheData({
|
||||
data: result,
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
},
|
||||
[api, isStale, getCachedData]
|
||||
)
|
||||
|
||||
const clearCache = useCallback(() => {
|
||||
setCacheData(null)
|
||||
api.reset()
|
||||
}, [api])
|
||||
|
||||
return {
|
||||
data: api.data || getCachedData(),
|
||||
loading: api.loading,
|
||||
error: api.error,
|
||||
fetchData,
|
||||
clearCache,
|
||||
get isStale() {
|
||||
return isStale()
|
||||
},
|
||||
}
|
||||
}
|
||||
211
frontend/app/hooks/useTask.test.ts
Normal file
211
frontend/app/hooks/useTask.test.ts
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { renderHook, act, waitFor } from '@testing-library/react'
|
||||
import { useTask } from './useTask'
|
||||
import type { Task, UpdateTaskRequest } from '~/types/task'
|
||||
import { TaskStatus } from '~/types/task'
|
||||
import { apiClient } from '~/services/api'
|
||||
|
||||
// Mock the API client
|
||||
vi.mock('~/services/api', () => ({
|
||||
apiClient: {
|
||||
getTask: vi.fn(),
|
||||
updateTask: vi.fn(),
|
||||
deleteTask: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
const mockApiClient = apiClient as unknown as {
|
||||
getTask: ReturnType<typeof vi.fn>
|
||||
updateTask: ReturnType<typeof vi.fn>
|
||||
deleteTask: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
// Sample task data
|
||||
const mockTask: Task = {
|
||||
id: '550e8400-e29b-41d4-a716-446655440000',
|
||||
title: 'Test Task',
|
||||
description: 'Test Description',
|
||||
status: TaskStatus.Todo,
|
||||
created_at: '2023-01-01T00:00:00Z',
|
||||
updated_at: '2023-01-01T00:00:00Z',
|
||||
completed_at: null,
|
||||
}
|
||||
|
||||
describe('useTask', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('should initialize with default state', () => {
|
||||
const { result } = renderHook(() => useTask())
|
||||
|
||||
expect(result.current.task).toBeNull()
|
||||
expect(result.current.loading).toBe(false)
|
||||
expect(result.current.error).toBeNull()
|
||||
})
|
||||
|
||||
describe('getTask', () => {
|
||||
it('should fetch task successfully', async () => {
|
||||
mockApiClient.getTask.mockResolvedValueOnce(mockTask)
|
||||
|
||||
const { result } = renderHook(() => useTask())
|
||||
|
||||
act(() => {
|
||||
result.current.getTask(mockTask.id)
|
||||
})
|
||||
|
||||
expect(result.current.loading).toBe(true)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false)
|
||||
})
|
||||
|
||||
expect(result.current.task).toEqual(mockTask)
|
||||
expect(result.current.error).toBeNull()
|
||||
expect(mockApiClient.getTask).toHaveBeenCalledWith(mockTask.id)
|
||||
})
|
||||
|
||||
it('should handle fetch errors', async () => {
|
||||
const errorMessage = 'Task not found'
|
||||
mockApiClient.getTask.mockRejectedValueOnce({ message: errorMessage })
|
||||
|
||||
const { result } = renderHook(() => useTask())
|
||||
|
||||
act(() => {
|
||||
result.current.getTask(mockTask.id)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false)
|
||||
})
|
||||
|
||||
expect(result.current.task).toBeNull()
|
||||
expect(result.current.error).toBe(errorMessage)
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateTask', () => {
|
||||
it('should update task with optimistic update', async () => {
|
||||
const updateData: UpdateTaskRequest = {
|
||||
title: 'Updated Task',
|
||||
status: TaskStatus.Done,
|
||||
}
|
||||
const updatedTask = { ...mockTask, ...updateData }
|
||||
|
||||
mockApiClient.getTask.mockResolvedValueOnce(mockTask)
|
||||
mockApiClient.updateTask.mockResolvedValueOnce(updatedTask)
|
||||
|
||||
const { result } = renderHook(() => useTask())
|
||||
|
||||
// Set initial task
|
||||
await act(async () => {
|
||||
await result.current.getTask(mockTask.id)
|
||||
})
|
||||
|
||||
let updateResult: Task | null = null
|
||||
|
||||
await act(async () => {
|
||||
updateResult = await result.current.updateTask(mockTask.id, updateData)
|
||||
})
|
||||
|
||||
expect(updateResult).toEqual(updatedTask)
|
||||
expect(mockApiClient.updateTask).toHaveBeenCalledWith(
|
||||
mockTask.id,
|
||||
updateData
|
||||
)
|
||||
})
|
||||
|
||||
it('should handle update errors and revert optimistic update', async () => {
|
||||
const updateData: UpdateTaskRequest = { status: TaskStatus.Done }
|
||||
const errorMessage = 'Update failed'
|
||||
|
||||
// Setup initial task
|
||||
mockApiClient.getTask.mockResolvedValueOnce(mockTask)
|
||||
|
||||
const { result } = renderHook(() => useTask())
|
||||
|
||||
// Set initial task state
|
||||
await act(async () => {
|
||||
await result.current.getTask(mockTask.id)
|
||||
})
|
||||
|
||||
expect(result.current.task).toEqual(mockTask)
|
||||
|
||||
// Mock update failure and revert call
|
||||
mockApiClient.updateTask.mockRejectedValueOnce({ message: errorMessage })
|
||||
mockApiClient.getTask.mockResolvedValueOnce(mockTask)
|
||||
|
||||
let updateResult: Task | null = null
|
||||
|
||||
await act(async () => {
|
||||
updateResult = await result.current.updateTask(mockTask.id, updateData)
|
||||
})
|
||||
|
||||
expect(updateResult).toBeNull()
|
||||
expect(result.current.error).toBe(errorMessage)
|
||||
expect(result.current.loading).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleteTask', () => {
|
||||
it('should delete task successfully', async () => {
|
||||
mockApiClient.deleteTask.mockResolvedValueOnce(undefined)
|
||||
|
||||
const { result } = renderHook(() => useTask())
|
||||
|
||||
// Set initial task
|
||||
await act(async () => {
|
||||
mockApiClient.getTask.mockResolvedValueOnce(mockTask)
|
||||
await result.current.getTask(mockTask.id)
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.deleteTask(mockTask.id)
|
||||
})
|
||||
|
||||
expect(result.current.task).toBeNull()
|
||||
expect(result.current.error).toBeNull()
|
||||
expect(mockApiClient.deleteTask).toHaveBeenCalledWith(mockTask.id)
|
||||
})
|
||||
|
||||
it('should handle delete errors', async () => {
|
||||
const errorMessage = 'Delete failed'
|
||||
mockApiClient.deleteTask.mockRejectedValueOnce({ message: errorMessage })
|
||||
|
||||
const { result } = renderHook(() => useTask())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.deleteTask(mockTask.id)
|
||||
})
|
||||
|
||||
expect(result.current.error).toBe(errorMessage)
|
||||
expect(result.current.loading).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('clearError', () => {
|
||||
it('should clear error state', async () => {
|
||||
mockApiClient.getTask.mockRejectedValueOnce({ message: 'Test error' })
|
||||
|
||||
const { result } = renderHook(() => useTask())
|
||||
|
||||
// Trigger an error
|
||||
await act(async () => {
|
||||
await result.current.getTask(mockTask.id)
|
||||
})
|
||||
|
||||
expect(result.current.error).toBeTruthy()
|
||||
|
||||
// Clear the error
|
||||
act(() => {
|
||||
result.current.clearError()
|
||||
})
|
||||
|
||||
expect(result.current.error).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
114
frontend/app/hooks/useTask.ts
Normal file
114
frontend/app/hooks/useTask.ts
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
import { useState, useCallback } from 'react'
|
||||
import type { Task, UpdateTaskRequest, ApiError } from '~/types/task'
|
||||
import { apiClient } from '~/services/api'
|
||||
|
||||
interface UseTaskState {
|
||||
task: Task | null
|
||||
loading: boolean
|
||||
error: string | null
|
||||
}
|
||||
|
||||
interface UseTaskActions {
|
||||
getTask: (id: string) => Promise<void>
|
||||
updateTask: (id: string, data: UpdateTaskRequest) => Promise<Task | null>
|
||||
deleteTask: (id: string) => Promise<void>
|
||||
clearError: () => void
|
||||
}
|
||||
|
||||
export function useTask(): UseTaskState & UseTaskActions {
|
||||
const [state, setState] = useState<UseTaskState>({
|
||||
task: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
})
|
||||
|
||||
const clearError = useCallback(() => {
|
||||
setState(prev => ({ ...prev, error: null }))
|
||||
}, [])
|
||||
|
||||
const getTask = useCallback(async (id: string) => {
|
||||
setState(prev => ({ ...prev, loading: true, error: null }))
|
||||
|
||||
try {
|
||||
const task = await apiClient.getTask(id)
|
||||
setState(prev => ({ ...prev, task, loading: false }))
|
||||
} catch (error) {
|
||||
const apiError = error as ApiError
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
loading: false,
|
||||
error: apiError.message,
|
||||
}))
|
||||
}
|
||||
}, [])
|
||||
|
||||
const updateTask = useCallback(
|
||||
async (id: string, data: UpdateTaskRequest): Promise<Task | null> => {
|
||||
setState(prev => ({ ...prev, loading: true, error: null }))
|
||||
|
||||
try {
|
||||
// Optimistic update
|
||||
if (state.task && state.task.id === id) {
|
||||
const optimisticTask: Task = {
|
||||
...state.task,
|
||||
...data,
|
||||
updated_at: new Date().toISOString(),
|
||||
}
|
||||
setState(prev => ({ ...prev, task: optimisticTask }))
|
||||
}
|
||||
|
||||
const updatedTask = await apiClient.updateTask(id, data)
|
||||
setState(prev => ({ ...prev, task: updatedTask, loading: false }))
|
||||
return updatedTask
|
||||
} catch (error) {
|
||||
const apiError = error as ApiError
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
loading: false,
|
||||
error: apiError.message,
|
||||
}))
|
||||
|
||||
// Revert optimistic update on error
|
||||
if (state.task && state.task.id === id) {
|
||||
try {
|
||||
const originalTask = await apiClient.getTask(id)
|
||||
setState(prev => ({ ...prev, task: originalTask }))
|
||||
} catch {
|
||||
// If we can't revert, just keep the optimistic state
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
},
|
||||
[state.task]
|
||||
)
|
||||
|
||||
const deleteTask = useCallback(async (id: string) => {
|
||||
setState(prev => ({ ...prev, loading: true, error: null }))
|
||||
|
||||
try {
|
||||
await apiClient.deleteTask(id)
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
task: prev.task?.id === id ? null : prev.task,
|
||||
loading: false,
|
||||
}))
|
||||
} catch (error) {
|
||||
const apiError = error as ApiError
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
loading: false,
|
||||
error: apiError.message,
|
||||
}))
|
||||
}
|
||||
}, [])
|
||||
|
||||
return {
|
||||
...state,
|
||||
getTask,
|
||||
updateTask,
|
||||
deleteTask,
|
||||
clearError,
|
||||
}
|
||||
}
|
||||
318
frontend/app/hooks/useTasks.test.ts
Normal file
318
frontend/app/hooks/useTasks.test.ts
Normal file
|
|
@ -0,0 +1,318 @@
|
|||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { renderHook, act, waitFor } from '@testing-library/react'
|
||||
import { useTasks } from './useTasks'
|
||||
import type { Task, CreateTaskRequest } from '~/types/task'
|
||||
import { TaskStatus } from '~/types/task'
|
||||
import { apiClient } from '~/services/api'
|
||||
|
||||
// Mock the API client
|
||||
vi.mock('~/services/api', () => ({
|
||||
apiClient: {
|
||||
listTasks: vi.fn(),
|
||||
createTask: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
const mockApiClient = apiClient as unknown as {
|
||||
listTasks: ReturnType<typeof vi.fn>
|
||||
createTask: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
// Sample task data
|
||||
const mockTasks: Task[] = [
|
||||
{
|
||||
id: '550e8400-e29b-41d4-a716-446655440000',
|
||||
title: 'Test Task 1',
|
||||
description: 'Test Description 1',
|
||||
status: TaskStatus.Todo,
|
||||
created_at: '2023-01-01T00:00:00Z',
|
||||
updated_at: '2023-01-01T00:00:00Z',
|
||||
completed_at: null,
|
||||
},
|
||||
{
|
||||
id: '550e8400-e29b-41d4-a716-446655440001',
|
||||
title: 'Test Task 2',
|
||||
description: 'Test Description 2',
|
||||
status: TaskStatus.Done,
|
||||
created_at: '2023-01-01T01:00:00Z',
|
||||
updated_at: '2023-01-01T01:00:00Z',
|
||||
completed_at: '2023-01-01T02:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '550e8400-e29b-41d4-a716-446655440002',
|
||||
title: 'Test Task 3',
|
||||
description: null,
|
||||
status: TaskStatus.Backlog,
|
||||
created_at: '2023-01-01T02:00:00Z',
|
||||
updated_at: '2023-01-01T02:00:00Z',
|
||||
completed_at: null,
|
||||
},
|
||||
]
|
||||
|
||||
describe('useTasks', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('should initialize with default state', () => {
|
||||
const { result } = renderHook(() => useTasks({ autoFetch: false }))
|
||||
|
||||
expect(result.current.tasks).toEqual([])
|
||||
expect(result.current.loading).toBe(false)
|
||||
expect(result.current.error).toBeNull()
|
||||
expect(result.current.lastFetch).toBeNull()
|
||||
})
|
||||
|
||||
it('should auto-fetch tasks on mount by default', async () => {
|
||||
mockApiClient.listTasks.mockResolvedValueOnce(mockTasks)
|
||||
|
||||
renderHook(() => useTasks())
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockApiClient.listTasks).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
it('should not auto-fetch when disabled', () => {
|
||||
renderHook(() => useTasks({ autoFetch: false }))
|
||||
|
||||
expect(mockApiClient.listTasks).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
describe('fetchTasks', () => {
|
||||
it('should fetch tasks successfully', async () => {
|
||||
mockApiClient.listTasks.mockResolvedValueOnce(mockTasks)
|
||||
|
||||
const { result } = renderHook(() => useTasks({ autoFetch: false }))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.fetchTasks()
|
||||
})
|
||||
|
||||
expect(result.current.tasks).toEqual(mockTasks)
|
||||
expect(result.current.loading).toBe(false)
|
||||
expect(result.current.error).toBeNull()
|
||||
expect(result.current.lastFetch).toBeInstanceOf(Date)
|
||||
})
|
||||
|
||||
it('should handle fetch errors', async () => {
|
||||
const errorMessage = 'Fetch failed'
|
||||
mockApiClient.listTasks.mockRejectedValueOnce({ message: errorMessage })
|
||||
|
||||
const { result } = renderHook(() => useTasks({ autoFetch: false }))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.fetchTasks()
|
||||
})
|
||||
|
||||
expect(result.current.tasks).toEqual([])
|
||||
expect(result.current.loading).toBe(false)
|
||||
expect(result.current.error).toBe(errorMessage)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createTask', () => {
|
||||
it('should create task successfully', async () => {
|
||||
const newTaskData: CreateTaskRequest = {
|
||||
title: 'New Task',
|
||||
description: 'New Description',
|
||||
}
|
||||
const newTask: Task = {
|
||||
id: 'new-task-id',
|
||||
title: newTaskData.title,
|
||||
description: newTaskData.description || null,
|
||||
status: TaskStatus.Todo,
|
||||
created_at: '2023-01-01T03:00:00Z',
|
||||
updated_at: '2023-01-01T03:00:00Z',
|
||||
completed_at: null,
|
||||
}
|
||||
|
||||
mockApiClient.createTask.mockResolvedValueOnce(newTask)
|
||||
|
||||
const { result } = renderHook(() => useTasks({ autoFetch: false }))
|
||||
|
||||
// Set initial tasks
|
||||
await act(async () => {
|
||||
mockApiClient.listTasks.mockResolvedValueOnce(mockTasks)
|
||||
await result.current.fetchTasks()
|
||||
})
|
||||
|
||||
let createResult: Task | null = null
|
||||
|
||||
await act(async () => {
|
||||
createResult = await result.current.createTask(newTaskData)
|
||||
})
|
||||
|
||||
expect(createResult).toEqual(newTask)
|
||||
expect(result.current.tasks[0]).toEqual(newTask) // Should be first in list
|
||||
expect(result.current.tasks).toHaveLength(mockTasks.length + 1)
|
||||
expect(mockApiClient.createTask).toHaveBeenCalledWith(newTaskData)
|
||||
})
|
||||
|
||||
it('should handle create errors', async () => {
|
||||
const newTaskData: CreateTaskRequest = { title: '' }
|
||||
const errorMessage = 'Title must not be empty'
|
||||
|
||||
mockApiClient.createTask.mockRejectedValueOnce({ message: errorMessage })
|
||||
|
||||
const { result } = renderHook(() => useTasks({ autoFetch: false }))
|
||||
|
||||
let createResult: Task | null = null
|
||||
|
||||
await act(async () => {
|
||||
createResult = await result.current.createTask(newTaskData)
|
||||
})
|
||||
|
||||
expect(createResult).toBeNull()
|
||||
expect(result.current.error).toBe(errorMessage)
|
||||
expect(result.current.loading).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('refreshTasks', () => {
|
||||
it('should refresh tasks without loading state when tasks exist', async () => {
|
||||
const { result } = renderHook(() => useTasks({ autoFetch: false }))
|
||||
|
||||
// Set initial tasks
|
||||
await act(async () => {
|
||||
mockApiClient.listTasks.mockResolvedValueOnce(mockTasks)
|
||||
await result.current.fetchTasks()
|
||||
})
|
||||
|
||||
// Refresh with updated tasks
|
||||
const updatedTasks = [...mockTasks, { ...mockTasks[0], id: 'new-id' }]
|
||||
mockApiClient.listTasks.mockResolvedValueOnce(updatedTasks)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.refreshTasks()
|
||||
})
|
||||
|
||||
expect(result.current.tasks).toEqual(updatedTasks)
|
||||
})
|
||||
|
||||
it('should show loading state when no tasks exist', async () => {
|
||||
mockApiClient.listTasks.mockResolvedValueOnce(mockTasks)
|
||||
|
||||
const { result } = renderHook(() => useTasks({ autoFetch: false }))
|
||||
|
||||
act(() => {
|
||||
result.current.refreshTasks()
|
||||
})
|
||||
|
||||
expect(result.current.loading).toBe(true)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('utility functions', () => {
|
||||
beforeEach(async () => {
|
||||
mockApiClient.listTasks.mockResolvedValueOnce(mockTasks)
|
||||
})
|
||||
|
||||
it('should get task by ID', async () => {
|
||||
const { result } = renderHook(() => useTasks())
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.tasks).toHaveLength(mockTasks.length)
|
||||
})
|
||||
|
||||
const foundTask = result.current.getTaskById(mockTasks[0].id)
|
||||
expect(foundTask).toEqual(mockTasks[0])
|
||||
|
||||
const notFoundTask = result.current.getTaskById('nonexistent-id')
|
||||
expect(notFoundTask).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should filter tasks by status', async () => {
|
||||
const { result } = renderHook(() => useTasks())
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.tasks).toHaveLength(mockTasks.length)
|
||||
})
|
||||
|
||||
const todoTasks = result.current.filterTasksByStatus(TaskStatus.Todo)
|
||||
expect(todoTasks).toHaveLength(1)
|
||||
expect(todoTasks[0].status).toBe(TaskStatus.Todo)
|
||||
|
||||
const doneTasks = result.current.filterTasksByStatus(TaskStatus.Done)
|
||||
expect(doneTasks).toHaveLength(1)
|
||||
expect(doneTasks[0].status).toBe(TaskStatus.Done)
|
||||
|
||||
const backlogTasks = result.current.filterTasksByStatus(
|
||||
TaskStatus.Backlog
|
||||
)
|
||||
expect(backlogTasks).toHaveLength(1)
|
||||
expect(backlogTasks[0].status).toBe(TaskStatus.Backlog)
|
||||
})
|
||||
})
|
||||
|
||||
describe('clearError', () => {
|
||||
it('should clear error state', async () => {
|
||||
mockApiClient.listTasks.mockRejectedValueOnce({ message: 'Test error' })
|
||||
|
||||
const { result } = renderHook(() => useTasks({ autoFetch: false }))
|
||||
|
||||
// Trigger an error
|
||||
await act(async () => {
|
||||
await result.current.fetchTasks()
|
||||
})
|
||||
|
||||
expect(result.current.error).toBeTruthy()
|
||||
|
||||
// Clear the error
|
||||
act(() => {
|
||||
result.current.clearError()
|
||||
})
|
||||
|
||||
expect(result.current.error).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('refresh interval', () => {
|
||||
it('should set up refresh interval', async () => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
const updatedTasks = [...mockTasks, { ...mockTasks[0], id: 'new-id' }]
|
||||
|
||||
mockApiClient.listTasks
|
||||
.mockResolvedValueOnce(mockTasks)
|
||||
.mockResolvedValueOnce(updatedTasks)
|
||||
.mockResolvedValue(updatedTasks) // Handle any extra calls
|
||||
|
||||
const { result, unmount } = renderHook(() =>
|
||||
useTasks({ refreshInterval: 1000, autoFetch: false })
|
||||
)
|
||||
|
||||
// Manual initial fetch
|
||||
await act(async () => {
|
||||
await result.current.fetchTasks()
|
||||
})
|
||||
|
||||
expect(result.current.tasks).toHaveLength(mockTasks.length)
|
||||
|
||||
const initialCallCount = mockApiClient.listTasks.mock.calls.length
|
||||
|
||||
// Advance timer to trigger refresh
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1000)
|
||||
await vi.runOnlyPendingTimersAsync()
|
||||
})
|
||||
|
||||
// Should have made at least one more call
|
||||
expect(mockApiClient.listTasks.mock.calls.length).toBeGreaterThan(
|
||||
initialCallCount
|
||||
)
|
||||
expect(result.current.tasks).toHaveLength(updatedTasks.length)
|
||||
|
||||
unmount()
|
||||
vi.useRealTimers()
|
||||
}, 10000)
|
||||
})
|
||||
})
|
||||
161
frontend/app/hooks/useTasks.ts
Normal file
161
frontend/app/hooks/useTasks.ts
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
import { useState, useCallback, useEffect } from 'react'
|
||||
import type { Task, CreateTaskRequest, ApiError } from '~/types/task'
|
||||
import { TaskStatus } from '~/types/task'
|
||||
import { apiClient } from '~/services/api'
|
||||
|
||||
interface UseTasksState {
|
||||
tasks: Task[]
|
||||
loading: boolean
|
||||
error: string | null
|
||||
lastFetch: Date | null
|
||||
}
|
||||
|
||||
interface UseTasksActions {
|
||||
fetchTasks: () => Promise<void>
|
||||
createTask: (data: CreateTaskRequest) => Promise<Task | null>
|
||||
refreshTasks: () => Promise<void>
|
||||
clearError: () => void
|
||||
getTaskById: (id: string) => Task | undefined
|
||||
filterTasksByStatus: (status: TaskStatus) => Task[]
|
||||
}
|
||||
|
||||
interface UseTasksOptions {
|
||||
autoFetch?: boolean
|
||||
refreshInterval?: number
|
||||
}
|
||||
|
||||
export function useTasks(
|
||||
options: UseTasksOptions = {}
|
||||
): UseTasksState & UseTasksActions {
|
||||
const { autoFetch = true, refreshInterval } = options
|
||||
|
||||
const [state, setState] = useState<UseTasksState>({
|
||||
tasks: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
lastFetch: null,
|
||||
})
|
||||
|
||||
const clearError = useCallback(() => {
|
||||
setState(prev => ({ ...prev, error: null }))
|
||||
}, [])
|
||||
|
||||
const fetchTasks = useCallback(async () => {
|
||||
setState(prev => ({ ...prev, loading: true, error: null }))
|
||||
|
||||
try {
|
||||
const tasks = await apiClient.listTasks()
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
tasks,
|
||||
loading: false,
|
||||
lastFetch: new Date(),
|
||||
}))
|
||||
} catch (error) {
|
||||
const apiError = error as ApiError
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
loading: false,
|
||||
error: apiError.message,
|
||||
}))
|
||||
}
|
||||
}, [])
|
||||
|
||||
const createTask = useCallback(
|
||||
async (data: CreateTaskRequest): Promise<Task | null> => {
|
||||
setState(prev => ({ ...prev, loading: true, error: null }))
|
||||
|
||||
try {
|
||||
const newTask = await apiClient.createTask(data)
|
||||
|
||||
// Add the new task to the beginning of the list (most recent first)
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
tasks: [newTask, ...prev.tasks],
|
||||
loading: false,
|
||||
}))
|
||||
|
||||
return newTask
|
||||
} catch (error) {
|
||||
const apiError = error as ApiError
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
loading: false,
|
||||
error: apiError.message,
|
||||
}))
|
||||
return null
|
||||
}
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const refreshTasks = useCallback(async () => {
|
||||
// Force refresh without showing loading state if tasks already exist
|
||||
const showLoading = state.tasks.length === 0
|
||||
|
||||
if (showLoading) {
|
||||
setState(prev => ({ ...prev, loading: true, error: null }))
|
||||
} else {
|
||||
setState(prev => ({ ...prev, error: null }))
|
||||
}
|
||||
|
||||
try {
|
||||
const tasks = await apiClient.listTasks()
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
tasks,
|
||||
loading: false,
|
||||
lastFetch: new Date(),
|
||||
}))
|
||||
} catch (error) {
|
||||
const apiError = error as ApiError
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
loading: false,
|
||||
error: apiError.message,
|
||||
}))
|
||||
}
|
||||
}, [state.tasks])
|
||||
|
||||
const getTaskById = useCallback(
|
||||
(id: string): Task | undefined => {
|
||||
return state.tasks.find(task => task.id === id)
|
||||
},
|
||||
[state.tasks]
|
||||
)
|
||||
|
||||
const filterTasksByStatus = useCallback(
|
||||
(status: TaskStatus): Task[] => {
|
||||
return state.tasks.filter(task => task.status === status)
|
||||
},
|
||||
[state.tasks]
|
||||
)
|
||||
|
||||
// Auto-fetch tasks on mount
|
||||
useEffect(() => {
|
||||
if (autoFetch) {
|
||||
fetchTasks()
|
||||
}
|
||||
}, [autoFetch, fetchTasks])
|
||||
|
||||
// Set up refresh interval if specified
|
||||
useEffect(() => {
|
||||
if (!refreshInterval) return
|
||||
|
||||
const interval = setInterval(() => {
|
||||
refreshTasks()
|
||||
}, refreshInterval)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [refreshInterval, refreshTasks])
|
||||
|
||||
return {
|
||||
...state,
|
||||
fetchTasks,
|
||||
createTask,
|
||||
refreshTasks,
|
||||
clearError,
|
||||
getTaskById,
|
||||
filterTasksByStatus,
|
||||
}
|
||||
}
|
||||
|
|
@ -6,8 +6,12 @@ import {
|
|||
Scripts,
|
||||
ScrollRestoration,
|
||||
} from 'react-router'
|
||||
import { ThemeProvider } from '@mui/material/styles'
|
||||
import { CssBaseline } from '@mui/material'
|
||||
|
||||
import type { Route } from './+types/root'
|
||||
import { theme } from './theme'
|
||||
import AppLayout from './components/Layout'
|
||||
import './app.css'
|
||||
|
||||
export const links: Route.LinksFunction = () => [
|
||||
|
|
@ -42,7 +46,14 @@ export function Layout({ children }: { children: React.ReactNode }) {
|
|||
}
|
||||
|
||||
export default function App() {
|
||||
return <Outlet />
|
||||
return (
|
||||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
<AppLayout>
|
||||
<Outlet />
|
||||
</AppLayout>
|
||||
</ThemeProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
|
||||
|
|
@ -62,14 +73,19 @@ export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
|
|||
}
|
||||
|
||||
return (
|
||||
<main className="pt-16 p-4 container mx-auto">
|
||||
<h1>{message}</h1>
|
||||
<p>{details}</p>
|
||||
{stack && (
|
||||
<pre className="w-full p-4 overflow-x-auto">
|
||||
<code>{stack}</code>
|
||||
</pre>
|
||||
)}
|
||||
</main>
|
||||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
<AppLayout>
|
||||
<main className="pt-16 p-4 container mx-auto">
|
||||
<h1>{message}</h1>
|
||||
<p>{details}</p>
|
||||
{stack && (
|
||||
<pre className="w-full p-4 overflow-x-auto">
|
||||
<code>{stack}</code>
|
||||
</pre>
|
||||
)}
|
||||
</main>
|
||||
</AppLayout>
|
||||
</ThemeProvider>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,8 +3,14 @@ import { describe, it, expect } from 'vitest'
|
|||
import Home from './home'
|
||||
|
||||
describe('Home component', () => {
|
||||
it('should render welcome component', () => {
|
||||
it('should render task management interface', () => {
|
||||
render(<Home />)
|
||||
expect(screen.getByText(/React Router/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/Tasks/i)).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText(/GTD-inspired task management system/i)
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText(/Task Management Interface Coming Soon/i)
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,13 +1,44 @@
|
|||
import type { Route } from './+types/home'
|
||||
import { Welcome } from '../welcome/welcome'
|
||||
import { Box, Typography, Container } from '@mui/material'
|
||||
|
||||
export function meta(_: Route.MetaArgs) {
|
||||
return [
|
||||
{ title: 'New React Router App' },
|
||||
{ name: 'description', content: 'Welcome to React Router!' },
|
||||
{ title: "Captain's Log - Tasks" },
|
||||
{ name: 'description', content: 'GTD-inspired task management system' },
|
||||
]
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
return <Welcome />
|
||||
return (
|
||||
<Container maxWidth="lg">
|
||||
<Box sx={{ py: 4 }}>
|
||||
<Typography variant="h1" component="h1" gutterBottom>
|
||||
Tasks
|
||||
</Typography>
|
||||
<Typography variant="body1" color="text.secondary" sx={{ mb: 4 }}>
|
||||
Your GTD-inspired task management system. Capture everything, see only
|
||||
what matters.
|
||||
</Typography>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
p: 4,
|
||||
textAlign: 'center',
|
||||
color: 'text.secondary',
|
||||
border: '2px dashed',
|
||||
borderColor: 'grey.300',
|
||||
borderRadius: 2,
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Task Management Interface Coming Soon
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
The task list, task cards, and quick capture components will be
|
||||
implemented in the next phase.
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
267
frontend/app/services/api.test.ts
Normal file
267
frontend/app/services/api.test.ts
Normal file
|
|
@ -0,0 +1,267 @@
|
|||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { apiClient } from './api'
|
||||
import type { Task, CreateTaskRequest, UpdateTaskRequest } from '~/types/task'
|
||||
import { TaskStatus } from '~/types/task'
|
||||
|
||||
// Mock fetch globally
|
||||
const mockFetch = vi.fn()
|
||||
global.fetch = mockFetch
|
||||
|
||||
// Sample task data for testing
|
||||
const mockTask: Task = {
|
||||
id: '550e8400-e29b-41d4-a716-446655440000',
|
||||
title: 'Test Task',
|
||||
description: 'Test Description',
|
||||
status: TaskStatus.Todo,
|
||||
created_at: '2023-01-01T00:00:00Z',
|
||||
updated_at: '2023-01-01T00:00:00Z',
|
||||
completed_at: null,
|
||||
}
|
||||
|
||||
const mockTasks: Task[] = [
|
||||
mockTask,
|
||||
{
|
||||
...mockTask,
|
||||
id: '550e8400-e29b-41d4-a716-446655440001',
|
||||
title: 'Another Task',
|
||||
status: TaskStatus.Done,
|
||||
completed_at: '2023-01-01T01:00:00Z',
|
||||
},
|
||||
]
|
||||
|
||||
describe('API Client', () => {
|
||||
beforeEach(() => {
|
||||
mockFetch.mockClear()
|
||||
// Silence console.log during tests
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('listTasks', () => {
|
||||
it('should fetch all tasks successfully', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
json: () => Promise.resolve(mockTasks),
|
||||
})
|
||||
|
||||
const result = await apiClient.listTasks()
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith('/api/tasks', {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
expect(result).toEqual(mockTasks)
|
||||
})
|
||||
|
||||
it('should handle empty task list', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
json: () => Promise.resolve([]),
|
||||
})
|
||||
|
||||
const result = await apiClient.listTasks()
|
||||
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('should throw error on failed request', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 500,
|
||||
statusText: 'Internal Server Error',
|
||||
json: () => Promise.resolve({ message: 'Server error' }),
|
||||
})
|
||||
|
||||
await expect(apiClient.listTasks()).rejects.toThrow('Server error')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getTask', () => {
|
||||
it('should fetch single task successfully', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
json: () => Promise.resolve(mockTask),
|
||||
})
|
||||
|
||||
const result = await apiClient.getTask(mockTask.id)
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(`/api/tasks/${mockTask.id}`, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
expect(result).toEqual(mockTask)
|
||||
})
|
||||
|
||||
it('should handle task not found', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 404,
|
||||
statusText: 'Not Found',
|
||||
json: () => Promise.resolve({ message: 'Task not found' }),
|
||||
})
|
||||
|
||||
await expect(apiClient.getTask('nonexistent-id')).rejects.toThrow(
|
||||
'Task not found'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createTask', () => {
|
||||
it('should create task successfully', async () => {
|
||||
const newTaskData: CreateTaskRequest = {
|
||||
title: 'New Task',
|
||||
description: 'New Description',
|
||||
}
|
||||
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 201,
|
||||
statusText: 'Created',
|
||||
json: () => Promise.resolve(mockTask),
|
||||
})
|
||||
|
||||
const result = await apiClient.createTask(newTaskData)
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith('/api/tasks', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(newTaskData),
|
||||
})
|
||||
expect(result).toEqual(mockTask)
|
||||
})
|
||||
|
||||
it('should handle validation errors', async () => {
|
||||
const invalidData: CreateTaskRequest = {
|
||||
title: '',
|
||||
}
|
||||
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 422,
|
||||
statusText: 'Unprocessable Entity',
|
||||
json: () => Promise.resolve({ message: 'Title must not be empty' }),
|
||||
})
|
||||
|
||||
await expect(apiClient.createTask(invalidData)).rejects.toThrow(
|
||||
'Title must not be empty'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateTask', () => {
|
||||
it('should update task successfully', async () => {
|
||||
const updateData: UpdateTaskRequest = {
|
||||
title: 'Updated Task',
|
||||
status: TaskStatus.Done,
|
||||
}
|
||||
|
||||
const updatedTask = { ...mockTask, ...updateData }
|
||||
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
json: () => Promise.resolve(updatedTask),
|
||||
})
|
||||
|
||||
const result = await apiClient.updateTask(mockTask.id, updateData)
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(`/api/tasks/${mockTask.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(updateData),
|
||||
})
|
||||
expect(result).toEqual(updatedTask)
|
||||
})
|
||||
|
||||
it('should handle partial updates', async () => {
|
||||
const updateData: UpdateTaskRequest = {
|
||||
status: TaskStatus.Done,
|
||||
}
|
||||
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
json: () => Promise.resolve({ ...mockTask, status: TaskStatus.Done }),
|
||||
})
|
||||
|
||||
const result = await apiClient.updateTask(mockTask.id, updateData)
|
||||
|
||||
expect(result.status).toBe(TaskStatus.Done)
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleteTask', () => {
|
||||
it('should delete task successfully', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 204,
|
||||
statusText: 'No Content',
|
||||
})
|
||||
|
||||
const result = await apiClient.deleteTask(mockTask.id)
|
||||
expect(result).toBeNull()
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(`/api/tasks/${mockTask.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle delete errors', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 404,
|
||||
statusText: 'Not Found',
|
||||
json: () => Promise.resolve({ message: 'Task not found' }),
|
||||
})
|
||||
|
||||
await expect(apiClient.deleteTask('nonexistent-id')).rejects.toThrow(
|
||||
'Task not found'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should handle network errors', async () => {
|
||||
mockFetch.mockRejectedValueOnce(new Error('Network error'))
|
||||
|
||||
await expect(apiClient.listTasks()).rejects.toThrow('Network error')
|
||||
})
|
||||
|
||||
it('should handle malformed JSON responses', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 500,
|
||||
statusText: 'Internal Server Error',
|
||||
json: () => Promise.reject(new Error('Invalid JSON')),
|
||||
})
|
||||
|
||||
await expect(apiClient.listTasks()).rejects.toThrow(
|
||||
'HTTP 500: Internal Server Error'
|
||||
)
|
||||
})
|
||||
|
||||
it('should handle responses with no error message', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 400,
|
||||
statusText: 'Bad Request',
|
||||
json: () => Promise.resolve({}),
|
||||
})
|
||||
|
||||
await expect(apiClient.listTasks()).rejects.toThrow(
|
||||
'HTTP 400: Bad Request'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
113
frontend/app/services/api.ts
Normal file
113
frontend/app/services/api.ts
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
import type {
|
||||
Task,
|
||||
CreateTaskRequest,
|
||||
UpdateTaskRequest,
|
||||
ApiError,
|
||||
} from '~/types/task'
|
||||
|
||||
const API_BASE_URL = '/api'
|
||||
|
||||
class ApiClient {
|
||||
private async fetchWrapper<T>(
|
||||
endpoint: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<T> {
|
||||
const url = `${API_BASE_URL}${endpoint}`
|
||||
|
||||
const config: RequestInit = {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
},
|
||||
...options,
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log(`API Request: ${config.method || 'GET'} ${url}`, {
|
||||
body: config.body,
|
||||
headers: config.headers,
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url, config)
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log(`API Response: ${response.status} ${response.statusText}`, {
|
||||
url,
|
||||
status: response.status,
|
||||
})
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
let errorMessage = `HTTP ${response.status}: ${response.statusText}`
|
||||
|
||||
try {
|
||||
const errorData = await response.json()
|
||||
errorMessage = errorData.message || errorMessage
|
||||
} catch {
|
||||
// If JSON parsing fails, use the default error message
|
||||
}
|
||||
|
||||
throw new Error(errorMessage)
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
return null as T
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log('API Response Data:', data)
|
||||
}
|
||||
|
||||
return data
|
||||
} catch (error) {
|
||||
const apiError: ApiError = {
|
||||
message:
|
||||
error instanceof Error ? error.message : 'Unknown error occurred',
|
||||
status:
|
||||
error instanceof Error && 'status' in error
|
||||
? (error as { status?: number }).status
|
||||
: undefined,
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.error('API Error:', apiError)
|
||||
}
|
||||
|
||||
throw apiError
|
||||
}
|
||||
}
|
||||
|
||||
async listTasks(): Promise<Task[]> {
|
||||
return this.fetchWrapper<Task[]>('/tasks')
|
||||
}
|
||||
|
||||
async getTask(id: string): Promise<Task> {
|
||||
return this.fetchWrapper<Task>(`/tasks/${id}`)
|
||||
}
|
||||
|
||||
async createTask(data: CreateTaskRequest): Promise<Task> {
|
||||
return this.fetchWrapper<Task>('/tasks', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
}
|
||||
|
||||
async updateTask(id: string, data: UpdateTaskRequest): Promise<Task> {
|
||||
return this.fetchWrapper<Task>(`/tasks/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
}
|
||||
|
||||
async deleteTask(id: string): Promise<void> {
|
||||
return this.fetchWrapper<void>(`/tasks/${id}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const apiClient = new ApiClient()
|
||||
178
frontend/app/theme.ts
Normal file
178
frontend/app/theme.ts
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
import { createTheme } from '@mui/material/styles'
|
||||
|
||||
// Color palette constants
|
||||
const colors = {
|
||||
// Primary Colors
|
||||
deepNavy: '#1e3a5f', // Main brand color for headers and primary actions
|
||||
oceanBlue: '#2c5282', // Secondary blue for links and active states
|
||||
compassGold: '#d69e2e', // Accent color for highlights and call-to-actions
|
||||
|
||||
// Status Colors
|
||||
chartGreen: '#48bb78', // Completed tasks and success states
|
||||
sunsetCoral: '#f56565', // Urgent tasks and error states
|
||||
seaFoam: '#4fd1c7', // Information and notification states
|
||||
|
||||
// Neutrals
|
||||
parchment: '#f7fafc', // Clean background color
|
||||
fogGray: '#e2e8f0', // Subtle borders and dividers
|
||||
stormGray: '#718096', // Secondary text
|
||||
anchorDark: '#2d3748', // Primary text and headings
|
||||
}
|
||||
|
||||
declare module '@mui/material/styles' {
|
||||
interface Theme {
|
||||
custom: {
|
||||
task: {
|
||||
todo: string
|
||||
done: string
|
||||
backlog: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface ThemeOptions {
|
||||
custom?: {
|
||||
task?: {
|
||||
todo?: string
|
||||
done?: string
|
||||
backlog?: string
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const theme = createTheme({
|
||||
palette: {
|
||||
mode: 'light',
|
||||
primary: {
|
||||
main: colors.deepNavy,
|
||||
light: colors.oceanBlue,
|
||||
contrastText: '#ffffff',
|
||||
},
|
||||
secondary: {
|
||||
main: colors.compassGold,
|
||||
contrastText: '#ffffff',
|
||||
},
|
||||
success: {
|
||||
main: colors.chartGreen,
|
||||
},
|
||||
error: {
|
||||
main: colors.sunsetCoral,
|
||||
},
|
||||
info: {
|
||||
main: colors.seaFoam,
|
||||
},
|
||||
background: {
|
||||
default: colors.parchment,
|
||||
paper: '#ffffff',
|
||||
},
|
||||
text: {
|
||||
primary: colors.anchorDark,
|
||||
secondary: colors.stormGray,
|
||||
},
|
||||
grey: {
|
||||
100: '#edf2f7',
|
||||
200: colors.fogGray,
|
||||
300: '#cbd5e0',
|
||||
500: colors.stormGray,
|
||||
700: colors.anchorDark,
|
||||
},
|
||||
},
|
||||
typography: {
|
||||
fontFamily: '"Inter", ui-sans-serif, system-ui, sans-serif',
|
||||
h1: {
|
||||
fontSize: '2rem',
|
||||
fontWeight: 700,
|
||||
lineHeight: 1.2,
|
||||
},
|
||||
h2: {
|
||||
fontSize: '1.5rem',
|
||||
fontWeight: 600,
|
||||
lineHeight: 1.3,
|
||||
},
|
||||
h3: {
|
||||
fontSize: '1.25rem',
|
||||
fontWeight: 600,
|
||||
lineHeight: 1.4,
|
||||
},
|
||||
body1: {
|
||||
fontSize: '1rem',
|
||||
lineHeight: 1.6,
|
||||
},
|
||||
body2: {
|
||||
fontSize: '0.875rem',
|
||||
lineHeight: 1.5,
|
||||
},
|
||||
},
|
||||
shape: {
|
||||
borderRadius: 12,
|
||||
},
|
||||
components: {
|
||||
MuiAppBar: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
backgroundColor: colors.deepNavy,
|
||||
color: '#ffffff',
|
||||
boxShadow:
|
||||
'0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)',
|
||||
borderBottom: 'none',
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiDrawer: {
|
||||
styleOverrides: {
|
||||
paper: {
|
||||
backgroundColor: '#ffffff',
|
||||
borderRight: `1px solid ${colors.fogGray}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiButton: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
textTransform: 'none',
|
||||
borderRadius: '0.75rem',
|
||||
fontWeight: 500,
|
||||
},
|
||||
contained: {
|
||||
boxShadow: '0 1px 2px 0 rgb(0 0 0 / 0.05)',
|
||||
'&:hover': {
|
||||
boxShadow:
|
||||
'0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiCard: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
borderRadius: '0.75rem',
|
||||
boxShadow:
|
||||
'0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)',
|
||||
border: `1px solid ${colors.fogGray}`,
|
||||
'&:hover': {
|
||||
boxShadow:
|
||||
'0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)',
|
||||
borderColor: '#cbd5e0',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiTextField: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
'& .MuiOutlinedInput-root': {
|
||||
borderRadius: '0.75rem',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
custom: {
|
||||
task: {
|
||||
todo: colors.oceanBlue,
|
||||
done: colors.chartGreen,
|
||||
backlog: colors.stormGray,
|
||||
},
|
||||
},
|
||||
})
|
||||
35
frontend/app/types/task.ts
Normal file
35
frontend/app/types/task.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
export enum TaskStatus {
|
||||
Todo = 'todo',
|
||||
Done = 'done',
|
||||
Backlog = 'backlog',
|
||||
}
|
||||
|
||||
export interface Task {
|
||||
id: string
|
||||
title: string
|
||||
description: string | null
|
||||
status: TaskStatus
|
||||
created_at: string
|
||||
updated_at: string
|
||||
completed_at: string | null
|
||||
}
|
||||
|
||||
export interface CreateTaskRequest {
|
||||
title: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export interface UpdateTaskRequest {
|
||||
title?: string
|
||||
description?: string
|
||||
status?: TaskStatus
|
||||
}
|
||||
|
||||
export interface ApiError {
|
||||
message: string
|
||||
status?: number
|
||||
}
|
||||
|
||||
export interface TaskListResponse {
|
||||
tasks: Task[]
|
||||
}
|
||||
1335
frontend/package-lock.json
generated
1335
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -16,6 +16,10 @@
|
|||
"typecheck": "react-router typegen && tsc"
|
||||
},
|
||||
"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/serve": "^7.7.1",
|
||||
"isbot": "^5.1.27",
|
||||
|
|
@ -26,7 +30,6 @@
|
|||
"devDependencies": {
|
||||
"@eslint/js": "^9.36.0",
|
||||
"@react-router/dev": "^7.7.1",
|
||||
"@tailwindcss/vite": "^4.1.4",
|
||||
"@testing-library/jest-dom": "^6.8.0",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@types/eslint__js": "^8.42.3",
|
||||
|
|
@ -39,7 +42,6 @@
|
|||
"eslint-plugin-react-refresh": "^0.4.20",
|
||||
"jsdom": "^27.0.0",
|
||||
"prettier": "^3.6.2",
|
||||
"tailwindcss": "^4.1.4",
|
||||
"typescript": "^5.8.3",
|
||||
"typescript-eslint": "^8.44.0",
|
||||
"vite": "^6.3.3",
|
||||
|
|
|
|||
|
|
@ -1,76 +0,0 @@
|
|||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./app/**/*.{js,jsx,ts,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ["Inter", "ui-sans-serif", "system-ui", "sans-serif"],
|
||||
},
|
||||
colors: {
|
||||
primary: {
|
||||
50: '#eff6ff',
|
||||
100: '#dbeafe',
|
||||
200: '#bfdbfe',
|
||||
300: '#93c5fd',
|
||||
400: '#60a5fa',
|
||||
500: '#3b82f6',
|
||||
600: '#2563eb',
|
||||
700: '#1d4ed8',
|
||||
800: '#1e40af',
|
||||
900: '#1e3a8a',
|
||||
},
|
||||
success: {
|
||||
50: '#f0fdf4',
|
||||
100: '#dcfce7',
|
||||
200: '#bbf7d0',
|
||||
300: '#86efac',
|
||||
400: '#4ade80',
|
||||
500: '#22c55e',
|
||||
600: '#16a34a',
|
||||
700: '#15803d',
|
||||
800: '#166534',
|
||||
900: '#14532d',
|
||||
},
|
||||
warning: {
|
||||
50: '#fffbeb',
|
||||
100: '#fef3c7',
|
||||
200: '#fde68a',
|
||||
300: '#fcd34d',
|
||||
400: '#fbbf24',
|
||||
500: '#f59e0b',
|
||||
600: '#d97706',
|
||||
700: '#b45309',
|
||||
800: '#92400e',
|
||||
900: '#78350f',
|
||||
},
|
||||
danger: {
|
||||
50: '#fef2f2',
|
||||
100: '#fee2e2',
|
||||
200: '#fecaca',
|
||||
300: '#fca5a5',
|
||||
400: '#f87171',
|
||||
500: '#ef4444',
|
||||
600: '#dc2626',
|
||||
700: '#b91c1c',
|
||||
800: '#991b1b',
|
||||
900: '#7f1d1d',
|
||||
},
|
||||
},
|
||||
spacing: {
|
||||
'18': '4.5rem',
|
||||
'88': '22rem',
|
||||
},
|
||||
borderRadius: {
|
||||
'xl': '0.75rem',
|
||||
'2xl': '1rem',
|
||||
},
|
||||
boxShadow: {
|
||||
'card': '0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06)',
|
||||
'card-hover': '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
|
|
@ -1,10 +1,9 @@
|
|||
import { reactRouter } from "@react-router/dev/vite";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import { defineConfig } from "vite";
|
||||
import tsconfigPaths from "vite-tsconfig-paths";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [tailwindcss(), reactRouter(), tsconfigPaths()],
|
||||
plugins: [reactRouter(), tsconfigPaths()],
|
||||
server: {
|
||||
proxy: {
|
||||
"/api": {
|
||||
|
|
|
|||
2
justfile
2
justfile
|
|
@ -25,5 +25,7 @@ fmt-check: fmt-check-backend fmt-check-frontend
|
|||
|
||||
lint: lint-backend lint-frontend
|
||||
|
||||
check: fmt-check lint test
|
||||
|
||||
clean: clean-backend clean-frontend
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# Frontend MVP Implementation Plan
|
||||
|
||||
## Overview
|
||||
This plan details the concrete implementation steps for the Captain's Log frontend MVP using Vite + React + TypeScript + Tailwind CSS with comprehensive testing infrastructure.
|
||||
This plan details the concrete implementation steps for the Captain's Log frontend MVP using Vite + React + TypeScript with comprehensive testing infrastructure.
|
||||
|
||||
## Project Structure (Planned Implementation)
|
||||
```
|
||||
|
|
@ -15,7 +15,7 @@ captains-log/
|
|||
├── package.json # Node.js project manifest
|
||||
├── vite.config.ts # Vite configuration
|
||||
├── tsconfig.json # TypeScript configuration
|
||||
├── tailwind.config.js # Tailwind CSS configuration
|
||||
├── postcss.config.js # PostCSS configuration
|
||||
├── index.html # Entry HTML file
|
||||
├── src/
|
||||
│ ├── main.tsx # Application entry point
|
||||
|
|
@ -35,7 +35,7 @@ captains-log/
|
|||
│ ├── types/
|
||||
│ │ └── task.ts # TypeScript type definitions
|
||||
│ └── styles/
|
||||
│ └── index.css # Tailwind imports and custom styles
|
||||
│ └── index.css # CSS imports and custom styles
|
||||
└── tests/
|
||||
└── components/ # Component unit tests
|
||||
```
|
||||
|
|
@ -45,16 +45,16 @@ captains-log/
|
|||
### Task 1.1: Initialize Frontend Project
|
||||
- [x] **File**: `frontend/package.json`
|
||||
- Create new Vite + React + TypeScript project
|
||||
- Add dependencies: react, react-dom, react-router-dom, tailwindcss, @types/*
|
||||
- Add dependencies: react, react-dom, react-router-dom, @types/*
|
||||
- Add dev dependencies: vitest, @testing-library/react, @testing-library/jest-dom
|
||||
- Configure scripts for dev, build, test, lint
|
||||
- **Expected outcome**: `npm install` succeeds
|
||||
|
||||
### Task 1.2: Setup Build Tools and Configuration
|
||||
- [x] **Files**: `frontend/vite.config.ts`, `frontend/tsconfig.json`, `frontend/tailwind.config.js`
|
||||
- [x] **Files**: `frontend/vite.config.ts`, `frontend/tsconfig.json`, `frontend/postcss.config.js`
|
||||
- Configure Vite with React plugin and proxy to backend
|
||||
- Setup TypeScript with strict mode and path aliases
|
||||
- Configure Tailwind CSS with custom design tokens
|
||||
- Configure CSS styling framework
|
||||
- Setup Vitest for testing
|
||||
- **Expected outcome**: `npm run dev` starts frontend development server
|
||||
|
||||
|
|
@ -75,14 +75,14 @@ captains-log/
|
|||
## Phase 2: Core API Integration (Days 3-4)
|
||||
|
||||
### Task 2.1: Define TypeScript Types
|
||||
- [ ] **File**: `frontend/src/types/task.ts`
|
||||
- [x] **File**: `frontend/src/types/task.ts`
|
||||
- Create Task interface matching backend TaskModel
|
||||
- Add TaskStatus enum (Todo, Done, Backlog)
|
||||
- Include API response types and error types
|
||||
- **Expected outcome**: Type definitions compile without errors
|
||||
|
||||
### Task 2.2: Backend API Client
|
||||
- [ ] **File**: `frontend/src/services/api.ts`
|
||||
- [x] **File**: `frontend/src/services/api.ts`
|
||||
- Implement API client with fetch wrapper
|
||||
- Add all task endpoints: GET, POST, PUT, DELETE /api/tasks
|
||||
- Include error handling and response parsing
|
||||
|
|
@ -90,7 +90,7 @@ captains-log/
|
|||
- **Expected outcome**: API client can communicate with backend
|
||||
|
||||
### Task 2.3: Custom React Hooks for API
|
||||
- [ ] **Files**: `frontend/src/hooks/useTask.ts`, `frontend/src/hooks/useTasks.ts`
|
||||
- [x] **Files**: `frontend/src/hooks/useTask.ts`, `frontend/src/hooks/useTasks.ts`
|
||||
- Create useTask hook for single task operations (get, update, delete)
|
||||
- Create useTasks hook for task list operations (list, create)
|
||||
- Include loading states, error handling, and optimistic updates
|
||||
|
|
@ -98,7 +98,7 @@ captains-log/
|
|||
- **Expected outcome**: Hooks provide clean API for components
|
||||
|
||||
### Task 2.4: API Integration Tests
|
||||
- [ ] **File**: `frontend/tests/api.test.ts`
|
||||
- [x] **File**: `frontend/tests/api.test.ts`
|
||||
- Test API client with mock responses
|
||||
- Test custom hooks with mock API calls
|
||||
- Test error handling scenarios
|
||||
|
|
@ -107,7 +107,26 @@ captains-log/
|
|||
|
||||
## Phase 3: Core Components (Days 5-6)
|
||||
|
||||
### Task 3.1: Task Card Component
|
||||
**UI Framework**: Use Material-UI (MUI) for consistent design system and components alongside CSS for custom styling.
|
||||
|
||||
### Task 3.1: Main App Component and Routing
|
||||
- [ ] **Files**: `frontend/src/App.tsx`, `frontend/src/main.tsx`
|
||||
- Setup React Router with basic navigation
|
||||
- Create main layout with MUI AppBar/Drawer and task area
|
||||
- Implement responsive design with MUI breakpoints and CSS
|
||||
- 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`
|
||||
- Display task with title, description, status, dates
|
||||
- Implement inline editing for title and description
|
||||
|
|
@ -116,15 +135,16 @@ captains-log/
|
|||
- Mobile-friendly touch interactions
|
||||
- **Expected outcome**: TaskCard displays and edits tasks correctly
|
||||
|
||||
### Task 3.2: Task Form Component
|
||||
### Task 3.4: Task Form Component
|
||||
- [ ] **File**: `frontend/src/components/TaskForm.tsx`
|
||||
- Create/edit form with all task properties
|
||||
- Form validation and error display
|
||||
- Handle form submission with API calls
|
||||
- Support both modal and inline modes
|
||||
- **Expected outcome**: TaskForm creates and updates tasks
|
||||
- Create/edit form using Formik for form state management
|
||||
- Use MUI TextField, Select, and Button components
|
||||
- Form validation with Yup schema and error display
|
||||
- Handle form submission with API calls through Formik onSubmit
|
||||
- Support both MUI Dialog modal and inline modes
|
||||
- **Expected outcome**: TaskForm creates and updates tasks with robust form handling
|
||||
|
||||
### Task 3.3: Quick Capture Component
|
||||
### Task 3.5: Quick Capture Component
|
||||
- [ ] **File**: `frontend/src/components/QuickCapture.tsx`
|
||||
- Minimal input for rapid task creation
|
||||
- Auto-focus and keyboard shortcuts
|
||||
|
|
@ -132,14 +152,14 @@ captains-log/
|
|||
- Smart defaults for new tasks
|
||||
- **Expected outcome**: QuickCapture enables fast task entry
|
||||
|
||||
### Task 3.4: Status Badge Component
|
||||
### Task 3.6: Status Badge Component
|
||||
- [ ] **File**: `frontend/src/components/StatusBadge.tsx`
|
||||
- Visual status indicators with colors
|
||||
- Consistent styling across components
|
||||
- Accessible color schemes
|
||||
- **Expected outcome**: StatusBadge provides clear status visualization
|
||||
|
||||
### Task 3.5: Component Unit Tests
|
||||
### Task 3.7: Component Unit Tests
|
||||
- [ ] **Files**: `frontend/tests/components/*.test.tsx`
|
||||
- Test all components with React Testing Library
|
||||
- Test user interactions and state changes
|
||||
|
|
@ -149,22 +169,6 @@ captains-log/
|
|||
|
||||
## 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
|
||||
- [ ] **File**: `frontend/src/hooks/useApi.ts`
|
||||
- Implement localStorage for offline task caching
|
||||
|
|
@ -231,7 +235,6 @@ captains-log/
|
|||
"@vitejs/plugin-react": "^4.3.0",
|
||||
"vite": "^5.4.0",
|
||||
"typescript": "^5.5.0",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"vitest": "^2.0.0",
|
||||
"@testing-library/react": "^16.0.0",
|
||||
"@testing-library/jest-dom": "^6.5.0"
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ CREATE TABLE tasks (
|
|||
);
|
||||
```
|
||||
|
||||
#### Frontend: Vite + React + Tailwind
|
||||
#### Frontend: Vite + React
|
||||
- Single-page task management interface with React Router
|
||||
- Quick capture form with React hooks
|
||||
- Task list with inline editing using React components
|
||||
|
|
@ -81,7 +81,7 @@ CREATE TABLE tasks (
|
|||
#### Week 2: Frontend Foundation
|
||||
1. **Vite + React Setup**
|
||||
- Initialize Vite project with React template
|
||||
- Configure Tailwind CSS
|
||||
- Configure CSS styling
|
||||
- Setup React Router for navigation
|
||||
- Setup API client utilities
|
||||
- **Testing**: Setup Vitest, React Testing Library, and Playwright
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue