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.
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
┌─────────────────────────────────────────────────────────────────────────┐
│ 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?
Back to Course Overview
Review the complete course curriculum