Full-Stack Task Management Dashboard

Build a FastAPI + React + PostgreSQL task manager with raw SQL and no ORM.

Introduction

30 Second Summary

Every team needs a way to track who's doing what and when it's due. A good task manager keeps everything visible so nothing falls through the cracks.

In this project, you will build a full-stack task management dashboard from scratch using FastAPI, React, and PostgreSQL. You'll design a REST API with raw SQL queries on the backend and create a polished frontend with a live dashboard and filterable task lists.

What You'll Build

Picture opening your browser and seeing a dashboard with real-time task counts, then clicking into a filterable list to drill down into any task's details and comments.

By the end of this project, you'll have:

  • A live dashboard showing counts of pending, in-progress, completed, and overdue tasks pulled straight from PostgreSQL through raw SQL queries.
  • A filterable task list where you search by keyword, filter by status or priority, and page through results on a React frontend styled with Tailwind CSS.
  • Task detail pages with comments, plus forms to create, edit, and delete tasks. Every endpoint is explorable through auto-generated Swagger docs.
  • Secret Mission: A Kanban board view where you drag tasks between status columns and watch the changes persist to the database.

Are there any prerequisites?

This project assumes you have Python 3.12, Node.js, PostgreSQL, and VS Code installed on your machine. Step 1 will verify your setup and walk you through any remaining configuration.

Before We Start

Before we dive in, let's lock in what you're about to build and why it matters.

Set Up the Project and Database

Your task management dashboard needs a solid foundation before you can build features on top of it. That foundation is three things: a clean project structure, a running PostgreSQL database, and a FastAPI web server connected to it.

In this step, you'll scaffold the project directories, install your Python packages, create the database and its tables, and wire up FastAPI so its Swagger UI loads with a working health check endpoint.

In this step, get ready to:
  • Create the project structure and install dependencies.
  • Build the database connection layer with table definitions.
  • Wire up FastAPI with the database pool and a health check endpoint.
Create the project structure and install dependencies

A well-organized project separates concerns into distinct folders. You'll create directories that mirror how the backend code is organized: routes for API endpoints, services for business logic, schemas for data validation, and repositories for database queries.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder with all the backend subdirectories by running these commands:
mkdir -p task-manager/backend/routes
mkdir -p task-manager/backend/services
mkdir -p task-manager/backend/schemas
mkdir -p task-manager/backend/repositories
mkdir -p task-manager/backend/utils
mkdir -p task-manager/frontend
touch task-manager/backend/__init__.py
  • Confirm the directories were created by running:
ls task-manager/backend

You should see __init__.py, repositories, routes, schemas, services, and utils listed in the output.

Why the __init__.py file?

The __init__.py file tells Python that the backend folder is a package. Without it, import statements like from backend.database import create_pool would fail.

  • Move into the project folder by running:
cd task-manager
  • Create a Python virtual environment and activate it by running these commands:

macOS

python3 -m venv backend/venv
source backend/venv/bin/activate

These same commands also work on Linux.

Windows

python -m venv backend\venv
backend\venv\Scripts\activate

Your terminal prompt should now show (venv) at the beginning. This confirms the virtual environment is active.

Don't see (venv) in your prompt?

Make sure you ran both commands. The first creates the virtual environment folder. The second activates it. If python3 is not found on macOS, try python3.12 instead.

help me activate my Python virtual environment

  • Install the project dependencies by running:
pip install "fastapi[standard]" asyncpg pydantic httpx python-dotenv

The install output scrolls through each package. When it finishes, you should see a line that says Successfully installed followed by a list of packages.

What are these packages?

  • fastapi[standard] installs FastAPI plus the dev server (uvicorn and the CLI).
  • asyncpg is a high-performance async driver for PostgreSQL.
  • pydantic handles data validation for API requests and responses.
  • httpx is an async HTTP client for calling external APIs.
  • python-dotenv loads environment variables from a .env file.
  • Create the .env file to store your database connection string by running:
echo 'DATABASE_URL=postgresql://your_user:your_password@localhost:5432/taskmanager' > .env
  • Open .env in your editor and replace your_user with your PostgreSQL username and your_password with your PostgreSQL password.
  • Verify the file was created correctly by running:
cat .env

You should see the DATABASE_URL line with your credentials filled in.

Finding your PostgreSQL credentials

On macOS with Homebrew, your PostgreSQL username is typically your macOS username and no password is needed. In that case, use postgresql://yourmacuser@localhost:5432/taskmanager (omit the colon and password).

On Linux, the default superuser is often postgres.

  • Create the PostgreSQL database by running:
createdb taskmanager

If this command produces no output, it succeeded. That is the expected behavior.

Seeing an error from createdb?

  • If createdb is not found, PostgreSQL's bin directory is not in your PATH. Reinstall PostgreSQL or locate the full path to createdb.
  • If you see a message about the server not running, make sure PostgreSQL is started. Run pg_isready to check.
  • If you see a role does not exist error, your OS username does not match a PostgreSQL user. Try createdb -U postgres taskmanager to use the default superuser.

help me fix my createdb error

Build the database connection layer

Your backend needs a way to connect to PostgreSQL and create the tables that store users, tasks, and comments. You'll use asyncpg to create a connection pool, which efficiently reuses database connections instead of opening a new one for every request.

  • Open the task-manager project folder in VS Code by running:
code .
  • In VS Code, create a new file at backend/database.py.
  • Add the imports and connection pool function by pasting this code:
import asyncpg
import os
from dotenv import load_dotenv

# Load environment variables from .env file
load_dotenv()

# Read the database connection string
DATABASE_URL = os.getenv('DATABASE_URL')

async def create_pool():
    # Create a reusable connection pool for PostgreSQL
    return await asyncpg.create_pool(DATABASE_URL)

What does this code do?

  • load_dotenv() reads the .env file and makes its values available through os.getenv().
  • asyncpg.create_pool() opens a pool of database connections using your DATABASE_URL. The pool manages multiple connections so requests don't wait for a single shared connection.
  • Below the create_pool function, add the init_db function that creates the three database tables:
async def init_db(pool):
    async with pool.acquire() as conn:  # Get a connection from the pool
        await conn.execute('''
            CREATE TABLE IF NOT EXISTS users (
                id SERIAL PRIMARY KEY,
                name VARCHAR NOT NULL,
                email VARCHAR UNIQUE NOT NULL,
                role VARCHAR NOT NULL,
                created_at TIMESTAMPTZ DEFAULT NOW()
            );
            CREATE TABLE IF NOT EXISTS tasks (
                id SERIAL PRIMARY KEY,
                title VARCHAR NOT NULL,
                description TEXT,
                status VARCHAR DEFAULT 'pending',
                priority VARCHAR DEFAULT 'medium',
                assigned_to INTEGER REFERENCES users(id),
                due_date DATE,
                created_at TIMESTAMPTZ DEFAULT NOW(),
                updated_at TIMESTAMPTZ DEFAULT NOW()
            );
            CREATE TABLE IF NOT EXISTS comments (
                id SERIAL PRIMARY KEY,
                task_id INTEGER REFERENCES tasks(id) ON DELETE CASCADE,
                user_id INTEGER REFERENCES users(id),
                comment TEXT NOT NULL,
                created_at TIMESTAMPTZ DEFAULT NOW()
            );
        ''')
    print('Database tables created successfully')

What do these tables store?

  • users stores team members with a name, unique email, and role.
  • tasks stores each task with a title, status, priority, assignee (linked to users), and timestamps.
  • comments stores comments on tasks, linked to both a task and a user. ON DELETE CASCADE means deleting a task automatically removes its comments.
  • CREATE TABLE IF NOT EXISTS makes it safe to run on every server startup without errors.
  • Save backend/database.py.
  • Verify the module is valid by running this in your terminal:
python -c "from backend.database import create_pool, init_db; print('Database module ready')"

You should see Database module ready printed. This confirms the file has no syntax errors and the imports resolve.

Seeing an import error?

  • Make sure your virtual environment is active (you should see (venv) in your prompt). The packages are installed inside it.
  • Check that backend/__init__.py exists. Without it, Python cannot treat backend as a package.

help me fix my database module import error

✔️ Awesome, I've got everything!

Great. Double check you've saved backend/database.py before moving on.

ⓧ I'd like to double check the full code

Scroll up and confirm your backend/database.py contains both chunks in order:

  • The imports, load_dotenv(), DATABASE_URL, and create_pool() function at the top.
  • The init_db() function below it, with all three CREATE TABLE IF NOT EXISTS statements (users, tasks, comments) and the print line at the end.
Wire up the FastAPI application

The database layer is ready. Now you need a FastAPI server that connects to the database on startup, creates the tables, and exposes an API. A lifespan context manager handles the startup and shutdown lifecycle.

  • Create a new file at backend/main.py and paste this code for the imports, lifespan, and app setup:
from contextlib import asynccontextmanager
from fastapi import FastAPI, Depends
from fastapi.middleware.cors import CORSMiddleware
from backend.database import create_pool, init_db

# Start the database pool on startup, close it on shutdown
@asynccontextmanager
async def lifespan(app: FastAPI):
    app.state.db_pool = await create_pool()
    await init_db(app.state.db_pool)
    yield
    await app.state.db_pool.close()

app = FastAPI(title='Task Manager API', lifespan=lifespan)

How does the lifespan work?

  • The lifespan function runs code before the server starts accepting requests (create the pool, initialize tables) and after it stops (close the pool).
  • app.state.db_pool stores the connection pool so any route can access it.
  • The yield keyword separates startup from shutdown. Everything before yield runs on startup. Everything after runs on shutdown.
  • Below the app = FastAPI(...) line, add the CORS middleware, the dependency function, and the health check endpoint:
# Allow requests from the React frontend dev server
app.add_middleware(
    CORSMiddleware,
    allow_origins=['http://localhost:5173'],
    allow_credentials=True,
    allow_methods=['*'],
    allow_headers=['*'],
)

# Dependency that retrieves the database pool from app state
def get_pool(request):
    return request.app.state.db_pool

# Health check endpoint to verify database connectivity
@app.get('/api/health')
async def health_check(pool=Depends(get_pool)):
    async with pool.acquire() as conn:
        await conn.fetchval('SELECT 1')
    return {'status': 'ok'}

What does this code do?

  • CORSMiddleware allows the React frontend (running on port 5173) to make requests to this backend (running on port 8000). Without it, the browser blocks cross-origin requests.
  • get_pool is a dependency function that extracts the database pool from the app state. FastAPI injects it into any route that declares pool=Depends(get_pool).
  • The health check endpoint runs SELECT 1 against the database. If the query succeeds, the database connection is working.
  • Save backend/main.py.

✔️ Awesome, I've got everything!

Great. Double check you've saved backend/main.py before moving on.

ⓧ I'd like to double check the full code

Your complete backend/main.py should look like this:

from contextlib import asynccontextmanager
from fastapi import FastAPI, Depends
from fastapi.middleware.cors import CORSMiddleware
from backend.database import create_pool, init_db

@asynccontextmanager
async def lifespan(app: FastAPI):
    app.state.db_pool = await create_pool()
    await init_db(app.state.db_pool)
    yield
    await app.state.db_pool.close()

app = FastAPI(title='Task Manager API', lifespan=lifespan)

app.add_middleware(
    CORSMiddleware,
    allow_origins=['http://localhost:5173'],
    allow_credentials=True,
    allow_methods=['*'],
    allow_headers=['*'],
)

def get_pool(request):
    return request.app.state.db_pool

@app.get('/api/health')
async def health_check(pool=Depends(get_pool)):
    async with pool.acquire() as conn:
        await conn.fetchval('SELECT 1')
    return {'status': 'ok'}

Why CORS middleware?

CORS stands for Cross-Origin Resource Sharing. Browsers block requests from one origin (localhost:5173) to a different origin (localhost:8000) by default. The middleware tells the browser that requests from the React frontend are allowed.

Before you start the server, think about what you expect to happen. Will the database tables be created? Will the Swagger UI load?

  • Start the FastAPI development server by running this command in your terminal:
fastapi dev backend/main.py

What does this command do?

fastapi dev starts the development server with auto-reload enabled. It detects the FastAPI app in backend/main.py and serves it using Uvicorn. Any time you save a file, the server restarts automatically.

Look at the terminal output. You should see Database tables created successfully printed among the startup logs.

  • Open http://127.0.0.1:8000/docs in your browser.

The Swagger UI loads with the title Task Manager API at the top. You should see the GET /api/health endpoint listed.

Server not starting?

  • If you see ModuleNotFoundError, make sure your virtual environment is active (you should see (venv) in your prompt). Re-activate it and try again.
  • If you see a connection error mentioning PostgreSQL, your database server may not be running. Run pg_isready to check, then start it if needed.
  • If you see an authentication error, double check the username and password in your .env file match your PostgreSQL credentials.

help me fix my FastAPI server startup error

Your project foundation is solid. You have a running FastAPI server, a PostgreSQL database with three tables, and a working health check endpoint. Next up, you'll build the full CRUD API that lets you create, read, update, and delete tasks.

Build the Task CRUD API

Your FastAPI server is running and connected to PostgreSQL. The Swagger UI loads, but the only endpoint is /api/health. The API has nothing for a frontend to consume.

The API is the core of the application. Without working endpoints that support filtering, pagination, and search, neither the Swagger docs nor the frontend have anything useful to display. In this step, you will build a full CRUD API for tasks and users, backed by raw SQL queries with parameterized arguments.

In this step, get ready to:
  • Create Pydantic schemas for request validation and response serialization.
  • Build a repository layer that encapsulates all raw SQL queries.
  • Wire up API routes for tasks and users and test them through Swagger.
Create the Pydantic schemas

Before any data reaches the database, it needs to be validated. Pydantic schemas define the exact shape of incoming requests and outgoing responses, rejecting invalid data automatically.

  • The backend/schemas/, backend/repositories/, and backend/routes/ directories need __init__.py files to become Python packages. Create them by running this command:
touch backend/schemas/__init__.py backend/repositories/__init__.py backend/routes/__init__.py

Windows users

The touch command is macOS/Linux only. On Windows, create three empty files named __init__.py in each of those three folders using VS Code.

  • Create the task schemas file by adding this code to backend/schemas/task_schemas.py:
from pydantic import BaseModel
from typing import Literal, Optional
from datetime import date, datetime

# Schema for creating a new task
class TaskCreate(BaseModel):
    title: str
    description: Optional[str] = None
    # Only these four values are valid statuses
    status: Literal['pending', 'in_progress', 'completed', 'blocked'] = 'pending'
    priority: Literal['low', 'medium', 'high', 'urgent'] = 'medium'
    assigned_to: Optional[int] = None
    due_date: Optional[date] = None

# Schema for updating an existing task (all fields optional)
class TaskUpdate(BaseModel):
    title: Optional[str] = None
    description: Optional[str] = None
    status: Optional[Literal['pending', 'in_progress', 'completed', 'blocked']] = None
    priority: Optional[Literal['low', 'medium', 'high', 'urgent']] = None
    assigned_to: Optional[int] = None
    due_date: Optional[date] = None

What does this code do?

These two schemas use Literal types to enforce that status and priority can only be one of a fixed set of values. If someone sends status: 'banana', Pydantic rejects the request before it ever reaches the database.

TaskCreate is for POST requests where all fields have defaults. TaskUpdate makes every field Optional so partial updates work.

  • Add the response and pagination schemas below the TaskUpdate class in the same file:
# Schema for task responses from the API
class TaskResponse(BaseModel):
    id: int
    title: str
    description: Optional[str]
    status: str
    priority: str
    assigned_to: Optional[int]
    due_date: Optional[date]
    created_at: datetime
    updated_at: datetime

# Wrapper for paginated list responses
class PaginatedResponse(BaseModel):
    items: list
    total: int
    page: int
    limit: int
    pages: int

What does this code do?

TaskResponse mirrors the shape of a row from the tasks table. PaginatedResponse wraps a list of items with metadata like total count, current page, and total pages so the frontend can render pagination controls.

  • Save task_schemas.py.
  • Verify the schemas work by running this command:
python -c "from backend.schemas.task_schemas import TaskCreate; print(TaskCreate.model_json_schema())"

You should see a JSON schema dictionary printed to the terminal, including 'properties' with title, status, priority, and their allowed values.

Seeing a ModuleNotFoundError?

Make sure you are running the command from your project root directory (the folder that contains the backend/ folder). Also confirm your virtual environment is activated.

help me debug a ModuleNotFoundError when importing my Pydantic schemas

  • Create the user schemas file by adding this code to backend/schemas/user_schemas.py:
from pydantic import BaseModel
from datetime import datetime

# Schema for creating a new user
class UserCreate(BaseModel):
    name: str
    email: str
    role: str

# Schema for user responses from the API
class UserResponse(BaseModel):
    id: int
    name: str
    email: str
    role: str
    created_at: datetime

What does this code do?

UserCreate validates incoming user data with three required string fields. UserResponse defines the shape returned by the API, including the auto-generated id and created_at timestamp.

  • Save user_schemas.py.
  • Confirm the user schemas import cleanly by running:
python -c "from backend.schemas.user_schemas import UserCreate; print('Schemas OK')"

You should see Schemas OK printed to the terminal.

✔️ Awesome, I've got everything!

Great. Double check you have saved both task_schemas.py and user_schemas.py.

ⓧ I'd like to double check the full code

Scroll up and confirm your task_schemas.py has both chunks in order: the imports and TaskCreate/TaskUpdate classes, then the TaskResponse/PaginatedResponse classes. Here is the full user_schemas.py for reference:

from pydantic import BaseModel
from datetime import datetime

# Schema for creating a new user
class UserCreate(BaseModel):
    name: str
    email: str
    role: str

# Schema for user responses from the API
class UserResponse(BaseModel):
    id: int
    name: str
    email: str
    role: str
    created_at: datetime
Build the repository layer

The repository layer is where all raw SQL lives. Each repository class takes the asyncpg connection pool and exposes methods that map to database operations. This keeps SQL out of the route handlers.

  • Create the task repository by adding this code to backend/repositories/task_repository.py:
class TaskRepository:
    def __init__(self, pool):
        self.pool = pool

    async def get_all(self, status=None, priority=None, assignee=None, search=None, page=1, limit=10):
        # Build WHERE clauses dynamically based on active filters
        conditions = []
        params = []
        idx = 1
        if status:
            conditions.append(f'status = ${idx}')
            params.append(status)
            idx += 1
        if priority:
            conditions.append(f'priority = ${idx}')
            params.append(priority)
            idx += 1
        if assignee:
            conditions.append(f'assigned_to = ${idx}')
            params.append(assignee)
            idx += 1
        if search:
            conditions.append(f'title ILIKE ${idx}')
            params.append(f'%{search}%')
            idx += 1
        where = 'WHERE ' + ' AND '.join(conditions) if conditions else ''
        # Get total count for pagination metadata
        count_query = f'SELECT COUNT(*) FROM tasks {where}'
        total = await self.pool.fetchval(count_query, *params)
        offset = (page - 1) * limit
        data_query = f'SELECT * FROM tasks {where} ORDER BY created_at DESC LIMIT ${idx} OFFSET ${idx+1}'
        rows = await self.pool.fetch(data_query, *params, limit, offset)
        return rows, total

What does this code do?

The get_all method dynamically constructs a SQL query based on which filters are provided. It uses $1, $2 style parameterized placeholders (the PostgreSQL native syntax) to prevent SQL injection.

The idx counter tracks the next available placeholder number. A parallel COUNT(*) query with the same filters returns the total for pagination metadata.

  • Add the remaining CRUD methods below the get_all method in the same file:
    async def get_by_id(self, task_id):
        return await self.pool.fetchrow('SELECT * FROM tasks WHERE id = $1', task_id)

    async def create(self, data):
        # INSERT ... RETURNING * gives back the created row
        return await self.pool.fetchrow(
            'INSERT INTO tasks (title, description, status, priority, assigned_to, due_date) VALUES ($1,$2,$3,$4,$5,$6) RETURNING *',
            data.title, data.description, data.status, data.priority, data.assigned_to, data.due_date
        )

    async def update(self, task_id, data):
        # COALESCE keeps existing values for fields not provided
        return await self.pool.fetchrow(
            'UPDATE tasks SET title=COALESCE($1,title), description=COALESCE($2,description), status=COALESCE($3,status), priority=COALESCE($4,priority), assigned_to=COALESCE($5,assigned_to), due_date=COALESCE($6,due_date), updated_at=NOW() WHERE id=$7 RETURNING *',
            data.title, data.description, data.status, data.priority, data.assigned_to, data.due_date, task_id
        )

    async def delete(self, task_id):
        await self.pool.execute('DELETE FROM tasks WHERE id = $1', task_id)

What does this code do?

Each method wraps a single SQL operation. RETURNING * is a PostgreSQL feature that returns the inserted or updated row without needing a follow-up SELECT query.

The update method uses COALESCE so that only the fields the caller provides are changed. If a field is None, the existing database value is preserved.

  • Save task_repository.py.
  • Confirm the repository imports cleanly by running:
python -c "from backend.repositories.task_repository import TaskRepository; print('TaskRepository OK')"

You should see TaskRepository OK printed.

Seeing IndentationError?

The CRUD methods in the second chunk must be indented inside the TaskRepository class (4 spaces). Make sure they sit at the same indentation level as get_all.

help me fix an IndentationError in my task_repository.py

  • Create the user repository by adding this code to backend/repositories/user_repository.py:
class UserRepository:
    def __init__(self, pool):
        self.pool = pool

    async def get_all(self):
        return await self.pool.fetch('SELECT * FROM users ORDER BY created_at DESC')

    async def create(self, data):
        # RETURNING * gives back the created user row
        return await self.pool.fetchrow(
            'INSERT INTO users (name, email, role) VALUES ($1,$2,$3) RETURNING *',
            data.name, data.email, data.role
        )

What does this code do?

UserRepository follows the same pattern as TaskRepository but is simpler. It lists all users sorted by creation date and creates new users with RETURNING * to send back the full row.

  • Save user_repository.py.

✔️ Awesome, I've got everything!

Great. Double check you have saved both task_repository.py and user_repository.py.

ⓧ I'd like to double check the full code

Scroll up and confirm your task_repository.py has both chunks in order: the class definition with get_all, then the get_by_id/create/update/delete methods. Here is the full user_repository.py for reference:

class UserRepository:
    def __init__(self, pool):
        self.pool = pool

    async def get_all(self):
        return await self.pool.fetch('SELECT * FROM users ORDER BY created_at DESC')

    async def create(self, data):
        # RETURNING * gives back the created user row
        return await self.pool.fetchrow(
            'INSERT INTO users (name, email, role) VALUES ($1,$2,$3) RETURNING *',
            data.name, data.email, data.role
        )
Wire up the API routes and test through Swagger

The schemas validate data and the repositories talk to the database. Now you need route handlers that connect the two and expose them as HTTP endpoints.

  • Create the user routes by adding this code to backend/routes/user_routes.py:
from fastapi import APIRouter, Depends, Request
from backend.schemas.user_schemas import UserCreate
from backend.repositories.user_repository import UserRepository

router = APIRouter()

def get_pool(request: Request):
    return request.app.state.db_pool

# List all users
@router.get('/users')
async def list_users(pool=Depends(get_pool)):
    repo = UserRepository(pool)
    rows = await repo.get_all()
    return [dict(r) for r in rows]

# Create a new user
@router.post('/users', status_code=201)
async def create_user(data: UserCreate, pool=Depends(get_pool)):
    repo = UserRepository(pool)
    row = await repo.create(data)
    return dict(row)

What does this code do?

Each route handler creates a UserRepository instance with the connection pool, calls the appropriate method, and converts the asyncpg Record objects to dictionaries with dict(r).

The APIRouter groups related endpoints together. The get_pool dependency retrieves the connection pool stored on the app during startup.

  • Save user_routes.py.

Why a separate get_pool in each route file?

Each route module defines its own get_pool dependency function. This keeps each file self-contained and avoids circular imports between the routes and the main app module.

  • Now update backend/main.py to register the routers. Find this import line at the top of the file:
from fastapi import FastAPI
  • Replace it with these two lines:
from fastapi import FastAPI, Depends, Request
from backend.routes import task_routes, user_routes
  • Next, find the get_pool function:
def get_pool(request):
  • Add a type hint to the request parameter so it reads:
def get_pool(request: Request):
  • Finally, add these two lines at the very bottom of main.py:
app.include_router(task_routes.router, prefix='/api')
app.include_router(user_routes.router, prefix='/api')

What does include_router do?

include_router mounts each router's endpoints onto the main app. The prefix='/api' argument prepends /api to every route path, so @router.get('/users') becomes GET /api/users.

  • Save main.py.

The dev server's auto-reload will detect your changes and try to restart. It will fail because task_routes.py does not exist yet. That is expected. You will create it next.

  • Create the task routes file by adding this code to backend/routes/task_routes.py:
from fastapi import APIRouter, Depends, Request, HTTPException
from backend.schemas.task_schemas import TaskCreate, TaskUpdate, PaginatedResponse
from backend.repositories.task_repository import TaskRepository
from backend.repositories.user_repository import UserRepository
import math

router = APIRouter()

def get_pool(request: Request):
    return request.app.state.db_pool

# List tasks with optional filtering, search, and pagination
@router.get('/tasks')
async def list_tasks(status=None, priority=None, assignee=None, search=None, page=1, limit=10, pool=Depends(get_pool)):
    repo = TaskRepository(pool)
    rows, total = await repo.get_all(status, priority, assignee, search, int(page), int(limit))
    return {'items': [dict(r) for r in rows], 'total': total, 'page': int(page), 'limit': int(limit), 'pages': math.ceil(total / int(limit))}

# Get a single task by ID
@router.get('/tasks/{task_id}')
async def get_task(task_id: int, pool=Depends(get_pool)):
    repo = TaskRepository(pool)
    row = await repo.get_by_id(task_id)
    if not row:
        raise HTTPException(status_code=404, detail='Task not found')
    return dict(row)

What does this code do?

The list_tasks endpoint accepts optional query parameters for status, priority, assignee, search, page, and limit. FastAPI automatically reads these from the URL query string.

The get_task endpoint raises a 404 error if no task matches the given ID.

  • Add the create, update, and delete routes below get_task in the same file:
# Create a new task and return the created record
@router.post('/tasks', status_code=201)
async def create_task(data: TaskCreate, pool=Depends(get_pool)):
    repo = TaskRepository(pool)
    row = await repo.create(data)
    return dict(row)

# Update an existing task by ID
@router.put('/tasks/{task_id}')
async def update_task(task_id: int, data: TaskUpdate, pool=Depends(get_pool)):
    repo = TaskRepository(pool)
    row = await repo.update(task_id, data)
    if not row:
        raise HTTPException(status_code=404, detail='Task not found')
    return dict(row)

# Delete a task by ID
@router.delete('/tasks/{task_id}', status_code=204)
async def delete_task(task_id: int, pool=Depends(get_pool)):
    repo = TaskRepository(pool)
    await repo.delete(task_id)

What does this code do?

POST returns status_code=201 (Created). PUT returns the updated record. DELETE returns status_code=204 (No Content) with no response body.

The data: TaskCreate parameter tells FastAPI to validate the request body against the Pydantic schema before the handler runs. Invalid data never reaches the database.

  • Add the seed endpoint below delete_task in the same file:
# Populate the database with sample users and tasks
@router.post('/seed', status_code=201)
async def seed_database(pool=Depends(get_pool)):
    user_repo = UserRepository(pool)
    users_data = [
        ('Alice Johnson', 'alice@example.com', 'developer'),
        ('Bob Smith', 'bob@example.com', 'designer'),
        ('Carol White', 'carol@example.com', 'manager'),
        ('David Brown', 'david@example.com', 'developer'),
        ('Eve Davis', 'eve@example.com', 'qa'),
    ]
    await pool.executemany('INSERT INTO users (name, email, role) VALUES ($1,$2,$3) ON CONFLICT DO NOTHING', users_data)
    users = await pool.fetch('SELECT id FROM users LIMIT 5')
    user_ids = [u['id'] for u in users]
    tasks_data = []
    statuses = ['pending','in_progress','completed','blocked']
    priorities = ['low','medium','high','urgent']
    for i in range(20):
        tasks_data.append((f'Task {i+1}', f'Description for task {i+1}', statuses[i%4], priorities[i%4], user_ids[i%len(user_ids)], None))
    await pool.executemany('INSERT INTO tasks (title, description, status, priority, assigned_to, due_date) VALUES ($1,$2,$3,$4,$5,$6)', tasks_data)
    return {'message': 'Database seeded successfully'}

What does this code do?

The seed endpoint populates the database with 5 sample users and 20 tasks using pool.executemany(). This method runs the same INSERT statement for each tuple in the list atomically.

ON CONFLICT DO NOTHING on the users insert means calling the seed endpoint twice does not create duplicate users. Tasks cycle through all four statuses and priorities so you get a varied test dataset.

  • Save task_routes.py.

✔️ Awesome, I've got everything!

Great. Double check you have saved all route files and main.py.

ⓧ I'd like to double check the full code

Scroll up and confirm your task_routes.py has all three chunks in order: imports with list_tasks/get_task, then create_task/update_task/delete_task, then the seed_database function. Here is the full user_routes.py for reference:

from fastapi import APIRouter, Depends, Request
from backend.schemas.user_schemas import UserCreate
from backend.repositories.user_repository import UserRepository

router = APIRouter()

def get_pool(request: Request):
    return request.app.state.db_pool

# List all users
@router.get('/users')
async def list_users(pool=Depends(get_pool)):
    repo = UserRepository(pool)
    rows = await repo.get_all()
    return [dict(r) for r in rows]

# Create a new user
@router.post('/users', status_code=201)
async def create_user(data: UserCreate, pool=Depends(get_pool)):
    repo = UserRepository(pool)
    row = await repo.create(data)
    return dict(row)

The auto-reload should have picked up your new files. Check your terminal. If you see errors, stop the server with Ctrl+C and restart it by running:

fastapi dev backend/main.py

Before you test, think about what you expect to see. You have not created any users or tasks yet. What do you think GET /api/tasks will return?

  • Switch back to http://127.0.0.1:8000/docs in your browser and refresh the page.
  • You should see all the new endpoints listed: GET /api/tasks, POST /api/tasks, GET /api/users, POST /api/users, POST /api/seed, and more.
  • Expand POST /api/users, click Try it out, and enter this JSON body:
{
  "name": "Test User",
  "email": "test@example.com",
  "role": "developer"
}
  • Click Execute. You should see a 201 response with the created user including an auto-generated id and created_at timestamp.
  • Now expand POST /api/tasks, click Try it out, and enter this JSON body:
{
  "title": "My first task",
  "description": "Testing the API",
  "status": "pending",
  "priority": "high",
  "assigned_to": 1
}
  • Click Execute. You should see a 201 response with the full task record.
  • Finally, expand GET /api/tasks and click Try it out.
  • Set the status parameter to pending, page to 1, and limit to 10.
  • Click Execute.

The response returns a paginated JSON object with items, total, page, limit, and pages fields. The items array contains the task you just created.

Endpoints not showing in Swagger?

Check your terminal for import errors. The most common cause is a missing __init__.py file in backend/schemas/, backend/repositories/, or backend/routes/.

Also confirm that main.py has the two app.include_router lines at the bottom and that the import line reads from backend.routes import task_routes, user_routes.

help me debug why my FastAPI endpoints are not showing in Swagger

Your API is fully armed with CRUD endpoints, filtering, search, and pagination. Next up, you will add dashboard stats, comments, and an external API integration.

Add Dashboard Stats, Comments, and External API

Your task CRUD API is built and every endpoint is verified through Swagger. The frontend will need more than raw task records, though.

A dashboard page needs summary statistics like how many tasks are pending or overdue. Task detail pages need comments so collaborators can discuss work. The project assessment also requires an external API integration to demonstrate working with third-party services. This step adds all three.

In this step, get ready to:
  • Build a dashboard stats endpoint using SQL aggregation.
  • Add comment endpoints and seed the database with demo data.
  • Integrate an external API with caching and error handling.
Build the dashboard stats endpoint

The frontend dashboard needs a single API call that returns task counts grouped by status, plus how many tasks are overdue. PostgreSQL's COUNT(*) FILTER (WHERE ...) syntax lets you compute all of these in one query, avoiding multiple round-trips to the database.

  • Create a new file called backend/routes/dashboard_routes.py and add the following code:
from fastapi import APIRouter, Depends, Request

router = APIRouter()

def get_pool(request: Request):
    return request.app.state.db_pool

@router.get('/dashboard')
async def get_dashboard(pool=Depends(get_pool)):
    # Compute all status counts and overdue count in a single query
    row = await pool.fetchrow('''
        SELECT
            COUNT(*) as total_tasks,
            COUNT(*) FILTER (WHERE status = 'pending') as pending,
            COUNT(*) FILTER (WHERE status = 'in_progress') as in_progress,
            COUNT(*) FILTER (WHERE status = 'completed') as completed,
            COUNT(*) FILTER (WHERE status = 'blocked') as blocked,
            COUNT(*) FILTER (WHERE due_date < CURRENT_DATE AND status != 'completed') as overdue
        FROM tasks
    ''')
    return dict(row)

What does this code do?

Instead of running six separate queries to count tasks by status, COUNT(*) FILTER (WHERE condition) lets you compute conditional counts in a single SELECT. Each FILTER clause only counts rows that match its condition.

The overdue count finds tasks where due_date is before today AND the status is not completed. A finished task with a past due date is not overdue.

The new route file needs to be registered in the main app. This is a small edit to backend/main.py.

  • In backend/main.py, find this import line:
from backend.routes import task_routes, user_routes
  • Add dashboard_routes to the import so it looks like this:
from backend.routes import task_routes, user_routes, dashboard_routes
  • Then scroll to the bottom of backend/main.py and find this line:
app.include_router(user_routes.router, prefix='/api')
  • Add the following line directly below it:
app.include_router(dashboard_routes.router, prefix='/api')
  • Save both backend/routes/dashboard_routes.py and backend/main.py.

The dev server auto-reloads when you save. Before you check the new endpoint, think about what you expect the response to look like. The database has no task data yet.

  • Open http://127.0.0.1:8000/docs in your browser.
  • Find GET /api/dashboard, click Try it out, then click Execute.

You should see a JSON response with all counts at zero. That is correct. The database is empty, so every count is 0.

Endpoint not showing up in Swagger?

  • Make sure you saved both files. The auto-reload only triggers on file changes.
  • Check that the import in main.py includes dashboard_routes and that the include_router line is present.
  • If the endpoint still does not appear, stop the server with Ctrl+C and restart it by running fastapi dev backend/main.py again.

Still stuck? Help me debug why my dashboard endpoint is not appearing in Swagger.

Add comment endpoints and seed the database

Task detail pages need comments so team members can discuss work. You will add two endpoints to the existing task routes file: one to post a comment and one to retrieve comments for a task.

  • In backend/routes/task_routes.py, scroll to the bottom of the file (after the seed_database function) and add the following code:
@router.post('/tasks/{task_id}/comments', status_code=201)
async def add_comment(task_id: int, body: dict, pool=Depends(get_pool)):
    # Insert the comment and return the created row
    row = await pool.fetchrow(
        'INSERT INTO comments (task_id, user_id, comment) VALUES ($1,$2,$3) RETURNING *',
        task_id, body.get('user_id'), body.get('comment')
    )
    return dict(row)

@router.get('/tasks/{task_id}/comments')
async def get_comments(task_id: int, pool=Depends(get_pool)):
    # Join with users table to include the commenter's name
    rows = await pool.fetch(
        'SELECT c.*, u.name as user_name FROM comments c LEFT JOIN users u ON c.user_id = u.id WHERE c.task_id = $1 ORDER BY c.created_at DESC',
        task_id
    )
    return [dict(r) for r in rows]

What does this code do?

The add_comment endpoint uses INSERT INTO ... RETURNING * to insert the comment and return the created row in one step, avoiding a second SELECT query.

The get_comments endpoint uses a LEFT JOIN to pull in the commenter's name from the users table. Results are ordered by created_at DESC so the newest comment appears first.

  • Save backend/routes/task_routes.py.

Now you need test data. The POST /api/seed endpoint from the previous step populates the database with users and tasks so you can test everything.

  • In Swagger at http://127.0.0.1:8000/docs, find POST /api/seed, click Try it out, then click Execute.

You should see a 201 response with {"message": "Database seeded successfully"}.

With data in the database, go back and test the dashboard endpoint.

  • Execute GET /api/dashboard again in Swagger.

This time the response shows real counts. You should see total_tasks at 20, with pending, in_progress, completed, and blocked each at 5.

Now test the comment endpoints.

  • In Swagger, find POST /api/tasks/{task_id}/comments.
  • Click Try it out, set task_id to 1, and enter this request body:
{
  "user_id": 1,
  "comment": "This task needs more detail before we can start."
}
  • Click Execute.

You should see a 201 response with the created comment, including its id, task_id, and created_at timestamp.

  • Now execute GET /api/tasks/1/comments to retrieve comments for task 1.

The response includes the comment you just posted, along with user_name pulled from the users table via the join.

Seeing a 500 error on the comment endpoint?

  • Make sure you ran POST /api/seed first. The comment references a user_id and task_id that must exist in the database.
  • Check that the JSON body includes both user_id and comment fields.

Still stuck? Help me debug my comment endpoint returning a 500 error.

Create the external API integration

The project assessment requires an external API integration. You will use httpx to fetch user data from the JSONPlaceholder API, a free public API that returns mock user data. To avoid hitting the external API on every request, you will cache the response in memory for 5 minutes.

Why httpx instead of requests?

FastAPI route handlers are async functions. The popular requests library is synchronous, which means calling it inside an async handler would block the event loop and freeze all other requests. httpx supports async natively with AsyncClient, making it the right choice here.

  • Create a new file called backend/services/external_api.py and add the following code:
import httpx
from datetime import datetime, timedelta

# In-memory cache to avoid repeated external calls
_cache = {}
_cache_ttl = timedelta(minutes=5)

async def fetch_external_users():
    now = datetime.utcnow()
    # Return cached data if it is still fresh
    if 'users' in _cache and now - _cache['users']['timestamp'] < _cache_ttl:
        return _cache['users']['data']
    try:
        async with httpx.AsyncClient(timeout=10.0) as client:
            response = await client.get('https://jsonplaceholder.typicode.com/users')
            response.raise_for_status()
            data = response.json()
            # Store the response with a timestamp for TTL checks
            _cache['users'] = {'data': data, 'timestamp': now}
            return data
    except httpx.TimeoutException:
        return {'error': 'Request timed out'}
    except httpx.HTTPStatusError as e:
        return {'error': f'HTTP error: {e.response.status_code}'}

What does this code do?

The function first checks a module-level _cache dictionary. If valid cached data exists and its timestamp is less than 5 minutes old, the function returns it immediately without making a network call.

When the cache is empty or expired, httpx.AsyncClient makes an async GET request with a 10-second timeout. The raise_for_status() call throws an exception for 4xx/5xx responses, which the except blocks catch and convert to error dictionaries.

Now create the route file that exposes this service as an API endpoint.

  • Create a new file called backend/routes/external_routes.py and add the following code:
from fastapi import APIRouter
from backend.services.external_api import fetch_external_users

router = APIRouter()

@router.get('/external/users')
async def get_external_users():
    # Delegate to the service layer which handles caching
    return await fetch_external_users()

Finally, register this new route in the main app.

  • In backend/main.py, find the import line you updated earlier:
from backend.routes import task_routes, user_routes, dashboard_routes
  • Add external_routes so it looks like this:
from backend.routes import task_routes, user_routes, dashboard_routes, external_routes
  • Scroll to the bottom of backend/main.py and add this line below the dashboard_routes include:
app.include_router(external_routes.router, prefix='/api')
  • Save all three files: backend/services/external_api.py, backend/routes/external_routes.py, and backend/main.py.

Before you test this, what do you expect to see? The endpoint calls an external API that returns mock user data.

  • In Swagger, find GET /api/external/users, click Try it out, then click Execute.

You should see a JSON array of 10 user objects fetched from the JSONPlaceholder API, each with fields like name, email, phone, and company.

Seeing an import error or empty response?

  • Check that backend/services/external_api.py exists in the services directory, not in routes or the project root.
  • If the auto-reload did not pick up the new files, stop the server with Ctrl+C and restart it by running fastapi dev backend/main.py.
  • If you see a timeout error, check your internet connection. The endpoint calls https://jsonplaceholder.typicode.com/users which requires network access.

Still stuck? Help me debug my external API integration returning an error.

Your backend is now fully complete. Do a final check by running through the key endpoints one more time in Swagger.

  • Execute GET /api/dashboard and confirm the response includes total_tasks, pending, in_progress, completed, blocked, and overdue counts.
  • Execute GET /api/tasks?status=pending&page=1&limit=10 and confirm only pending tasks appear in the results.
  • Execute GET /api/tasks/1/comments and confirm the comment you posted earlier is there with the user_name field.
  • Execute GET /api/external/users and confirm the 10 mock users are returned.

✔️ Awesome, I've got everything!

Great. Double check you have saved all your files.

ⓧ I'd like to double check the full code

Here is the full code for backend/routes/dashboard_routes.py:

from fastapi import APIRouter, Depends, Request

router = APIRouter()

def get_pool(request: Request):
    return request.app.state.db_pool

@router.get('/dashboard')
async def get_dashboard(pool=Depends(get_pool)):
    row = await pool.fetchrow('''
        SELECT
            COUNT(*) as total_tasks,
            COUNT(*) FILTER (WHERE status = 'pending') as pending,
            COUNT(*) FILTER (WHERE status = 'in_progress') as in_progress,
            COUNT(*) FILTER (WHERE status = 'completed') as completed,
            COUNT(*) FILTER (WHERE status = 'blocked') as blocked,
            COUNT(*) FILTER (WHERE due_date < CURRENT_DATE AND status != 'completed') as overdue
        FROM tasks
    ''')
    return dict(row)

Here is the full code for backend/services/external_api.py:

import httpx
from datetime import datetime, timedelta

_cache = {}
_cache_ttl = timedelta(minutes=5)

async def fetch_external_users():
    now = datetime.utcnow()
    if 'users' in _cache and now - _cache['users']['timestamp'] < _cache_ttl:
        return _cache['users']['data']
    try:
        async with httpx.AsyncClient(timeout=10.0) as client:
            response = await client.get('https://jsonplaceholder.typicode.com/users')
            response.raise_for_status()
            data = response.json()
            _cache['users'] = {'data': data, 'timestamp': now}
            return data
    except httpx.TimeoutException:
        return {'error': 'Request timed out'}
    except httpx.HTTPStatusError as e:
        return {'error': f'HTTP error: {e.response.status_code}'}

Here is the full code for backend/routes/external_routes.py:

from fastapi import APIRouter
from backend.services.external_api import fetch_external_users

router = APIRouter()

@router.get('/external/users')
async def get_external_users():
    return await fetch_external_users()

Here is the full code for backend/main.py:

from contextlib import asynccontextmanager
from fastapi import FastAPI, Depends, Request
from fastapi.middleware.cors import CORSMiddleware
from backend.database import create_pool, init_db
from backend.routes import task_routes, user_routes, dashboard_routes, external_routes

@asynccontextmanager
async def lifespan(app: FastAPI):
    app.state.db_pool = await create_pool()
    await init_db(app.state.db_pool)
    yield
    await app.state.db_pool.close()

app = FastAPI(title='Task Manager API', lifespan=lifespan)

app.add_middleware(
    CORSMiddleware,
    allow_origins=['http://localhost:5173'],
    allow_credentials=True,
    allow_methods=['*'],
    allow_headers=['*'],
)

def get_pool(request: Request):
    return request.app.state.db_pool

@app.get('/api/health')
async def health_check(pool=Depends(get_pool)):
    async with pool.acquire() as conn:
        await conn.fetchval('SELECT 1')
    return {'status': 'ok'}

app.include_router(task_routes.router, prefix='/api')
app.include_router(user_routes.router, prefix='/api')
app.include_router(dashboard_routes.router, prefix='/api')
app.include_router(external_routes.router, prefix='/api')

For backend/routes/task_routes.py, confirm you have the two new comment endpoint functions at the bottom of the file, after the seed_database function: add_comment (POST) and get_comments (GET). The rest of the file is unchanged from the previous step.

The entire backend is now complete. You have a dashboard stats endpoint, comment endpoints, an external API integration with caching, and a database full of test data. Next up, you will build the React frontend to bring all of this to life in the browser.

Build the React Frontend with Dashboard and Task List

Your backend API is fully built and verified through Swagger. Every endpoint works. But an API without a user interface is just JSON in a browser tab.

In this step, you will scaffold a React frontend using Vite as the build tool and Tailwind CSS for styling. You will build reusable components, a dashboard page that pulls live stats from the API, and a task list page with server-side filtering, search, and pagination.

In this step, get ready to:
  • Scaffold the React project with Vite and configure Tailwind CSS.
  • Build reusable UI components and a dashboard page showing live task statistics.
  • Build a task list page with server-side status, priority, and search filters.
Scaffold the React frontend and create the API service

The backend is running in your current terminal. You need a second terminal for frontend work.

  • Open a new terminal window by pressing Cmd+Space (macOS) or the Windows key (Windows) and typing Terminal.
  • Navigate to your project's root directory (the folder that contains the backend/ directory) by running:
cd ~/Desktop/taskmanager
  • Scaffold the React frontend by running this command:
npm create vite@latest frontend -- --template react

What will this command ask?

The scaffolder will show two interactive prompts. For both, select the default by pressing Enter:

  • "Use rolldown-vite (Experimental)?" Select No (the default).
  • "Install with npm and start now?" Select No. You need to install additional packages before starting the dev server.

If you see a prompt saying "Need to install the following packages: create-vite. Ok to proceed?" type y and press Enter to continue.

  • Move into the new frontend directory and install the base dependencies by running:
cd frontend
npm install
  • Install Tailwind CSS, the Vite plugin, and React Router by running:
npm install tailwindcss @tailwindcss/vite react-router-dom

What are these packages?

  • tailwindcss and @tailwindcss/vite let you use utility CSS classes directly in your JSX without writing custom stylesheets.
  • react-router-dom provides client-side routing so the dashboard and task list live at different URLs.
  • Open frontend/vite.config.js in VS Code and replace its entire contents with the following to register the Tailwind plugin:
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'

// Register React and Tailwind CSS plugins
export default defineConfig({
  plugins: [react(), tailwindcss()],
})
  • Open frontend/src/index.css and replace its entire contents with the single Tailwind import:
@import "tailwindcss";
  • Save both files.
  • Start the Vite dev server to confirm everything compiles by running:
npm run dev

Open http://localhost:5173 in your browser. You should see the default Vite + React welcome page.

Page not loading?

  • Make sure you ran npm install before npm run dev.
  • Check that vite.config.js imports tailwindcss from @tailwindcss/vite and lists it in the plugins array.
  • If you see a port conflict, stop any other dev server using port 5173.

help me fix my Vite dev server

  • Stop the dev server with Ctrl+C so you can create the project directories.
  • Create the project structure inside src/ by running:
mkdir -p src/components src/pages src/services src/hooks src/utils
  • Confirm the directories were created by running:
ls src/

You should see components, pages, services, hooks, and utils listed alongside the existing scaffold files.

  • Create the API service module in src/services/api.js and paste the following code:
const BASE_URL = 'http://127.0.0.1:8000';

export async function apiFetch(path, options = {}) {
  const res = await fetch(`${BASE_URL}${path}`, {
    headers: { 'Content-Type': 'application/json', ...options.headers },
    ...options,
  });
  if (!res.ok) throw new Error(`API error: ${res.status}`);
  return res.json();
}

export const api = {
  getDashboard: () => apiFetch('/api/dashboard'),
  getTasks: (params) => apiFetch(`/api/tasks?${new URLSearchParams(params)}`),
  getTask: (id) => apiFetch(`/api/tasks/${id}`),
  createTask: (data) => apiFetch('/api/tasks', { method: 'POST', body: JSON.stringify(data) }),
  updateTask: (id, data) => apiFetch(`/api/tasks/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
  deleteTask: (id) => apiFetch(`/api/tasks/${id}`, { method: 'DELETE' }),
  getUsers: () => apiFetch('/api/users'),
  createUser: (data) => apiFetch('/api/users', { method: 'POST', body: JSON.stringify(data) }),
  getComments: (taskId) => apiFetch(`/api/tasks/${taskId}/comments`),
  addComment: (taskId, data) => apiFetch(`/api/tasks/${taskId}/comments`, { method: 'POST', body: JSON.stringify(data) }),
};

What does this code do?

  • BASE_URL points every request at your running FastAPI backend on port 8000.
  • apiFetch is a thin wrapper around the browser's native fetch that adds the JSON content-type header and throws on non-200 responses.
  • The api object maps each backend endpoint to a named function, so the rest of the frontend calls api.getDashboard() instead of building fetch URLs by hand.
  • Save api.js.

✔️ Awesome, I've got everything!

Great. Make sure api.js is saved in src/services/.

ⓧ I'd like to double check the full code

Here is the complete src/services/api.js:

const BASE_URL = 'http://127.0.0.1:8000';

export async function apiFetch(path, options = {}) {
  const res = await fetch(`${BASE_URL}${path}`, {
    headers: { 'Content-Type': 'application/json', ...options.headers },
    ...options,
  });
  if (!res.ok) throw new Error(`API error: ${res.status}`);
  return res.json();
}

export const api = {
  getDashboard: () => apiFetch('/api/dashboard'),
  getTasks: (params) => apiFetch(`/api/tasks?${new URLSearchParams(params)}`),
  getTask: (id) => apiFetch(`/api/tasks/${id}`),
  createTask: (data) => apiFetch('/api/tasks', { method: 'POST', body: JSON.stringify(data) }),
  updateTask: (id, data) => apiFetch(`/api/tasks/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
  deleteTask: (id) => apiFetch(`/api/tasks/${id}`, { method: 'DELETE' }),
  getUsers: () => apiFetch('/api/users'),
  createUser: (data) => apiFetch('/api/users', { method: 'POST', body: JSON.stringify(data) }),
  getComments: (taskId) => apiFetch(`/api/tasks/${taskId}/comments`),
  addComment: (taskId, data) => apiFetch(`/api/tasks/${taskId}/comments`, { method: 'POST', body: JSON.stringify(data) }),
};
Build the dashboard components and page

The dashboard page needs a StatCard component to display each metric. You will build the component first, then the page that uses it.

  • Create src/components/StatCard.jsx and paste the following:
// Renders a single dashboard metric with a label, value, and background color
export function StatCard({ label, value, color }) {
  return (
    <div className={`rounded-lg p-4 shadow ${color}`}>
      <p className='text-sm text-gray-500'>{label}</p>
      <p className='text-3xl font-bold'>{value}</p>
    </div>
  );
}
  • Save StatCard.jsx.
  • Create src/pages/DashboardPage.jsx and paste the following:
import { useEffect, useState } from 'react';
import { api } from '../services/api';
import { StatCard } from '../components/StatCard';

export function DashboardPage() {
  const [stats, setStats] = useState(null);
  // Fetch dashboard stats from the API on first render
  useEffect(() => { api.getDashboard().then(setStats); }, []);
  if (!stats) return <p>Loading...</p>;
  return (
    <div className='p-6'>
      <h1 className='text-2xl font-bold mb-4'>Dashboard</h1>
      <div className='grid grid-cols-3 gap-4'>
        <StatCard label='Total Tasks' value={stats.total_tasks} color='bg-white' />
        <StatCard label='Pending' value={stats.pending} color='bg-yellow-50' />
        <StatCard label='In Progress' value={stats.in_progress} color='bg-blue-50' />
        <StatCard label='Completed' value={stats.completed} color='bg-green-50' />
        <StatCard label='Blocked' value={stats.blocked} color='bg-red-50' />
        <StatCard label='Overdue' value={stats.overdue} color='bg-orange-50' />
      </div>
    </div>
  );
}

What does this code do?

  • useEffect calls api.getDashboard() once when the component mounts, fetching the counts from your backend's GET /api/dashboard endpoint.
  • Each StatCard receives a label, a value from the stats response, and a Tailwind background color class.
  • While the API call is in flight, the page shows a "Loading..." placeholder.
  • Save DashboardPage.jsx.
  • Confirm both new files exist by running:
ls src/components/ src/pages/

You should see StatCard.jsx listed under components/ and DashboardPage.jsx listed under pages/.

The dashboard page exists but nothing renders it yet. You need to wire up routing and a navigation bar in App.jsx.

  • Open src/App.jsx and replace its entire contents with the following:
import { BrowserRouter, Routes, Route, Link } from 'react-router-dom';
import { DashboardPage } from './pages/DashboardPage';

// App shell with navigation and routing
function App() {
  return (
    <BrowserRouter>
      <nav className='bg-gray-800 text-white p-4 flex gap-4'>
        <Link to='/' className='hover:underline'>Dashboard</Link>
        <Link to='/tasks' className='hover:underline'>Tasks</Link>
      </nav>
      <Routes>
        <Route path='/' element={<DashboardPage />} />
      </Routes>
    </BrowserRouter>
  );
}

export default App;

What does this code do?

  • BrowserRouter enables client-side routing so the browser URL changes without a full page reload.
  • The nav bar provides links to the Dashboard and Tasks pages. The Tasks route will be added in the next substep.
  • Route path='/' renders the DashboardPage component when the user visits the root URL.
  • Save App.jsx.
  • Start the dev server again by running:
npm run dev

Before you check the browser, what do you expect to see on the page?

Open http://localhost:5173 in your browser. You should see a dark navigation bar at the top with "Dashboard" and "Tasks" links. Below it, six stat cards display live counts pulled from your backend: Total Tasks, Pending, In Progress, Completed, Blocked, and Overdue.

Seeing a blank page or errors?

  • Make sure your FastAPI backend is still running in the other terminal. The dashboard fetches from http://127.0.0.1:8000/api/dashboard.
  • Open your browser's developer console (F12) and check for CORS or network errors. The backend's CORS middleware must allow http://localhost:5173.
  • Confirm api.js uses http://127.0.0.1:8000 as the BASE_URL.

help me debug my React dashboard

Build the task list page with filters and pagination

The task list page is the workhorse of the application. It sends filter, search, and pagination parameters to GET /api/tasks and renders the results in a table. All filtering happens server-side, so the page only receives the matching subset of tasks.

First, build the small UI components the task list needs.

  • Create src/components/StatusBadge.jsx and paste the following:
// Maps each task status to a colored badge
const colors = { pending: 'bg-yellow-100 text-yellow-800', in_progress: 'bg-blue-100 text-blue-800', completed: 'bg-green-100 text-green-800', blocked: 'bg-red-100 text-red-800' };
export function StatusBadge({ status }) {
  return <span className={`px-2 py-1 rounded text-xs font-medium ${colors[status] || ''}`}>{status}</span>;
}
  • Save StatusBadge.jsx.
  • Create src/components/PriorityBadge.jsx and paste the following:
// Maps each task priority to a colored badge
const colors = { low: 'bg-gray-100 text-gray-700', medium: 'bg-orange-100 text-orange-700', high: 'bg-red-100 text-red-700', urgent: 'bg-purple-100 text-purple-700' };
export function PriorityBadge({ priority }) {
  return <span className={`px-2 py-1 rounded text-xs font-medium ${colors[priority] || ''}`}>{priority}</span>;
}
  • Save PriorityBadge.jsx.
  • Confirm the badge components exist by running:
ls src/components/

You should see StatCard.jsx, StatusBadge.jsx, and PriorityBadge.jsx listed.

  • Create src/components/Pagination.jsx and paste the following:
// Prev/Next pagination controls with page counter
export function Pagination({ page, pages, onPageChange }) {
  return (
    <div className='flex gap-2 mt-4'>
      <button disabled={page <= 1} onClick={() => onPageChange(page - 1)} className='px-3 py-1 border rounded disabled:opacity-50'>Prev</button>
      <span className='px-3 py-1'>{page} / {pages}</span>
      <button disabled={page >= pages} onClick={() => onPageChange(page + 1)} className='px-3 py-1 border rounded disabled:opacity-50'>Next</button>
    </div>
  );
}
  • Save Pagination.jsx.

Now build the TaskListPage component. This file is longer, so you will add it in two parts.

  • Create src/pages/TaskListPage.jsx and start with the imports, state declarations, and data-fetching effect:
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { api } from '../services/api';
import { StatusBadge } from '../components/StatusBadge';
import { PriorityBadge } from '../components/PriorityBadge';
import { Pagination } from '../components/Pagination';

export function TaskListPage() {
  const [tasks, setTasks] = useState([]);
  const [total, setTotal] = useState(0);
  const [pages, setPages] = useState(1);
  const [page, setPage] = useState(1);
  const [status, setStatus] = useState('');
  const [priority, setPriority] = useState('');
  const [search, setSearch] = useState('');
  const navigate = useNavigate();

  // Re-fetch tasks whenever a filter, search, or page changes
  useEffect(() => {
    const params = { page, limit: 10 };
    if (status) params.status = status;
    if (priority) params.priority = priority;
    if (search) params.search = search;
    api.getTasks(params).then(data => { setTasks(data.items); setTotal(data.total); setPages(data.pages); });
  }, [page, status, priority, search]);

What does this code do?

  • Six pieces of state track the current filter selections (status, priority, search), pagination (page, pages), and the task data itself.
  • The useEffect hook watches all filter and page states. Whenever any of them changes, it rebuilds the query params and calls api.getTasks().
  • This means filtering and pagination happen on the server. The frontend never downloads all tasks at once.
  • Still in the same file, add the return statement directly below the useEffect block. This renders the filter controls:
  return (
    <div className='p-6'>
      <h1 className='text-2xl font-bold mb-4'>Tasks</h1>
      <div className='flex gap-2 mb-4'>
        <select value={status} onChange={e => setStatus(e.target.value)} className='border rounded px-2 py-1'>
          <option value=''>All Statuses</option>
          <option value='pending'>Pending</option>
          <option value='in_progress'>In Progress</option>
          <option value='completed'>Completed</option>
          <option value='blocked'>Blocked</option>
        </select>
        <select value={priority} onChange={e => setPriority(e.target.value)} className='border rounded px-2 py-1'>
          <option value=''>All Priorities</option>
          <option value='low'>Low</option>
          <option value='medium'>Medium</option>
          <option value='high'>High</option>
          <option value='urgent'>Urgent</option>
        </select>
        <input value={search} onChange={e => setSearch(e.target.value)} placeholder='Search tasks...' className='border rounded px-2 py-1' />
      </div>
  • Continue in the same file. Add the task table and pagination directly below the filter controls </div>, still inside the main wrapper:
      <table className='w-full border-collapse'>
        <thead><tr className='bg-gray-100'><th className='p-2 text-left'>Title</th><th>Status</th><th>Priority</th><th>Due Date</th></tr></thead>
        <tbody>
          {tasks.map(t => (
            <tr key={t.id} className='border-b cursor-pointer hover:bg-gray-50' onClick={() => navigate(`/tasks/${t.id}`)}>              <td className='p-2'>{t.title}</td>
              <td className='p-2'><StatusBadge status={t.status} /></td>
              <td className='p-2'><PriorityBadge priority={t.priority} /></td>
              <td className='p-2'>{t.due_date || '-'}</td>
            </tr>
          ))}
        </tbody>
      </table>
      <Pagination page={page} pages={pages} onPageChange={setPage} />
    </div>
  );
}

What does the table do?

  • Each task renders as a clickable table row. Clicking navigates to /tasks/{id} using React Router's useNavigate. You will build the detail page in the next step.
  • StatusBadge and PriorityBadge apply color-coded labels so status and priority are scannable at a glance.
  • The Pagination component at the bottom calls setPage, which triggers the useEffect to refetch the next page of results from the backend.
  • Save TaskListPage.jsx.

The TaskListPage component is ready, but App.jsx does not route to it yet.

  • In src/App.jsx, find this import line at the top:
import { DashboardPage } from './pages/DashboardPage';
  • Add the TaskListPage import directly below it, so the imports now look like this:
import { DashboardPage } from './pages/DashboardPage';
import { TaskListPage } from './pages/TaskListPage';
  • In the same file, find this route inside the <Routes> block:
        <Route path='/' element={<DashboardPage />} />
  • Add the tasks route directly below it, so the <Routes> block now looks like this:
      <Routes>
        <Route path='/' element={<DashboardPage />} />
        <Route path='/tasks' element={<TaskListPage />} />
      </Routes>
  • Save App.jsx.

Before you check the browser, what do you expect to see when you click the "Tasks" link?

Open http://localhost:5173 and click Tasks in the navigation bar. You should see a table listing your seeded tasks with Title, Status, Priority, and Due Date columns. Above the table, three controls let you filter by status, filter by priority, and search by keyword.

  • Select Pending from the status dropdown. The table updates to show only pending tasks.
  • Clear the filter back to All Statuses and type a task title in the search box. The table filters to matching tasks.
  • Click the Next button in the pagination controls. The page counter updates and the next batch of tasks loads.

Task list not showing data?

  • Confirm the backend is running and that you seeded data earlier with POST /api/seed in Swagger.
  • Open the browser console (F12) and look for fetch errors. A CORS error means the backend's allow_origins list does not include http://localhost:5173.
  • Verify TaskListPage.jsx imports api from ../services/api and not a different path.

help me fix my task list page

✔️ Awesome, I've got everything!

Great. Make sure all your files are saved and the dev server is running.

ⓧ I'd like to double check the full code

Scroll up and confirm each code block is present in the right file, in order:

  • src/services/api.js contains the apiFetch wrapper and api object.
  • src/components/StatCard.jsx exports the StatCard function.
  • src/components/StatusBadge.jsx exports the StatusBadge function with a colors map.
  • src/components/PriorityBadge.jsx exports the PriorityBadge function with a colors map.
  • src/components/Pagination.jsx exports the Pagination function with Prev/Next buttons.
  • src/pages/DashboardPage.jsx fetches api.getDashboard() and renders six StatCard components.
  • src/pages/TaskListPage.jsx includes all three code blocks: imports/state/effect, filter controls, and table/pagination.
  • src/App.jsx imports both DashboardPage and TaskListPage, with routes for / and /tasks.

Your frontend is alive. The dashboard pulls real statistics from the API, and the task list filters and paginates entirely on the server. Next up, you will build the task detail page with comments and the create/edit forms to complete the full CRUD experience.

Build Task Detail, Create, Edit, and Delete

Your dashboard and task list are pulling live data from the backend and displaying it beautifully. But the application is read-only. Clicking a task row navigates nowhere, and there is no way to create, edit, or delete tasks.

This step transforms the app from a display into a full CRUD tool. You will build a task detail page with comments, a reusable form modal for creating and editing tasks, and a delete confirmation flow.

In this step, get ready to:
  • Build a task detail page that shows full task info, comments, and a comment form.
  • Create a reusable form modal that handles both creating and editing tasks.
  • Add delete confirmation, loading states, and empty states to complete the user experience.
Build the task detail page with comments

The task detail page needs two supporting components: the existing Modal and a new TaskFormModal. You will create TaskFormModal first since the detail page imports it.

Before building the new components, the API helper needs a small fix. The backend returns a 204 No Content response when deleting a task. The current apiFetch function tries to parse JSON from every response, which would crash on an empty body.

  • In frontend/src/services/api.js, find this line inside the apiFetch function:
  return res.json();
  • Add this line directly above it, so 204 responses return null instead of trying to parse an empty body:
  if (res.status === 204) return null;
  return res.json();
  • Save api.js.

Now create the task form modal. This component will handle both creating new tasks and editing existing ones, switching behavior based on whether a task prop is passed in.

  • Create a new file at frontend/src/components/TaskFormModal.jsx and add the component logic:
import { useEffect, useState } from 'react';
import { api } from '../services/api';
import { Modal } from './Modal';

export function TaskFormModal({ open, onClose, task, onSaved }) {
  const [users, setUsers] = useState([]);
  const [form, setForm] = useState({ title: '', description: '', status: 'pending', priority: 'medium', assigned_to: '', due_date: '' });
  useEffect(() => { api.getUsers().then(setUsers); }, []);
  useEffect(() => {
    if (task) setForm({ title: task.title, description: task.description || '', status: task.status, priority: task.priority, assigned_to: task.assigned_to || '', due_date: task.due_date || '' });
    else setForm({ title: '', description: '', status: 'pending', priority: 'medium', assigned_to: '', due_date: '' });
  }, [task]);
  const handleSubmit = async (e) => {
    e.preventDefault();
    const data = { ...form, assigned_to: form.assigned_to ? parseInt(form.assigned_to) : null, due_date: form.due_date || null };
    if (task) await api.updateTask(task.id, data);
    else await api.createTask(data);
    onSaved();
    onClose();
  };

What does this code do?

  • The task prop determines the mode. When task is passed, the form pre-fills with existing data for editing. When null, it starts blank for creating.
  • The second useEffect syncs the form state whenever the task prop changes, resetting fields for create mode or populating them for edit mode.
  • On submit, assigned_to is converted from a string to an integer (or null) because the select element stores values as strings.
  • Below the closing }; of handleSubmit, add the form's JSX:
  return (
    <Modal open={open} onClose={onClose} title={task ? 'Edit Task' : 'New Task'}>
      <form onSubmit={handleSubmit} className='flex flex-col gap-3'>
        <input required value={form.title} onChange={e => setForm(f => ({...f, title: e.target.value}))} placeholder='Title' className='border rounded px-2 py-1' />
        <textarea value={form.description} onChange={e => setForm(f => ({...f, description: e.target.value}))} placeholder='Description' className='border rounded px-2 py-1' />
        <select value={form.status} onChange={e => setForm(f => ({...f, status: e.target.value}))} className='border rounded px-2 py-1'>
          <option value='pending'>Pending</option><option value='in_progress'>In Progress</option><option value='completed'>Completed</option><option value='blocked'>Blocked</option>
        </select>
        <select value={form.priority} onChange={e => setForm(f => ({...f, priority: e.target.value}))} className='border rounded px-2 py-1'>
          <option value='low'>Low</option><option value='medium'>Medium</option><option value='high'>High</option><option value='urgent'>Urgent</option>
        </select>
        <select value={form.assigned_to} onChange={e => setForm(f => ({...f, assigned_to: e.target.value}))} className='border rounded px-2 py-1'>
          <option value=''>Unassigned</option>
          {users.map(u => <option key={u.id} value={u.id}>{u.name}</option>)}
        </select>
        <input type='date' value={form.due_date} onChange={e => setForm(f => ({...f, due_date: e.target.value}))} className='border rounded px-2 py-1' />
        <button type='submit' className='bg-blue-600 text-white rounded px-4 py-2'>{task ? 'Save Changes' : 'Create Task'}</button>
      </form>
    </Modal>
  );
}

What does this JSX render?

The form renders inside the Modal wrapper you already have. It includes inputs for every task field. The assignee dropdown is populated from GET /api/users so the learner picks from real users.

The submit button label changes dynamically. It reads Save Changes in edit mode and Create Task in create mode.

  • Save TaskFormModal.jsx.

✔️ Awesome, I've got everything!

Great. Make sure you have saved the file before continuing.

ⓧ I'd like to double check the full code

Scroll up and confirm these two chunks are present in TaskFormModal.jsx, in this order:

  • Chunk 1: Imports, state, two useEffect hooks, and the handleSubmit function.
  • Chunk 2: The return statement with the Modal wrapper and form inputs, ending with the closing } of the function.

Now build the main event: the task detail page. This component fetches a single task and its comments, displays everything, and provides edit, delete, and comment actions.

  • Create a new file at frontend/src/pages/TaskDetailPage.jsx and add the imports, state, and data fetching:
import { useEffect, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { api } from '../services/api';
import { StatusBadge } from '../components/StatusBadge';
import { PriorityBadge } from '../components/PriorityBadge';
import { Modal } from '../components/Modal';
import { TaskFormModal } from '../components/TaskFormModal';

export function TaskDetailPage() {
  const { id } = useParams();
  const navigate = useNavigate();
  const [task, setTask] = useState(null);
  const [comments, setComments] = useState([]);
  const [loading, setLoading] = useState(true);
  const [editOpen, setEditOpen] = useState(false);
  const [deleteOpen, setDeleteOpen] = useState(false);
  const [commentText, setCommentText] = useState('');

  const loadTask = () => api.getTask(id).then(setTask);
  const loadComments = () => api.getComments(id).then(setComments);

  useEffect(() => {
    Promise.all([loadTask(), loadComments()]).finally(() => setLoading(false));
  }, [id]);

  const handleDelete = async () => {
    await api.deleteTask(id);
    navigate('/tasks');
  };

What does this code do?

  • useParams() extracts the id from the URL path /tasks/:id.
  • Promise.all() fetches the task and its comments in parallel, then sets loading to false when both finish.
  • handleDelete calls the delete endpoint, then redirects to the task list with navigate('/tasks').
  • Below the handleDelete function, add the comment handler and loading guards:
  const handleAddComment = async (e) => {
    e.preventDefault();
    await api.addComment(id, { comment: commentText, user_id: null });
    setCommentText('');
    loadComments();
  };

  if (loading) return <p className='p-6'>Loading...</p>;
  if (!task) return <p className='p-6'>Task not found.</p>;

What do the loading guards do?

The two if statements are early returns. While data is loading, the component shows a loading message. If the task is not found after loading completes, it shows a not-found message. The full JSX only renders when a valid task is ready.

  • Below the early return checks, add the component's JSX return:
  return (
    <div className='p-6 max-w-2xl'>
      <button onClick={() => navigate('/tasks')} className='text-blue-600 mb-4'>← Back to Tasks</button>
      <h1 className='text-2xl font-bold mb-2'>{task.title}</h1>
      <div className='flex gap-2 mb-4'><StatusBadge status={task.status} /><PriorityBadge priority={task.priority} /></div>
      <p className='text-gray-600 mb-4'>{task.description}</p>
      <p className='text-sm text-gray-500 mb-1'>Due: {task.due_date || 'No due date'}</p>
      <div className='flex gap-2 mt-4 mb-6'>
        <button onClick={() => setEditOpen(true)} className='bg-blue-600 text-white px-4 py-2 rounded'>Edit</button>
        <button onClick={() => setDeleteOpen(true)} className='bg-red-600 text-white px-4 py-2 rounded'>Delete</button>
      </div>
      <h2 className='text-lg font-semibold mb-2'>Comments</h2>
      {comments.length === 0 && <p className='text-gray-400 mb-4'>No comments yet.</p>}
      {comments.map(c => <div key={c.id} className='border-b py-2'><p className='text-sm font-medium'>{c.user_name || 'Anonymous'}</p><p>{c.comment}</p></div>)}
      <form onSubmit={handleAddComment} className='mt-4 flex gap-2'>
        <input value={commentText} onChange={e => setCommentText(e.target.value)} placeholder='Add a comment...' className='border rounded px-2 py-1 flex-1' />
        <button type='submit' className='bg-blue-600 text-white px-4 py-1 rounded'>Post</button>
      </form>
      <TaskFormModal open={editOpen} onClose={() => setEditOpen(false)} task={task} onSaved={loadTask} />
      <Modal open={deleteOpen} onClose={() => setDeleteOpen(false)} title='Delete Task'>
        <p className='mb-4'>Are you sure you want to delete this task?</p>
        <div className='flex gap-2'>
          <button onClick={handleDelete} className='bg-red-600 text-white px-4 py-2 rounded'>Delete</button>
          <button onClick={() => setDeleteOpen(false)} className='border px-4 py-2 rounded'>Cancel</button>
        </div>
      </Modal>
    </div>
  );
}

What is the JSX rendering?

  • The top section shows the task title, status and priority badges, description, and due date.
  • The Edit button opens TaskFormModal in edit mode (passing the current task as a prop). The Delete button opens a confirmation Modal.
  • The comments section maps over the comments array, showing each commenter's name and text. A form at the bottom posts new comments.
  • Save TaskDetailPage.jsx.

✔️ Awesome, I've got everything!

Great. Confirm the file is saved before moving on.

ⓧ I'd like to double check the full code

Scroll up and confirm these three chunks are present in TaskDetailPage.jsx, in this order:

  • Chunk 1: Imports, state declarations, loadTask/loadComments helpers, useEffect, and handleDelete.
  • Chunk 2: handleAddComment function plus the two early return guards for loading and not-found states.
  • Chunk 3: The full return statement with task info, Edit/Delete buttons, comments list, comment form, TaskFormModal, and delete confirmation Modal.

The component is built, but React Router does not know about it yet. You need to add a route so clicking a task row navigates to /tasks/:id.

  • Open frontend/src/App.jsx.
  • Add this import near the other page imports at the top of the file:
import { TaskDetailPage } from './pages/TaskDetailPage';
  • Find the Route element for /tasks and add a new route directly below it:
<Route path='/tasks/:id' element={<TaskDetailPage />} />
  • Save App.jsx.

Before you test, what do you expect to see when you click a task row in the task list?

  • Switch to your browser at http://localhost:5173.
  • Click Tasks in the navigation.
  • Click any task row in the table.

The task detail page loads, showing the task title, status and priority badges, description, and due date. Below the task info, you see the comments section.

  • Type a test comment in the input field and click Post.

The comment appears in the list immediately. The input field clears, ready for the next comment.

Detail page not loading?

  • Check your browser console for import errors. If you see a message about TaskFormModal or Modal not being found, confirm the file names and paths match exactly.
  • Confirm the route in App.jsx uses /tasks/:id (with the colon). Without the colon, React Router will not capture the task ID.
  • Make sure the FastAPI backend is still running. The detail page fetches data from /api/tasks/{id} and /api/tasks/{id}/comments.
  • help me debug why my task detail page is not loading
Wire up task creation and editing

The task detail page works, but there is no way to create new tasks or edit existing ones from the task list. The TaskFormModal component is ready. Now you need to wire it into the task list page with a New Task button.

The updated TaskListPage also adds a loading indicator during API calls and an empty-state message when no tasks match the current filters.

  • Open frontend/src/pages/TaskListPage.jsx and replace the entire contents with the updated imports, state, and fetch logic:
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { api } from '../services/api';
import { StatusBadge } from '../components/StatusBadge';
import { PriorityBadge } from '../components/PriorityBadge';
import { Pagination } from '../components/Pagination';
import { TaskFormModal } from '../components/TaskFormModal';

export function TaskListPage() {
  const [tasks, setTasks] = useState([]);
  const [total, setTotal] = useState(0);
  const [pages, setPages] = useState(1);
  const [page, setPage] = useState(1);
  const [status, setStatus] = useState('');
  const [priority, setPriority] = useState('');
  const [search, setSearch] = useState('');
  const [loading, setLoading] = useState(false);
  const [createOpen, setCreateOpen] = useState(false);
  const navigate = useNavigate();

  const loadTasks = () => {
    setLoading(true);
    const params = { page, limit: 10 };
    if (status) params.status = status;
    if (priority) params.priority = priority;
    if (search) params.search = search;
    api.getTasks(params).then(data => { setTasks(data.items); setTotal(data.total); setPages(data.pages); }).finally(() => setLoading(false));
  };

  useEffect(() => { loadTasks(); }, [page, status, priority, search]);

What changed from the original?

  • TaskFormModal is imported so the page can open a create form.
  • Two new state variables: loading tracks when an API call is in flight, and createOpen controls whether the create modal is visible.
  • The fetch logic is wrapped in a loadTasks function so it can be called again after creating a task.
  • Below the useEffect line, add the return statement with the new header, loading/empty states, and the modal:
  return (
    <div className='p-6'>
      <div className='flex justify-between items-center mb-4'>
        <h1 className='text-2xl font-bold'>Tasks</h1>
        <button onClick={() => setCreateOpen(true)} className='bg-blue-600 text-white px-4 py-2 rounded'>New Task</button>
      </div>
      <div className='flex gap-2 mb-4'>
        <select value={status} onChange={e => setStatus(e.target.value)} className='border rounded px-2 py-1'>
          <option value=''>All Statuses</option>
          <option value='pending'>Pending</option><option value='in_progress'>In Progress</option><option value='completed'>Completed</option><option value='blocked'>Blocked</option>
        </select>
        <select value={priority} onChange={e => setPriority(e.target.value)} className='border rounded px-2 py-1'>
          <option value=''>All Priorities</option>
          <option value='low'>Low</option><option value='medium'>Medium</option><option value='high'>High</option><option value='urgent'>Urgent</option>
        </select>
        <input value={search} onChange={e => setSearch(e.target.value)} placeholder='Search tasks...' className='border rounded px-2 py-1' />
      </div>
      {loading ? <p>Loading...</p> : tasks.length === 0 ? <p className='text-gray-400'>No tasks match your filters.</p> : (
        <table className='w-full border-collapse'>
          <thead><tr className='bg-gray-100'><th className='p-2 text-left'>Title</th><th>Status</th><th>Priority</th><th>Due Date</th></tr></thead>
          <tbody>
            {tasks.map(t => (
              <tr key={t.id} className='border-b cursor-pointer hover:bg-gray-50' onClick={() => navigate(`/tasks/${t.id}`)}>
                <td className='p-2'>{t.title}</td>
                <td className='p-2'><StatusBadge status={t.status} /></td>
                <td className='p-2'><PriorityBadge priority={t.priority} /></td>
                <td className='p-2'>{t.due_date || '-'}</td>
              </tr>
            ))}
          </tbody>
        </table>
      )}
      <Pagination page={page} pages={pages} onPageChange={setPage} />
      <TaskFormModal open={createOpen} onClose={() => setCreateOpen(false)} task={null} onSaved={loadTasks} />
    </div>
  );
}

What's new in the JSX?

  • A New Task button in the header opens TaskFormModal with task={null} (create mode). After submission, loadTasks re-fetches the list so the new task appears.
  • The ternary chain handles three states: loading shows a text indicator, an empty result shows a gray message, and a populated result renders the table.
  • Save TaskListPage.jsx.

✔️ Awesome, I've got everything!

Great. Confirm the file is saved before testing.

ⓧ I'd like to double check the full code

Scroll up and confirm these two chunks are present in TaskListPage.jsx, in this order:

  • Chunk 1: Imports (including TaskFormModal), state declarations (including loading and createOpen), the loadTasks function, and the useEffect.
  • Chunk 2: The return statement with the header row (including New Task button), filter controls, the loading/empty/table ternary, pagination, and TaskFormModal.
  • Switch to your browser and navigate to the Tasks page.
  • Click the New Task button in the top right.
  • Enter a title like Test from frontend, set a status and priority, and click Create Task.

The modal closes and the task list refreshes. Your new task appears in the table.

  • Click the new task row to open its detail page.
  • Click the Edit button.
  • Change the status to In Progress and click Save Changes.

The modal closes and the detail page reloads. The status badge now shows in_progress instead of the previous value.

New task not appearing in the list?

  • Check the browser console for network errors. A 422 response means the backend rejected the data. Make sure the title field is filled in (it is required).
  • If the modal closes but the list does not update, confirm that onSaved={loadTasks} is set on the TaskFormModal in TaskListPage.jsx.
  • help me debug why creating a task is not working
Test delete, loading, and empty states

The create and edit flows are working. The delete confirmation, loading indicator, and empty state are already built into the components you wrote. Time to test them.

From the task detail page, what do you expect to happen when you click Delete?

  • On the task detail page, click the Delete button.
  • A confirmation modal appears asking if you are sure.
  • Click Delete in the modal to confirm.

The app redirects you back to the task list. The deleted task is gone.

Delete not working?

  • If you see a JSON parse error in the browser console, double check that you added the if (res.status === 204) return null; line in api.js. The delete endpoint returns a 204 response with no body.
  • help me fix the delete task functionality
  • Test the empty state by typing a search term that does not match any task, like zzzznosuchtask.

The table disappears and the message No tasks match your filters. appears. Clear the search field and the task list returns.

Run through the full end-to-end check: from the task list, click a task to see its details and comments. Click New Task to create one. Click into the new task, edit its status, and confirm the change persists after the page reloads.

Secret mission

Add a Kanban Board View

Your task list works, but what if you could see all your tasks laid out in columns and drag them between statuses? Build a Kanban board view with drag-and-drop powered by the HTML Drag and Drop API.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • PostgreSQL database taskmanager on localhost.
  • Python virtual environment in backend/venv/.
  • Node modules in frontend/node_modules/.

Keep everything running

No action needed. Choose this if you are still actively building or want to keep the project ready to demonstrate.

  • Your FastAPI backend and React dev servers continue running as long as their terminal windows stay open.
  • Your PostgreSQL taskmanager database persists all task, user, and comment data between sessions.
  • Restart the servers at any time by running the dev commands again from the project folder.

Pause - I'll come back to this later

Shut down running processes to free up memory. Your files and database data stay intact for when you return.

  • Switch to each terminal window running a dev server and press Ctrl+C to stop it.
  • Your project files remain on your Desktop.
  • Your data persists in the taskmanager database. It will be there when you start the servers again.

Delete - I don't want to use this again

Remove all project resources and start fresh. This permanently deletes your database, code, and dependencies.

  • Stop any running dev servers by pressing Ctrl+C in each terminal window.
  • Drop the taskmanager PostgreSQL database by running:
dropdb taskmanager
  • Delete your project folder from the Desktop. This removes all code, the virtual environment, and node modules.

macOS

cd ~/Desktop
rm -rf [[PROJECT_FOLDER="your project folder name"]]

Windows

cd ~\Desktop
Remove-Item -Recurse -Force [[PROJECT_FOLDER="your project folder name"]]
  • Confirm the folder is gone by listing the contents of your Desktop.
ls ~/Desktop

Nice Work!

Nice Work!

You just built a complete full-stack task management dashboard from the ground up. The backend serves a REST API backed by raw PostgreSQL queries, and the frontend brings it all to life with a polished React interface.

You've learned how to:

  • Build a FastAPI backend with full CRUD endpoints, server-side filtering, pagination, and search using raw SQL queries via asyncpg with no ORM.
  • Design a PostgreSQL database with related tables using raw DDL, validate requests with Pydantic schemas, aggregate dashboard stats with SQL COUNT FILTER, and integrate an external API using httpx with caching and error handling.
  • Create a React + Vite + Tailwind CSS frontend with reusable components, a live dashboard, a filterable task list with pagination, a task detail page with comments, and create/edit/delete functionality with confirmation modals.
  • Secret Mission: Added a Kanban board view with drag-and-drop status updates that persist to the database.

Ready to quiz yourself?