diff --git a/backend/src/models/task.rs b/backend/src/models/task.rs index 3d4d5e0..3794288 100644 --- a/backend/src/models/task.rs +++ b/backend/src/models/task.rs @@ -94,15 +94,6 @@ 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 9a07611..1721b83 100644 --- a/backend/src/services/tasks.rs +++ b/backend/src/services/tasks.rs @@ -1,6 +1,6 @@ use axum::{ Json, Router, - extract::{Path, Query, State}, + extract::{Path, State}, http::StatusCode, routing::{get, post}, }; @@ -40,19 +40,10 @@ 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 = match filters.status { - Some(status) => TaskModel::list_by_status(&pool, status).await?, - None => TaskModel::list_all(&pool).await?, - }; + let tasks = 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 3326736..9117881 100644 --- a/frontend/app/components/Layout.tsx +++ b/frontend/app/components/Layout.tsx @@ -19,7 +19,6 @@ import { import { Menu as MenuIcon, Dashboard as DashboardIcon, - List as ListIcon, Add as AddIcon, Settings as SettingsIcon, } from '@mui/icons-material' @@ -42,15 +41,10 @@ export default function Layout({ children, loading = false }: LayoutProps) { const menuItems = [ { - text: 'Dashboard', + text: 'Tasks', icon: , path: '/', }, - { - text: 'All Tasks', - icon: , - path: '/all-tasks', - }, { text: 'Settings', icon: , @@ -64,8 +58,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 deleted file mode 100644 index 8866c2e..0000000 --- a/frontend/app/components/TaskList.test.tsx +++ /dev/null @@ -1,380 +0,0 @@ -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 4c96c0b..8503ffb 100644 --- a/frontend/app/components/TaskList.tsx +++ b/frontend/app/components/TaskList.tsx @@ -25,8 +25,6 @@ import { DialogContent, DialogContentText, DialogActions, - TableSortLabel, - Tooltip, } from '@mui/material' import { Delete as DeleteIcon } from '@mui/icons-material' import { useTasks } from '~/hooks/useTasks' @@ -124,19 +122,6 @@ 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))) @@ -180,43 +165,6 @@ 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)) @@ -339,35 +287,15 @@ export function TaskList({ className, initialTasks }: TaskListProps) { }} /> - - handleSort('title')} - > - Title - - + Title {!isMobile && ( Description )} - handleSort('status')} - > - Status - + Status - handleSort('created')} - > - Created - + Created {!isMobile && ( @@ -455,41 +383,17 @@ 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 d97b36e..a60a27f 100644 --- a/frontend/app/routes.ts +++ b/frontend/app/routes.ts @@ -2,7 +2,6 @@ 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 deleted file mode 100644 index ac27513..0000000 --- a/frontend/app/routes/all-tasks.tsx +++ /dev/null @@ -1,47 +0,0 @@ -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 3c7ed84..e5fd6ca 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: /Dashboard/i }) + screen.getByRole('heading', { level: 1, name: /Tasks/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 c3ddb0b..bffbd4a 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 - Dashboard" }, + { title: "Captain's Log - Tasks" }, { 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?status=todo`, { + const response = await fetch(`${apiUrl}/api/tasks`, { headers: { 'Content-Type': 'application/json', }, @@ -38,7 +38,7 @@ export default function Home({ loaderData }: Route.ComponentProps) { - Dashboard + Tasks diff --git a/frontend/app/services/api.ts b/frontend/app/services/api.ts index 6edffcb..3c999d3 100644 --- a/frontend/app/services/api.ts +++ b/frontend/app/services/api.ts @@ -1,6 +1,5 @@ import type { Task, - TaskStatus, CreateTaskRequest, UpdateTaskRequest, ApiError, @@ -82,9 +81,8 @@ class ApiClient { } } - async listTasks(status?: TaskStatus): Promise { - const url = status ? `/tasks?status=${status}` : '/tasks' - return this.fetchWrapper(url) + async listTasks(): Promise { + return this.fetchWrapper('/tasks') } async getTask(id: string): Promise {