Compare commits

...

5 commits

Author SHA1 Message Date
4a596abedf Set colorscheme. 2025-09-22 21:01:14 -07:00
6ef9843835 Landing page. 2025-09-22 20:40:08 -07:00
80539fd199 Update plan. 2025-09-22 02:32:35 -07:00
66e0a1d3f3 Add just check command. 2025-09-22 02:18:29 -07:00
c443a13a14 Frontend API structure. 2025-09-22 02:18:29 -07:00
23 changed files with 3106 additions and 237 deletions

View file

@ -74,38 +74,11 @@ tasks (id, title, description, status, created_at, updated_at, completed_at)
## Development Commands ## Development Commands
### Testing **Primary command**: Use `just` for all development tasks. Run `just --list` to see all available commands.
```bash
# Backend tests
just test-unit # Unit tests (cargo test)
just test-coverage # Coverage report (tarpaulin HTML)
just test-integration # API tests (Hurl)
# Individual commands **Key commands**:
cargo test # Direct unit test execution - `just check` - Validate all changes (primary validation command)
hurl --test tests/api/*.hurl # Direct API test execution - `just fmt` - Format code (resolve formatting errors)
# Frontend tests (when implemented)
npm test # Unit tests
npm run test:e2e # End-to-end tests
npm run test:coverage # Coverage report
```
### Development Server
```bash
# Backend (Rust server)
just dev # Run backend server (cargo run)
# Other backend commands
just build # Build project
just migrate # Run database migrations
just reset-db # Reset database
just fmt # Format code
just lint # Run clippy
# Frontend (when implemented)
npm run dev # Vite dev server
```
## Current Phase: Core MVP Backend ✅ ## Current Phase: Core MVP Backend ✅

View file

@ -1,59 +1,8 @@
@import 'tailwindcss'; /* Captain's Log - Global Styles */
@theme {
--font-sans:
'Inter', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji',
'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
/* Captain's Log Design Tokens */
--color-primary-50: #eff6ff;
--color-primary-100: #dbeafe;
--color-primary-500: #3b82f6;
--color-primary-950: #172554;
--color-task-todo: #3b82f6;
--color-task-done: #22c55e;
--color-task-backlog: #6b7280;
}
/* Base font family is handled by Material-UI theme */
html, html,
body { body {
@apply bg-gray-50 dark:bg-gray-950 text-gray-900 dark:text-gray-100; margin: 0;
padding: 0;
@media (prefers-color-scheme: dark) {
color-scheme: dark;
}
}
/* Captain's Log Component Styles */
.task-card {
@apply bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-4 transition-all duration-200;
}
.task-card:hover {
@apply shadow-md border-gray-300 dark:border-gray-600;
}
.quick-capture {
@apply bg-white dark:bg-gray-800 border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-xl p-4 transition-colors duration-200;
}
.quick-capture:focus-within {
@apply border-blue-500 bg-blue-50 dark:bg-blue-950;
}
.status-badge {
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium;
}
.status-todo {
@apply bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200;
}
.status-done {
@apply bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200;
}
.status-backlog {
@apply bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200;
} }

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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)
})
})

View 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()
},
}
}

View 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()
})
})
})

View 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,
}
}

View 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)
})
})

View 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,
}
}

View file

@ -6,8 +6,12 @@ import {
Scripts, Scripts,
ScrollRestoration, ScrollRestoration,
} from 'react-router' } from 'react-router'
import { ThemeProvider } from '@mui/material/styles'
import { CssBaseline } from '@mui/material'
import type { Route } from './+types/root' import type { Route } from './+types/root'
import { theme } from './theme'
import AppLayout from './components/Layout'
import './app.css' import './app.css'
export const links: Route.LinksFunction = () => [ export const links: Route.LinksFunction = () => [
@ -42,7 +46,14 @@ export function Layout({ children }: { children: React.ReactNode }) {
} }
export default function App() { export default function App() {
return <Outlet /> return (
<ThemeProvider theme={theme}>
<CssBaseline />
<AppLayout>
<Outlet />
</AppLayout>
</ThemeProvider>
)
} }
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) { export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
@ -62,14 +73,19 @@ export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
} }
return ( return (
<main className="pt-16 p-4 container mx-auto"> <ThemeProvider theme={theme}>
<h1>{message}</h1> <CssBaseline />
<p>{details}</p> <AppLayout>
{stack && ( <main className="pt-16 p-4 container mx-auto">
<pre className="w-full p-4 overflow-x-auto"> <h1>{message}</h1>
<code>{stack}</code> <p>{details}</p>
</pre> {stack && (
)} <pre className="w-full p-4 overflow-x-auto">
</main> <code>{stack}</code>
</pre>
)}
</main>
</AppLayout>
</ThemeProvider>
) )
} }

View file

@ -3,8 +3,14 @@ import { describe, it, expect } from 'vitest'
import Home from './home' import Home from './home'
describe('Home component', () => { describe('Home component', () => {
it('should render welcome component', () => { it('should render task management interface', () => {
render(<Home />) render(<Home />)
expect(screen.getByText(/React Router/i)).toBeInTheDocument() expect(screen.getByText(/Tasks/i)).toBeInTheDocument()
expect(
screen.getByText(/GTD-inspired task management system/i)
).toBeInTheDocument()
expect(
screen.getByText(/Task Management Interface Coming Soon/i)
).toBeInTheDocument()
}) })
}) })

View file

@ -1,13 +1,44 @@
import type { Route } from './+types/home' import type { Route } from './+types/home'
import { Welcome } from '../welcome/welcome' import { Box, Typography, Container } from '@mui/material'
export function meta(_: Route.MetaArgs) { export function meta(_: Route.MetaArgs) {
return [ return [
{ title: 'New React Router App' }, { title: "Captain's Log - Tasks" },
{ name: 'description', content: 'Welcome to React Router!' }, { name: 'description', content: 'GTD-inspired task management system' },
] ]
} }
export default function Home() { export default function Home() {
return <Welcome /> return (
<Container maxWidth="lg">
<Box sx={{ py: 4 }}>
<Typography variant="h1" component="h1" gutterBottom>
Tasks
</Typography>
<Typography variant="body1" color="text.secondary" sx={{ mb: 4 }}>
Your GTD-inspired task management system. Capture everything, see only
what matters.
</Typography>
<Box
sx={{
p: 4,
textAlign: 'center',
color: 'text.secondary',
border: '2px dashed',
borderColor: 'grey.300',
borderRadius: 2,
}}
>
<Typography variant="h6" gutterBottom>
Task Management Interface Coming Soon
</Typography>
<Typography variant="body2">
The task list, task cards, and quick capture components will be
implemented in the next phase.
</Typography>
</Box>
</Box>
</Container>
)
} }

View 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'
)
})
})
})

View 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
View 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,
},
},
})

View 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[]
}

File diff suppressed because it is too large Load diff

View file

@ -16,6 +16,10 @@
"typecheck": "react-router typegen && tsc" "typecheck": "react-router typegen && tsc"
}, },
"dependencies": { "dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@mui/icons-material": "^7.3.2",
"@mui/material": "^7.3.2",
"@react-router/node": "^7.7.1", "@react-router/node": "^7.7.1",
"@react-router/serve": "^7.7.1", "@react-router/serve": "^7.7.1",
"isbot": "^5.1.27", "isbot": "^5.1.27",
@ -39,7 +43,6 @@
"eslint-plugin-react-refresh": "^0.4.20", "eslint-plugin-react-refresh": "^0.4.20",
"jsdom": "^27.0.0", "jsdom": "^27.0.0",
"prettier": "^3.6.2", "prettier": "^3.6.2",
"tailwindcss": "^4.1.4",
"typescript": "^5.8.3", "typescript": "^5.8.3",
"typescript-eslint": "^8.44.0", "typescript-eslint": "^8.44.0",
"vite": "^6.3.3", "vite": "^6.3.3",

View file

@ -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: [],
}

View file

@ -25,5 +25,7 @@ fmt-check: fmt-check-backend fmt-check-frontend
lint: lint-backend lint-frontend lint: lint-backend lint-frontend
check: fmt-check lint test
clean: clean-backend clean-frontend clean: clean-backend clean-frontend

View file

@ -75,14 +75,14 @@ captains-log/
## Phase 2: Core API Integration (Days 3-4) ## Phase 2: Core API Integration (Days 3-4)
### Task 2.1: Define TypeScript Types ### Task 2.1: Define TypeScript Types
- [ ] **File**: `frontend/src/types/task.ts` - [x] **File**: `frontend/src/types/task.ts`
- Create Task interface matching backend TaskModel - Create Task interface matching backend TaskModel
- Add TaskStatus enum (Todo, Done, Backlog) - Add TaskStatus enum (Todo, Done, Backlog)
- Include API response types and error types - Include API response types and error types
- **Expected outcome**: Type definitions compile without errors - **Expected outcome**: Type definitions compile without errors
### Task 2.2: Backend API Client ### Task 2.2: Backend API Client
- [ ] **File**: `frontend/src/services/api.ts` - [x] **File**: `frontend/src/services/api.ts`
- Implement API client with fetch wrapper - Implement API client with fetch wrapper
- Add all task endpoints: GET, POST, PUT, DELETE /api/tasks - Add all task endpoints: GET, POST, PUT, DELETE /api/tasks
- Include error handling and response parsing - Include error handling and response parsing
@ -90,7 +90,7 @@ captains-log/
- **Expected outcome**: API client can communicate with backend - **Expected outcome**: API client can communicate with backend
### Task 2.3: Custom React Hooks for API ### Task 2.3: Custom React Hooks for API
- [ ] **Files**: `frontend/src/hooks/useTask.ts`, `frontend/src/hooks/useTasks.ts` - [x] **Files**: `frontend/src/hooks/useTask.ts`, `frontend/src/hooks/useTasks.ts`
- Create useTask hook for single task operations (get, update, delete) - Create useTask hook for single task operations (get, update, delete)
- Create useTasks hook for task list operations (list, create) - Create useTasks hook for task list operations (list, create)
- Include loading states, error handling, and optimistic updates - Include loading states, error handling, and optimistic updates
@ -98,7 +98,7 @@ captains-log/
- **Expected outcome**: Hooks provide clean API for components - **Expected outcome**: Hooks provide clean API for components
### Task 2.4: API Integration Tests ### Task 2.4: API Integration Tests
- [ ] **File**: `frontend/tests/api.test.ts` - [x] **File**: `frontend/tests/api.test.ts`
- Test API client with mock responses - Test API client with mock responses
- Test custom hooks with mock API calls - Test custom hooks with mock API calls
- Test error handling scenarios - Test error handling scenarios
@ -107,7 +107,26 @@ captains-log/
## Phase 3: Core Components (Days 5-6) ## Phase 3: Core Components (Days 5-6)
### Task 3.1: Task Card Component **UI Framework**: Use Material-UI (MUI) for consistent design system and components alongside Tailwind CSS for custom styling.
### Task 3.1: Main App Component and Routing
- [ ] **Files**: `frontend/src/App.tsx`, `frontend/src/main.tsx`
- Setup React Router with basic navigation
- Create main layout with MUI AppBar/Drawer and task area
- Implement responsive design with MUI breakpoints and Tailwind utilities
- Add MUI loading states (CircularProgress) and error boundaries
- Configure MUI theme with custom colors
- **Expected outcome**: Full application loads and navigates properly with Material Design
### Task 3.2: Task List Component
- [ ] **File**: `frontend/src/components/TaskList.tsx`
- Display tasks using MUI List/Grid components
- Filter tasks by status using MUI Chip/Select components
- Sort tasks with MUI Select dropdown
- Implement virtual scrolling with MUI virtualization
- **Expected outcome**: TaskList displays tasks efficiently with Material Design
### Task 3.3: Task Card Component
- [ ] **File**: `frontend/src/components/TaskCard.tsx` - [ ] **File**: `frontend/src/components/TaskCard.tsx`
- Display task with title, description, status, dates - Display task with title, description, status, dates
- Implement inline editing for title and description - Implement inline editing for title and description
@ -116,15 +135,16 @@ captains-log/
- Mobile-friendly touch interactions - Mobile-friendly touch interactions
- **Expected outcome**: TaskCard displays and edits tasks correctly - **Expected outcome**: TaskCard displays and edits tasks correctly
### Task 3.2: Task Form Component ### Task 3.4: Task Form Component
- [ ] **File**: `frontend/src/components/TaskForm.tsx` - [ ] **File**: `frontend/src/components/TaskForm.tsx`
- Create/edit form with all task properties - Create/edit form using Formik for form state management
- Form validation and error display - Use MUI TextField, Select, and Button components
- Handle form submission with API calls - Form validation with Yup schema and error display
- Support both modal and inline modes - Handle form submission with API calls through Formik onSubmit
- **Expected outcome**: TaskForm creates and updates tasks - Support both MUI Dialog modal and inline modes
- **Expected outcome**: TaskForm creates and updates tasks with robust form handling
### Task 3.3: Quick Capture Component ### Task 3.5: Quick Capture Component
- [ ] **File**: `frontend/src/components/QuickCapture.tsx` - [ ] **File**: `frontend/src/components/QuickCapture.tsx`
- Minimal input for rapid task creation - Minimal input for rapid task creation
- Auto-focus and keyboard shortcuts - Auto-focus and keyboard shortcuts
@ -132,14 +152,14 @@ captains-log/
- Smart defaults for new tasks - Smart defaults for new tasks
- **Expected outcome**: QuickCapture enables fast task entry - **Expected outcome**: QuickCapture enables fast task entry
### Task 3.4: Status Badge Component ### Task 3.6: Status Badge Component
- [ ] **File**: `frontend/src/components/StatusBadge.tsx` - [ ] **File**: `frontend/src/components/StatusBadge.tsx`
- Visual status indicators with colors - Visual status indicators with colors
- Consistent styling across components - Consistent styling across components
- Accessible color schemes - Accessible color schemes
- **Expected outcome**: StatusBadge provides clear status visualization - **Expected outcome**: StatusBadge provides clear status visualization
### Task 3.5: Component Unit Tests ### Task 3.7: Component Unit Tests
- [ ] **Files**: `frontend/tests/components/*.test.tsx` - [ ] **Files**: `frontend/tests/components/*.test.tsx`
- Test all components with React Testing Library - Test all components with React Testing Library
- Test user interactions and state changes - Test user interactions and state changes
@ -149,22 +169,6 @@ captains-log/
## Phase 4: Main Application (Days 7-8) ## Phase 4: Main Application (Days 7-8)
### Task 4.1: Task List Component
- [ ] **File**: `frontend/src/components/TaskList.tsx`
- Display tasks in organized list/grid layout
- Filter tasks by status (Todo, In Progress, Done, Someday)
- Sort tasks by created date, priority, due date
- Implement virtual scrolling for performance
- **Expected outcome**: TaskList displays tasks efficiently
### Task 4.2: Main App Component and Routing
- [ ] **Files**: `frontend/src/App.tsx`, `frontend/src/main.tsx`
- Setup React Router with basic navigation
- Create main layout with header and task area
- Implement responsive design with Tailwind
- Add loading states and error boundaries
- **Expected outcome**: Full application loads and navigates properly
### Task 4.3: State Management and Persistence ### Task 4.3: State Management and Persistence
- [ ] **File**: `frontend/src/hooks/useApi.ts` - [ ] **File**: `frontend/src/hooks/useApi.ts`
- Implement localStorage for offline task caching - Implement localStorage for offline task caching