Skip to main content
Angular Capstone Project

Capstone Overview

Estimated Time: 8-12 hours | Difficulty: Advanced | Prerequisites: All previous modules
Congratulations on reaching the capstone! You’ll build a Task Management Application (like Trello) that incorporates everything you’ve learned throughout this course. 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

# Create the project
ng new task-manager --style=scss --routing --ssr

# Add required packages
cd task-manager
npm install @angular/cdk
npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init

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

// core/services/api.service.ts
@Injectable({ providedIn: 'root' })
export class ApiService {
  private http = inject(HttpClient);
  private baseUrl = environment.apiUrl;
  
  // 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