/** * Service d'analytics Jira pour la surveillance d'équipe * Calcule des métriques avancées sur un projet spécifique */ import { JiraService } from './jira'; import { JiraAnalytics, JiraTask, AssigneeDistribution, SprintVelocity, CycleTimeByType, StatusDistribution, AssigneeWorkload } from '@/lib/types'; export interface JiraAnalyticsConfig { baseUrl: string; email: string; apiToken: string; projectKey: string; } export class JiraAnalyticsService { private jiraService: JiraService; private projectKey: string; constructor(config: JiraAnalyticsConfig) { this.jiraService = new JiraService(config); this.projectKey = config.projectKey; } /** * Récupère toutes les analytics du projet */ async getProjectAnalytics(): Promise { try { console.log(`📊 Début de l'analyse du projet ${this.projectKey}...`); // Récupérer les informations du projet const projectInfo = await this.getProjectInfo(); // Récupérer tous les tickets du projet (pas seulement assignés) const allIssues = await this.getAllProjectIssues(); console.log(`📋 ${allIssues.length} tickets récupérés pour l'analyse`); // Calculer les différentes métriques const [ teamMetrics, velocityMetrics, cycleTimeMetrics, workInProgress ] = await Promise.all([ this.calculateTeamMetrics(allIssues), this.calculateVelocityMetrics(allIssues), this.calculateCycleTimeMetrics(allIssues), this.calculateWorkInProgress(allIssues) ]); return { project: { key: this.projectKey, name: projectInfo.name, totalIssues: allIssues.length }, teamMetrics, velocityMetrics, cycleTimeMetrics, workInProgress }; } catch (error) { console.error('Erreur lors du calcul des analytics:', error); throw error; } } /** * Récupère les informations de base du projet */ private async getProjectInfo(): Promise<{ name: string }> { const validation = await this.jiraService.validateProject(this.projectKey); if (!validation.exists) { throw new Error(`Projet ${this.projectKey} introuvable`); } return { name: validation.name || this.projectKey }; } /** * Récupère TOUS les tickets du projet (pas seulement assignés à l'utilisateur) */ private async getAllProjectIssues(): Promise { try { const jql = `project = "${this.projectKey}" ORDER BY created DESC`; // Utiliser la nouvelle méthode searchIssues qui gère la pagination correctement const jiraTasks = await this.jiraService.searchIssues(jql); // Retourner les tâches mappées (elles sont déjà converties par searchIssues) return jiraTasks; } catch (error) { console.error('Erreur lors de la récupération des tickets du projet:', error); throw error; } } /** * Calcule les métriques d'équipe (répartition par assignee) */ private async calculateTeamMetrics(issues: JiraTask[]): Promise<{ totalAssignees: number; activeAssignees: number; issuesDistribution: AssigneeDistribution[]; }> { const assigneeStats = new Map(); // Analyser chaque ticket issues.forEach(issue => { const assignee = issue.assignee; const status = issue.status?.name || 'Unknown'; // Utiliser "Unassigned" si pas d'assignee const assigneeKey = assignee?.emailAddress || 'unassigned'; const displayName = assignee?.displayName || 'Non assigné'; if (!assigneeStats.has(assigneeKey)) { assigneeStats.set(assigneeKey, { displayName, total: 0, completed: 0, inProgress: 0 }); } const stats = assigneeStats.get(assigneeKey)!; stats.total++; // Catégoriser par statut (logique simplifiée) const statusLower = status.toLowerCase(); if (statusLower.includes('done') || statusLower.includes('closed') || statusLower.includes('resolved')) { stats.completed++; } else if (statusLower.includes('progress') || statusLower.includes('review') || statusLower.includes('testing')) { stats.inProgress++; } }); // Convertir en tableau et calculer les pourcentages const distribution: AssigneeDistribution[] = Array.from(assigneeStats.entries()).map(([assignee, stats]) => ({ assignee, displayName: stats.displayName, totalIssues: stats.total, completedIssues: stats.completed, inProgressIssues: stats.inProgress, percentage: Math.round((stats.total / issues.length) * 100) })).sort((a, b) => b.totalIssues - a.totalIssues); const activeAssignees = distribution.filter(d => d.inProgressIssues > 0).length; return { totalAssignees: assigneeStats.size, activeAssignees, issuesDistribution: distribution }; } /** * Calcule les métriques de vélocité (basées sur les story points) */ private async calculateVelocityMetrics(issues: JiraTask[]): Promise<{ currentSprintPoints: number; averageVelocity: number; sprintHistory: SprintVelocity[]; }> { // Pour l'instant, implémentation basique // TODO: Intégrer avec l'API Jira Agile pour les vrais sprints const completedIssues = issues.filter(issue => { const statusCategory = issue.status?.category?.toLowerCase(); const statusName = issue.status?.name?.toLowerCase() || ''; // Support Jira français ET anglais const isCompleted = statusCategory === 'done' || statusCategory === 'terminé' || statusName.includes('done') || statusName.includes('closed') || statusName.includes('resolved') || statusName.includes('complete') || statusName.includes('fait') || statusName.includes('clôturé') || statusName.includes('cloturé') || statusName.includes('en production') || statusName.includes('finished') || statusName.includes('delivered'); return isCompleted; }); // Calculer les points (1 point par ticket pour simplifier) const getStoryPoints = () => { return 1; // Simplifié pour l'instant, pas de story points dans JiraTask }; const currentSprintPoints = completedIssues .reduce((sum) => sum + getStoryPoints(), 0); // Créer un historique basé sur les données réelles des 4 dernières périodes const sprintHistory = this.generateSprintHistoryFromIssues(issues, completedIssues); const averageVelocity = sprintHistory.length > 0 ? Math.round(sprintHistory.reduce((sum, sprint) => sum + sprint.completedPoints, 0) / sprintHistory.length) : 0; return { currentSprintPoints, averageVelocity, sprintHistory }; } /** * Génère un historique de sprints basé sur les dates de création/résolution des tickets */ private generateSprintHistoryFromIssues(allIssues: JiraTask[], completedIssues: JiraTask[]): SprintVelocity[] { const now = new Date(); const sprintHistory: SprintVelocity[] = []; // Créer 4 périodes de 2 semaines (8 semaines au total) for (let i = 3; i >= 0; i--) { const endDate = new Date(now.getTime() - (i * 14 * 24 * 60 * 60 * 1000)); const startDate = new Date(endDate.getTime() - (14 * 24 * 60 * 60 * 1000)); // Compter les tickets complétés dans cette période const completedInPeriod = completedIssues.filter(issue => { const updatedDate = new Date(issue.updated); return updatedDate >= startDate && updatedDate <= endDate; }); // Compter les tickets créés dans cette période (approximation du planifié) const createdInPeriod = allIssues.filter(issue => { const createdDate = new Date(issue.created); return createdDate >= startDate && createdDate <= endDate; }); const completedPoints = completedInPeriod.length; const plannedPoints = Math.max(completedPoints, createdInPeriod.length); const completionRate = plannedPoints > 0 ? Math.round((completedPoints / plannedPoints) * 100) : 0; sprintHistory.push({ sprintName: i === 0 ? 'Sprint actuel' : `Sprint -${i}`, startDate: startDate.toISOString(), endDate: endDate.toISOString(), completedPoints, plannedPoints, completionRate }); } return sprintHistory; } /** * Calcule les métriques de cycle time */ private async calculateCycleTimeMetrics(issues: JiraTask[]): Promise<{ averageCycleTime: number; cycleTimeByType: CycleTimeByType[]; }> { const completedIssues = issues.filter(issue => { const statusCategory = issue.status?.category?.toLowerCase(); const statusName = issue.status?.name?.toLowerCase() || ''; // Support Jira français ET anglais return statusCategory === 'done' || statusCategory === 'terminé' || statusName.includes('done') || statusName.includes('closed') || statusName.includes('resolved') || statusName.includes('complete') || statusName.includes('fait') || statusName.includes('clôturé') || statusName.includes('cloturé') || statusName.includes('en production') || statusName.includes('finished') || statusName.includes('delivered'); }); // Calculer le cycle time (de création à résolution) const cycleTimes = completedIssues .filter(issue => issue.created && issue.updated) // S'assurer qu'on a les dates .map(issue => { const created = new Date(issue.created); const resolved = new Date(issue.updated); const days = Math.max(0.1, (resolved.getTime() - created.getTime()) / (1000 * 60 * 60 * 24)); // Minimum 0.1 jour return Math.round(days * 10) / 10; // Arrondir à 1 décimale }) .filter(time => time > 0 && time < 365); // Filtrer les valeurs aberrantes (plus d'un an) const averageCycleTime = cycleTimes.length > 0 ? Math.round(cycleTimes.reduce((sum, time) => sum + time, 0) / cycleTimes.length * 10) / 10 : 0; // Grouper par type d'issue (recalculer avec les données filtrées) const validCompletedIssues = completedIssues.filter(issue => issue.created && issue.updated); const typeStats = new Map(); validCompletedIssues.forEach((issue, index) => { if (index < cycleTimes.length) { // Sécurité pour éviter l'index out of bounds const issueType = issue.issuetype?.name || 'Unknown'; if (!typeStats.has(issueType)) { typeStats.set(issueType, []); } const cycleTime = cycleTimes[index]; if (cycleTime > 0 && cycleTime < 365) { // Même filtre que plus haut typeStats.get(issueType)!.push(cycleTime); } } }); const cycleTimeByType: CycleTimeByType[] = Array.from(typeStats.entries()).map(([type, times]) => { const average = times.reduce((sum, time) => sum + time, 0) / times.length; const sorted = [...times].sort((a, b) => a - b); const median = sorted.length % 2 === 0 ? (sorted[sorted.length / 2 - 1] + sorted[sorted.length / 2]) / 2 : sorted[Math.floor(sorted.length / 2)]; return { issueType: type, averageDays: Math.round(average * 10) / 10, medianDays: Math.round(median * 10) / 10, samples: times.length }; }).sort((a, b) => b.samples - a.samples); return { averageCycleTime, cycleTimeByType }; } /** * Calcule le work in progress (WIP) */ private async calculateWorkInProgress(issues: JiraTask[]): Promise<{ byStatus: StatusDistribution[]; byAssignee: AssigneeWorkload[]; }> { // Grouper par statut const statusCounts = new Map(); issues.forEach(issue => { const status = issue.status?.name || 'Unknown'; statusCounts.set(status, (statusCounts.get(status) || 0) + 1); }); const byStatus: StatusDistribution[] = Array.from(statusCounts.entries()).map(([status, count]) => ({ status, count, percentage: Math.round((count / issues.length) * 100) })).sort((a, b) => b.count - a.count); // Grouper par assignee (WIP seulement) const wipIssues = issues.filter(issue => { const statusCategory = issue.status?.category?.toLowerCase(); const statusName = issue.status?.name?.toLowerCase() || ''; // Exclure les tickets terminés (support français ET anglais) return statusCategory !== 'done' && statusCategory !== 'terminé' && !statusName.includes('done') && !statusName.includes('closed') && !statusName.includes('resolved') && !statusName.includes('complete') && !statusName.includes('fait') && !statusName.includes('clôturé') && !statusName.includes('cloturé') && !statusName.includes('en production') && !statusName.includes('finished') && !statusName.includes('delivered'); }); const assigneeWorkload = new Map(); wipIssues.forEach(issue => { const assignee = issue.assignee; const status = issue.status?.name?.toLowerCase() || ''; const assigneeKey = assignee?.emailAddress || 'unassigned'; const displayName = assignee?.displayName || 'Non assigné'; if (!assigneeWorkload.has(assigneeKey)) { assigneeWorkload.set(assigneeKey, { displayName, todo: 0, inProgress: 0, review: 0 }); } const workload = assigneeWorkload.get(assigneeKey)!; const statusCategory = issue.status?.category?.toLowerCase(); // Classification robuste français/anglais basée sur les catégories et noms Jira if (statusCategory === 'indeterminate' || statusCategory === 'en cours' || status.includes('progress') || status.includes('en cours') || status.includes('developing') || status.includes('implementation')) { workload.inProgress++; } else if (status.includes('review') || status.includes('testing') || status.includes('validation') || status.includes('validating') || status.includes('ready for')) { workload.review++; } else if (statusCategory === 'new' || statusCategory === 'a faire' || status.includes('todo') || status.includes('to do') || status.includes('a faire') || status.includes('backlog') || status.includes('product backlog') || status.includes('ready to sprint') || status.includes('estimating') || status.includes('refinement') || status.includes('open') || status.includes('created')) { workload.todo++; } else { // Fallback: si on ne peut pas classifier, mettre en "À faire" workload.todo++; } }); const byAssignee: AssigneeWorkload[] = Array.from(assigneeWorkload.entries()).map(([assignee, workload]) => ({ assignee, displayName: workload.displayName, todoCount: workload.todo, inProgressCount: workload.inProgress, reviewCount: workload.review, totalActive: workload.todo + workload.inProgress + workload.review })).sort((a, b) => b.totalActive - a.totalActive); return { byStatus, byAssignee }; } }