diff --git a/frontend/app/components/TaskList.tsx b/frontend/app/components/TaskList.tsx new file mode 100644 index 0000000..8503ffb --- /dev/null +++ b/frontend/app/components/TaskList.tsx @@ -0,0 +1,432 @@ +import React, { useState, useMemo } from 'react' +import { + Box, + Typography, + FormControl, + InputLabel, + Select, + MenuItem, + Chip, + Stack, + Paper, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + useMediaQuery, + useTheme, + Checkbox, + Toolbar, + Button, + Dialog, + DialogTitle, + DialogContent, + DialogContentText, + DialogActions, +} from '@mui/material' +import { Delete as DeleteIcon } from '@mui/icons-material' +import { useTasks } from '~/hooks/useTasks' +import { TaskStatus, type Task } from '~/types/task' +import LoadingSpinner from './LoadingSpinner' + +type SortOption = + | 'created_desc' + | 'created_asc' + | 'title_asc' + | 'title_desc' + | 'status' + +interface TaskListProps { + className?: string + initialTasks?: Task[] +} + +export function TaskList({ className, initialTasks }: TaskListProps) { + const { tasks, loading, error, deleteTask } = useTasks({ + autoFetch: !initialTasks, + initialData: initialTasks, + }) + const [statusFilter, setStatusFilter] = useState('all') + const [sortBy, setSortBy] = useState('created_desc') + const [selectedTaskIds, setSelectedTaskIds] = useState>(new Set()) + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false) + const theme = useTheme() + const isMobile = useMediaQuery(theme.breakpoints.down('md')) + + const filteredAndSortedTasks = useMemo(() => { + let filteredTasks = tasks + + // Apply status filter + if (statusFilter !== 'all') { + filteredTasks = tasks.filter(task => task.status === statusFilter) + } + + // Apply sorting + const sorted = [...filteredTasks].sort((a, b) => { + switch (sortBy) { + case 'created_desc': + return ( + new Date(b.created_at).getTime() - new Date(a.created_at).getTime() + ) + case 'created_asc': + return ( + new Date(a.created_at).getTime() - new Date(b.created_at).getTime() + ) + case 'title_asc': + return a.title.localeCompare(b.title) + case 'title_desc': + return b.title.localeCompare(a.title) + case 'status': { + const statusOrder = { + [TaskStatus.Todo]: 0, + [TaskStatus.Backlog]: 1, + [TaskStatus.Done]: 2, + } + return statusOrder[a.status] - statusOrder[b.status] + } + default: + return 0 + } + }) + + return sorted + }, [tasks, statusFilter, sortBy]) + + const getStatusColor = (status: TaskStatus) => { + switch (status) { + case TaskStatus.Todo: + return 'primary' + case TaskStatus.Done: + return 'success' + case TaskStatus.Backlog: + return 'default' + default: + return 'default' + } + } + + const formatCompactDate = (dateString: string) => { + const date = new Date(dateString) + const now = new Date() + const diffInDays = Math.floor( + (now.getTime() - date.getTime()) / (1000 * 60 * 60 * 24) + ) + + if (diffInDays === 0) return 'Today' + if (diffInDays === 1) return 'Yesterday' + if (diffInDays < 7) return `${diffInDays}d ago` + if (diffInDays < 30) return `${Math.floor(diffInDays / 7)}w ago` + if (diffInDays < 365) return `${Math.floor(diffInDays / 30)}mo ago` + return `${Math.floor(diffInDays / 365)}y ago` + } + + const handleSelectAll = (checked: boolean) => { + if (checked) { + setSelectedTaskIds(new Set(filteredAndSortedTasks.map(task => task.id))) + } else { + setSelectedTaskIds(new Set()) + } + } + + const handleSelectTask = (taskId: string, checked: boolean) => { + const newSelected = new Set(selectedTaskIds) + if (checked) { + newSelected.add(taskId) + } else { + newSelected.delete(taskId) + } + setSelectedTaskIds(newSelected) + } + + const handleBulkDelete = async () => { + try { + const results = await Promise.all( + Array.from(selectedTaskIds).map(taskId => deleteTask(taskId)) + ) + + // Check if all deletions were successful + const allSuccessful = results.every(result => result === true) + + if (allSuccessful) { + setSelectedTaskIds(new Set()) + setDeleteDialogOpen(false) + } else { + console.error('Some tasks failed to delete') + // Clear selection of successfully deleted tasks + const failedTaskIds = Array.from(selectedTaskIds).filter( + (taskId, index) => !results[index] + ) + setSelectedTaskIds(new Set(failedTaskIds)) + } + } catch (error) { + console.error('Error deleting tasks:', error) + } + } + + const isAllSelected = + filteredAndSortedTasks.length > 0 && + filteredAndSortedTasks.every(task => selectedTaskIds.has(task.id)) + const isIndeterminate = selectedTaskIds.size > 0 && !isAllSelected + + if (loading) { + return + } + + if (error) { + return ( + + + Error loading tasks + + + {error} + + + ) + } + + return ( + + {/* Filter and Sort Controls */} + + + + Status + + + + + Sort by + + + + + {filteredAndSortedTasks.length} task + {filteredAndSortedTasks.length !== 1 ? 's' : ''} + + + + + {/* Bulk Actions Toolbar */} + {selectedTaskIds.size > 0 && ( + + + + {selectedTaskIds.size} task{selectedTaskIds.size > 1 ? 's' : ''}{' '} + selected + + + + + )} + + {/* Task Table */} + {filteredAndSortedTasks.length === 0 ? ( + + + No tasks found + + + {statusFilter === 'all' + ? 'Create your first task to get started!' + : `No ${statusFilter} tasks found.`} + + + ) : ( + + + + + + handleSelectAll(e.target.checked)} + inputProps={{ + 'aria-label': 'select all tasks', + }} + /> + + Title + {!isMobile && ( + Description + )} + + Status + + + Created + + {!isMobile && ( + + Completed + + )} + + + + {filteredAndSortedTasks.map(task => ( + + + + handleSelectTask(task.id, e.target.checked) + } + inputProps={{ + 'aria-labelledby': `task-${task.id}`, + }} + /> + + + + + {task.title} + + {isMobile && task.description && ( + + {task.description} + + )} + + + {!isMobile && ( + + + {task.description || '—'} + + + )} + + + + + + {formatCompactDate(task.created_at)} + + + {!isMobile && ( + + + {task.completed_at + ? formatCompactDate(task.completed_at) + : '—'} + + + )} + + ))} + +
+
+ )} + + {/* Delete Confirmation Dialog */} + setDeleteDialogOpen(false)} + aria-labelledby="delete-dialog-title" + aria-describedby="delete-dialog-description" + > + + Delete {selectedTaskIds.size} task + {selectedTaskIds.size > 1 ? 's' : ''}? + + + + This action cannot be undone. Are you sure you want to delete the + selected task{selectedTaskIds.size > 1 ? 's' : ''}? + + + + + + + +
+ ) +} diff --git a/frontend/app/hooks/useTasks.ts b/frontend/app/hooks/useTasks.ts index 740d096..3b91795 100644 --- a/frontend/app/hooks/useTasks.ts +++ b/frontend/app/hooks/useTasks.ts @@ -13,6 +13,7 @@ interface UseTasksState { interface UseTasksActions { fetchTasks: () => Promise createTask: (data: CreateTaskRequest) => Promise + deleteTask: (id: string) => Promise refreshTasks: () => Promise clearError: () => void getTaskById: (id: string) => Task | undefined @@ -22,18 +23,19 @@ interface UseTasksActions { interface UseTasksOptions { autoFetch?: boolean refreshInterval?: number + initialData?: Task[] } export function useTasks( options: UseTasksOptions = {} ): UseTasksState & UseTasksActions { - const { autoFetch = true, refreshInterval } = options + const { autoFetch = true, refreshInterval, initialData } = options const [state, setState] = useState({ - tasks: [], + tasks: initialData || [], loading: false, error: null, - lastFetch: null, + lastFetch: initialData ? new Date() : null, }) const clearError = useCallback(() => { @@ -89,6 +91,27 @@ export function useTasks( [] ) + const deleteTask = useCallback(async (id: string): Promise => { + try { + await apiClient.deleteTask(id) + + // Remove the task from the local state immediately + setState(prev => ({ + ...prev, + tasks: prev.tasks.filter(task => task.id !== id), + })) + + return true + } catch (error) { + const apiError = error as ApiError + setState(prev => ({ + ...prev, + error: apiError.message, + })) + return false + } + }, []) + const refreshTasks = useCallback(async () => { // Force refresh without showing loading state if tasks already exist const showLoading = state.tasks.length === 0 @@ -153,6 +176,7 @@ export function useTasks( ...state, fetchTasks, createTask, + deleteTask, refreshTasks, clearError, getTaskById, diff --git a/frontend/app/routes.ts b/frontend/app/routes.ts index f8effb3..c74f179 100644 --- a/frontend/app/routes.ts +++ b/frontend/app/routes.ts @@ -1,3 +1,6 @@ -import { type RouteConfig, index } from '@react-router/dev/routes' +import { type RouteConfig, index, route } from '@react-router/dev/routes' -export default [index('routes/home.tsx')] satisfies RouteConfig +export default [ + index('routes/home.tsx'), + route('*', 'routes/$.tsx'), +] satisfies RouteConfig diff --git a/frontend/app/routes/$.tsx b/frontend/app/routes/$.tsx new file mode 100644 index 0000000..a567239 --- /dev/null +++ b/frontend/app/routes/$.tsx @@ -0,0 +1,25 @@ +import { redirect } from 'react-router' +import type { Route } from './+types/$' + +export async function loader({ request }: Route.LoaderArgs) { + const url = new URL(request.url) + + // Handle React DevTools and other development files + if ( + url.pathname.endsWith('.js.map') || + url.pathname.includes('installHook') || + url.pathname.startsWith('/__') || + url.pathname.startsWith('/node_modules/') + ) { + // Return a 404 response for these dev-only requests + throw new Response('Not Found', { status: 404 }) + } + + // For any other unmatched routes, redirect to home + return redirect('/') +} + +export default function CatchAll() { + // This component should never render since we always redirect or throw + return null +} diff --git a/frontend/app/routes/home.test.tsx b/frontend/app/routes/home.test.tsx index 80dd150..e15502f 100644 --- a/frontend/app/routes/home.test.tsx +++ b/frontend/app/routes/home.test.tsx @@ -4,13 +4,12 @@ import Home from './home' describe('Home component', () => { it('should render task management interface', () => { - render() - expect(screen.getByText(/Tasks/i)).toBeInTheDocument() + const mockLoaderData = { tasks: [] } + render() expect( - screen.getByText(/GTD-inspired task management system/i) - ).toBeInTheDocument() - expect( - screen.getByText(/Task Management Interface Coming Soon/i) + screen.getByRole('heading', { level: 1, name: /Tasks/i }) ).toBeInTheDocument() + // TaskList component should be rendered with empty state + expect(screen.getByText(/No tasks found/i)).toBeInTheDocument() }) }) diff --git a/frontend/app/routes/home.tsx b/frontend/app/routes/home.tsx index b818ac6..bffbd4a 100644 --- a/frontend/app/routes/home.tsx +++ b/frontend/app/routes/home.tsx @@ -1,43 +1,46 @@ import type { Route } from './+types/home' import { Box, Typography, Container } from '@mui/material' +import { TaskList } from '~/components/TaskList' +import type { Task } from '~/types/task' export function meta(_: Route.MetaArgs) { return [ { title: "Captain's Log - Tasks" }, - { name: 'description', content: 'GTD-inspired task management system' }, + { name: 'description', content: 'Task Dashboard' }, ] } -export default function Home() { +export async function loader(): Promise<{ tasks: Task[] }> { + try { + // Fetch tasks from the backend API during SSR + const apiUrl = process.env.API_URL || 'http://localhost:3000' + const response = await fetch(`${apiUrl}/api/tasks`, { + headers: { + 'Content-Type': 'application/json', + }, + }) + + if (!response.ok) { + console.error('Failed to fetch tasks:', response.statusText) + return { tasks: [] } + } + + const tasks = await response.json() + return { tasks } + } catch (error) { + console.error('Error fetching tasks during SSR:', error) + return { tasks: [] } + } +} + +export default function Home({ loaderData }: Route.ComponentProps) { return ( Tasks - - Your GTD-inspired task management system. Capture everything, see only - what matters. - - - - - Task Management Interface Coming Soon - - - The task list, task cards, and quick capture components will be - implemented in the next phase. - - + ) diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index d2e73cc..5442d2b 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -12,4 +12,7 @@ export default defineConfig({ }, }, }, + build: { + sourcemap: true, + }, }); diff --git a/plan/01_CORE_MVP/frontend.md b/plan/01_CORE_MVP/frontend.md index 325f4c7..adbca39 100644 --- a/plan/01_CORE_MVP/frontend.md +++ b/plan/01_CORE_MVP/frontend.md @@ -110,7 +110,7 @@ captains-log/ **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` +- [x] **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