Create a TaskList for the home page. #8

Merged
drew merged 2 commits from tasklist into main 2025-09-23 04:39:29 +00:00
7 changed files with 235 additions and 21 deletions
Showing only changes of commit b09a072fb9 - Show all commits

View file

@ -17,9 +17,18 @@ import {
TableRow, TableRow,
useMediaQuery, useMediaQuery,
useTheme, useTheme,
Checkbox,
Toolbar,
Button,
Dialog,
DialogTitle,
DialogContent,
DialogContentText,
DialogActions,
} from '@mui/material' } from '@mui/material'
import { Delete as DeleteIcon } from '@mui/icons-material'
import { useTasks } from '~/hooks/useTasks' import { useTasks } from '~/hooks/useTasks'
import { TaskStatus } from '~/types/task' import { TaskStatus, type Task } from '~/types/task'
import LoadingSpinner from './LoadingSpinner' import LoadingSpinner from './LoadingSpinner'
type SortOption = type SortOption =
@ -31,12 +40,18 @@ type SortOption =
interface TaskListProps { interface TaskListProps {
className?: string className?: string
initialTasks?: Task[]
} }
export function TaskList({ className }: TaskListProps) { export function TaskList({ className, initialTasks }: TaskListProps) {
const { tasks, loading, error } = useTasks() const { tasks, loading, error, deleteTask } = useTasks({
autoFetch: !initialTasks,
initialData: initialTasks,
})
const [statusFilter, setStatusFilter] = useState<TaskStatus | 'all'>('all') const [statusFilter, setStatusFilter] = useState<TaskStatus | 'all'>('all')
const [sortBy, setSortBy] = useState<SortOption>('created_desc') const [sortBy, setSortBy] = useState<SortOption>('created_desc')
const [selectedTaskIds, setSelectedTaskIds] = useState<Set<string>>(new Set())
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
const theme = useTheme() const theme = useTheme()
const isMobile = useMediaQuery(theme.breakpoints.down('md')) const isMobile = useMediaQuery(theme.breakpoints.down('md'))
@ -107,6 +122,54 @@ export function TaskList({ className }: TaskListProps) {
return `${Math.floor(diffInDays / 365)}y 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) { if (loading) {
return <LoadingSpinner /> return <LoadingSpinner />
} }
@ -171,6 +234,31 @@ export function TaskList({ className }: TaskListProps) {
</Stack> </Stack>
</Paper> </Paper>
{/* Bulk Actions Toolbar */}
{selectedTaskIds.size > 0 && (
<Paper sx={{ mb: 2 }}>
<Toolbar sx={{ pl: 2, pr: 1 }}>
<Typography
sx={{ flex: '1 1 100%' }}
color="inherit"
variant="subtitle1"
component="div"
>
{selectedTaskIds.size} task{selectedTaskIds.size > 1 ? 's' : ''}{' '}
selected
</Typography>
<Button
variant="contained"
color="error"
startIcon={<DeleteIcon />}
onClick={() => setDeleteDialogOpen(true)}
>
Delete
</Button>
</Toolbar>
</Paper>
)}
{/* Task Table */} {/* Task Table */}
{filteredAndSortedTasks.length === 0 ? ( {filteredAndSortedTasks.length === 0 ? (
<Paper sx={{ p: 4, textAlign: 'center' }}> <Paper sx={{ p: 4, textAlign: 'center' }}>
@ -188,6 +276,17 @@ export function TaskList({ className }: TaskListProps) {
<Table size="small" sx={{ '& .MuiTableCell-root': { py: 1 } }}> <Table size="small" sx={{ '& .MuiTableCell-root': { py: 1 } }}>
<TableHead> <TableHead>
<TableRow> <TableRow>
<TableCell padding="checkbox">
<Checkbox
color="primary"
indeterminate={isIndeterminate}
checked={isAllSelected}
onChange={e => handleSelectAll(e.target.checked)}
inputProps={{
'aria-label': 'select all tasks',
}}
/>
</TableCell>
<TableCell sx={{ fontWeight: 'bold' }}>Title</TableCell> <TableCell sx={{ fontWeight: 'bold' }}>Title</TableCell>
{!isMobile && ( {!isMobile && (
<TableCell sx={{ fontWeight: 'bold' }}>Description</TableCell> <TableCell sx={{ fontWeight: 'bold' }}>Description</TableCell>
@ -214,11 +313,27 @@ export function TaskList({ className }: TaskListProps) {
backgroundColor: 'action.hover', backgroundColor: 'action.hover',
}, },
'&:last-child td, &:last-child th': { border: 0 }, '&:last-child td, &:last-child th': { border: 0 },
backgroundColor: selectedTaskIds.has(task.id)
? 'action.selected'
: 'inherit',
}} }}
> >
<TableCell padding="checkbox">
<Checkbox
color="primary"
checked={selectedTaskIds.has(task.id)}
onChange={e =>
handleSelectTask(task.id, e.target.checked)
}
inputProps={{
'aria-labelledby': `task-${task.id}`,
}}
/>
</TableCell>
<TableCell> <TableCell>
<Box> <Box>
<Typography <Typography
id={`task-${task.id}`}
variant="body1" variant="body1"
component="div" component="div"
sx={{ sx={{
@ -287,6 +402,31 @@ export function TaskList({ className }: TaskListProps) {
</Table> </Table>
</TableContainer> </TableContainer>
)} )}
{/* Delete Confirmation Dialog */}
<Dialog
open={deleteDialogOpen}
onClose={() => setDeleteDialogOpen(false)}
aria-labelledby="delete-dialog-title"
aria-describedby="delete-dialog-description"
>
<DialogTitle id="delete-dialog-title">
Delete {selectedTaskIds.size} task
{selectedTaskIds.size > 1 ? 's' : ''}?
</DialogTitle>
<DialogContent>
<DialogContentText id="delete-dialog-description">
This action cannot be undone. Are you sure you want to delete the
selected task{selectedTaskIds.size > 1 ? 's' : ''}?
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={() => setDeleteDialogOpen(false)}>Cancel</Button>
<Button onClick={handleBulkDelete} color="error" autoFocus>
Delete
</Button>
</DialogActions>
</Dialog>
</Box> </Box>
) )
} }

View file

@ -13,6 +13,7 @@ interface UseTasksState {
interface UseTasksActions { interface UseTasksActions {
fetchTasks: () => Promise<void> fetchTasks: () => Promise<void>
createTask: (data: CreateTaskRequest) => Promise<Task | null> createTask: (data: CreateTaskRequest) => Promise<Task | null>
deleteTask: (id: string) => Promise<boolean>
refreshTasks: () => Promise<void> refreshTasks: () => Promise<void>
clearError: () => void clearError: () => void
getTaskById: (id: string) => Task | undefined getTaskById: (id: string) => Task | undefined
@ -22,18 +23,19 @@ interface UseTasksActions {
interface UseTasksOptions { interface UseTasksOptions {
autoFetch?: boolean autoFetch?: boolean
refreshInterval?: number refreshInterval?: number
initialData?: Task[]
} }
export function useTasks( export function useTasks(
options: UseTasksOptions = {} options: UseTasksOptions = {}
): UseTasksState & UseTasksActions { ): UseTasksState & UseTasksActions {
const { autoFetch = true, refreshInterval } = options const { autoFetch = true, refreshInterval, initialData } = options
const [state, setState] = useState<UseTasksState>({ const [state, setState] = useState<UseTasksState>({
tasks: [], tasks: initialData || [],
loading: false, loading: false,
error: null, error: null,
lastFetch: null, lastFetch: initialData ? new Date() : null,
}) })
const clearError = useCallback(() => { const clearError = useCallback(() => {
@ -89,6 +91,27 @@ export function useTasks(
[] []
) )
const deleteTask = useCallback(async (id: string): Promise<boolean> => {
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 () => { const refreshTasks = useCallback(async () => {
// Force refresh without showing loading state if tasks already exist // Force refresh without showing loading state if tasks already exist
const showLoading = state.tasks.length === 0 const showLoading = state.tasks.length === 0
@ -153,6 +176,7 @@ export function useTasks(
...state, ...state,
fetchTasks, fetchTasks,
createTask, createTask,
deleteTask,
refreshTasks, refreshTasks,
clearError, clearError,
getTaskById, getTaskById,

View file

@ -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

25
frontend/app/routes/$.tsx Normal file
View file

@ -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
}

View file

@ -4,12 +4,12 @@ import Home from './home'
describe('Home component', () => { describe('Home component', () => {
it('should render task management interface', () => { it('should render task management interface', () => {
render(<Home />) const mockLoaderData = { tasks: [] }
expect(screen.getByText(/Tasks/i)).toBeInTheDocument() render(<Home loaderData={mockLoaderData} />)
expect( expect(
screen.getByText(/GTD-inspired task management system/i) screen.getByRole('heading', { level: 1, name: /Tasks/i })
).toBeInTheDocument() ).toBeInTheDocument()
// TaskList component should be rendered (initially shows loading state) // TaskList component should be rendered with empty state
expect(screen.getByText(/Loading.../i)).toBeInTheDocument() expect(screen.getByText(/No tasks found/i)).toBeInTheDocument()
}) })
}) })

View file

@ -1,27 +1,46 @@
import type { Route } from './+types/home' import type { Route } from './+types/home'
import { Box, Typography, Container } from '@mui/material' import { Box, Typography, Container } from '@mui/material'
import { TaskList } from '~/components/TaskList' import { TaskList } from '~/components/TaskList'
import type { Task } from '~/types/task'
export function meta(_: Route.MetaArgs) { export function meta(_: Route.MetaArgs) {
return [ return [
{ title: "Captain's Log - Tasks" }, { 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 ( return (
<Container maxWidth="lg"> <Container maxWidth="lg">
<Box sx={{ py: 4 }}> <Box sx={{ py: 4 }}>
<Typography variant="h1" component="h1" gutterBottom> <Typography variant="h1" component="h1" gutterBottom>
Tasks Tasks
</Typography> </Typography>
<Typography variant="body1" color="text.secondary" sx={{ mb: 4 }}> <TaskList initialTasks={loaderData.tasks} />
Your GTD-inspired task management system. Capture everything, see only
what matters.
</Typography>
<TaskList />
</Box> </Box>
</Container> </Container>
) )

View file

@ -12,4 +12,7 @@ export default defineConfig({
}, },
}, },
}, },
build: {
sourcemap: true,
},
}); });