0%
Projet complet TypeScript - Application de gestion de tâches

Projet complet

Créez une application de gestion de tâches moderne avec TypeScript

10-15 min

Projet complet TypeScript - Application de gestion de tâches

Dans ce tutoriel final, nous allons créer une application complète de gestion de tâches avec TypeScript en utilisant tous les concepts appris dans les tutoriels précédents. Notre application sera une solution moderne et robuste avec une architecture bien structurée.

Vue d’ensemble du projet

Fonctionnalités de l’application

Notre application de gestion de tâches aura les fonctionnalités suivantes :

  • Gestion des tâches : Créer, modifier, supprimer et marquer comme terminées
  • Catégories : Organiser les tâches par catégories personnalisées
  • Priorités : Système de priorités (Haute, Moyenne, Basse)
  • Filtres et recherche : Filtrer par statut, catégorie, priorité
  • Statistiques : Dashboard avec métriques et graphiques
  • Persistance : Sauvegarde locale avec localStorage
  • Interface moderne : UI responsive avec animations
  • Tests : Tests unitaires et d’intégration
  • Architecture modulaire : Code organisé et maintenable

Stack technique

  • TypeScript : Langage principal avec types stricts
  • Vite : Build tool moderne et rapide
  • Vanilla TypeScript : Pas de framework, TypeScript pur
  • CSS Modules : Styles modulaires et typés
  • Jest : Framework de tests
  • ESLint + Prettier : Qualité de code
  • Husky : Git hooks pour la qualité

Phase 1 : Configuration du projet

Initialisation du projet

# Créer le dossier du projet
mkdir task-manager-typescript
cd task-manager-typescript

# Initialiser npm
npm init -y

# Installer TypeScript et les outils de développement
npm install -D typescript @types/node vite
npm install -D jest @types/jest ts-jest
npm install -D eslint @typescript-eslint/parser @typescript-eslint/eslint-plugin
npm install -D prettier eslint-config-prettier eslint-plugin-prettier
npm install -D husky lint-staged

# Installer les dépendances pour l'UI
npm install -D sass

Configuration TypeScript

Créez tsconfig.json :

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "lib": ["ES2022", "DOM", "DOM.Iterable"],
    "allowJs": false,
    "strict": true,
    "noImplicitAny": true,
    "strictNullChecks": true,
    "strictFunctionTypes": true,
    "noImplicitReturns": true,
    "noFallthroughCasesInSwitch": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true,
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true,
    "outDir": "./dist",
    "rootDir": "./src",
    "removeComments": false,
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "skipLibCheck": true,
    "resolveJsonModule": true,
    "baseUrl": "./src",
    "paths": {
      "@/*": ["./*"],
      "@/types/*": ["./types/*"],
      "@/utils/*": ["./utils/*"],
      "@/components/*": ["./components/*"],
      "@/services/*": ["./services/*"]
    }
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist", "**/*.test.ts"]
}

Configuration Vite

Créez vite.config.ts :

import { defineConfig } from 'vite'
import { resolve } from 'path'

export default defineConfig({
  root: 'src',
  build: {
    outDir: '../dist',
    emptyOutDir: true,
    rollupOptions: {
      input: {
        main: resolve(__dirname, 'src/index.html')
      }
    }
  },
  resolve: {
    alias: {
      '@': resolve(__dirname, 'src')
    }
  },
  css: {
    modules: {
      localsConvention: 'camelCase'
    }
  }
})

Phase 2 : Architecture et types

Types de base

Créez src/types/index.ts :

// Types de base pour l'application
export type TaskId = string
export type CategoryId = string
export type UserId = string

export enum TaskStatus {
  TODO = 'todo',
  IN_PROGRESS = 'in_progress',
  COMPLETED = 'completed'
}

export enum TaskPriority {
  LOW = 'low',
  MEDIUM = 'medium',
  HIGH = 'high'
}

export interface Task {
  readonly id: TaskId
  title: string
  description?: string
  status: TaskStatus
  priority: TaskPriority
  categoryId?: CategoryId
  createdAt: Date
  updatedAt: Date
  completedAt?: Date
  dueDate?: Date
  tags: string[]
}

export interface Category {
  readonly id: CategoryId
  name: string
  color: string
  description?: string
  createdAt: Date
}

export interface TaskFilter {
  status?: TaskStatus[]
  priority?: TaskPriority[]
  categoryId?: CategoryId[]
  search?: string
  dateRange?: {
    start: Date
    end: Date
  }
}

export interface TaskStatistics {
  total: number
  completed: number
  inProgress: number
  todo: number
  byPriority: Record<TaskPriority, number>
  byCategory: Record<CategoryId, number>
  completionRate: number
  averageCompletionTime: number // en heures
}

// Types utilitaires
export type CreateTaskData = Omit<Task, 'id' | 'createdAt' | 'updatedAt' | 'completedAt'>
export type UpdateTaskData = Partial<Omit<Task, 'id' | 'createdAt'>>
export type CreateCategoryData = Omit<Category, 'id' | 'createdAt'>

// Types pour les événements
export interface TaskEvent {
  type: 'task_created' | 'task_updated' | 'task_deleted' | 'task_completed'
  taskId: TaskId
  timestamp: Date
  data?: unknown
}

// Types pour les erreurs
export class TaskError extends Error {
  constructor(
    message: string,
    public code: string,
    public details?: unknown
  ) {
    super(message)
    this.name = 'TaskError'
  }
}

export class ValidationError extends TaskError {
  constructor(message: string, public field: string) {
    super(message, 'VALIDATION_ERROR', { field })
  }
}

// Types génériques pour les services
export interface Repository<T, K> {
  findById(id: K): Promise<T | null>
  findAll(): Promise<T[]>
  create(data: Omit<T, 'id'>): Promise<T>
  update(id: K, data: Partial<T>): Promise<T>
  delete(id: K): Promise<boolean>
}

export interface EventEmitter<T> {
  on<K extends keyof T>(event: K, listener: (data: T[K]) => void): void
  off<K extends keyof T>(event: K, listener: (data: T[K]) => void): void
  emit<K extends keyof T>(event: K, data: T[K]): void
}

Utilitaires TypeScript avancés

Créez src/utils/type-guards.ts :

import { Task, Category, TaskStatus, TaskPriority } from '@/types'

// Type guards pour la validation runtime
export function isTask(obj: unknown): obj is Task {
  return (
    typeof obj === 'object' &&
    obj !== null &&
    'id' in obj &&
    'title' in obj &&
    'status' in obj &&
    'priority' in obj &&
    typeof (obj as Task).id === 'string' &&
    typeof (obj as Task).title === 'string' &&
    Object.values(TaskStatus).includes((obj as Task).status) &&
    Object.values(TaskPriority).includes((obj as Task).priority)
  )
}

export function isCategory(obj: unknown): obj is Category {
  return (
    typeof obj === 'object' &&
    obj !== null &&
    'id' in obj &&
    'name' in obj &&
    'color' in obj &&
    typeof (obj as Category).id === 'string' &&
    typeof (obj as Category).name === 'string' &&
    typeof (obj as Category).color === 'string'
  )
}

export function isTaskStatus(value: string): value is TaskStatus {
  return Object.values(TaskStatus).includes(value as TaskStatus)
}

export function isTaskPriority(value: string): value is TaskPriority {
  return Object.values(TaskPriority).includes(value as TaskPriority)
}

// Type guard générique
export function hasProperty<T, K extends PropertyKey>(
  obj: T,
  prop: K
): obj is T & Record<K, unknown> {
  return typeof obj === 'object' && obj !== null && prop in obj
}

// Validation avec types conditionnels
export type ValidationResult<T> = 
  | { success: true; data: T }
  | { success: false; errors: string[] }

export function validateTask(data: unknown): ValidationResult<Task> {
  const errors: string[] = []

  if (!hasProperty(data, 'title') || typeof data.title !== 'string') {
    errors.push('Title is required and must be a string')
  }

  if (!hasProperty(data, 'status') || !isTaskStatus(data.status)) {
    errors.push('Status is required and must be a valid TaskStatus')
  }

  if (!hasProperty(data, 'priority') || !isTaskPriority(data.priority)) {
    errors.push('Priority is required and must be a valid TaskPriority')
  }

  if (errors.length > 0) {
    return { success: false, errors }
  }

  return { success: true, data: data as Task }
}

Phase 3 : Services et logique métier

Service de gestion des tâches

Créez src/services/TaskService.ts :

import { 
  Task, 
  TaskId, 
  CreateTaskData, 
  UpdateTaskData, 
  TaskFilter, 
  TaskStatistics,
  TaskStatus,
  TaskPriority,
  Repository,
  EventEmitter,
  TaskEvent,
  ValidationError
} from '@/types'
import { generateId } from '@/utils/id-generator'
import { validateTask } from '@/utils/type-guards'

interface TaskServiceEvents {
  taskCreated: Task
  taskUpdated: { task: Task; previousTask: Task }
  taskDeleted: TaskId
  taskCompleted: Task
}

export class TaskService implements EventEmitter<TaskServiceEvents> {
  private tasks: Map<TaskId, Task> = new Map()
  private listeners: Map<keyof TaskServiceEvents, Function[]> = new Map()

  constructor(private repository: Repository<Task, TaskId>) {
    this.initializeListeners()
  }

  private initializeListeners(): void {
    Object.keys({} as TaskServiceEvents).forEach(event => {
      this.listeners.set(event as keyof TaskServiceEvents, [])
    })
  }

  // Implémentation EventEmitter
  on<K extends keyof TaskServiceEvents>(
    event: K, 
    listener: (data: TaskServiceEvents[K]) => void
  ): void {
    const eventListeners = this.listeners.get(event) || []
    eventListeners.push(listener)
    this.listeners.set(event, eventListeners)
  }

  off<K extends keyof TaskServiceEvents>(
    event: K, 
    listener: (data: TaskServiceEvents[K]) => void
  ): void {
    const eventListeners = this.listeners.get(event) || []
    const index = eventListeners.indexOf(listener)
    if (index > -1) {
      eventListeners.splice(index, 1)
    }
  }

  emit<K extends keyof TaskServiceEvents>(
    event: K, 
    data: TaskServiceEvents[K]
  ): void {
    const eventListeners = this.listeners.get(event) || []
    eventListeners.forEach(listener => listener(data))
  }

  // Méthodes CRUD
  async createTask(data: CreateTaskData): Promise<Task> {
    // Validation
    if (!data.title.trim()) {
      throw new ValidationError('Title cannot be empty', 'title')
    }

    const now = new Date()
    const task: Task = {
      id: generateId(),
      ...data,
      createdAt: now,
      updatedAt: now,
      tags: data.tags || []
    }

    // Validation complète
    const validation = validateTask(task)
    if (!validation.success) {
      throw new ValidationError(
        `Invalid task data: ${validation.errors.join(', ')}`,
        'task'
      )
    }

    // Sauvegarde
    const savedTask = await this.repository.create(task)
    this.tasks.set(savedTask.id, savedTask)

    // Événement
    this.emit('taskCreated', savedTask)

    return savedTask
  }

  async updateTask(id: TaskId, data: UpdateTaskData): Promise<Task> {
    const existingTask = await this.getTaskById(id)
    if (!existingTask) {
      throw new ValidationError('Task not found', 'id')
    }

    const now = new Date()
    const updatedTask: Task = {
      ...existingTask,
      ...data,
      updatedAt: now,
      // Marquer comme complété si le statut change vers COMPLETED
      completedAt: data.status === TaskStatus.COMPLETED && 
                   existingTask.status !== TaskStatus.COMPLETED 
                   ? now 
                   : existingTask.completedAt
    }

    // Validation
    const validation = validateTask(updatedTask)
    if (!validation.success) {
      throw new ValidationError(
        `Invalid task data: ${validation.errors.join(', ')}`,
        'task'
      )
    }

    // Sauvegarde
    const savedTask = await this.repository.update(id, updatedTask)
    this.tasks.set(id, savedTask)

    // Événements
    this.emit('taskUpdated', { task: savedTask, previousTask: existingTask })
    
    if (savedTask.status === TaskStatus.COMPLETED && 
        existingTask.status !== TaskStatus.COMPLETED) {
      this.emit('taskCompleted', savedTask)
    }

    return savedTask
  }

  async deleteTask(id: TaskId): Promise<boolean> {
    const success = await this.repository.delete(id)
    if (success) {
      this.tasks.delete(id)
      this.emit('taskDeleted', id)
    }
    return success
  }

  async getTaskById(id: TaskId): Promise<Task | null> {
    // Vérifier le cache d'abord
    if (this.tasks.has(id)) {
      return this.tasks.get(id)!
    }

    // Sinon, charger depuis le repository
    const task = await this.repository.findById(id)
    if (task) {
      this.tasks.set(id, task)
    }
    return task
  }

  async getAllTasks(): Promise<Task[]> {
    const tasks = await this.repository.findAll()
    
    // Mettre à jour le cache
    tasks.forEach(task => this.tasks.set(task.id, task))
    
    return tasks
  }

  // Méthodes de filtrage avec types génériques
  async getFilteredTasks<T extends TaskFilter>(filter: T): Promise<Task[]> {
    const allTasks = await this.getAllTasks()
    
    return allTasks.filter(task => {
      // Filtrer par statut
      if (filter.status && !filter.status.includes(task.status)) {
        return false
      }

      // Filtrer par priorité
      if (filter.priority && !filter.priority.includes(task.priority)) {
        return false
      }

      // Filtrer par catégorie
      if (filter.categoryId && task.categoryId && 
          !filter.categoryId.includes(task.categoryId)) {
        return false
      }

      // Filtrer par recherche
      if (filter.search) {
        const searchLower = filter.search.toLowerCase()
        const matchesTitle = task.title.toLowerCase().includes(searchLower)
        const matchesDescription = task.description?.toLowerCase().includes(searchLower)
        const matchesTags = task.tags.some(tag => 
          tag.toLowerCase().includes(searchLower)
        )
        
        if (!matchesTitle && !matchesDescription && !matchesTags) {
          return false
        }
      }

      // Filtrer par plage de dates
      if (filter.dateRange) {
        const taskDate = task.createdAt
        if (taskDate < filter.dateRange.start || taskDate > filter.dateRange.end) {
          return false
        }
      }

      return true
    })
  }

  // Statistiques avec types calculés
  async getStatistics(): Promise<TaskStatistics> {
    const tasks = await this.getAllTasks()
    
    const total = tasks.length
    const completed = tasks.filter(t => t.status === TaskStatus.COMPLETED).length
    const inProgress = tasks.filter(t => t.status === TaskStatus.IN_PROGRESS).length
    const todo = tasks.filter(t => t.status === TaskStatus.TODO).length

    // Statistiques par priorité
    const byPriority = tasks.reduce((acc, task) => {
      acc[task.priority] = (acc[task.priority] || 0) + 1
      return acc
    }, {} as Record<TaskPriority, number>)

    // Statistiques par catégorie
    const byCategory = tasks.reduce((acc, task) => {
      if (task.categoryId) {
        acc[task.categoryId] = (acc[task.categoryId] || 0) + 1
      }
      return acc
    }, {} as Record<string, number>)

    // Taux de completion
    const completionRate = total > 0 ? (completed / total) * 100 : 0

    // Temps moyen de completion
    const completedTasks = tasks.filter(t => t.completedAt && t.createdAt)
    const averageCompletionTime = completedTasks.length > 0
      ? completedTasks.reduce((acc, task) => {
          const duration = task.completedAt!.getTime() - task.createdAt.getTime()
          return acc + (duration / (1000 * 60 * 60)) // en heures
        }, 0) / completedTasks.length
      : 0

    return {
      total,
      completed,
      inProgress,
      todo,
      byPriority,
      byCategory,
      completionRate,
      averageCompletionTime
    }
  }

  // Méthodes utilitaires avec types conditionnels
  async getTasksByStatus<T extends TaskStatus>(
    status: T
  ): Promise<Task[]> {
    return this.getFilteredTasks({ status: [status] })
  }

  async getOverdueTasks(): Promise<Task[]> {
    const now = new Date()
    const tasks = await this.getAllTasks()
    
    return tasks.filter(task => 
      task.dueDate && 
      task.dueDate < now && 
      task.status !== TaskStatus.COMPLETED
    )
  }

  async getTasksCompletedToday(): Promise<Task[]> {
    const today = new Date()
    today.setHours(0, 0, 0, 0)
    const tomorrow = new Date(today)
    tomorrow.setDate(tomorrow.getDate() + 1)

    const tasks = await this.getAllTasks()
    
    return tasks.filter(task =>
      task.completedAt &&
      task.completedAt >= today &&
      task.completedAt < tomorrow
    )
  }
}

Repository avec localStorage

Créez src/services/LocalStorageRepository.ts :

import { Repository } from '@/types'

export class LocalStorageRepository<T extends { id: string }, K extends string> 
  implements Repository<T, K> {
  
  constructor(private storageKey: string) {}

  private getItems(): T[] {
    try {
      const data = localStorage.getItem(this.storageKey)
      return data ? JSON.parse(data, this.dateReviver) : []
    } catch (error) {
      console.error(`Error reading from localStorage (${this.storageKey}):`, error)
      return []
    }
  }

  private setItems(items: T[]): void {
    try {
      localStorage.setItem(this.storageKey, JSON.stringify(items, this.dateReplacer))
    } catch (error) {
      console.error(`Error writing to localStorage (${this.storageKey}):`, error)
      throw new Error('Failed to save data')
    }
  }

  // Gestion des dates dans JSON
  private dateReplacer(key: string, value: unknown): unknown {
    if (value instanceof Date) {
      return { __type: 'Date', value: value.toISOString() }
    }
    return value
  }

  private dateReviver(key: string, value: unknown): unknown {
    if (typeof value === 'object' && value !== null && 
        'value' in value && '__type' in value && value.__type === 'Date') {
      return new Date(value.value as string)
    }
    return value
  }

  async findById(id: K): Promise<T | null> {
    const items = this.getItems()
    return items.find(item => item.id === id) || null
  }

  async findAll(): Promise<T[]> {
    return this.getItems()
  }

  async create(data: Omit<T, 'id'>): Promise<T> {
    const items = this.getItems()
    const newItem = { ...data, id: this.generateId() } as T
    items.push(newItem)
    this.setItems(items)
    return newItem
  }

  async update(id: K, data: Partial<T>): Promise<T> {
    const items = this.getItems()
    const index = items.findIndex(item => item.id === id)
    
    if (index === -1) {
      throw new Error(`Item with id ${id} not found`)
    }

    const updatedItem = { ...items[index], ...data }
    items[index] = updatedItem
    this.setItems(items)
    return updatedItem
  }

  async delete(id: K): Promise<boolean> {
    const items = this.getItems()
    const index = items.findIndex(item => item.id === id)
    
    if (index === -1) {
      return false
    }

    items.splice(index, 1)
    this.setItems(items)
    return true
  }

  private generateId(): string {
    return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
  }

  // Méthodes utilitaires
  async clear(): Promise<void> {
    localStorage.removeItem(this.storageKey)
  }

  async count(): Promise<number> {
    return this.getItems().length
  }

  async exists(id: K): Promise<boolean> {
    const item = await this.findById(id)
    return item !== null
  }
}

Phase 4 : Interface utilisateur

Composant principal de l’application

Créez src/components/TaskApp.ts :

import { TaskService } from '@/services/TaskService'
import { LocalStorageRepository } from '@/services/LocalStorageRepository'
import { Task, Category, TaskStatus, TaskPriority, TaskFilter } from '@/types'
import { TaskList } from './TaskList'
import { TaskForm } from './TaskForm'
import { TaskStats } from './TaskStats'
import { FilterPanel } from './FilterPanel'

export class TaskApp {
  private taskService: TaskService
  private currentFilter: TaskFilter = {}
  private tasks: Task[] = []

  // Éléments DOM
  private container: HTMLElement
  private taskList: TaskList
  private taskForm: TaskForm
  private taskStats: TaskStats
  private filterPanel: FilterPanel

  constructor(containerId: string) {
    const container = document.getElementById(containerId)
    if (!container) {
      throw new Error(`Container with id "${containerId}" not found`)
    }
    this.container = container

    // Initialiser les services
    const taskRepository = new LocalStorageRepository<Task, string>('tasks')
    this.taskService = new TaskService(taskRepository)

    // Initialiser les composants
    this.initializeComponents()
    this.setupEventListeners()
    this.loadInitialData()
  }

  private initializeComponents(): void {
    // Créer la structure HTML
    this.container.innerHTML = `
      <div class="task-app">
        <header class="app-header">
          <h1>Task Manager</h1>
          <div id="task-stats"></div>
        </header>
        
        <main class="app-main">
          <aside class="app-sidebar">
            <div id="task-form"></div>
            <div id="filter-panel"></div>
          </aside>
          
          <section class="app-content">
            <div id="task-list"></div>
          </section>
        </main>
      </div>
    `

    // Initialiser les composants
    this.taskStats = new TaskStats('task-stats', this.taskService)
    this.taskForm = new TaskForm('task-form', this.taskService)
    this.filterPanel = new FilterPanel('filter-panel')
    this.taskList = new TaskList('task-list', this.taskService)
  }

  private setupEventListeners(): void {
    // Écouter les événements du service
    this.taskService.on('taskCreated', (task) => {
      this.tasks.push(task)
      this.updateDisplay()
    })

    this.taskService.on('taskUpdated', ({ task }) => {
      const index = this.tasks.findIndex(t => t.id === task.id)
      if (index !== -1) {
        this.tasks[index] = task
        this.updateDisplay()
      }
    })

    this.taskService.on('taskDeleted', (taskId) => {
      this.tasks = this.tasks.filter(t => t.id !== taskId)
      this.updateDisplay()
    })

    // Écouter les changements de filtre
    this.filterPanel.on('filterChanged', (filter) => {
      this.currentFilter = filter
      this.updateDisplay()
    })
  }

  private async loadInitialData(): Promise<void> {
    try {
      this.tasks = await this.taskService.getAllTasks()
      this.updateDisplay()
    } catch (error) {
      console.error('Error loading initial data:', error)
      this.showError('Failed to load tasks')
    }
  }

  private async updateDisplay(): Promise<void> {
    try {
      // Filtrer les tâches
      const filteredTasks = await this.taskService.getFilteredTasks(this.currentFilter)
      
      // Mettre à jour les composants
      this.taskList.updateTasks(filteredTasks)
      await this.taskStats.updateStats()
      
    } catch (error) {
      console.error('Error updating display:', error)
      this.showError('Failed to update display')
    }
  }

  private showError(message: string): void {
    // Créer une notification d'erreur
    const errorDiv = document.createElement('div')
    errorDiv.className = 'error-notification'
    errorDiv.textContent = message
    
    this.container.appendChild(errorDiv)
    
    // Supprimer après 5 secondes
    setTimeout(() => {
      if (errorDiv.parentNode) {
        errorDiv.parentNode.removeChild(errorDiv)
      }
    }, 5000)
  }

  // Méthodes publiques pour l'API
  public async addTask(title: string, priority: TaskPriority = TaskPriority.MEDIUM): Promise<void> {
    await this.taskService.createTask({
      title,
      status: TaskStatus.TODO,
      priority,
      tags: []
    })
  }

  public async completeTask(taskId: string): Promise<void> {
    await this.taskService.updateTask(taskId, {
      status: TaskStatus.COMPLETED
    })
  }

  public getTaskCount(): number {
    return this.tasks.length
  }

  public async exportTasks(): Promise<string> {
    const tasks = await this.taskService.getAllTasks()
    return JSON.stringify(tasks, null, 2)
  }

  public async importTasks(jsonData: string): Promise<void> {
    try {
      const tasks = JSON.parse(jsonData) as Task[]
      
      for (const task of tasks) {
        await this.taskService.createTask({
          title: task.title,
          description: task.description,
          status: task.status,
          priority: task.priority,
          categoryId: task.categoryId,
          dueDate: task.dueDate,
          tags: task.tags
        })
      }
    } catch (error) {
      throw new Error('Invalid JSON data')
    }
  }
}

// Types pour les événements des composants
export interface ComponentEvents {
  filterChanged: TaskFilter
  taskSelected: string
  taskAction: { action: string; taskId: string }
}

export abstract class Component<T extends Record<string, unknown> = {}> {
  protected element: HTMLElement
  protected listeners: Map<keyof T, Function[]> = new Map()

  constructor(protected containerId: string) {
    const container = document.getElementById(containerId)
    if (!container) {
      throw new Error(`Container with id "${containerId}" not found`)
    }
    this.element = container
  }

  // Système d'événements pour les composants
  on<K extends keyof T>(event: K, listener: (data: T[K]) => void): void {
    const eventListeners = this.listeners.get(event) || []
    eventListeners.push(listener)
    this.listeners.set(event, eventListeners)
  }

  protected emit<K extends keyof T>(event: K, data: T[K]): void {
    const eventListeners = this.listeners.get(event) || []
    eventListeners.forEach(listener => listener(data))
  }

  abstract render(): void
}

Phase 5 : Tests TypeScript

Configuration des tests

Créez jest.config.js :

module.exports = {
  preset: 'ts-jest',
  testEnvironment: 'jsdom',
  roots: ['<rootDir>/src'],
  testMatch: ['**/__tests__/**/*.ts', '**/?(*.)+(spec|test).ts'],
  transform: {
    '^.+\\.ts$': 'ts-jest'
  },
  moduleNameMapping: {
    '^@/(.*)$': '<rootDir>/src/$1'
  },
  setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
  collectCoverageFrom: [
    'src/**/*.ts',
    '!src/**/*.d.ts',
    '!src/test-setup.ts'
  ]
}

Tests du service de tâches

Créez src/services/__tests__/TaskService.test.ts :

import { TaskService } from '../TaskService'
import { LocalStorageRepository } from '../LocalStorageRepository'
import { Task, TaskStatus, TaskPriority, ValidationError } from '@/types'

// Mock du localStorage
const localStorageMock = (() => {
  let store: Record<string, string> = {}
  return {
    getItem: jest.fn((key: string) => store[key] || null),
    setItem: jest.fn((key: string, value: string) => {
      store[key] = value
    }),
    removeItem: jest.fn((key: string) => {
      delete store[key]
    }),
    clear: jest.fn(() => {
      store = {}
    })
  }
})()

Object.defineProperty(window, 'localStorage', {
  value: localStorageMock
})

describe('TaskService', () => {
  let taskService: TaskService
  let repository: LocalStorageRepository<Task, string>

  beforeEach(() => {
    localStorageMock.clear()
    repository = new LocalStorageRepository<Task, string>('test-tasks')
    taskService = new TaskService(repository)
  })

  describe('createTask', () => {
    it('should create a task with valid data', async () => {
      const taskData = {
        title: 'Test Task',
        status: TaskStatus.TODO,
        priority: TaskPriority.MEDIUM,
        tags: ['test']
      }

      const task = await taskService.createTask(taskData)

      expect(task).toMatchObject({
        title: 'Test Task',
        status: TaskStatus.TODO,
        priority: TaskPriority.MEDIUM,
        tags: ['test']
      })
      expect(task.id).toBeDefined()
      expect(task.createdAt).toBeInstanceOf(Date)
      expect(task.updatedAt).toBeInstanceOf(Date)
    })

    it('should throw ValidationError for empty title', async () => {
      const taskData = {
        title: '',
        status: TaskStatus.TODO,
        priority: TaskPriority.MEDIUM,
        tags: []
      }

      await expect(taskService.createTask(taskData))
        .rejects
        .toThrow(ValidationError)
    })

    it('should emit taskCreated event', async () => {
      const mockListener = jest.fn()
      taskService.on('taskCreated', mockListener)

      const taskData = {
        title: 'Test Task',
        status: TaskStatus.TODO,
        priority: TaskPriority.MEDIUM,
        tags: []
      }

      const task = await taskService.createTask(taskData)

      expect(mockListener).toHaveBeenCalledWith(task)
    })
  })

  describe('updateTask', () => {
    let existingTask: Task

    beforeEach(async () => {
      existingTask = await taskService.createTask({
        title: 'Existing Task',
        status: TaskStatus.TODO,
        priority: TaskPriority.LOW,
        tags: []
      })
    })

    it('should update task successfully', async () => {
      const updateData = {
        title: 'Updated Task',
        priority: TaskPriority.HIGH
      }

      const updatedTask = await taskService.updateTask(existingTask.id, updateData)

      expect(updatedTask.title).toBe('Updated Task')
      expect(updatedTask.priority).toBe(TaskPriority.HIGH)
      expect(updatedTask.status).toBe(TaskStatus.TODO) // Unchanged
      expect(updatedTask.updatedAt.getTime()).toBeGreaterThan(existingTask.updatedAt.getTime())
    })

    it('should set completedAt when status changes to COMPLETED', async () => {
      const updatedTask = await taskService.updateTask(existingTask.id, {
        status: TaskStatus.COMPLETED
      })

      expect(updatedTask.status).toBe(TaskStatus.COMPLETED)
      expect(updatedTask.completedAt).toBeInstanceOf(Date)
    })

    it('should emit taskCompleted event when task is completed', async () => {
      const mockListener = jest.fn()
      taskService.on('taskCompleted', mockListener)

      const updatedTask = await taskService.updateTask(existingTask.id, {
        status: TaskStatus.COMPLETED
      })

      expect(mockListener).toHaveBeenCalledWith(updatedTask)
    })

    it('should throw ValidationError for non-existent task', async () => {
      await expect(taskService.updateTask('non-existent-id', { title: 'Test' }))
        .rejects
        .toThrow(ValidationError)
    })
  })

  describe('getFilteredTasks', () => {
    beforeEach(async () => {
      // Créer des tâches de test
      await taskService.createTask({
        title: 'High Priority Task',
        status: TaskStatus.TODO,
        priority: TaskPriority.HIGH,
        tags: ['urgent']
      })

      await taskService.createTask({
        title: 'Completed Task',
        status: TaskStatus.COMPLETED,
        priority: TaskPriority.MEDIUM,
        tags: ['done']
      })

      await taskService.createTask({
        title: 'Low Priority Task',
        status: TaskStatus.IN_PROGRESS,
        priority: TaskPriority.LOW,
        tags: ['later']
      })
    })

    it('should filter by status', async () => {
      const completedTasks = await taskService.getFilteredTasks({
        status: [TaskStatus.COMPLETED]
      })

      expect(completedTasks).toHaveLength(1)
      expect(completedTasks[0]?.title).toBe('Completed Task')
    })

    it('should filter by priority', async () => {
      const highPriorityTasks = await taskService.getFilteredTasks({
        priority: [TaskPriority.HIGH]
      })

      expect(highPriorityTasks).toHaveLength(1)
      expect(highPriorityTasks[0]?.title).toBe('High Priority Task')
    })

    it('should filter by search term', async () => {
      const searchResults = await taskService.getFilteredTasks({
        search: 'priority'
      })

      expect(searchResults).toHaveLength(2) // High Priority et Low Priority
    })

    it('should combine multiple filters', async () => {
      const filteredTasks = await taskService.getFilteredTasks({
        status: [TaskStatus.TODO, TaskStatus.IN_PROGRESS],
        priority: [TaskPriority.HIGH, TaskPriority.LOW]
      })

      expect(filteredTasks).toHaveLength(2)
      expect(filteredTasks.map(t => t.title)).toEqual([
        'High Priority Task',
        'Low Priority Task'
      ])
    })
  })

  describe('getStatistics', () => {
    beforeEach(async () => {
      // Créer des tâches pour les statistiques
      await taskService.createTask({
        title: 'Task 1',
        status: TaskStatus.COMPLETED,
        priority: TaskPriority.HIGH,
        tags: []
      })

      await taskService.createTask({
        title: 'Task 2',
        status: TaskStatus.TODO,
        priority: TaskPriority.MEDIUM,
        tags: []
      })

      await taskService.createTask({
        title: 'Task 3',
        status: TaskStatus.IN_PROGRESS,
        priority: TaskPriority.HIGH,
        tags: []
      })
    })

    it('should calculate correct statistics', async () => {
      const stats = await taskService.getStatistics()

      expect(stats.total).toBe(3)
      expect(stats.completed).toBe(1)
      expect(stats.todo).toBe(1)
      expect(stats.inProgress).toBe(1)
      expect(stats.completionRate).toBeCloseTo(33.33, 2)
      expect(stats.byPriority[TaskPriority.HIGH]).toBe(2)
      expect(stats.byPriority[TaskPriority.MEDIUM]).toBe(1)
    })
  })
})

Phase 6 : Build et déploiement

Scripts package.json

Ajoutez dans package.json :

{
  "scripts": {
    "dev": "vite",
    "build": "tsc && vite build",
    "preview": "vite preview",
    "test": "jest",
    "test:watch": "jest --watch",
    "test:coverage": "jest --coverage",
    "lint": "eslint src --ext .ts",
    "lint:fix": "eslint src --ext .ts --fix",
    "format": "prettier --write src/**/*.ts",
    "type-check": "tsc --noEmit",
    "prepare": "husky install"
  }
}

Configuration ESLint

Créez .eslintrc.js :

module.exports = {
  parser: '@typescript-eslint/parser',
  extends: [
    '@typescript-eslint/recommended',
    'prettier'
  ],
  plugins: ['@typescript-eslint'],
  rules: {
    '@typescript-eslint/no-unused-vars': 'error',
    '@typescript-eslint/explicit-function-return-type': 'warn',
    '@typescript-eslint/no-explicit-any': 'error',
    '@typescript-eslint/prefer-readonly': 'error',
    '@typescript-eslint/prefer-nullish-coalescing': 'error',
    '@typescript-eslint/prefer-optional-chain': 'error'
  }
}

Conclusion

Félicitations ! Vous avez créé une application complète de gestion de tâches avec TypeScript qui démontre :

Concepts TypeScript maîtrisés

  • Types avancés : Unions, intersections, types conditionnels
  • Génériques : Contraintes, types utilitaires, inférence
  • Classes et interfaces : Héritage, implémentation, abstractions
  • Type guards : Validation runtime avec types
  • Modules : Organisation du code, imports/exports
  • Décorateurs : Métaprogrammation (si utilisés)

Architecture moderne

  • Séparation des responsabilités : Services, repositories, composants
  • Inversion de dépendance : Interfaces et injection
  • Événements typés : Communication entre composants
  • Gestion d’erreurs : Types d’erreurs personnalisés
  • Tests complets : Unitaires et d’intégration

Bonnes pratiques

  • Types stricts : Configuration TypeScript stricte
  • Validation : Runtime et compile-time
  • Documentation : Types auto-documentés
  • Performance : Lazy loading, cache
  • Maintenabilité : Code modulaire et testable

Prochaines étapes

Pour aller plus loin, vous pourriez ajouter :

  • Framework UI : React, Vue ou Angular avec TypeScript
  • Base de données : Integration avec une vraie DB
  • API REST : Backend TypeScript avec Express
  • PWA : Service workers et cache
  • Déploiement : CI/CD avec GitHub Actions

Ce projet vous a permis de mettre en pratique tous les concepts TypeScript appris dans les tutoriels précédents. Vous avez maintenant une base solide pour créer des applications TypeScript robustes et maintenables !

Ressources utiles

Commentaires

Les commentaires sont alimentés par GitHub Discussions

Connectez-vous avec GitHub pour participer à la discussion

Lien copié !