Angular Project 3: Online Learning Platform (Capstone)

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

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.