diff --git a/backend/src/models/task.rs b/backend/src/models/task.rs index 3794288..3d4d5e0 100644 --- a/backend/src/models/task.rs +++ b/backend/src/models/task.rs @@ -94,6 +94,15 @@ impl TaskModel { .await?; Ok(tasks) } + + pub async fn list_by_status(pool: &SqlitePool, status: TaskStatus) -> Result> { + let tasks = + sqlx::query_as("SELECT * FROM tasks t WHERE t.status = $1 ORDER BY created_at DESC") + .bind(status) + .fetch_all(pool) + .await?; + Ok(tasks) + } } #[cfg(test)] diff --git a/backend/src/services/tasks.rs b/backend/src/services/tasks.rs index 1721b83..9a07611 100644 --- a/backend/src/services/tasks.rs +++ b/backend/src/services/tasks.rs @@ -1,6 +1,6 @@ use axum::{ Json, Router, - extract::{Path, State}, + extract::{Path, Query, State}, http::StatusCode, routing::{get, post}, }; @@ -40,10 +40,19 @@ pub async fn create_task( Ok((StatusCode::CREATED, Json(model))) } +#[derive(Deserialize)] +pub struct Filters { + status: Option, +} + pub async fn list_tasks( State(pool): State>, + Query(filters): Query, ) -> Result<(StatusCode, Json>), AppError> { - let tasks = TaskModel::list_all(&pool).await?; + let tasks = match filters.status { + Some(status) => TaskModel::list_by_status(&pool, status).await?, + None => TaskModel::list_all(&pool).await?, + }; Ok((StatusCode::OK, Json(tasks))) } 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 {