Time to combine what you learned. In this project you build a Task Manager where you add tasks with a priority, mark them done, filter them, delete them, and keep them after a page refresh.
What you will practise
- Signals,
computed()and a store service - Reactive forms with validation
- Component inputs and outputs
- Saving data in
localStorage - A unit test for the store
Step 1: The model
// src/app/manager/manager.model.ts
export type Priority = 'low' | 'medium' | 'high';
export type Filter = 'all' | 'open' | 'done';
export interface ManagedTask {
id: number;
title: string;
priority: Priority;
done: boolean;
}
Step 2: The store
The store holds the data and all the rules. Components stay small.
// src/app/manager/manager-store.ts
import { Injectable, computed, signal } from '@angular/core';
import { Filter, ManagedTask, Priority } from './manager.model';
const STORAGE_KEY = 'manager-tasks';
@Injectable({ providedIn: 'root' })
export class ManagerStore {
private readonly tasks = signal<ManagedTask[]>(this.read());
readonly filter = signal<Filter>('all');
readonly visible = computed(() => {
const mode = this.filter();
return this.tasks().filter((task) =>
mode === 'all' ? true : mode === 'done' ? task.done : !task.done,
);
});
readonly total = computed(() => this.tasks().length);
readonly openCount = computed(() => this.tasks().filter((task) => !task.done).length);
add(title: string, priority: Priority): void {
const clean = title.trim();
if (!clean) {
return;
}
const nextId = this.tasks().reduce((max, task) => Math.max(max, task.id), 0) + 1;
this.update((list) => [...list, { id: nextId, title: clean, priority, done: false }]);
}
toggle(id: number): void {
this.update((list) => list.map((task) => (task.id === id ? { ...task, done: !task.done } : task)));
}
remove(id: number): void {
this.update((list) => list.filter((task) => task.id !== id));
}
clearDone(): void {
this.update((list) => list.filter((task) => !task.done));
}
private update(change: (list: ManagedTask[]) => ManagedTask[]): void {
this.tasks.update(change);
this.write();
}
private read(): ManagedTask[] {
try {
const raw = localStorage.getItem(STORAGE_KEY);
return raw ? (JSON.parse(raw) as ManagedTask[]) : [];
} catch {
return []; // storage blocked or data damaged
}
}
private write(): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(this.tasks()));
} catch {
// Ignore: the app still works, it just will not remember the tasks.
}
}
}
Two details matter here. The signal is never changed in place: we create new arrays and objects with map, filter and the spread operator. And every localStorage call is in try/catch, because browsers can block storage or the saved text can be damaged.
Step 3: The form component
// src/app/manager/manager-form.ts
import { Component, inject } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { ManagerStore } from './manager-store';
import { Priority } from './manager.model';
@Component({
selector: 'app-manager-form',
imports: [ReactiveFormsModule],
template: `
<form [formGroup]="form" (ngSubmit)="submit()" novalidate>
<label for="title">New task</label>
<input id="title" formControlName="title" placeholder="What needs to be done?" />
<label for="priority">Priority</label>
<select id="priority" formControlName="priority">
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
</select>
<button type="submit">Add task</button>
@if (form.controls.title.invalid && form.controls.title.touched) {
<p role="alert">Enter a title of at least 3 characters.</p>
}
</form>
`,
styles: `
form { display: flex; flex-wrap: wrap; gap: .5rem; align-items: center; }
`,
})
export class ManagerForm {
private readonly store = inject(ManagerStore);
private readonly fb = inject(FormBuilder).nonNullable;
protected readonly form = this.fb.group({
title: ['', [Validators.required, Validators.minLength(3)]],
priority: ['medium' as Priority],
});
protected submit(): void {
if (this.form.invalid) {
this.form.markAllAsTouched();
return;
}
const { title, priority } = this.form.getRawValue();
this.store.add(title, priority);
this.form.reset({ title: '', priority: 'medium' });
}
}
Step 4: The list
// src/app/manager/manager-list.ts
import { Component, inject } from '@angular/core';
import { ManagerStore } from './manager-store';
import { Filter } from './manager.model';
@Component({
selector: 'app-manager-list',
template: `
<p role="status">{{ store.openCount() }} open of {{ store.total() }} tasks</p>
<div role="group" aria-label="Filter tasks">
@for (f of filters; track f.value) {
<button type="button" [attr.aria-pressed]="store.filter() === f.value" (click)="store.filter.set(f.value)">
{{ f.label }}
</button>
}
<button type="button" (click)="store.clearDone()">Clear completed</button>
</div>
<ul>
@for (task of store.visible(); track task.id) {
<li [class.done]="task.done">
<label>
<input type="checkbox" [checked]="task.done" (change)="store.toggle(task.id)" />
{{ task.title }}
</label>
<span class="priority priority-{{ task.priority }}">{{ task.priority }}</span>
<button type="button" (click)="store.remove(task.id)" [attr.aria-label]="'Delete ' + task.title">Delete</button>
</li>
} @empty {
<li>No tasks to show.</li>
}
</ul>
`,
styles: `
ul { list-style: none; padding: 0; }
li { display: flex; gap: .75rem; align-items: center; padding: .4rem 0; border-bottom: 1px solid #ddd; }
li.done label { text-decoration: line-through; opacity: .6; }
button[aria-pressed='true'] { font-weight: bold; }
.priority { font-size: .8rem; padding: 0 .5rem; border-radius: 1rem; background: #eee; }
.priority-high { background: #fcd5d5; }
.priority-medium { background: #fdf0c4; }
.priority-low { background: #d6efd6; }
`,
})
export class ManagerList {
protected readonly store = inject(ManagerStore);
protected readonly filters: { value: Filter; label: string }[] = [
{ value: 'all', label: 'All' },
{ value: 'open', label: 'Open' },
{ value: 'done', label: 'Done' },
];
}
Step 5: Put it together
// src/app/app.ts
import { Component } from '@angular/core';
import { ManagerForm } from './manager/manager-form';
import { ManagerList } from './manager/manager-list';
@Component({
imports: [ManagerForm, ManagerList],
selector: 'app-root',
templateUrl: './app.html',
styleUrl: './app.css',
})
export class App {}
<!-- src/app/app.html -->
<main>
<h1>Task Manager</h1>
<app-manager-form />
<app-manager-list />
</main>
Run ng serve, add a few tasks and refresh the page. The tasks stay.
Step 6: Test the store
// src/app/manager/manager-store.spec.ts
import { TestBed } from '@angular/core/testing';
import { ManagerStore } from './manager-store';
describe('ManagerStore', () => {
let store: ManagerStore;
beforeEach(() => {
localStorage.clear();
store = TestBed.inject(ManagerStore);
});
it('adds a task and ignores an empty title', () => {
store.add('Learn signals', 'high');
store.add(' ', 'low');
expect(store.total()).toBe(1);
expect(store.openCount()).toBe(1);
});
it('toggles and filters tasks', () => {
store.add('One', 'low');
store.add('Two', 'medium');
store.toggle(1);
store.filter.set('done');
expect(store.visible().map((t) => t.title)).toEqual(['One']);
store.filter.set('open');
expect(store.visible().map((t) => t.title)).toEqual(['Two']);
});
it('removes completed tasks', () => {
store.add('One', 'low');
store.add('Two', 'low');
store.toggle(1);
store.clearDone();
expect(store.total()).toBe(1);
});
it('saves tasks in localStorage', () => {
store.add('Remember me', 'low');
expect(localStorage.getItem('manager-tasks')).toContain('Remember me');
});
});
Run ng test --watch=false. All tests should pass.
Challenges
- Add an "Edit" button that changes the title of a task.
- Sort tasks by priority (high first) using
computed(). - Add a due date field and highlight overdue tasks.
- Move the page into a route and lazy load it.
Quick check
- Why does the store create new arrays instead of changing the old one?
- Why is
localStoragewrapped intry/catch? - Which part of the app holds the business rules?
Next project: an Employee Management Dashboard.