Skip to main content

Project Architecture

This guide explains how Angular concepts are applied in the TimeLock project, showing you the overall architecture and design patterns used.

Table of Contents

  1. Application Overview
  2. Architecture Patterns
  3. State Management with Signals
  4. Data Flow
  5. Component Hierarchy
  6. Service Layer
  7. Routing Strategy
  8. Data Persistence

Application Overview

TimeLock is a task management application that demonstrates modern Angular patterns:

TimeLock Features:
├── 📋 Project Management
├── ✅ Task Management (with subtasks)
├── 📅 Calendar View
├── ⏰ Time Planning
├── 🔍 Search & Filtering
├── 🏷️ Tags & Categories
├── 🔄 Recurring Tasks
└── 📱 Responsive Design

Technology Stack

  • Angular 20 - Latest version with modern features
  • TypeScript - Strong typing and better developer experience
  • Signals - Reactive state management
  • IndexedDB - Client-side data persistence
  • CSS3 - Modern styling with CSS Grid and Flexbox
  • Playwright - End-to-end testing

Architecture Patterns

1. Standalone Component Architecture

TimeLock uses standalone components throughout, eliminating the need for NgModules:

// Traditional approach (NOT used)
@NgModule({
declarations: [TodoComponent],
imports: [CommonModule]
})

// Modern approach (USED in TimeLock)
@Component({
standalone: true,
imports: [CommonModule, FormsModule]
})
export class TodoComponent { }

2. Signal-Based State Management

Instead of RxJS Observables for local state, TimeLock uses Angular Signals:

// Service layer
@Injectable({ providedIn: 'root' })
export class TodoService {
private todosSignal = signal<Todo[]>([]);

// Read-only access for components
todos = this.todosSignal.asReadonly();

// Computed derived state
activeTodos = computed(() =>
this.todosSignal().filter(todo => !todo.completed)
);
}

3. Dependency Injection Pattern

Services are injected using the modern inject() function:

export class TodoComponent {
private todoService = inject(TodoService);
private projectService = inject(ProjectService);
private router = inject(Router);

// Clean, functional approach
}

4. Lazy Loading Strategy

Routes are lazy-loaded for better performance:

export const routes: Routes = [
{
path: 'projects',
loadComponent: () => import('./pages/projects/projects.component')
.then(m => m.ProjectsComponent)
}
];

State Management with Signals

TimeLock demonstrates a comprehensive signal-based state management pattern:

Service-Level State

@Injectable({ providedIn: 'root' })
export class TodoService {
// Private writable signals
private todosSignal = signal<Todo[]>([]);
private filterSignal = signal<FilterType>('all');
private searchTermSignal = signal<string>('');

// Public read-only signals
todos = this.todosSignal.asReadonly();
filter = this.filterSignal.asReadonly();
searchTerm = this.searchTermSignal.asReadonly();

// Computed signals for derived state
filteredTodos = computed(() => {
const todos = this.todosSignal();
const filter = this.filterSignal();
const searchTerm = this.searchTermSignal();

return todos
.filter(todo => this.matchesFilter(todo, filter))
.filter(todo => this.matchesSearch(todo, searchTerm));
});

activeTodos = computed(() =>
this.todosSignal().filter(todo => !todo.completed && !todo.archived)
);

completedTodos = computed(() =>
this.todosSignal().filter(todo => todo.completed)
);
}

Component-Level State

export class TodoItemComponent {
@Input() todo!: Todo;

// Local component state
isEditing = signal(false);
isExpanded = signal(false);

// Computed local state
hasSubtasks = computed(() => this.todo.subtasks.length > 0);
completionPercentage = computed(() => {
if (!this.hasSubtasks()) return 0;
const completed = this.todo.subtasks.filter(t => t.completed).length;
return (completed / this.todo.subtasks.length) * 100;
});
}

Data Flow

TimeLock follows a unidirectional data flow pattern:

┌─────────────────┐    ┌──────────────────┐    ┌─────────────────┐
│ Components │───▶│ Services │───▶│ IndexedDB │
│ │ │ │ │ │
│ - Display data │ │ - Business logic │ │ - Data storage │
│ - Handle events │ │ - State management│ │ - Persistence │
│ - User input │ │ - API calls │ │ │
└─────────────────┘ └──────────────────┘ └─────────────────┘
▲ │
│ ▼
└────── Signals ─────────┘

Example Data Flow: Adding a Todo

  1. User Action: User clicks "Add Todo" button
  2. Component: TodoFormComponent emits event
  3. Parent Component: Calls TodoService.addTodo()
  4. Service: Updates signal and persists to IndexedDB
  5. Automatic Update: All components using the signal automatically re-render
// 1. User clicks button in template
<button (click)="addTodo()">Add Todo</button>

// 2. Component method
addTodo() {
this.todoService.addTodo(this.newTodo);
}

// 3. Service updates state
async addTodo(todo: Omit<Todo, 'id'>) {
const newTodo = { ...todo, id: this.generateId() };
this.todosSignal.update(todos => [...todos, newTodo]);
await this.indexedDBService.saveTodos(this.todosSignal());
}

// 4. All components automatically update via signals

Component Hierarchy

TimeLock's component structure follows a clear hierarchy:

App (Root Component)
├── Sidebar Navigation
├── Router Outlet
├── ProjectsComponent
│ ├── ProjectCardComponent
│ └── ProjectFormComponent
├── ProjectDetailComponent
│ ├── TodoFiltersComponent
│ ├── TodoFormComponent
│ └── TodoItemComponent
│ ├── TaskEditorComponent
│ └── SubtaskComponent
├── CalendarComponent
├── TimetableComponent
├── UpcomingComponent
└── SearchComponent

Smart vs Dumb Components

Smart Components (Container Components):

  • Inject services
  • Manage state
  • Handle business logic
  • Examples: ProjectsComponent, ProjectDetailComponent
export class ProjectsComponent {
private projectService = inject(ProjectService);
private todoService = inject(TodoService);

projects = this.projectService.activeProjects;

async createProject(projectData: CreateProjectData) {
await this.projectService.addProject(projectData);
}
}

Dumb Components (Presentation Components):

  • Receive data via @Input()
  • Emit events via @Output()
  • No service dependencies
  • Examples: TodoItemComponent, ProjectCardComponent
export class TodoItemComponent {
@Input() todo!: Todo;
@Output() todoUpdated = new EventEmitter<Todo>();
@Output() todoDeleted = new EventEmitter<string>();

toggleComplete() {
this.todoUpdated.emit({
...this.todo,
completed: !this.todo.completed
});
}
}

Service Layer

TimeLock's service layer is organized by domain:

Core Services

TodoService - Task management

@Injectable({ providedIn: 'root' })
export class TodoService {
// CRUD operations
async addTodo(todo: Omit<Todo, 'id'>): Promise<string>
async updateTodo(id: string, updates: Partial<Todo>): Promise<void>
async deleteTodo(id: string): Promise<void>

// Business logic
async toggleComplete(id: string): Promise<void>
async addSubtask(parentId: string, subtask: Omit<Todo, 'id'>): Promise<void>
async autoPlanTasks(tasks: Todo[], timeSlots: TimeSlot[]): Promise<void>
}

ProjectService - Project management

@Injectable({ providedIn: 'root' })
export class ProjectService {
async addProject(project: Omit<Project, 'id'>): Promise<string>
async updateProject(id: string, updates: Partial<Project>): Promise<void>
async deleteProject(id: string): Promise<void>
setCurrentProject(projectId: string | null): void
}

IndexedDBService - Data persistence

@Injectable({ providedIn: 'root' })
export class IndexedDBService {
async saveTodos(todos: Todo[]): Promise<void>
async loadTodos(): Promise<Todo[]>
async saveProjects(projects: Project[]): Promise<void>
async loadProjects(): Promise<Project[]>
}

Utility Services

TagsService - Tag management RecurringTasksService - Recurring task logic ConfirmationService - Dialog management

Routing Strategy

TimeLock uses a feature-based routing structure:

export const routes: Routes = [
{ path: '', redirectTo: '/projects', pathMatch: 'full' },

// Main pages
{ path: 'projects', loadComponent: () => import('./pages/projects/projects.component') },
{ path: 'project/:id', loadComponent: () => import('./pages/project-detail/project-detail.component') },

// Views
{ path: 'calendar', loadComponent: () => import('./pages/calendar/calendar.component') },
{ path: 'timetable', loadComponent: () => import('./pages/timetable/timetable.component') },
{ path: 'upcoming', loadComponent: () => import('./pages/upcoming/upcoming.component') },
{ path: 'search', loadComponent: () => import('./pages/search/search.component') },

// Fallback
{ path: '**', redirectTo: '/projects' }
];

Route Guards and Resolvers

While not currently implemented, TimeLock's architecture supports:

// Example guard
export const projectExistsGuard: CanActivateFn = (route) => {
const projectService = inject(ProjectService);
const projectId = route.paramMap.get('id');
return projectService.getProjectById(projectId!) !== undefined;
};

// Example resolver
export const projectResolver: ResolveFn<Project> = (route) => {
const projectService = inject(ProjectService);
const projectId = route.paramMap.get('id')!;
return projectService.getProjectById(projectId)!;
};

Data Persistence

TimeLock uses IndexedDB for client-side data persistence:

Why IndexedDB?

  • Offline capability - Works without internet
  • Large storage - Much more than localStorage
  • Structured data - Supports complex objects
  • Asynchronous - Non-blocking operations

Data Schema

interface DBSchema {
todos: {
key: string;
value: Todo;
indexes: { 'by-project': string; 'by-due-date': Date; };
};
projects: {
key: string;
value: Project;
};
tags: {
key: string;
value: Tag;
};
}

Service Integration

@Injectable({ providedIn: 'root' })
export class IndexedDBService {
private dbName = 'TimeLockDB';
private version = 1;

async initDB(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.dbName, this.version);

request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result);

request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
this.createObjectStores(db);
};
});
}
}

Performance Considerations

1. Lazy Loading

  • Routes are loaded on-demand
  • Reduces initial bundle size
  • Faster application startup

2. OnPush Change Detection

@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
// ...
})
export class OptimizedComponent { }

3. Signal-Based Updates

  • Automatic dependency tracking
  • Minimal re-renders
  • Better performance than traditional observables

4. Virtual Scrolling (Future Enhancement)

// For large lists
<cdk-virtual-scroll-viewport itemSize="50">
<div *cdkVirtualFor="let todo of todos">{{todo.title}}</div>
</cdk-virtual-scroll-viewport>

Next Steps

Continue learning about TimeLock:

  1. Getting Started - Set up your development environment
  2. Code Structure - Detailed code organization
  3. Development Guide - How to extend the application