Create task view and form.
This commit is contained in:
parent
d60d834f38
commit
843d2a8c7b
4 changed files with 297 additions and 22 deletions
292
frontend/app/components/TaskList.tsx
Normal file
292
frontend/app/components/TaskList.tsx
Normal file
|
|
@ -0,0 +1,292 @@
|
|||
import React, { useState, useMemo } from 'react'
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Select,
|
||||
MenuItem,
|
||||
Chip,
|
||||
Stack,
|
||||
Paper,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
useMediaQuery,
|
||||
useTheme,
|
||||
} from '@mui/material'
|
||||
import { useTasks } from '~/hooks/useTasks'
|
||||
import { TaskStatus } from '~/types/task'
|
||||
import LoadingSpinner from './LoadingSpinner'
|
||||
|
||||
type SortOption =
|
||||
| 'created_desc'
|
||||
| 'created_asc'
|
||||
| 'title_asc'
|
||||
| 'title_desc'
|
||||
| 'status'
|
||||
|
||||
interface TaskListProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function TaskList({ className }: TaskListProps) {
|
||||
const { tasks, loading, error } = useTasks()
|
||||
const [statusFilter, setStatusFilter] = useState<TaskStatus | 'all'>('all')
|
||||
const [sortBy, setSortBy] = useState<SortOption>('created_desc')
|
||||
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`
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <LoadingSpinner />
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Box sx={{ p: 2, textAlign: 'center' }}>
|
||||
<Typography color="error" variant="h6">
|
||||
Error loading tasks
|
||||
</Typography>
|
||||
<Typography color="text.secondary" variant="body2">
|
||||
{error}
|
||||
</Typography>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box className={className}>
|
||||
{/* Filter and Sort Controls */}
|
||||
<Paper sx={{ p: 2, mb: 2 }}>
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2}>
|
||||
<FormControl size="small" sx={{ minWidth: 120 }}>
|
||||
<InputLabel>Status</InputLabel>
|
||||
<Select
|
||||
value={statusFilter}
|
||||
label="Status"
|
||||
onChange={e =>
|
||||
setStatusFilter(e.target.value as TaskStatus | 'all')
|
||||
}
|
||||
>
|
||||
<MenuItem value="all">All Tasks</MenuItem>
|
||||
<MenuItem value={TaskStatus.Todo}>Todo</MenuItem>
|
||||
<MenuItem value={TaskStatus.Done}>Done</MenuItem>
|
||||
<MenuItem value={TaskStatus.Backlog}>Backlog</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControl size="small" sx={{ minWidth: 150 }}>
|
||||
<InputLabel>Sort by</InputLabel>
|
||||
<Select
|
||||
value={sortBy}
|
||||
label="Sort by"
|
||||
onChange={e => setSortBy(e.target.value as SortOption)}
|
||||
>
|
||||
<MenuItem value="created_desc">Newest First</MenuItem>
|
||||
<MenuItem value="created_asc">Oldest First</MenuItem>
|
||||
<MenuItem value="title_asc">Title A-Z</MenuItem>
|
||||
<MenuItem value="title_desc">Title Z-A</MenuItem>
|
||||
<MenuItem value="status">By Status</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
sx={{ display: 'flex', alignItems: 'center' }}
|
||||
>
|
||||
{filteredAndSortedTasks.length} task
|
||||
{filteredAndSortedTasks.length !== 1 ? 's' : ''}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{/* Task Table */}
|
||||
{filteredAndSortedTasks.length === 0 ? (
|
||||
<Paper sx={{ p: 4, textAlign: 'center' }}>
|
||||
<Typography variant="h6" color="text.secondary" gutterBottom>
|
||||
No tasks found
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{statusFilter === 'all'
|
||||
? 'Create your first task to get started!'
|
||||
: `No ${statusFilter} tasks found.`}
|
||||
</Typography>
|
||||
</Paper>
|
||||
) : (
|
||||
<TableContainer component={Paper}>
|
||||
<Table size="small" sx={{ '& .MuiTableCell-root': { py: 1 } }}>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell sx={{ fontWeight: 'bold' }}>Title</TableCell>
|
||||
{!isMobile && (
|
||||
<TableCell sx={{ fontWeight: 'bold' }}>Description</TableCell>
|
||||
)}
|
||||
<TableCell sx={{ fontWeight: 'bold', width: 100 }}>
|
||||
Status
|
||||
</TableCell>
|
||||
<TableCell sx={{ fontWeight: 'bold', width: 120 }}>
|
||||
Created
|
||||
</TableCell>
|
||||
{!isMobile && (
|
||||
<TableCell sx={{ fontWeight: 'bold', width: 120 }}>
|
||||
Completed
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{filteredAndSortedTasks.map(task => (
|
||||
<TableRow
|
||||
key={task.id}
|
||||
sx={{
|
||||
'&:hover': {
|
||||
backgroundColor: 'action.hover',
|
||||
},
|
||||
'&:last-child td, &:last-child th': { border: 0 },
|
||||
}}
|
||||
>
|
||||
<TableCell>
|
||||
<Box>
|
||||
<Typography
|
||||
variant="body1"
|
||||
component="div"
|
||||
sx={{
|
||||
textDecoration:
|
||||
task.status === TaskStatus.Done
|
||||
? 'line-through'
|
||||
: 'none',
|
||||
opacity: task.status === TaskStatus.Done ? 0.7 : 1,
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{task.title}
|
||||
</Typography>
|
||||
{isMobile && task.description && (
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
sx={{ mt: 0.5 }}
|
||||
>
|
||||
{task.description}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</TableCell>
|
||||
{!isMobile && (
|
||||
<TableCell>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
sx={{
|
||||
maxWidth: 300,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{task.description || '—'}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
)}
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={task.status}
|
||||
color={getStatusColor(task.status)}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{formatCompactDate(task.created_at)}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
{!isMobile && (
|
||||
<TableCell>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{task.completed_at
|
||||
? formatCompactDate(task.completed_at)
|
||||
: '—'}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
|
@ -9,8 +9,7 @@ describe('Home component', () => {
|
|||
expect(
|
||||
screen.getByText(/GTD-inspired task management system/i)
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText(/Task Management Interface Coming Soon/i)
|
||||
).toBeInTheDocument()
|
||||
// TaskList component should be rendered (initially shows loading state)
|
||||
expect(screen.getByText(/Loading.../i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import type { Route } from './+types/home'
|
||||
import { Box, Typography, Container } from '@mui/material'
|
||||
import { TaskList } from '~/components/TaskList'
|
||||
|
||||
export function meta(_: Route.MetaArgs) {
|
||||
return [
|
||||
|
|
@ -20,24 +21,7 @@ export default function Home() {
|
|||
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>
|
||||
<TaskList />
|
||||
</Box>
|
||||
</Container>
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue