On this page
Final project: complete TaskBoard
Final project: TaskBoard
In this final lesson, we will integrate everything we have learned to build a complete Kanban-style TaskBoard. This application demonstrates the fundamental concepts of Angular 21.
Integrated concepts
This project uses:
| Concept | Lesson | Usage in the project |
|---|---|---|
| Components | L2 | TaskBoard, TaskColumn, TaskCard |
| Templates | L3 | @if, @for, @switch with control flow |
| Signals | L4 | Task state, filters |
| Computed | L4 | Statistics, column filtering |
| Effects | L5 | localStorage persistence |
| Inputs/Outputs | L6 | Communication between components |
| Services | L7 | Centralized TaskService |
| Routing | L8 | Navigation between views |
| Forms | L9 | Task creation |
| Pipes | L12 | Date and text formatting |
Project architecture
task-board/
task.service.ts # Global state with signals
task-board.ts # Main container component
task-board.html # Board template
task-column.ts # Kanban column (pending/in progress/completed)
task-card.ts # Individual task cardTaskService: centralized state
The service manages all task state. It uses signals for reactivity and computed to derive the columns:
// Private state
private readonly _tasks = signal<Task[]>([]);
// Public reads
readonly tasks = this._tasks.asReadonly();
readonly pending = computed(() =>
this._tasks().filter(t => t.status === 'pending')
);The key pattern is: private state, public reading, mutation via methods.
TaskColumn: presentational component
Each column receives its task list via input and emits events to the parent:
@Component({
selector: 'app-task-column',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [TaskCard],
template: `
<div class="column">
<h2>{{ title() }} ({{ tasks().length }})</h2>
@for (task of tasks(); track task.id) {
<app-task-card
[task]="task"
(move)="move.emit($event)"
(remove)="remove.emit($event)"
/>
} @empty {
<p class="empty">No tasks</p>
}
</div>
`,
})
export class TaskColumn {
readonly title = input.required<string>();
readonly tasks = input.required<Task[]>();
readonly move = output<{ id: number; status: Task['status'] }>();
readonly remove = output<number>();
}TaskCard: individual card
Each card displays a task's information with actions:
@Component({
selector: 'app-task-card',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<article class="card" [class]="'priority-' + task().priority">
<h3>{{ task().title }}</h3>
@if (task().description) {
<p>{{ task().description }}</p>
}
<div class="actions">
@switch (task().status) {
@case ('pending') {
<button (click)="move.emit({ id: task().id, status: 'in-progress' })">
Start
</button>
}
@case ('in-progress') {
<button (click)="move.emit({ id: task().id, status: 'completed' })">
Complete
</button>
}
}
<button (click)="remove.emit(task().id)">Delete</button>
</div>
</article>
`,
})
export class TaskCard {
readonly task = input.required<Task>();
readonly move = output<{ id: number; status: Task['status'] }>();
readonly remove = output<number>();
}Persistence with effect
Add local persistence with an effect in the service:
constructor() {
// Load saved tasks
const saved = localStorage.getItem('tasks');
if (saved) {
this._tasks.set(JSON.parse(saved));
}
// Save automatically every time they change
effect(() => {
localStorage.setItem('tasks', JSON.stringify(this._tasks()));
});
}Challenges to keep learning
- Drag and drop — Implement dragging cards between columns using CDK DragDrop
- Filters — Add priority filtering using a signal + computed
- Search — Use a FormControl with debounceTime to search tasks
- Real API — Connect with Firebase Firestore using HttpClient or @angular/fire
- Tests — Write unit tests for TaskService with Vitest
- Routing — Add a task detail view with route parameters
Congratulations
You have completed the Angular for Developers course. You now have the foundations to build modern Angular 21 applications with:
- Standalone components and signals
- Native control flow in templates
- Services with dependency injection
- Reactive forms with validation
- HttpClient and RxJS
- SSR and pre-rendering
- Basic testing
Keep practicing by building real projects. Check the official documentation at angular.dev to dive deeper into each topic.
// === task.service.ts ===
import { Injectable, signal, computed } from '@angular/core';
interface Task {
id: number;
title: string;
description: string;
status: 'pending' | 'in-progress' | 'completed';
priority: 'high' | 'medium' | 'low';
createdAt: Date;
}
@Injectable({ providedIn: 'root' })
export class TaskService {
private readonly _tasks = signal<Task[]>([]);
readonly tasks = this._tasks.asReadonly();
readonly pending = computed(
() => this._tasks().filter(t => t.status === 'pending')
);
readonly inProgress = computed(
() => this._tasks().filter(t => t.status === 'in-progress')
);
readonly completed = computed(
() => this._tasks().filter(t => t.status === 'completed')
);
readonly totalTasks = computed(() => this._tasks().length);
readonly completionPercentage = computed(() => {
const total = this._tasks().length;
if (total === 0) return 0;
return Math.round(
(this._tasks().filter(t => t.status === 'completed').length / total) * 100
);
});
add(title: string, description: string, priority: Task['priority']): void {
this._tasks.update(list => [
...list,
{
id: Date.now(),
title,
description,
status: 'pending',
priority,
createdAt: new Date(),
},
]);
}
moveStatus(id: number, status: Task['status']): void {
this._tasks.update(list =>
list.map(t => t.id === id ? { ...t, status } : t)
);
}
remove(id: number): void {
this._tasks.update(list => list.filter(t => t.id !== id));
}
}
// === task-board.ts ===
import {
Component, inject, signal,
ChangeDetectionStrategy,
} from '@angular/core';
import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms';
import { TaskService } from './task.service';
import { TaskColumn } from './task-column';
@Component({
selector: 'app-task-board',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ReactiveFormsModule, TaskColumn],
templateUrl: './task-board.html',
})
export class TaskBoard {
readonly taskService = inject(TaskService);
private readonly fb = inject(FormBuilder);
readonly showForm = signal(false);
readonly form = this.fb.nonNullable.group({
title: ['', [Validators.required, Validators.minLength(3)]],
description: [''],
priority: ['medium' as const, Validators.required],
});
addTask(): void {
if (this.form.invalid) return;
const { title, description, priority } = this.form.getRawValue();
this.taskService.add(title, description, priority);
this.form.reset({ title: '', description: '', priority: 'medium' });
this.showForm.set(false);
}
}
Sign in to track your progress