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