Capstone Overview
Estimated Time: 8-12 hours | Difficulty: Advanced | Prerequisites: All previous modules
- User authentication with JWT
- Real-time task updates
- Drag-and-drop kanban board
- Team collaboration
- Responsive design
- Full CRUD operations
Project Architecture
Copy
┌─────────────────────────────────────────────────────────────────────────┐
│ 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
Copy
# 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
Copy
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
Copy
// 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
Copy
// 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
Copy
// 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
Copy
// 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
Copy
// 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
Copy
// 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
Copy
// 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
Copy
// 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
Copy
// 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
Copy
// 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
Copy
┌─────────────────────────────────────────────────────────────────────────┐
│ 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