diff --git a/frontend/app/components/Layout.tsx b/frontend/app/components/Layout.tsx
index 9117881..3326736 100644
--- a/frontend/app/components/Layout.tsx
+++ b/frontend/app/components/Layout.tsx
@@ -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: ,
path: '/',
},
+ {
+ text: 'All Tasks',
+ icon: ,
+ path: '/all-tasks',
+ },
{
text: 'Settings',
icon: ,
@@ -58,8 +64,8 @@ export default function Layout({ children, loading = false }: LayoutProps) {
{menuItems.map(item => (
{children}
-
)
}
diff --git a/frontend/app/components/TaskList.test.tsx b/frontend/app/components/TaskList.test.tsx
new file mode 100644
index 0000000..8866c2e
--- /dev/null
+++ b/frontend/app/components/TaskList.test.tsx
@@ -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
+
+// 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(
+
+
+
+ )
+}
+
+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()
+ })
+ })
+})
diff --git a/frontend/app/components/TaskList.tsx b/frontend/app/components/TaskList.tsx
index 8503ffb..4c96c0b 100644
--- a/frontend/app/components/TaskList.tsx
+++ b/frontend/app/components/TaskList.tsx
@@ -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) {
}}
/>
- Title
+
+ handleSort('title')}
+ >
+ Title
+
+
{!isMobile && (
Description
)}
- Status
+ handleSort('status')}
+ >
+ Status
+
- Created
+ handleSort('created')}
+ >
+ Created
+
{!isMobile && (
@@ -383,17 +455,41 @@ export function TaskList({ className, initialTasks }: TaskListProps) {
/>
-
- {formatCompactDate(task.created_at)}
-
+
+
+ {formatCompactDate(task.created_at)}
+
+
{!isMobile && (
-
- {task.completed_at
- ? formatCompactDate(task.completed_at)
- : '—'}
-
+ {task.completed_at ? (
+
+
+ {formatCompactDate(task.completed_at)}
+
+
+ ) : (
+
+ —
+
+ )}
)}
diff --git a/frontend/app/routes.ts b/frontend/app/routes.ts
index a60a27f..d97b36e 100644
--- a/frontend/app/routes.ts
+++ b/frontend/app/routes.ts
@@ -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
diff --git a/frontend/app/routes/all-tasks.tsx b/frontend/app/routes/all-tasks.tsx
new file mode 100644
index 0000000..ac27513
--- /dev/null
+++ b/frontend/app/routes/all-tasks.tsx
@@ -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 (
+
+
+
+ All Tasks
+
+
+
+
+ )
+}
diff --git a/frontend/app/routes/home.test.tsx b/frontend/app/routes/home.test.tsx
index e5fd6ca..3c7ed84 100644
--- a/frontend/app/routes/home.test.tsx
+++ b/frontend/app/routes/home.test.tsx
@@ -12,7 +12,7 @@ describe('Home component', () => {
)
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
diff --git a/frontend/app/routes/home.tsx b/frontend/app/routes/home.tsx
index bffbd4a..c3ddb0b 100644
--- a/frontend/app/routes/home.tsx
+++ b/frontend/app/routes/home.tsx
@@ -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) {
- Tasks
+ Dashboard
diff --git a/frontend/app/services/api.ts b/frontend/app/services/api.ts
index 3c999d3..6edffcb 100644
--- a/frontend/app/services/api.ts
+++ b/frontend/app/services/api.ts
@@ -1,5 +1,6 @@
import type {
Task,
+ TaskStatus,
CreateTaskRequest,
UpdateTaskRequest,
ApiError,
@@ -81,8 +82,9 @@ class ApiClient {
}
}
- async listTasks(): Promise {
- return this.fetchWrapper('/tasks')
+ async listTasks(status?: TaskStatus): Promise {
+ const url = status ? `/tasks?status=${status}` : '/tasks'
+ return this.fetchWrapper(url)
}
async getTask(id: string): Promise {