Aurelia Capstone Project: Building a Task Management Application
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
Not handling token expiration - Tokens expire. The interceptor should redirect to login on 401 errors, but also attempt a token refresh before giving up.
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.
Missing error boundaries - Every async method should have try/catch blocks to prevent unhandled promise rejections from crashing the app.
PushState routing without server fallback - When deploying with pushState, the server must serve
index.htmlfor all routes, or users get 404 errors on page refresh.Not cleaning up subscriptions - If you use
EventAggregatoror observe arrays, unsubscribe indeactivate()to prevent memory leaks.
Practice Questions
- What is the benefit of using a service layer between components and the HTTP client?
- How does the interceptor pattern help with authentication across the application?
- Why should the task service own the tasks array instead of individual components?
- What server configuration is required for pushState routing in production?
- 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
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