Projet complet
Créez une application de gestion de tâches moderne avec TypeScript
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 !
Commentaires
Les commentaires sont alimentés par GitHub Discussions
Connectez-vous avec GitHub pour participer à la discussion