Aller au contenu principal

Development Guide

This guide shows you how to extend and modify the TimeLock application, providing practical examples and best practices for Angular development.

Table of Contents

  1. Development Workflow
  2. Adding New Features
  3. Modifying Existing Features
  4. Working with Signals
  5. Database Operations
  6. Component Development
  7. Testing Your Changes
  8. Best Practices

Development Workflow

Setting Up Your Environment

  1. Start the development server:
npm start
  1. Open your browser to:
http://localhost:4200
  1. Open your code editor:
  • Recommended: Visual Studio Code with Angular Language Service

Making Changes

  1. Edit files in src/app/
  2. Save changes - Angular CLI automatically recompiles
  3. Check browser - Changes appear automatically
  4. Test functionality - Verify your changes work
  5. Commit changes when satisfied

Adding New Features

Example: Adding a "Notes" Feature to Tasks

Let's walk through adding a notes feature that allows users to add rich text notes to tasks.

Step 1: Update the Data Model

// src/app/models/todo.model.ts
export interface Todo {
// ... existing properties
notes?: string; // Add this new property
}

Step 2: Update the Service

// src/app/services/todo.service.ts
export class TodoService {
// Add method to update notes
async updateTodoNotes(id: string, notes: string): Promise<void> {
await this.updateTodo(id, { notes, updatedAt: new Date() });
}

// Add computed signal for todos with notes
todosWithNotes = computed(() =>
this.todosSignal().filter(todo => todo.notes && todo.notes.trim().length > 0)
);
}

Step 3: Create Notes Component

ng generate component components/todo-notes
// src/app/components/todo-notes/todo-notes.component.ts
@Component({
selector: 'app-todo-notes',
standalone: true,
imports: [CommonModule, FormsModule],
template: `
<div class="notes-container">
<label for="notes">Notes:</label>
<textarea
id="notes"
[(ngModel)]="notes"
(blur)="saveNotes()"
placeholder="Add notes for this task..."
class="notes-textarea">
</textarea>
</div>
`,
styles: [`
.notes-container {
margin-top: 1rem;
}

.notes-textarea {
width: 100%;
min-height: 100px;
padding: 0.5rem;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
resize: vertical;
}
`]
})
export class TodoNotesComponent {
@Input() todo!: Todo;
@Output() notesUpdated = new EventEmitter<string>();

notes = '';

ngOnInit() {
this.notes = this.todo.notes || '';
}

saveNotes() {
if (this.notes !== this.todo.notes) {
this.notesUpdated.emit(this.notes);
}
}
}

Step 4: Integrate with TodoItem Component

// src/app/components/todo-item/todo-item.component.ts
@Component({
// ... existing configuration
imports: [CommonModule, FormsModule, TaskEditorComponent, TodoNotesComponent],
template: `
<!-- Existing todo item content -->

<!-- Add notes section -->
<app-todo-notes
*ngIf="showNotes()"
[todo]="todo"
(notesUpdated)="updateNotes($event)">
</app-todo-notes>

<!-- Add toggle button -->
<button
class="notes-toggle"
(click)="toggleNotes()"
[class.active]="showNotes()">
📝 Notes
</button>
`
})
export class TodoItemComponent {
// ... existing properties
showNotes = signal(false);

toggleNotes() {
this.showNotes.update(show => !show);
}

updateNotes(notes: string) {
this.todoUpdated.emit({
...this.todo,
notes,
updatedAt: new Date()
});
}
}

Step 5: Update Database Schema

// src/app/services/indexeddb.service.ts
export class IndexedDBService {
// The notes field will be automatically saved as part of the Todo object
// No schema changes needed for simple field additions
}

Example: Adding a New Page

Let's add a "Statistics" page that shows task completion analytics.

Step 1: Generate the Component

ng generate component pages/statistics

Step 2: Create the Statistics Component

// src/app/pages/statistics/statistics.component.ts
@Component({
selector: 'app-statistics',
standalone: true,
imports: [CommonModule],
template: `
<div class="statistics-page">
<h1>Task Statistics</h1>

<div class="stats-grid">
<div class="stat-card">
<h3>Total Tasks</h3>
<p class="stat-number">{{totalTasks()}}</p>
</div>

<div class="stat-card">
<h3>Completed Tasks</h3>
<p class="stat-number">{{completedTasks()}}</p>
</div>

<div class="stat-card">
<h3>Completion Rate</h3>
<p class="stat-number">{{completionRate()}}%</p>
</div>

<div class="stat-card">
<h3>Average Tasks per Day</h3>
<p class="stat-number">{{averageTasksPerDay()}}</p>
</div>
</div>

<div class="charts-section">
<!-- Add charts here -->
</div>
</div>
`,
styles: [`
.statistics-page {
padding: 2rem;
}

.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
margin: 2rem 0;
}

.stat-card {
background: white;
padding: 1.5rem;
border-radius: 0.5rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
text-align: center;
}

.stat-number {
font-size: 2rem;
font-weight: bold;
color: #3b82f6;
margin: 0.5rem 0;
}
`]
})
export class StatisticsComponent {
private todoService = inject(TodoService);

todos = this.todoService.todos;

totalTasks = computed(() => this.todos().length);

completedTasks = computed(() =>
this.todos().filter(todo => todo.completed).length
);

completionRate = computed(() => {
const total = this.totalTasks();
const completed = this.completedTasks();
return total > 0 ? Math.round((completed / total) * 100) : 0;
});

averageTasksPerDay = computed(() => {
const todos = this.todos();
if (todos.length === 0) return 0;

const oldestTask = todos.reduce((oldest, todo) =>
todo.createdAt < oldest.createdAt ? todo : oldest
);

const daysSinceOldest = Math.ceil(
(Date.now() - oldestTask.createdAt.getTime()) / (1000 * 60 * 60 * 24)
);

return Math.round(todos.length / Math.max(daysSinceOldest, 1));
});
}

Step 3: Add Route

// src/app/app.routes.ts
export const routes: Routes = [
// ... existing routes
{
path: 'statistics',
loadComponent: () => import('./pages/statistics/statistics.component')
.then(m => m.StatisticsComponent)
},
// ... rest of routes
];
// src/app/app.ts - Add to sidebar navigation
template: `
<nav class="sidebar-nav">
<!-- Existing navigation items -->
<a routerLink="/statistics"
routerLinkActive="active"
class="nav-item">
📊 Statistics
</a>
</nav>
`

Modifying Existing Features

Example: Adding Priority Colors to Tasks

Step 1: Update Component Styles

// src/app/components/todo-item/todo-item.component.ts
@Component({
template: `
<div class="todo-item" [class]="getPriorityClass()">
<!-- existing content -->
</div>
`,
styles: [`
.todo-item.priority-high {
border-left: 4px solid #ef4444;
}

.todo-item.priority-medium {
border-left: 4px solid #f59e0b;
}

.todo-item.priority-low {
border-left: 4px solid #10b981;
}
`]
})
export class TodoItemComponent {
getPriorityClass(): string {
return `priority-${this.todo.priority}`;
}
}

Step 2: Add Priority Indicator

template: `
<div class="todo-item" [class]="getPriorityClass()">
<span class="priority-indicator" [class]="getPriorityClass()">
{{getPriorityIcon()}}
</span>
<!-- rest of content -->
</div>
`

getPriorityIcon(): string {
switch (this.todo.priority) {
case 'high': return '🔴';
case 'medium': return '🟡';
case 'low': return '🟢';
default: return '';
}
}

Example: Adding Keyboard Shortcuts

Step 1: Create Keyboard Service

ng generate service services/keyboard
// src/app/services/keyboard.service.ts
@Injectable({ providedIn: 'root' })
export class KeyboardService {
private shortcuts = new Map<string, () => void>();

constructor() {
this.setupGlobalListeners();
}

registerShortcut(key: string, callback: () => void): void {
this.shortcuts.set(key, callback);
}

unregisterShortcut(key: string): void {
this.shortcuts.delete(key);
}

private setupGlobalListeners(): void {
document.addEventListener('keydown', (event) => {
const key = this.getKeyString(event);
const callback = this.shortcuts.get(key);

if (callback) {
event.preventDefault();
callback();
}
});
}

private getKeyString(event: KeyboardEvent): string {
const parts = [];

if (event.ctrlKey) parts.push('ctrl');
if (event.altKey) parts.push('alt');
if (event.shiftKey) parts.push('shift');

parts.push(event.key.toLowerCase());

return parts.join('+');
}
}

Step 2: Use in Components

// src/app/pages/project-detail/project-detail.component.ts
export class ProjectDetailComponent {
private keyboardService = inject(KeyboardService);

ngOnInit() {
// Register shortcuts
this.keyboardService.registerShortcut('ctrl+n', () => this.addNewTask());
this.keyboardService.registerShortcut('ctrl+f', () => this.focusSearch());
this.keyboardService.registerShortcut('escape', () => this.clearSelection());
}

ngOnDestroy() {
// Clean up shortcuts
this.keyboardService.unregisterShortcut('ctrl+n');
this.keyboardService.unregisterShortcut('ctrl+f');
this.keyboardService.unregisterShortcut('escape');
}

addNewTask() {
// Implementation
}

focusSearch() {
// Implementation
}

clearSelection() {
// Implementation
}
}

Working with Signals

Creating Reactive State

export class MyComponent {
// Simple signal
count = signal(0);

// Signal with object
user = signal<User | null>(null);

// Computed signal
doubleCount = computed(() => this.count() * 2);

// Effect (side effects)
constructor() {
effect(() => {
console.log('Count changed:', this.count());
});
}

// Update signals
increment() {
this.count.update(value => value + 1);
}

setUser(user: User) {
this.user.set(user);
}

updateUser(updates: Partial<User>) {
this.user.update(current =>
current ? { ...current, ...updates } : null
);
}
}

Signal Best Practices

  1. Use readonly signals for public API:
private dataSignal = signal<Data[]>([]);
public data = this.dataSignal.asReadonly();
  1. Prefer computed over manual updates:
// Good
filteredData = computed(() =>
this.data().filter(item => item.active)
);

// Avoid
updateFilteredData() {
this.filteredData.set(this.data().filter(item => item.active));
}
  1. Use effects sparingly:
// Good - for side effects only
effect(() => {
console.log('Data changed:', this.data().length);
});

// Avoid - use computed instead
effect(() => {
this.filteredData.set(this.data().filter(item => item.active));
});

Database Operations

Adding New Data Types

// 1. Update IndexedDB schema
private createObjectStores(db: IDBDatabase): void {
// Add new object store
if (!db.objectStoreNames.contains('notes')) {
const notesStore = db.createObjectStore('notes', { keyPath: 'id' });
notesStore.createIndex('todoId', 'todoId', { unique: false });
}
}

// 2. Add service methods
async saveNotes(notes: Note[]): Promise<void> {
const transaction = this.db!.transaction(['notes'], 'readwrite');
const store = transaction.objectStore('notes');

for (const note of notes) {
await store.put(note);
}
}

async loadNotes(): Promise<Note[]> {
const transaction = this.db!.transaction(['notes'], 'readonly');
const store = transaction.objectStore('notes');
const request = store.getAll();

return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result || []);
request.onerror = () => reject(request.error);
});
}

Data Migration

// Handle version upgrades
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
const oldVersion = event.oldVersion;

if (oldVersion < 2) {
// Migration from version 1 to 2
this.migrateToV2(db);
}

if (oldVersion < 3) {
// Migration from version 2 to 3
this.migrateToV3(db);
}
};

private migrateToV2(db: IDBDatabase): void {
// Add new fields to existing data
const transaction = db.transaction(['todos'], 'readwrite');
const store = transaction.objectStore('todos');

store.openCursor().onsuccess = (event) => {
const cursor = (event.target as IDBRequest).result;
if (cursor) {
const todo = cursor.value;
todo.notes = ''; // Add new field
cursor.update(todo);
cursor.continue();
}
};
}

Component Development

Component Lifecycle

export class MyComponent implements OnInit, OnDestroy {
private subscription = new Subscription();

ngOnInit() {
// Initialize component
this.loadData();
this.setupSubscriptions();
}

ngOnDestroy() {
// Cleanup
this.subscription.unsubscribe();
}

private setupSubscriptions() {
// Note: With signals, you often don't need subscriptions
// But for external observables:
this.subscription.add(
this.externalService.data$.subscribe(data => {
// Handle data
})
);
}
}

Component Communication

// Child component
@Component({
selector: 'app-child',
template: `
<button (click)="sendData()">Send Data</button>
`
})
export class ChildComponent {
@Input() inputData!: any;
@Output() dataChanged = new EventEmitter<any>();

sendData() {
this.dataChanged.emit({ message: 'Hello from child' });
}
}

// Parent component
@Component({
template: `
<app-child
[inputData]="parentData"
(dataChanged)="handleChildData($event)">
</app-child>
`
})
export class ParentComponent {
parentData = { value: 'Hello child' };

handleChildData(data: any) {
console.log('Received from child:', data);
}
}

Testing Your Changes

Unit Testing

// Component test
describe('TodoItemComponent', () => {
let component: TodoItemComponent;
let fixture: ComponentFixture<TodoItemComponent>;
let mockTodoService: jasmine.SpyObj<TodoService>;

beforeEach(async () => {
const spy = jasmine.createSpyObj('TodoService', ['updateTodo']);

await TestBed.configureTestingModule({
imports: [TodoItemComponent],
providers: [
{ provide: TodoService, useValue: spy }
]
}).compileComponents();

fixture = TestBed.createComponent(TodoItemComponent);
component = fixture.componentInstance;
mockTodoService = TestBed.inject(TodoService) as jasmine.SpyObj<TodoService>;

component.todo = {
id: '1',
title: 'Test Todo',
completed: false,
// ... other required properties
};

fixture.detectChanges();
});

it('should toggle completion', () => {
component.toggleComplete();
expect(component.todoUpdated.emit).toHaveBeenCalledWith(
jasmine.objectContaining({ completed: true })
);
});
});

E2E Testing

// e2e test
test('should add a new todo', async ({ page }) => {
await page.goto('/projects');

// Click on first project
await page.click('[data-testid="project-card"]:first-child');

// Add new todo
await page.fill('[data-testid="todo-input"]', 'New test todo');
await page.click('[data-testid="add-todo-button"]');

// Verify todo appears
await expect(page.locator('[data-testid="todo-item"]').last())
.toContainText('New test todo');
});

Manual Testing Checklist

  • Feature works as expected
  • No console errors
  • Responsive design works
  • Data persists after page reload
  • Error handling works
  • Performance is acceptable

Best Practices

Code Organization

  1. Follow the folder structure:

    • Components in components/
    • Pages in pages/
    • Services in services/
    • Models in models/
  2. Use meaningful names:

    // Good
    getUserTodos()
    isTaskOverdue()
    calculateCompletionPercentage()

    // Avoid
    getData()
    check()
    calc()
  3. Keep components focused:

    • Single responsibility
    • Small, manageable size
    • Clear inputs and outputs

Performance

  1. Use OnPush change detection:

    @Component({
    changeDetection: ChangeDetectionStrategy.OnPush
    })
  2. Lazy load routes:

    {
    path: 'feature',
    loadComponent: () => import('./feature.component')
    }
  3. Use trackBy for lists:

    <div *ngFor="let item of items; trackBy: trackByFn">

Accessibility

  1. Use semantic HTML:

    <button type="button">Action</button>
    <nav aria-label="Main navigation">
    <main role="main">
  2. Add ARIA labels:

    <button aria-label="Delete task">🗑️</button>
    <input aria-describedby="help-text">
  3. Ensure keyboard navigation:

    @HostListener('keydown', ['$event'])
    onKeyDown(event: KeyboardEvent) {
    if (event.key === 'Enter') {
    this.activate();
    }
    }

Error Handling

  1. Handle async operations:

    async saveData() {
    try {
    await this.service.save(this.data);
    this.showSuccess('Data saved');
    } catch (error) {
    this.showError('Failed to save data');
    console.error(error);
    }
    }
  2. Validate user input:

    validateInput(value: string): boolean {
    return value && value.trim().length > 0;
    }
  3. Provide user feedback:

    isLoading = signal(false);
    errorMessage = signal<string | null>(null);

Next Steps

You now have the knowledge to:

  1. Add new features to TimeLock
  2. Modify existing functionality
  3. Work with Angular signals
  4. Handle data persistence
  5. Test your changes

Start with small changes and gradually work on more complex features. The TimeLock codebase provides excellent examples of modern Angular patterns that you can learn from and extend.

Happy coding! 🚀