Split the Dashboard from All Tasks. (#10)

Reviewed-on: #10
Co-authored-by: Drew Galbraith <drew@tiramisu.one>
Co-committed-by: Drew Galbraith <drew@tiramisu.one>
This commit is contained in:
Drew 2025-09-24 01:02:23 +00:00 committed by Drew
parent b0916990fb
commit 8e6ac03f70
10 changed files with 572 additions and 23 deletions

View file

@ -19,6 +19,7 @@ import {
import {
Menu as MenuIcon,
Dashboard as DashboardIcon,
List as ListIcon,
Add as AddIcon,
Settings as SettingsIcon,
} from '@mui/icons-material'
@ -41,10 +42,15 @@ export default function Layout({ children, loading = false }: LayoutProps) {
const menuItems = [
{
text: 'Tasks',
text: 'Dashboard',
icon: <DashboardIcon />,
path: '/',
},
{
text: 'All Tasks',
icon: <ListIcon />,
path: '/all-tasks',
},
{
text: 'Settings',
icon: <SettingsIcon />,
@ -58,8 +64,8 @@ export default function Layout({ children, loading = false }: LayoutProps) {
{menuItems.map(item => (
<ListItem key={item.text} disablePadding>
<ListItemButton
component="a"
href={item.path}
component={Link}
to={item.path}
sx={{
borderRadius: 2,
mx: 1,
@ -220,7 +226,6 @@ export default function Layout({ children, loading = false }: LayoutProps) {
<Toolbar />
{children}
</Box>
</Box>
)
}

View file

@ -0,0 +1,380 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { MemoryRouter } from 'react-router'
import { TaskList } from './TaskList'
import type { Task } from '~/types/task'
import { TaskStatus } from '~/types/task'
import { useTasks } from '~/hooks/useTasks'
// Mock the useTasks hook
vi.mock('~/hooks/useTasks')
const mockUseTasks = useTasks as unknown as ReturnType<typeof vi.fn>
// Sample task data for testing
const mockTasks: Task[] = [
{
id: '1',
title: 'Alpha Task',
description: 'First task description',
status: TaskStatus.Todo,
created_at: '2023-01-01T00:00:00Z',
updated_at: '2023-01-01T00:00:00Z',
completed_at: null,
},
{
id: '2',
title: 'Beta Task',
description: 'Second task description',
status: TaskStatus.Done,
created_at: '2023-01-02T00:00:00Z',
updated_at: '2023-01-02T00:00:00Z',
completed_at: '2023-01-02T01:00:00Z',
},
{
id: '3',
title: 'Charlie Task',
description: null,
status: TaskStatus.Backlog,
created_at: '2023-01-03T00:00:00Z',
updated_at: '2023-01-03T00:00:00Z',
completed_at: null,
},
]
const defaultMockUseTasks = {
tasks: mockTasks,
loading: false,
error: null,
lastFetch: new Date(),
fetchTasks: vi.fn(),
createTask: vi.fn(),
refreshTasks: vi.fn(),
deleteTask: vi.fn().mockResolvedValue(true),
getTaskById: vi.fn(),
filterTasksByStatus: vi.fn(),
clearError: vi.fn(),
}
const renderTaskList = (initialTasks?: Task[]) => {
return render(
<MemoryRouter>
<TaskList initialTasks={initialTasks} />
</MemoryRouter>
)
}
describe('TaskList Sorting Functionality', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseTasks.mockReturnValue(defaultMockUseTasks)
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('Default Sorting', () => {
it('should default to created_desc sorting (newest first)', () => {
renderTaskList()
// Tasks should be ordered by creation date descending (newest first)
const taskRows = screen.getAllByRole('row')
// Skip header row (index 0)
const firstTaskRow = taskRows[1]
const secondTaskRow = taskRows[2]
const thirdTaskRow = taskRows[3]
// Charlie Task (2023-01-03) should be first
expect(firstTaskRow).toHaveTextContent('Charlie Task')
// Beta Task (2023-01-02) should be second
expect(secondTaskRow).toHaveTextContent('Beta Task')
// Alpha Task (2023-01-01) should be third
expect(thirdTaskRow).toHaveTextContent('Alpha Task')
})
it('should show correct sort indicator for created column by default', () => {
renderTaskList()
const createdHeader = screen.getByRole('button', { name: /created/i })
expect(createdHeader).toHaveClass('Mui-active')
// Should have descending indicator (direction class)
expect(createdHeader).toHaveClass('MuiTableSortLabel-directionDesc')
})
})
describe('Title Column Sorting', () => {
it('should sort by title ascending when clicked first time', async () => {
renderTaskList()
const titleHeader = screen.getByRole('button', { name: /title/i })
fireEvent.click(titleHeader)
await waitFor(() => {
const taskRows = screen.getAllByRole('row')
// Skip header row
const firstTaskRow = taskRows[1]
const secondTaskRow = taskRows[2]
const thirdTaskRow = taskRows[3]
// Should be in alphabetical order
expect(firstTaskRow).toHaveTextContent('Alpha Task')
expect(secondTaskRow).toHaveTextContent('Beta Task')
expect(thirdTaskRow).toHaveTextContent('Charlie Task')
})
})
it('should sort by title descending when clicked second time', async () => {
renderTaskList()
const titleHeader = screen.getByRole('button', { name: /title/i })
// First click - ascending
fireEvent.click(titleHeader)
// Second click - descending
fireEvent.click(titleHeader)
await waitFor(() => {
const taskRows = screen.getAllByRole('row')
const firstTaskRow = taskRows[1]
const secondTaskRow = taskRows[2]
const thirdTaskRow = taskRows[3]
// Should be in reverse alphabetical order
expect(firstTaskRow).toHaveTextContent('Charlie Task')
expect(secondTaskRow).toHaveTextContent('Beta Task')
expect(thirdTaskRow).toHaveTextContent('Alpha Task')
})
})
it('should show correct sort indicators for title column', async () => {
renderTaskList()
const titleHeader = screen.getByRole('button', { name: /title/i })
// Initially not active
expect(titleHeader).not.toHaveClass('Mui-active')
// First click - ascending
fireEvent.click(titleHeader)
await waitFor(() => {
expect(titleHeader).toHaveClass('Mui-active')
expect(titleHeader).toHaveClass('MuiTableSortLabel-directionAsc')
})
// Second click - descending
fireEvent.click(titleHeader)
await waitFor(() => {
expect(titleHeader).toHaveClass('Mui-active')
expect(titleHeader).toHaveClass('MuiTableSortLabel-directionDesc')
})
})
})
describe('Status Column Sorting', () => {
it('should sort by status priority (Todo, Backlog, Done) when clicked', async () => {
renderTaskList()
const statusHeader = screen.getByRole('button', { name: /status/i })
fireEvent.click(statusHeader)
await waitFor(() => {
const taskRows = screen.getAllByRole('row')
const firstTaskRow = taskRows[1]
const secondTaskRow = taskRows[2]
const thirdTaskRow = taskRows[3]
// Should be ordered by status priority
expect(firstTaskRow).toHaveTextContent('Alpha Task') // Todo
expect(secondTaskRow).toHaveTextContent('Charlie Task') // Backlog
expect(thirdTaskRow).toHaveTextContent('Beta Task') // Done
})
})
it('should show correct sort indicator for status column', async () => {
renderTaskList()
const statusHeader = screen.getByRole('button', { name: /status/i })
// Initially not active
expect(statusHeader).not.toHaveClass('Mui-active')
fireEvent.click(statusHeader)
await waitFor(() => {
expect(statusHeader).toHaveClass('Mui-active')
expect(statusHeader).toHaveClass('MuiTableSortLabel-directionAsc')
})
})
})
describe('Created Column Sorting', () => {
it('should toggle between ascending and descending when clicked', async () => {
renderTaskList()
const createdHeader = screen.getByRole('button', { name: /created/i })
// First click should change to ascending (oldest first)
fireEvent.click(createdHeader)
await waitFor(() => {
const taskRows = screen.getAllByRole('row')
const firstTaskRow = taskRows[1]
const thirdTaskRow = taskRows[3]
expect(firstTaskRow).toHaveTextContent('Alpha Task') // 2023-01-01
expect(thirdTaskRow).toHaveTextContent('Charlie Task') // 2023-01-03
})
// Second click should change back to descending (newest first)
fireEvent.click(createdHeader)
await waitFor(() => {
const taskRows = screen.getAllByRole('row')
const firstTaskRow = taskRows[1]
const thirdTaskRow = taskRows[3]
expect(firstTaskRow).toHaveTextContent('Charlie Task') // 2023-01-03
expect(thirdTaskRow).toHaveTextContent('Alpha Task') // 2023-01-01
})
})
})
describe('Sort Persistence', () => {
it('should maintain sort order after clicking different headers', async () => {
renderTaskList()
// Sort by title ascending
const titleHeader = screen.getByRole('button', { name: /title/i })
fireEvent.click(titleHeader)
await waitFor(() => {
const taskRows = screen.getAllByRole('row')
expect(taskRows[1]).toHaveTextContent('Alpha Task')
expect(taskRows[2]).toHaveTextContent('Beta Task')
expect(taskRows[3]).toHaveTextContent('Charlie Task')
})
// Sort by status
const statusHeader = screen.getByRole('button', { name: /status/i })
fireEvent.click(statusHeader)
await waitFor(() => {
const taskRows = screen.getAllByRole('row')
// Should be ordered by status priority: Todo, Backlog, Done
expect(taskRows[1]).toHaveTextContent('Alpha Task') // Todo
expect(taskRows[2]).toHaveTextContent('Charlie Task') // Backlog
expect(taskRows[3]).toHaveTextContent('Beta Task') // Done
})
})
})
describe('Empty State', () => {
it('should show empty state message when no tasks', () => {
mockUseTasks.mockReturnValue({
...defaultMockUseTasks,
tasks: [],
})
renderTaskList()
// Should show empty state
expect(screen.getByText(/No tasks found/i)).toBeInTheDocument()
expect(
screen.getByText(/Create your first task to get started!/i)
).toBeInTheDocument()
// Table headers should not be rendered in empty state
expect(
screen.queryByRole('button', { name: /title/i })
).not.toBeInTheDocument()
})
})
describe('Mobile Responsive Sorting', () => {
it('should show sortable headers on mobile', () => {
renderTaskList()
// All sortable headers should be visible
expect(screen.getByRole('button', { name: /title/i })).toBeInTheDocument()
expect(
screen.getByRole('button', { name: /status/i })
).toBeInTheDocument()
expect(
screen.getByRole('button', { name: /created/i })
).toBeInTheDocument()
// Description column should be visible but not sortable (no button)
expect(screen.getByText('Description')).toBeInTheDocument()
expect(
screen.queryByRole('button', { name: /description/i })
).not.toBeInTheDocument()
})
})
describe('Tooltip Functionality', () => {
it('should show cursor help styling on created date with tooltip', () => {
renderTaskList()
// Find created date elements (they should have relative dates like "2y ago")
const dateElements = screen.getAllByText(/ago$|Today|Yesterday/)
// Verify at least one date element exists with proper cursor styling
expect(dateElements.length).toBeGreaterThan(0)
// Check that date elements have cursor help styling
dateElements.forEach(element => {
expect(element).toHaveStyle('cursor: help')
})
})
it('should have tooltip structure in place for completed dates', () => {
renderTaskList()
// Find the table to ensure it rendered
const table = screen.getByRole('table')
expect(table).toBeInTheDocument()
// Verify that the TaskList component rendered without errors
// The tooltip functionality would be tested in e2e tests for actual hover behavior
expect(screen.getByText('Beta Task')).toBeInTheDocument()
})
})
describe('Loading and Error States', () => {
it('should not render sort headers during loading', () => {
mockUseTasks.mockReturnValue({
...defaultMockUseTasks,
loading: true,
tasks: [],
})
renderTaskList()
// Should show loading spinner instead of table
expect(
screen.queryByRole('button', { name: /title/i })
).not.toBeInTheDocument()
expect(screen.getByText('Loading...')).toBeInTheDocument()
})
it('should not render sort headers during error state', () => {
mockUseTasks.mockReturnValue({
...defaultMockUseTasks,
error: 'Failed to load tasks',
tasks: [],
})
renderTaskList()
// Should show error message instead of table
expect(
screen.queryByRole('button', { name: /title/i })
).not.toBeInTheDocument()
expect(screen.getByText(/Error loading tasks/i)).toBeInTheDocument()
})
})
})

View file

@ -25,6 +25,8 @@ import {
DialogContent,
DialogContentText,
DialogActions,
TableSortLabel,
Tooltip,
} from '@mui/material'
import { Delete as DeleteIcon } from '@mui/icons-material'
import { useTasks } from '~/hooks/useTasks'
@ -122,6 +124,19 @@ export function TaskList({ className, initialTasks }: TaskListProps) {
return `${Math.floor(diffInDays / 365)}y ago`
}
const formatFullTimestamp = (dateString: string) => {
const date = new Date(dateString)
return date.toLocaleString(undefined, {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
timeZoneName: 'short',
})
}
const handleSelectAll = (checked: boolean) => {
if (checked) {
setSelectedTaskIds(new Set(filteredAndSortedTasks.map(task => task.id)))
@ -165,6 +180,43 @@ export function TaskList({ className, initialTasks }: TaskListProps) {
}
}
const handleSort = (field: string) => {
let newSortBy: SortOption
switch (field) {
case 'title':
newSortBy = sortBy === 'title_asc' ? 'title_desc' : 'title_asc'
break
case 'status':
newSortBy = 'status'
break
case 'created':
newSortBy = sortBy === 'created_asc' ? 'created_desc' : 'created_asc'
break
default:
return
}
setSortBy(newSortBy)
}
const getSortDirection = (field: string): 'asc' | 'desc' | false => {
switch (field) {
case 'title':
if (sortBy === 'title_asc') return 'asc'
if (sortBy === 'title_desc') return 'desc'
return false
case 'status':
return sortBy === 'status' ? 'asc' : false
case 'created':
if (sortBy === 'created_asc') return 'asc'
if (sortBy === 'created_desc') return 'desc'
return false
default:
return false
}
}
const isAllSelected =
filteredAndSortedTasks.length > 0 &&
filteredAndSortedTasks.every(task => selectedTaskIds.has(task.id))
@ -287,15 +339,35 @@ export function TaskList({ className, initialTasks }: TaskListProps) {
}}
/>
</TableCell>
<TableCell sx={{ fontWeight: 'bold' }}>Title</TableCell>
<TableCell sx={{ fontWeight: 'bold' }}>
<TableSortLabel
active={getSortDirection('title') !== false}
direction={getSortDirection('title') || 'asc'}
onClick={() => handleSort('title')}
>
Title
</TableSortLabel>
</TableCell>
{!isMobile && (
<TableCell sx={{ fontWeight: 'bold' }}>Description</TableCell>
)}
<TableCell sx={{ fontWeight: 'bold', width: 100 }}>
Status
<TableSortLabel
active={getSortDirection('status') !== false}
direction={getSortDirection('status') || 'asc'}
onClick={() => handleSort('status')}
>
Status
</TableSortLabel>
</TableCell>
<TableCell sx={{ fontWeight: 'bold', width: 120 }}>
Created
<TableSortLabel
active={getSortDirection('created') !== false}
direction={getSortDirection('created') || 'asc'}
onClick={() => handleSort('created')}
>
Created
</TableSortLabel>
</TableCell>
{!isMobile && (
<TableCell sx={{ fontWeight: 'bold', width: 120 }}>
@ -383,17 +455,41 @@ export function TaskList({ className, initialTasks }: TaskListProps) {
/>
</TableCell>
<TableCell>
<Typography variant="body2" color="text.secondary">
{formatCompactDate(task.created_at)}
</Typography>
<Tooltip
title={formatFullTimestamp(task.created_at)}
arrow
placement="top"
>
<Typography
variant="body2"
color="text.secondary"
sx={{ cursor: 'help' }}
>
{formatCompactDate(task.created_at)}
</Typography>
</Tooltip>
</TableCell>
{!isMobile && (
<TableCell>
<Typography variant="body2" color="text.secondary">
{task.completed_at
? formatCompactDate(task.completed_at)
: '—'}
</Typography>
{task.completed_at ? (
<Tooltip
title={formatFullTimestamp(task.completed_at)}
arrow
placement="top"
>
<Typography
variant="body2"
color="text.secondary"
sx={{ cursor: 'help' }}
>
{formatCompactDate(task.completed_at)}
</Typography>
</Tooltip>
) : (
<Typography variant="body2" color="text.secondary">
</Typography>
)}
</TableCell>
)}
</TableRow>

View file

@ -2,6 +2,7 @@ import { type RouteConfig, index, route } from '@react-router/dev/routes'
export default [
index('routes/home.tsx'),
route('all-tasks', 'routes/all-tasks.tsx'),
route('task/new', 'routes/task.new.tsx'),
route('*', 'routes/$.tsx'),
] satisfies RouteConfig

View file

@ -0,0 +1,47 @@
import type { Route } from './+types/all-tasks'
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 - All Tasks" },
{ name: 'description', content: 'Complete Task List' },
]
}
export async function loader(): Promise<{ tasks: Task[] }> {
try {
// Fetch all 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 AllTasks({ loaderData }: Route.ComponentProps) {
return (
<Container maxWidth="lg">
<Box sx={{ py: 4 }}>
<Typography variant="h1" component="h1" gutterBottom>
All Tasks
</Typography>
<TaskList initialTasks={loaderData.tasks} />
</Box>
</Container>
)
}

View file

@ -12,7 +12,7 @@ describe('Home component', () => {
</MemoryRouter>
)
expect(
screen.getByRole('heading', { level: 1, name: /Tasks/i })
screen.getByRole('heading', { level: 1, name: /Dashboard/i })
).toBeInTheDocument()
// TaskList component should be rendered with empty state

View file

@ -5,7 +5,7 @@ import type { Task } from '~/types/task'
export function meta(_: Route.MetaArgs) {
return [
{ title: "Captain's Log - Tasks" },
{ title: "Captain's Log - Dashboard" },
{ name: 'description', content: 'Task Dashboard' },
]
}
@ -14,7 +14,7 @@ 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`, {
const response = await fetch(`${apiUrl}/api/tasks?status=todo`, {
headers: {
'Content-Type': 'application/json',
},
@ -38,7 +38,7 @@ export default function Home({ loaderData }: Route.ComponentProps) {
<Container maxWidth="lg">
<Box sx={{ py: 4 }}>
<Typography variant="h1" component="h1" gutterBottom>
Tasks
Dashboard
</Typography>
<TaskList initialTasks={loaderData.tasks} />
</Box>

View file

@ -1,5 +1,6 @@
import type {
Task,
TaskStatus,
CreateTaskRequest,
UpdateTaskRequest,
ApiError,
@ -81,8 +82,9 @@ class ApiClient {
}
}
async listTasks(): Promise<Task[]> {
return this.fetchWrapper<Task[]>('/tasks')
async listTasks(status?: TaskStatus): Promise<Task[]> {
const url = status ? `/tasks?status=${status}` : '/tasks'
return this.fetchWrapper<Task[]>(url)
}
async getTask(id: string): Promise<Task> {