Angular Project 3: Online Learning Platform (Capstone)

Angular Tutorial: Beginner to Advanced · 12. Projects and deployment · 5 min read · Updated

Learning platform

  1. The capstone combines most of what you learned.
  2. Login uses a guard and sends a token with requests.
  3. Courses load from an API, on lazy routes.
  4. A lesson page reads its id from the route.
  5. Progress is kept in signals and saved back to the API.

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:

  1. Browse a list of courses and open a course page.
  2. Open a lesson and mark it complete.
  3. See progress for every course (for example "3 of 5 lessons done").
  4. Sign in (a fake login is fine) and see "My learning" only when signed in.
  5. 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: Course has many Lesson items. Progress is a set of completed lesson ids per student.
  • Services: CourseCatalog (data), ProgressStore (completed lessons, saved to localStorage), 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

  1. Course page at /courses/:courseId: show the title and the list of lessons with a checkmark for completed ones. Handle an unknown course id.
  2. Lesson page at /courses/:courseId/lessons/:lessonId: show the lesson, a "Mark as complete" toggle, and Previous and Next lesson links.
  3. My learning at /my-learning: list only courses with progress above zero. Protect it with authGuard and add /login, reusing the Auth service from the route guards lesson.
  4. Lazy load the courses, lessons and my-learning areas.
  5. Tests: at least four tests for ProgressStore (toggle, percent, empty course, persistence).
  6. Accessibility: keyboard-only use works, every page has one h1 or h2 heading 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-learning while signed out redirects to /login and returns afterwards.
  • Unknown course or lesson ids show a friendly message with a link back.
  • ng build and ng test --watch=false pass 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 valueChanges stream.
  • 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

  1. Why can a template call progress.isDone() and still update correctly?
  2. Why is a route guard not enough to protect private data?
  3. Which parts of this app are good candidates for lazy loading?

Finish by deploying your Angular app.

Frequently asked questions

Should I write all the code myself or use AI?

Use AI as a tutor: ask it to explain errors or review your code, but type and understand the solution yourself. Employers test understanding, not copying.

Is this enough for a portfolio?

Yes if you add a clear README, screenshots, tests and a live link. Quality and a short write-up matter more than size.