On this page

Final project: complete TaskBoard

25 min read TextCh. 5 — Production

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 card

TaskService: 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

  1. Drag and drop — Implement dragging cards between columns using CDK DragDrop
  2. Filters — Add priority filtering using a signal + computed
  3. Search — Use a FormControl with debounceTime to search tasks
  4. Real API — Connect with Firebase Firestore using HttpClient or @angular/fire
  5. Tests — Write unit tests for TaskService with Vitest
  6. 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.

Extend the project
Additional challenges: add persistence with localStorage using an effect(), implement drag-and-drop between columns, add priority filters with computed(), or connect with a real API using HttpClient.
Recommended structure
Organize the project with feature folders: task-board/ contains the main component, task-column/ the column component, task-card/ the individual card, and task.service.ts the state logic.
// === 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);
  }
}