This capstone brings routing, guards, services, forms, HTTP and testing together. It is a guided brief with a verified starter, not a full walkthrough. Planning the app yourself is part of the exercise.
The brief
Build an online learning platform where a student can:
- Browse a list of courses and open a course page.
- Open a lesson and mark it complete.
- See progress for every course (for example "3 of 5 lessons done").
- Sign in (a fake login is fine) and see "My learning" only when signed in.
- Keep progress after a page refresh.
Plan before coding
Write these down first:
- Pages (routes):
/courses,/courses/:courseId,/courses/:courseId/lessons/:lessonId,/my-learning(guarded),/login. - Data:
Coursehas manyLessonitems.Progressis a set of completed lesson ids per student. - Services:
CourseCatalog(data),ProgressStore(completed lessons, saved tolocalStorage),Auth(from the guards lesson). - Folders:
courses/,lessons/,progress/,auth/. Each feature is lazy loaded.
Starter: data and progress store
// src/app/learning/learning.model.ts
export interface Lesson {
id: string;
title: string;
minutes: number;
}
export interface LearningCourse {
id: string;
title: string;
summary: string;
lessons: Lesson[];
}
// src/app/learning/course-catalog.ts
import { Injectable } from '@angular/core';
import { LearningCourse } from './learning.model';
@Injectable({ providedIn: 'root' })
export class CourseCatalog {
private readonly courses: LearningCourse[] = [
{
id: 'angular-basics',
title: 'Angular Basics',
summary: 'Components, templates and signals.',
lessons: [
{ id: 'components', title: 'Your first component', minutes: 10 },
{ id: 'templates', title: 'Templates and binding', minutes: 12 },
{ id: 'signals', title: 'Signals', minutes: 15 },
],
},
{
id: 'typescript-basics',
title: 'TypeScript Basics',
summary: 'Types, interfaces and classes.',
lessons: [
{ id: 'types', title: 'Basic types', minutes: 8 },
{ id: 'interfaces', title: 'Interfaces', minutes: 9 },
],
},
];
all(): LearningCourse[] {
return this.courses;
}
find(id: string): LearningCourse | undefined {
return this.courses.find((course) => course.id === id);
}
}
// src/app/learning/progress-store.ts
import { Injectable, signal } from '@angular/core';
import { LearningCourse } from './learning.model';
const KEY = 'learning-progress';
@Injectable({ providedIn: 'root' })
export class ProgressStore {
// Keys look like "angular-basics/signals"
private readonly done = signal<string[]>(this.read());
isDone(courseId: string, lessonId: string): boolean {
return this.done().includes(`${courseId}/${lessonId}`);
}
toggle(courseId: string, lessonId: string): void {
const key = `${courseId}/${lessonId}`;
this.done.update((list) => (list.includes(key) ? list.filter((k) => k !== key) : [...list, key]));
this.write();
}
completedCount(course: LearningCourse): number {
return course.lessons.filter((lesson) => this.isDone(course.id, lesson.id)).length;
}
percent(course: LearningCourse): number {
if (course.lessons.length === 0) {
return 0;
}
return Math.round((this.completedCount(course) / course.lessons.length) * 100);
}
private read(): string[] {
try {
const raw = localStorage.getItem(KEY);
return raw ? (JSON.parse(raw) as string[]) : [];
} catch {
return [];
}
}
private write(): void {
try {
localStorage.setItem(KEY, JSON.stringify(this.done()));
} catch {
// The app still works without saving.
}
}
}
Methods such as isDone() read the done signal, so templates that call them update automatically when progress changes.
Starter: the course list page
// src/app/learning/course-list.ts
import { Component, inject } from '@angular/core';
import { RouterLink } from '@angular/router';
import { CourseCatalog } from './course-catalog';
import { ProgressStore } from './progress-store';
@Component({
selector: 'app-course-list',
imports: [RouterLink],
template: `
<h2>Courses</h2>
<ul class="courses">
@for (course of catalog.all(); track course.id) {
<li>
<h3><a [routerLink]="['/courses', course.id]">{{ course.title }}</a></h3>
<p>{{ course.summary }}</p>
<progress [value]="progress.percent(course)" max="100" [attr.aria-label]="course.title + ' progress'"></progress>
<span>{{ progress.completedCount(course) }} of {{ course.lessons.length }} lessons done</span>
</li>
}
</ul>
`,
styles: `
.courses { list-style: none; padding: 0; display: grid; gap: 1rem; }
li { border: 1px solid #ddd; border-radius: .5rem; padding: 1rem; }
`,
})
export class CourseList {
protected readonly catalog = inject(CourseCatalog);
protected readonly progress = inject(ProgressStore);
}
// src/app/app.routes.ts
import { Routes } from '@angular/router';
import { CourseList } from './learning/course-list';
export const routes: Routes = [
{ path: '', pathMatch: 'full', redirectTo: 'courses' },
{ path: 'courses', component: CourseList, title: 'Courses' },
// TODO (your work): course page, lesson page, my-learning (guarded), login
{ path: '**', redirectTo: 'courses' },
];
// src/app/app.ts
import { Component } from '@angular/core';
import { RouterLink, RouterOutlet } from '@angular/router';
@Component({
imports: [RouterOutlet, RouterLink],
selector: 'app-root',
templateUrl: './app.html',
styleUrl: './app.css',
})
export class App {}
<!-- src/app/app.html -->
<header>
<a routerLink="/courses"><strong>Learning Platform</strong></a>
</header>
<main>
<router-outlet />
</main>
Your tasks
- Course page at
/courses/:courseId: show the title and the list of lessons with a checkmark for completed ones. Handle an unknown course id. - Lesson page at
/courses/:courseId/lessons/:lessonId: show the lesson, a "Mark as complete" toggle, and Previous and Next lesson links. - My learning at
/my-learning: list only courses with progress above zero. Protect it withauthGuardand add/login, reusing theAuthservice from the route guards lesson. - Lazy load the courses, lessons and my-learning areas.
- Tests: at least four tests for
ProgressStore(toggle, percent, empty course, persistence). - Accessibility: keyboard-only use works, every page has one
h1orh2heading structure, and progress bars have labels.
Acceptance criteria
- Completing a lesson updates the progress on the course list immediately.
- Refreshing the page keeps the progress.
- Visiting
/my-learningwhile signed out redirects to/loginand returns afterwards. - Unknown course or lesson ids show a friendly message with a link back.
ng buildandng test --watch=falsepass with no warnings.
Stretch goals
- Load courses from a JSON file or API with
HttpClient, with loading and error states. - Add a search box with a debounced
valueChangesstream. - Add server-side rendering for the public course pages so they can be indexed by search engines.
- Deploy the finished app with the next lesson.
Quick check
- Why can a template call
progress.isDone()and still update correctly? - Why is a route guard not enough to protect private data?
- Which parts of this app are good candidates for lazy loading?
Finish by deploying your Angular app.