Skip to main content

Documentation Index

Fetch the complete documentation index at: https://resources.devweekends.com/llms.txt

Use this file to discover all available pages before exploring further.

Angular Capstone Project

Capstone Overview

Estimated Time: 8-12 hours | Difficulty: Advanced | Prerequisites: All previous modules
Congratulations on reaching the capstone. This is where everything comes together. You will build a Task Management Application (like Trello) that incorporates the full spectrum of Angular skills from this course — components, signals, services, routing, forms, HTTP, RxJS, change detection, and testing. The project is structured in phases that mirror how real applications evolve. You will start with core setup and data models, build the UI components, add real-time functionality, and then test everything. Each phase builds on the previous one, just like sprints in a real development cycle. Project Features:
  • User authentication with JWT
  • Real-time task updates
  • Drag-and-drop kanban board
  • Team collaboration
  • Responsive design
  • Full CRUD operations

Project Architecture

┌─────────────────────────────────────────────────────────────────────────┐
│              Task Manager Application Architecture                       │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                          │
│   ┌─────────────────────────────────────────────────────────────────┐   │
│   │                        FEATURES                                  │   │
│   ├─────────────────┬─────────────────┬─────────────────────────────┤   │
│   │      Auth       │     Boards      │         Tasks               │   │
│   │  ─────────────  │  ─────────────  │  ─────────────────────────  │   │
│   │  • Login        │  • Board List   │  • Task Cards               │   │
│   │  • Register     │  • Board Detail │  • Task Form                │   │
│   │  • Profile      │  • Board Form   │  • Drag & Drop              │   │
│   └─────────────────┴─────────────────┴─────────────────────────────┘   │
│                                                                          │
│   ┌─────────────────────────────────────────────────────────────────┐   │
│   │                        SHARED                                    │   │
│   ├─────────────────────────────────────────────────────────────────┤   │
│   │  Components: Button, Card, Modal, Input, Spinner, Toast         │   │
│   │  Directives: ClickOutside, Autofocus, Permission                │   │
│   │  Pipes: TimeAgo, FilterBy, SortBy                               │   │
│   └─────────────────────────────────────────────────────────────────┘   │
│                                                                          │
│   ┌─────────────────────────────────────────────────────────────────┐   │
│   │                        CORE                                      │   │
│   ├─────────────────────────────────────────────────────────────────┤   │
│   │  Services: Auth, API, WebSocket, Toast, Storage                  │   │
│   │  Guards: Auth, Role, Board Owner                                 │   │
│   │  Interceptors: Auth, Error, Loading                              │   │
│   └─────────────────────────────────────────────────────────────────┘   │
│                                                                          │
└─────────────────────────────────────────────────────────────────────────┘

Project Setup

Why these choices? SCSS gives you variables, nesting, and mixins — essential for any project beyond a demo. The --ssr flag sets up server-side rendering from the start (retrofitting it later is more work). Angular CDK provides drag-and-drop without a full component library. Tailwind handles utility styling so you can move fast without writing custom CSS for every element.
# Create the project with SCSS, routing, and SSR from the start.
# Adding these later is possible but more disruptive to the codebase.
ng new task-manager --style=scss --routing --ssr

# Add required packages
cd task-manager
npm install @angular/cdk        # Drag-and-drop, virtual scroll, a11y utilities
npm install -D tailwindcss postcss autoprefixer  # Utility-first CSS framework
npx tailwindcss init             # Generates tailwind.config.js

Folder Structure

src/app/
├── core/
│   ├── guards/
│   │   ├── auth.guard.ts
│   │   └── board-owner.guard.ts
│   ├── interceptors/
│   │   ├── auth.interceptor.ts
│   │   ├── error.interceptor.ts
│   │   └── loading.interceptor.ts
│   ├── services/
│   │   ├── auth.service.ts
│   │   ├── api.service.ts
│   │   ├── websocket.service.ts
│   │   └── toast.service.ts
│   └── models/
│       ├── user.model.ts
│       ├── board.model.ts
│       └── task.model.ts
├── shared/
│   ├── components/
│   │   ├── button/
│   │   ├── card/
│   │   ├── modal/
│   │   └── ...
│   ├── directives/
│   └── pipes/
├── features/
│   ├── auth/
│   │   ├── login/
│   │   ├── register/
│   │   └── auth.routes.ts
│   ├── boards/
│   │   ├── board-list/
│   │   ├── board-detail/
│   │   └── boards.routes.ts
│   └── tasks/
│       ├── task-card/
│       ├── task-form/
│       └── task-column/
├── app.component.ts
├── app.config.ts
└── app.routes.ts

Phase 1: Core Setup

Models

// core/models/user.model.ts
export interface User {
  id: string;
  email: string;
  name: string;
  avatar?: string;
  createdAt: Date;
}

// core/models/board.model.ts
export interface Board {
  id: string;
  name: string;
  description?: string;
  ownerId: string;
  members: string[];
  columns: Column[];
  createdAt: Date;
  updatedAt: Date;
}

export interface Column {
  id: string;
  name: string;
  order: number;
  taskIds: string[];
}

// core/models/task.model.ts
export interface Task {
  id: string;
  title: string;
  description?: string;
  columnId: string;
  boardId: string;
  assigneeId?: string;
  priority: 'low' | 'medium' | 'high';
  dueDate?: Date;
  labels: string[];
  order: number;
  createdAt: Date;
  updatedAt: Date;
}

API Service

The API service is your single point of contact with the backend. Every HTTP call goes through here, which makes it easy to change the base URL, add consistent error handling, or swap the backend entirely without touching component code.
// core/services/api.service.ts
@Injectable({ providedIn: 'root' })
export class ApiService {
  private http = inject(HttpClient);
  private baseUrl = environment.apiUrl;  // Configured per environment (dev, staging, prod)
  
  // Boards
  getBoards(): Observable<Board[]> {
    return this.http.get<Board[]>(`${this.baseUrl}/boards`);
  }
  
  getBoard(id: string): Observable<Board> {
    return this.http.get<Board>(`${this.baseUrl}/boards/${id}`);
  }
  
  createBoard(data: CreateBoardDto): Observable<Board> {
    return this.http.post<Board>(`${this.baseUrl}/boards`, data);
  }
  
  updateBoard(id: string, data: UpdateBoardDto): Observable<Board> {
    return this.http.patch<Board>(`${this.baseUrl}/boards/${id}`, data);
  }
  
  deleteBoard(id: string): Observable<void> {
    return this.http.delete<void>(`${this.baseUrl}/boards/${id}`);
  }
  
  // Tasks
  getTasks(boardId: string): Observable<Task[]> {
    return this.http.get<Task[]>(`${this.baseUrl}/boards/${boardId}/tasks`);
  }
  
  createTask(boardId: string, data: CreateTaskDto): Observable<Task> {
    return this.http.post<Task>(`${this.baseUrl}/boards/${boardId}/tasks`, data);
  }
  
  updateTask(taskId: string, data: UpdateTaskDto): Observable<Task> {
    return this.http.patch<Task>(`${this.baseUrl}/tasks/${taskId}`, data);
  }
  
  deleteTask(taskId: string): Observable<void> {
    return this.http.delete<void>(`${this.baseUrl}/tasks/${taskId}`);
  }
  
  moveTask(taskId: string, columnId: string, order: number): Observable<Task> {
    return this.http.patch<Task>(`${this.baseUrl}/tasks/${taskId}/move`, {
      columnId,
      order
    });
  }
}

Board Facade

// features/boards/state/board.facade.ts
@Injectable({ providedIn: 'root' })
export class BoardFacade {
  private api = inject(ApiService);
  private toast = inject(ToastService);
  private websocket = inject(WebSocketService);
  
  // State
  private _boards = signal<Board[]>([]);
  private _currentBoard = signal<Board | null>(null);
  private _tasks = signal<Map<string, Task>>(new Map());
  private _loading = signal(false);
  
  // Selectors
  readonly boards = this._boards.asReadonly();
  readonly currentBoard = this._currentBoard.asReadonly();
  readonly loading = this._loading.asReadonly();
  
  readonly columns = computed(() => {
    const board = this._currentBoard();
    if (!board) return [];
    return [...board.columns].sort((a, b) => a.order - b.order);
  });
  
  readonly tasksByColumn = computed(() => {
    const map = new Map<string, Task[]>();
    const tasks = this._tasks();
    
    for (const column of this.columns()) {
      const columnTasks = column.taskIds
        .map(id => tasks.get(id))
        .filter((t): t is Task => !!t)
        .sort((a, b) => a.order - b.order);
      map.set(column.id, columnTasks);
    }
    
    return map;
  });
  
  // Actions
  async loadBoards(): Promise<void> {
    this._loading.set(true);
    try {
      const boards = await firstValueFrom(this.api.getBoards());
      this._boards.set(boards);
    } finally {
      this._loading.set(false);
    }
  }
  
  async loadBoard(id: string): Promise<void> {
    this._loading.set(true);
    try {
      const [board, tasks] = await Promise.all([
        firstValueFrom(this.api.getBoard(id)),
        firstValueFrom(this.api.getTasks(id))
      ]);
      
      this._currentBoard.set(board);
      this._tasks.set(new Map(tasks.map(t => [t.id, t])));
      
      // Subscribe to real-time updates
      this.websocket.joinBoard(id);
    } finally {
      this._loading.set(false);
    }
  }
  
  async createTask(data: CreateTaskDto): Promise<void> {
    const board = this._currentBoard();
    if (!board) return;
    
    const task = await firstValueFrom(this.api.createTask(board.id, data));
    this._tasks.update(map => new Map(map).set(task.id, task));
    this.updateColumnTaskIds(data.columnId, [...this.getColumnTaskIds(data.columnId), task.id]);
    this.toast.success('Task created');
  }
  
  async updateTask(taskId: string, data: UpdateTaskDto): Promise<void> {
    const task = await firstValueFrom(this.api.updateTask(taskId, data));
    this._tasks.update(map => new Map(map).set(taskId, task));
    this.toast.success('Task updated');
  }
  
  async moveTask(taskId: string, targetColumnId: string, newOrder: number): Promise<void> {
    const task = this._tasks().get(taskId);
    if (!task) return;
    
    const sourceColumnId = task.columnId;
    
    // Optimistic update
    this._tasks.update(map => {
      const newMap = new Map(map);
      newMap.set(taskId, { ...task, columnId: targetColumnId, order: newOrder });
      return newMap;
    });
    
    // Update column task IDs
    if (sourceColumnId !== targetColumnId) {
      this.updateColumnTaskIds(
        sourceColumnId, 
        this.getColumnTaskIds(sourceColumnId).filter(id => id !== taskId)
      );
      const targetIds = this.getColumnTaskIds(targetColumnId);
      targetIds.splice(newOrder, 0, taskId);
      this.updateColumnTaskIds(targetColumnId, targetIds);
    }
    
    // API call
    try {
      await firstValueFrom(this.api.moveTask(taskId, targetColumnId, newOrder));
    } catch {
      // Rollback on error
      this.loadBoard(this._currentBoard()!.id);
    }
  }
  
  async deleteTask(taskId: string): Promise<void> {
    const task = this._tasks().get(taskId);
    if (!task) return;
    
    await firstValueFrom(this.api.deleteTask(taskId));
    
    this._tasks.update(map => {
      const newMap = new Map(map);
      newMap.delete(taskId);
      return newMap;
    });
    
    this.updateColumnTaskIds(
      task.columnId,
      this.getColumnTaskIds(task.columnId).filter(id => id !== taskId)
    );
    
    this.toast.success('Task deleted');
  }
  
  private getColumnTaskIds(columnId: string): string[] {
    const board = this._currentBoard();
    return board?.columns.find(c => c.id === columnId)?.taskIds ?? [];
  }
  
  private updateColumnTaskIds(columnId: string, taskIds: string[]): void {
    this._currentBoard.update(board => {
      if (!board) return null;
      return {
        ...board,
        columns: board.columns.map(col =>
          col.id === columnId ? { ...col, taskIds } : col
        )
      };
    });
  }
}

Phase 2: Components

Board List Component

// features/boards/board-list/board-list.component.ts
@Component({
  selector: 'app-board-list',
  standalone: true,
  imports: [RouterLink],
  template: `
    <div class="container mx-auto p-6">
      <div class="flex justify-between items-center mb-6">
        <h1 class="text-2xl font-bold">My Boards</h1>
        <button 
          class="btn btn-primary"
          (click)="showCreateModal.set(true)"
        >
          Create Board
        </button>
      </div>
      
      @if (facade.loading()) {
        <div class="grid grid-cols-1 md:grid-cols-3 gap-4">
          @for (i of [1,2,3]; track i) {
            <div class="skeleton h-32 rounded-lg"></div>
          }
        </div>
      } @else {
        <div class="grid grid-cols-1 md:grid-cols-3 gap-4">
          @for (board of facade.boards(); track board.id) {
            <a 
              [routerLink]="['/boards', board.id]"
              class="card hover:shadow-lg transition-shadow"
            >
              <h2 class="font-semibold">{{ board.name }}</h2>
              <p class="text-gray-600 text-sm">{{ board.description }}</p>
              <div class="mt-4 flex items-center gap-2">
                <span class="text-xs text-gray-500">
                  {{ board.columns.length }} columns
                </span>
              </div>
            </a>
          } @empty {
            <div class="col-span-3 text-center py-12">
              <p class="text-gray-500 mb-4">No boards yet</p>
              <button 
                class="btn btn-primary"
                (click)="showCreateModal.set(true)"
              >
                Create your first board
              </button>
            </div>
          }
        </div>
      }
      
      @if (showCreateModal()) {
        <app-board-form
          (save)="createBoard($event)"
          (cancel)="showCreateModal.set(false)"
        />
      }
    </div>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class BoardListComponent implements OnInit {
  facade = inject(BoardFacade);
  showCreateModal = signal(false);
  
  ngOnInit() {
    this.facade.loadBoards();
  }
  
  async createBoard(data: CreateBoardDto) {
    await this.facade.createBoard(data);
    this.showCreateModal.set(false);
  }
}

Kanban Board Component

// features/boards/board-detail/board-detail.component.ts
@Component({
  selector: 'app-board-detail',
  standalone: true,
  imports: [TaskColumnComponent, CdkDragDrop, CdkDropList, CdkDropListGroup],
  template: `
    <div class="h-screen flex flex-col">
      <header class="p-4 border-b flex justify-between items-center">
        <div>
          <h1 class="text-xl font-bold">{{ facade.currentBoard()?.name }}</h1>
          <p class="text-sm text-gray-600">{{ facade.currentBoard()?.description }}</p>
        </div>
        <div class="flex gap-2">
          <button class="btn btn-secondary" (click)="showSettings.set(true)">
            Settings
          </button>
          <button class="btn btn-secondary" (click)="showInvite.set(true)">
            Invite
          </button>
        </div>
      </header>
      
      <div class="flex-1 overflow-x-auto p-4" cdkDropListGroup>
        <div class="flex gap-4 h-full">
          @for (column of facade.columns(); track column.id) {
            <app-task-column
              [column]="column"
              [tasks]="facade.tasksByColumn().get(column.id) ?? []"
              (taskMoved)="onTaskMoved($event)"
              (taskCreated)="onTaskCreated($event)"
              (taskClicked)="openTaskDetail($event)"
            />
          }
          
          <button 
            class="flex-shrink-0 w-72 h-12 rounded-lg border-2 border-dashed 
                   border-gray-300 flex items-center justify-center
                   hover:border-gray-400 transition-colors"
            (click)="addColumn()"
          >
            + Add Column
          </button>
        </div>
      </div>
    </div>
    
    @if (selectedTask()) {
      <app-task-detail
        [task]="selectedTask()!"
        (save)="updateTask($event)"
        (delete)="deleteTask($event)"
        (close)="selectedTask.set(null)"
      />
    }
  `,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class BoardDetailComponent implements OnInit {
  private route = inject(ActivatedRoute);
  facade = inject(BoardFacade);
  
  showSettings = signal(false);
  showInvite = signal(false);
  selectedTask = signal<Task | null>(null);
  
  ngOnInit() {
    const boardId = this.route.snapshot.params['id'];
    this.facade.loadBoard(boardId);
  }
  
  onTaskMoved(event: { taskId: string; columnId: string; order: number }) {
    this.facade.moveTask(event.taskId, event.columnId, event.order);
  }
  
  onTaskCreated(data: { columnId: string; title: string }) {
    this.facade.createTask({
      ...data,
      priority: 'medium'
    });
  }
  
  openTaskDetail(task: Task) {
    this.selectedTask.set(task);
  }
  
  updateTask(data: { id: string; updates: UpdateTaskDto }) {
    this.facade.updateTask(data.id, data.updates);
    this.selectedTask.set(null);
  }
  
  deleteTask(taskId: string) {
    this.facade.deleteTask(taskId);
    this.selectedTask.set(null);
  }
  
  addColumn() {
    // Implement column creation
  }
}

Task Column with Drag & Drop

// features/tasks/task-column/task-column.component.ts
@Component({
  selector: 'app-task-column',
  standalone: true,
  imports: [CdkDropList, CdkDrag, TaskCardComponent],
  template: `
    <div class="flex-shrink-0 w-72 bg-gray-100 rounded-lg p-3 flex flex-col h-full">
      <header class="flex justify-between items-center mb-3">
        <h3 class="font-semibold">{{ column().name }}</h3>
        <span class="text-sm text-gray-500">{{ tasks().length }}</span>
      </header>
      
      <div 
        class="flex-1 overflow-y-auto space-y-2"
        cdkDropList
        [cdkDropListData]="tasks()"
        [id]="column().id"
        (cdkDropListDropped)="onDrop($event)"
      >
        @for (task of tasks(); track task.id) {
          <app-task-card 
            [task]="task"
            cdkDrag
            [cdkDragData]="task"
            (click)="taskClicked.emit(task)"
          />
        }
      </div>
      
      @if (showNewTaskInput()) {
        <div class="mt-2">
          <input
            #newTaskInput
            type="text"
            class="input w-full"
            placeholder="Task title..."
            (keyup.enter)="createTask(newTaskInput.value); newTaskInput.value = ''"
            (keyup.escape)="showNewTaskInput.set(false)"
            appAutofocus
          />
        </div>
      } @else {
        <button 
          class="mt-2 w-full py-2 text-gray-500 hover:text-gray-700 
                 hover:bg-gray-200 rounded transition-colors text-sm"
          (click)="showNewTaskInput.set(true)"
        >
          + Add task
        </button>
      }
    </div>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class TaskColumnComponent {
  column = input.required<Column>();
  tasks = input.required<Task[]>();
  
  taskMoved = output<{ taskId: string; columnId: string; order: number }>();
  taskCreated = output<{ columnId: string; title: string }>();
  taskClicked = output<Task>();
  
  showNewTaskInput = signal(false);
  
  onDrop(event: CdkDragDrop<Task[]>) {
    if (event.previousContainer === event.container) {
      // Reorder within same column
      this.taskMoved.emit({
        taskId: event.item.data.id,
        columnId: this.column().id,
        order: event.currentIndex
      });
    } else {
      // Move to different column
      this.taskMoved.emit({
        taskId: event.item.data.id,
        columnId: this.column().id,
        order: event.currentIndex
      });
    }
  }
  
  createTask(title: string) {
    if (!title.trim()) return;
    
    this.taskCreated.emit({
      columnId: this.column().id,
      title: title.trim()
    });
    
    this.showNewTaskInput.set(false);
  }
}

Task Card

// features/tasks/task-card/task-card.component.ts
@Component({
  selector: 'app-task-card',
  standalone: true,
  imports: [DatePipe],
  template: `
    <div 
      class="bg-white rounded-lg p-3 shadow-sm hover:shadow cursor-pointer
             border-l-4 transition-shadow"
      [class.border-red-500]="task().priority === 'high'"
      [class.border-yellow-500]="task().priority === 'medium'"
      [class.border-green-500]="task().priority === 'low'"
    >
      <h4 class="font-medium text-sm">{{ task().title }}</h4>
      
      @if (task().description) {
        <p class="text-xs text-gray-500 mt-1 line-clamp-2">
          {{ task().description }}
        </p>
      }
      
      <div class="flex items-center gap-2 mt-2">
        @if (task().dueDate) {
          <span 
            class="text-xs px-2 py-1 rounded"
            [class.bg-red-100]="isOverdue()"
            [class.text-red-600]="isOverdue()"
            [class.bg-gray-100]="!isOverdue()"
          >
            {{ task().dueDate | date:'MMM d' }}
          </span>
        }
        
        @for (label of task().labels; track label) {
          <span class="text-xs px-2 py-0.5 bg-blue-100 text-blue-600 rounded">
            {{ label }}
          </span>
        }
      </div>
      
      @if (task().assigneeId) {
        <div class="mt-2">
          <div class="w-6 h-6 rounded-full bg-gray-300"></div>
        </div>
      }
    </div>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class TaskCardComponent {
  task = input.required<Task>();
  
  isOverdue(): boolean {
    const dueDate = this.task().dueDate;
    if (!dueDate) return false;
    return new Date(dueDate) < new Date();
  }
}

Phase 3: Real-time Updates

WebSocket Service

// core/services/websocket.service.ts
@Injectable({ providedIn: 'root' })
export class WebSocketService {
  private socket: WebSocket | null = null;
  private boardId: string | null = null;
  
  private _events = new Subject<WebSocketEvent>();
  readonly events$ = this._events.asObservable();
  
  connect() {
    if (this.socket) return;
    
    this.socket = new WebSocket(environment.wsUrl);
    
    this.socket.onmessage = (event) => {
      const data = JSON.parse(event.data);
      this._events.next(data);
    };
    
    this.socket.onclose = () => {
      // Reconnect after delay
      setTimeout(() => this.connect(), 3000);
    };
  }
  
  joinBoard(boardId: string) {
    this.boardId = boardId;
    this.send({ type: 'join', boardId });
  }
  
  leaveBoard() {
    if (this.boardId) {
      this.send({ type: 'leave', boardId: this.boardId });
      this.boardId = null;
    }
  }
  
  private send(data: any) {
    if (this.socket?.readyState === WebSocket.OPEN) {
      this.socket.send(JSON.stringify(data));
    }
  }
}

// In BoardFacade - subscribe to real-time updates
constructor() {
  this.websocket.events$.pipe(
    takeUntilDestroyed()
  ).subscribe(event => {
    switch (event.type) {
      case 'task_created':
        this._tasks.update(map => new Map(map).set(event.task.id, event.task));
        break;
      case 'task_updated':
        this._tasks.update(map => new Map(map).set(event.task.id, event.task));
        break;
      case 'task_moved':
        this.handleTaskMoved(event);
        break;
      case 'task_deleted':
        this._tasks.update(map => {
          const newMap = new Map(map);
          newMap.delete(event.taskId);
          return newMap;
        });
        break;
    }
  });
}

Phase 4: Testing

Component Tests

// features/tasks/task-card/task-card.component.spec.ts
describe('TaskCardComponent', () => {
  let fixture: ComponentFixture<TaskCardComponent>;
  
  const mockTask: Task = {
    id: '1',
    title: 'Test Task',
    description: 'Description',
    columnId: 'col-1',
    boardId: 'board-1',
    priority: 'high',
    dueDate: new Date('2024-12-31'),
    labels: ['bug', 'urgent'],
    order: 0,
    createdAt: new Date(),
    updatedAt: new Date()
  };
  
  beforeEach(async () => {
    await TestBed.configureTestingModule({
      imports: [TaskCardComponent]
    }).compileComponents();
    
    fixture = TestBed.createComponent(TaskCardComponent);
    fixture.componentRef.setInput('task', mockTask);
    fixture.detectChanges();
  });
  
  it('should display task title', () => {
    const title = fixture.nativeElement.querySelector('h4');
    expect(title.textContent).toContain('Test Task');
  });
  
  it('should show high priority border', () => {
    const card = fixture.nativeElement.querySelector('div');
    expect(card.classList).toContain('border-red-500');
  });
  
  it('should display labels', () => {
    const labels = fixture.nativeElement.querySelectorAll('.bg-blue-100');
    expect(labels.length).toBe(2);
  });
  
  it('should show overdue indicator for past dates', () => {
    const pastTask = { ...mockTask, dueDate: new Date('2020-01-01') };
    fixture.componentRef.setInput('task', pastTask);
    fixture.detectChanges();
    
    const dueBadge = fixture.nativeElement.querySelector('.bg-red-100');
    expect(dueBadge).toBeTruthy();
  });
});

Facade Tests

// features/boards/state/board.facade.spec.ts
describe('BoardFacade', () => {
  let facade: BoardFacade;
  let apiSpy: jasmine.SpyObj<ApiService>;
  
  beforeEach(() => {
    apiSpy = jasmine.createSpyObj('ApiService', [
      'getBoards', 'getBoard', 'getTasks', 'createTask', 'updateTask', 'moveTask'
    ]);
    
    TestBed.configureTestingModule({
      providers: [
        BoardFacade,
        { provide: ApiService, useValue: apiSpy },
        { provide: ToastService, useValue: { success: () => {} } },
        { provide: WebSocketService, useValue: { joinBoard: () => {}, events$: EMPTY } }
      ]
    });
    
    facade = TestBed.inject(BoardFacade);
  });
  
  it('should load boards', async () => {
    const mockBoards = [{ id: '1', name: 'Board 1' }];
    apiSpy.getBoards.and.returnValue(of(mockBoards));
    
    await facade.loadBoards();
    
    expect(facade.boards()).toEqual(mockBoards);
    expect(facade.loading()).toBeFalse();
  });
  
  it('should create task and update state', async () => {
    const mockBoard = { id: '1', columns: [{ id: 'col-1', taskIds: [] }] };
    const mockTask = { id: 'task-1', title: 'New Task', columnId: 'col-1' };
    
    facade['_currentBoard'].set(mockBoard);
    apiSpy.createTask.and.returnValue(of(mockTask));
    
    await facade.createTask({ columnId: 'col-1', title: 'New Task', priority: 'medium' });
    
    expect(facade['_tasks']().get('task-1')).toEqual(mockTask);
  });
});

Deployment Checklist

┌─────────────────────────────────────────────────────────────────────────┐
│              Deployment Checklist                                        │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                          │
│   Pre-deployment:                                                        │
│   □ All tests passing (ng test)                                         │
│   □ E2E tests passing (ng e2e)                                          │
│   □ No linting errors (ng lint)                                         │
│   □ Build succeeds (ng build --configuration production)                │
│   □ Bundle size analyzed                                                │
│   □ Security audit passed (npm audit)                                   │
│                                                                          │
│   Environment:                                                           │
│   □ Production environment variables set                                │
│   □ API URLs configured                                                  │
│   □ CORS settings correct                                               │
│   □ SSL certificates ready                                               │
│                                                                          │
│   Performance:                                                           │
│   □ Lazy loading configured                                              │
│   □ Images optimized                                                     │
│   □ Gzip/Brotli compression enabled                                     │
│   □ CDN configured                                                       │
│   □ Caching headers set                                                  │
│                                                                          │
│   Monitoring:                                                            │
│   □ Error tracking set up (Sentry, etc.)                                │
│   □ Analytics configured                                                 │
│   □ Logging in place                                                     │
│   □ Health checks configured                                             │
│                                                                          │
└─────────────────────────────────────────────────────────────────────────┘

Congratulations! 🎉

You’ve completed the Angular Crash Course! You now have the skills to:

Build Components

Create reusable, maintainable components with signals and modern Angular

Manage State

Handle complex application state with services and facades

Handle Data

Work with APIs, HTTP, and real-time updates

Test & Deploy

Write tests and deploy production-ready applications

What’s Next?

1

Build More Projects

Practice by building different types of applications
2

Explore Advanced Topics

Dive deeper into NgRx, micro-frontends, and Angular libraries
3

Join the Community

Participate in Angular Discord, Twitter, and local meetups
4

Stay Updated

Follow Angular blog and release notes for new features

Back to Course Overview

Review the complete course curriculum