Skip to content

Aurelia Capstone Project: Building a Task Management Application

DodaTech Updated 2026-06-28 8 min read

In this tutorial, you will learn about Aurelia Capstone Project: Building a Task Management Application. We cover key concepts, practical examples, and best practices to help you master this topic.

This capstone project combines everything you have learned about Aurelia into a full-featured task management application with data persistence, routing, validation, and automated tests.

What You'll Learn

  • Structuring a real Aurelia application
  • Integrating components, routing, and HTTP services
  • Implementing authentication and protected routes
  • Writing end-to-end feature tests
  • Deploying an Aurelia application

Why It Matters

Building a complete application from scratch teaches you how the pieces fit together in practice. You will encounter real-world challenges like state management, error handling, and code organization.

Real-World Use

A project management tool like Trello or Asana, where users create boards, manage tasks, assign team members, and track progress through a clean single-page interface.

Project Architecture

flowchart TD
    A[App Shell] --> B[Auth Module]
    A --> C[Dashboard Module]
    A --> D[Board Module]
    A --> E[Settings Module]
    B --> F[Login]
    B --> G[Register]
    C --> H[Board List]
    C --> I[Recent Activity]
    D --> J[Task List]
    D --> K[Task Detail]
    D --> L[Task Form]
    E --> M[Profile]
    E --> N[Notifications]
    style A fill:#e6f3ff,stroke:#4a90d9,stroke-width:2px

Project Structure

task-manager/
  src/
    components/
      task-card/
        task-card.ts
        task-card.html
      board-list/
        board-list.ts
        board-list.html
    services/
      api-service.ts
      auth-service.ts
      task-service.ts
    models/
      task.ts
      board.ts
      user.ts
    routes/
      app.ts
      app.html
      app-router.ts
    validation/
      task-validator.ts
    test/
      unit/
        task-service.spec.ts
      component/
        task-card.spec.ts
    main.ts
  static/
    styles.css
  package.json
  aurelia.json

Step 1: Set Up the Project

au new task-manager
# Select "Custom Project" and include:
# - TypeScript
# - aurelia-fetch-client
# - aurelia-validation
# - aurelia-testing

Install additional dependencies:

npm install aurelia-store
npm install --save-dev @types/jasmine

Step 2: Define Data Models

// src/models/task.ts
export interface Task {
  id?: number;
  title: string;
  description: string;
  status: 'todo' | 'in-progress' | 'done';
  priority: 'low' | 'medium' | 'high';
  assignee?: string;
  boardId: number;
  createdAt: Date;
  updatedAt: Date;
}

// src/models/board.ts
export interface Board {
  id?: number;
  name: string;
  description: string;
  ownerId: number;
  tasks: Task[];
}

Step 3: Create the API Service

// src/services/api-service.ts
import { autoinject } from 'aurelia-framework';
import { HttpClient } from 'aurelia-fetch-client';

@autoinject
export class ApiService {
  constructor(private http: HttpClient) {
    http.configure(config => {
      config
        .withBaseUrl('http://localhost:3000/api/')
        .withDefaults({ headers: { 'Content-Type': 'application/json' } })
        .withInterceptor({
          request(request) {
            const token = localStorage.getItem('token');
            if (token) {
              request.headers.append('Authorization', `Bearer ${token}`);
            }
            return request;
          },
          responseError(error) {
            if (error.status === 401) {
              window.location.hash = '#/login';
            }
            return Promise.reject(error);
          }
        });
    });
  }

  async get<T>(endpoint: string): Promise<T> {
    const response = await this.http.fetch(endpoint);
    return response.json();
  }

  async post<T>(endpoint: string, data: any): Promise<T> {
    const response = await this.http.fetch(endpoint, {
      method: 'POST', body: JSON.stringify(data)
    });
    return response.json();
  }

  async put<T>(endpoint: string, data: any): Promise<T> {
    const response = await this.http.fetch(endpoint, {
      method: 'PUT', body: JSON.stringify(data)
    });
    return response.json();
  }

  async delete(endpoint: string): Promise<void> {
    await this.http.fetch(endpoint, { method: 'DELETE' });
  }
}

Step 4: Implement the Task Service

// src/services/task-service.ts
import { autoinject } from 'aurelia-framework';
import { ApiService } from './api-service';
import { Task } from '../models/task';

@autoinject
export class TaskService {
  tasks: Task[] = [];

  constructor(private api: ApiService) {}

  async loadTasks(boardId: number): Promise<void> {
    this.tasks = await this.api.get<Task[]>(`boards/${boardId}/tasks`);
  }

  async createTask(task: Task): Promise<Task> {
    const created = await this.api.post<Task>('tasks', task);
    this.tasks.push(created);
    return created;
  }

  async updateTask(task: Task): Promise<Task> {
    const updated = await this.api.put<Task>(`tasks/${task.id}`, task);
    const index = this.tasks.findIndex(t => t.id === task.id);
    if (index !== -1) this.tasks[index] = updated;
    return updated;
  }

  async deleteTask(id: number): Promise<void> {
    await this.api.delete(`tasks/${id}`);
    this.tasks = this.tasks.filter(t => t.id !== id);
  }

  getTasksByStatus(status: Task['status']): Task[] {
    return this.tasks.filter(t => t.status === status);
  }
}

Step 5: Build the Task Card Component

// src/components/task-card/task-card.ts
import { autoinject, bindable } from 'aurelia-framework';
import { Task } from '../../models/task';
import { TaskService } from '../../services/task-service';

@autoinject
export class TaskCard {
  @bindable task: Task;

  constructor(private taskService: TaskService) {}

  async deleteTask(): Promise<void> {
    if (confirm('Delete this task?')) {
      await this.taskService.deleteTask(this.task.id);
    }
  }

  get statusClass(): string {
    return `status-${this.task.status}`;
  }

  get priorityClass(): string {
    return `priority-${this.task.priority}`;
  }
}
<!-- src/components/task-card/task-card.html -->
<template>
  <div class="task-card ${statusClass} ${priorityClass}">
    <h3>${task.title}</h3>
    <p>${task.description}</p>
    <div class="task-meta">
      <span class="priority-badge">${task.priority}</span>
      <span class="status-badge">${task.status}</span>
      <span class="assignee" if.bind="task.assignee">${task.assignee}</span>
    </div>
    <div class="task-actions">
      <button click.delegate="deleteTask()" class="btn-delete">Delete</button>
    </div>
  </div>
</template>

Expected output: Each task renders as a card with title, description, priority badge, status badge, assignee, and a delete button.

Step 6: Configure Routing

// src/routes/app-router.ts
import { Router, RouterConfiguration } from 'aurelia-router';

export class AppRouter {
  router: Router;

  configureRouter(config: RouterConfiguration, router: Router): void {
    config.options.pushState = true;
    config.options.root = '/';
    config.title = 'Task Manager';
    config.map([
      { route: '',           redirect: '/dashboard' },
      { route: 'login',      moduleId: './auth/login',      title: 'Login' },
      { route: 'register',   moduleId: './auth/register',   title: 'Register' },
      { route: 'dashboard',  moduleId: './dashboard',       title: 'Dashboard' },
      { route: 'board/:id',  moduleId: './board/board',     title: 'Board' },
      { route: 'settings',   moduleId: './settings',        title: 'Settings' }
    ]);
    this.router = router;
  }
}

Step 7: Add Validation to the Task Form

// src/validation/task-validator.ts
import { ValidationRules, ValidationController, ValidationControllerFactory } from 'aurelia-validation';
import { Task } from '../models/task';

export class TaskValidator {
  controller: ValidationController;

  constructor(controllerFactory: ValidationControllerFactory) {
    this.controller = controllerFactory.createForCurrentScope();
  }

  setupValidation(task: Task): void {
    ValidationRules
      .ensure('title').required().minLength(3).maxLength(100)
      .ensure('description').required().minLength(10).maxLength(500)
      .ensure('priority').required()
      .on(task);
  }

  async validate(): Promise<boolean> {
    const result = await this.controller.validate();
    return result.valid;
  }
}

Step 8: Write Tests

// src/test/unit/task-service.spec.ts
import { TaskService } from '../../services/task-service';
import { ApiService } from '../../services/api-service';

describe('TaskService', () => {
  let service: TaskService;
  let api: jasmine.SpyObj<ApiService>;

  beforeEach(() => {
    api = jasmine.createSpyObj('ApiService', ['get', 'post', 'put', 'delete']);
    service = new TaskService(api);
  });

  it('loads tasks for a board', async () => {
    const mockTasks = [{ id: 1, title: 'Test', boardId: 1 }];
    api.get.and.returnValue(Promise.resolve(mockTasks));
    await service.loadTasks(1);
    expect(service.tasks.length).toBe(1);
    expect(api.get).toHaveBeenCalledWith('boards/1/tasks');
  });

  it('creates a task and adds it to the list', async () => {
    const newTask = { title: 'New Task', boardId: 1 };
    const createdTask = { id: 1, ...newTask };
    api.post.and.returnValue(Promise.resolve(createdTask));

    const result = await service.createTask(newTask as any);
    expect(result.id).toBe(1);
    expect(service.tasks).toContain(createdTask);
  });

  it('deletes a task and removes it from the list', async () => {
    service.tasks = [{ id: 1, title: 'Test', boardId: 1 } as any];
    api.delete.and.returnValue(Promise.resolve());

    await service.deleteTask(1);
    expect(service.tasks.length).toBe(0);
  });
});

Step 9: Style the Application

/* static/styles.css */
.task-board {
  display: flex;
  gap: 20px;
  padding: 20px;
}

.task-column {
  flex: 1;
  background: #f5f5f5;
  border-radius: 8px;
  padding: 16px;
  min-height: 300px;
}

.task-card {
  background: white;
  border: 1px solid #e0e0e0;
  border-radius: 6px;
  padding: 12px;
  margin-bottom: 12px;
  box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}

.priority-high { border-left: 4px solid #e74c3c; }
.priority-medium { border-left: 4px solid #f39c12; }
.priority-low { border-left: 4px solid #27ae60; }

Step 10: Deploy the Application

Build for production:

au build --env prod

The built files are in the dist/ directory. Deploy to any static server:

npx serve dist/

For production hosting, consider Netlify, Vercel, or GitHub Pages with proper redirect rules for pushState routing.

Common Mistakes

  1. Not handling token expiration - Tokens expire. The interceptor should redirect to login on 401 errors, but also attempt a token refresh before giving up.

  2. Mutating state directly - Always use methods on the service (createTask, updateTask, deleteTask) instead of directly pushing to or splicing the tasks array, so the service stays as the single source of truth.

  3. Missing error boundaries - Every async method should have try/catch blocks to prevent unhandled promise rejections from crashing the app.

  4. PushState routing without server fallback - When deploying with pushState, the server must serve index.html for all routes, or users get 404 errors on page refresh.

  5. Not cleaning up subscriptions - If you use EventAggregator or observe arrays, unsubscribe in deactivate() to prevent memory leaks.

Practice Questions

  1. What is the benefit of using a service layer between components and the HTTP client?
  2. How does the interceptor pattern help with authentication across the application?
  3. Why should the task service own the tasks array instead of individual components?
  4. What server configuration is required for pushState routing in production?
  5. How do you prevent memory leaks from event subscriptions in Aurelia components?

Challenge: Add drag-and-drop support to move tasks between columns, implement a real-time collaboration feature using WebSockets, and add unit tests for the drag-and-drop logic.

FAQ

How do I handle file uploads in the project?

Use FormData with the HttpClient. Set the body to a FormData object and do not set the Content-Type header; the browser sets it automatically with the boundary parameter.

Can I use Aurelia Store for state management?

Yes, aurelia-store provides a Redux-like store. It is useful for complex state that many components need to access, such as the current user or board data.

How do I implement real-time updates?

Use the WebSocket API directly or a library like Socket.IO. Listen for events in a service and update the task array, then manually trigger change detection.

What is the best way to handle environment-specific configuration?

Use the aurelia-env plugin or create a config service that reads from a JSON file bundled with the output. The CLI's --env flag lets you swap configurations per build.

How do I optimize production build size?

Enable tree-shaking in the bundler, lazy-load routes with dynamic imports, remove debug logging in production with build-time flags, and enable gzip compression on the server.

Summary

You have built a complete task management application with Aurelia. The project demonstrates component composition, child routing, HTTP communication with interceptors, form validation, and automated testing. These patterns apply directly to production Aurelia applications of any scale.

What's Next

Explore other frameworks in the Aurelia ecosystem: Aurelia 2 for the next generation of this framework.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro