- Updated `TODO.md` to mark several tasks as complete, including anomaly detection and sprint detail integration. - Enhanced `VelocityChart` to support click events for sprint details, improving user interaction. - Added `FilterBar` and `AnomalyDetectionPanel` components to `JiraDashboardPageClient` for advanced filtering capabilities. - Implemented state management for selected sprints and modal display for detailed sprint information. - Introduced new types for advanced filtering in `types.ts`, expanding the filtering options available in the analytics.
298 lines
12 KiB
TypeScript
298 lines
12 KiB
TypeScript
/**
|
|
* Service de détection d'anomalies dans les métriques Jira
|
|
* Analyse les patterns et tendances pour identifier des problèmes potentiels
|
|
*/
|
|
|
|
import { JiraAnalytics, SprintVelocity, CycleTimeByType, AssigneeWorkload } from '@/lib/types';
|
|
|
|
export interface JiraAnomaly {
|
|
id: string;
|
|
type: 'velocity' | 'cycle_time' | 'workload' | 'completion' | 'blockers';
|
|
severity: 'low' | 'medium' | 'high' | 'critical';
|
|
title: string;
|
|
description: string;
|
|
value: number;
|
|
threshold: number;
|
|
recommendation: string;
|
|
affectedItems: string[];
|
|
timestamp: string;
|
|
}
|
|
|
|
export interface AnomalyDetectionConfig {
|
|
velocityVarianceThreshold: number; // % de variance acceptable
|
|
cycleTimeThreshold: number; // multiplicateur du cycle time moyen
|
|
workloadImbalanceThreshold: number; // ratio max entre assignees
|
|
completionRateThreshold: number; // % minimum de completion
|
|
stalledItemsThreshold: number; // jours sans changement
|
|
}
|
|
|
|
export class JiraAnomalyDetectionService {
|
|
private readonly defaultConfig: AnomalyDetectionConfig = {
|
|
velocityVarianceThreshold: 30, // 30% de variance
|
|
cycleTimeThreshold: 2.0, // 2x le cycle time moyen
|
|
workloadImbalanceThreshold: 3.0, // 3:1 ratio max
|
|
completionRateThreshold: 70, // 70% completion minimum
|
|
stalledItemsThreshold: 7 // 7 jours
|
|
};
|
|
|
|
constructor(private config: Partial<AnomalyDetectionConfig> = {}) {
|
|
this.config = { ...this.defaultConfig, ...config };
|
|
}
|
|
|
|
/**
|
|
* Analyse toutes les métriques et détecte les anomalies
|
|
*/
|
|
async detectAnomalies(analytics: JiraAnalytics): Promise<JiraAnomaly[]> {
|
|
const anomalies: JiraAnomaly[] = [];
|
|
const timestamp = new Date().toISOString();
|
|
|
|
// 1. Détection d'anomalies de vélocité
|
|
const velocityAnomalies = this.detectVelocityAnomalies(analytics.velocityMetrics, timestamp);
|
|
anomalies.push(...velocityAnomalies);
|
|
|
|
// 2. Détection d'anomalies de cycle time
|
|
const cycleTimeAnomalies = this.detectCycleTimeAnomalies(analytics.cycleTimeMetrics, timestamp);
|
|
anomalies.push(...cycleTimeAnomalies);
|
|
|
|
// 3. Détection de déséquilibres de charge
|
|
const workloadAnomalies = this.detectWorkloadAnomalies(analytics.workInProgress.byAssignee, timestamp);
|
|
anomalies.push(...workloadAnomalies);
|
|
|
|
// 4. Détection de problèmes de completion
|
|
const completionAnomalies = this.detectCompletionAnomalies(analytics.velocityMetrics, timestamp);
|
|
anomalies.push(...completionAnomalies);
|
|
|
|
// Trier par sévérité
|
|
return anomalies.sort((a, b) => this.getSeverityWeight(b.severity) - this.getSeverityWeight(a.severity));
|
|
}
|
|
|
|
/**
|
|
* Détecte les anomalies de vélocité (variance excessive, tendance négative)
|
|
*/
|
|
private detectVelocityAnomalies(velocityMetrics: { sprintHistory: SprintVelocity[]; averageVelocity: number }, timestamp: string): JiraAnomaly[] {
|
|
const anomalies: JiraAnomaly[] = [];
|
|
const { sprintHistory, averageVelocity } = velocityMetrics;
|
|
|
|
if (sprintHistory.length < 3) return anomalies;
|
|
|
|
// Calcul de la variance de vélocité
|
|
const velocities = sprintHistory.map((s: SprintVelocity) => s.completedPoints);
|
|
const variance = this.calculateVariance(velocities);
|
|
const variancePercent = (Math.sqrt(variance) / averageVelocity) * 100;
|
|
|
|
if (variancePercent > (this.config.velocityVarianceThreshold ?? this.defaultConfig.velocityVarianceThreshold)) {
|
|
anomalies.push({
|
|
id: `velocity-variance-${Date.now()}`,
|
|
type: 'velocity',
|
|
severity: variancePercent > 50 ? 'high' : 'medium',
|
|
title: 'Vélocité très variable',
|
|
description: `La vélocité de l'équipe varie de ${variancePercent.toFixed(1)}% autour de la moyenne`,
|
|
value: variancePercent,
|
|
threshold: this.config.velocityVarianceThreshold ?? this.defaultConfig.velocityVarianceThreshold,
|
|
recommendation: 'Analysez les facteurs causant cette instabilité : estimation, complexité, blockers',
|
|
affectedItems: sprintHistory.slice(-3).map((s: SprintVelocity) => s.sprintName),
|
|
timestamp
|
|
});
|
|
}
|
|
|
|
// Détection de tendance décroissante
|
|
const recentSprints = sprintHistory.slice(-3);
|
|
const isDecreasing = recentSprints.every((sprint: SprintVelocity, i: number) =>
|
|
i === 0 || sprint.completedPoints < recentSprints[i - 1].completedPoints
|
|
);
|
|
|
|
if (isDecreasing && recentSprints.length >= 3) {
|
|
const decline = ((recentSprints[0].completedPoints - recentSprints[recentSprints.length - 1].completedPoints) / recentSprints[0].completedPoints) * 100;
|
|
|
|
anomalies.push({
|
|
id: `velocity-decline-${Date.now()}`,
|
|
type: 'velocity',
|
|
severity: decline > 30 ? 'critical' : 'high',
|
|
title: 'Vélocité en déclin',
|
|
description: `La vélocité a diminué de ${decline.toFixed(1)}% sur les 3 derniers sprints`,
|
|
value: decline,
|
|
threshold: 0,
|
|
recommendation: 'Identifiez les causes : technical debt, complexité croissante, ou problèmes d\'équipe',
|
|
affectedItems: recentSprints.map((s: SprintVelocity) => s.sprintName),
|
|
timestamp
|
|
});
|
|
}
|
|
|
|
return anomalies;
|
|
}
|
|
|
|
/**
|
|
* Détecte les anomalies de cycle time (temps excessifs, types problématiques)
|
|
*/
|
|
private detectCycleTimeAnomalies(cycleTimeMetrics: { averageCycleTime: number; cycleTimeByType: CycleTimeByType[] }, timestamp: string): JiraAnomaly[] {
|
|
const anomalies: JiraAnomaly[] = [];
|
|
const { averageCycleTime, cycleTimeByType } = cycleTimeMetrics;
|
|
|
|
// Détection des types avec cycle time excessif
|
|
cycleTimeByType.forEach((typeMetrics: CycleTimeByType) => {
|
|
const ratio = typeMetrics.averageDays / averageCycleTime;
|
|
|
|
if (ratio > (this.config.cycleTimeThreshold ?? this.defaultConfig.cycleTimeThreshold)) {
|
|
anomalies.push({
|
|
id: `cycle-time-${typeMetrics.issueType}-${Date.now()}`,
|
|
type: 'cycle_time',
|
|
severity: ratio > 3 ? 'high' : 'medium',
|
|
title: `Cycle time excessif - ${typeMetrics.issueType}`,
|
|
description: `Le type "${typeMetrics.issueType}" prend ${ratio.toFixed(1)}x plus de temps que la moyenne`,
|
|
value: typeMetrics.averageDays,
|
|
threshold: averageCycleTime * (this.config.cycleTimeThreshold ?? this.defaultConfig.cycleTimeThreshold),
|
|
recommendation: 'Analysez les blockers spécifiques à ce type de ticket',
|
|
affectedItems: [typeMetrics.issueType],
|
|
timestamp
|
|
});
|
|
}
|
|
});
|
|
|
|
// Détection cycle time global excessif (> 14 jours)
|
|
if (averageCycleTime > 14) {
|
|
anomalies.push({
|
|
id: `global-cycle-time-${Date.now()}`,
|
|
type: 'cycle_time',
|
|
severity: averageCycleTime > 21 ? 'critical' : 'high',
|
|
title: 'Cycle time global élevé',
|
|
description: `Le cycle time moyen de ${averageCycleTime.toFixed(1)} jours est préoccupant`,
|
|
value: averageCycleTime,
|
|
threshold: 14,
|
|
recommendation: 'Réduisez la taille des tâches et identifiez les goulots d\'étranglement',
|
|
affectedItems: ['Projet global'],
|
|
timestamp
|
|
});
|
|
}
|
|
|
|
return anomalies;
|
|
}
|
|
|
|
/**
|
|
* Détecte les déséquilibres de charge de travail
|
|
*/
|
|
private detectWorkloadAnomalies(assigneeWorkloads: AssigneeWorkload[], timestamp: string): JiraAnomaly[] {
|
|
const anomalies: JiraAnomaly[] = [];
|
|
|
|
if (assigneeWorkloads.length < 2) return anomalies;
|
|
|
|
const workloads = assigneeWorkloads.map(a => a.totalActive);
|
|
const maxWorkload = Math.max(...workloads);
|
|
const minWorkload = Math.min(...workloads.filter(w => w > 0));
|
|
|
|
if (minWorkload === 0) return anomalies; // Éviter division par zéro
|
|
|
|
const imbalanceRatio = maxWorkload / minWorkload;
|
|
|
|
if (imbalanceRatio > (this.config.workloadImbalanceThreshold ?? this.defaultConfig.workloadImbalanceThreshold)) {
|
|
const overloadedMember = assigneeWorkloads.find(a => a.totalActive === maxWorkload);
|
|
const underloadedMember = assigneeWorkloads.find(a => a.totalActive === minWorkload);
|
|
|
|
anomalies.push({
|
|
id: `workload-imbalance-${Date.now()}`,
|
|
type: 'workload',
|
|
severity: imbalanceRatio > 5 ? 'high' : 'medium',
|
|
title: 'Déséquilibre de charge',
|
|
description: `Ratio de ${imbalanceRatio.toFixed(1)}:1 entre membres les plus/moins chargés`,
|
|
value: imbalanceRatio,
|
|
threshold: this.config.workloadImbalanceThreshold ?? this.defaultConfig.workloadImbalanceThreshold,
|
|
recommendation: 'Redistribuez les tâches pour équilibrer la charge de travail',
|
|
affectedItems: [
|
|
`Surchargé: ${overloadedMember?.displayName} (${maxWorkload} tâches)`,
|
|
`Sous-chargé: ${underloadedMember?.displayName} (${minWorkload} tâches)`
|
|
],
|
|
timestamp
|
|
});
|
|
}
|
|
|
|
// Détection de membres avec trop de tâches en cours
|
|
assigneeWorkloads.forEach(assignee => {
|
|
if (assignee.inProgressCount > 5) {
|
|
anomalies.push({
|
|
id: `wip-limit-${assignee.assignee}-${Date.now()}`,
|
|
type: 'workload',
|
|
severity: assignee.inProgressCount > 8 ? 'high' : 'medium',
|
|
title: 'WIP limite dépassée',
|
|
description: `${assignee.displayName} a ${assignee.inProgressCount} tâches en cours`,
|
|
value: assignee.inProgressCount,
|
|
threshold: 5,
|
|
recommendation: 'Limitez le WIP à 3-5 tâches par personne pour améliorer le focus',
|
|
affectedItems: [assignee.displayName],
|
|
timestamp
|
|
});
|
|
}
|
|
});
|
|
|
|
return anomalies;
|
|
}
|
|
|
|
/**
|
|
* Détecte les problèmes de completion rate
|
|
*/
|
|
private detectCompletionAnomalies(velocityMetrics: { sprintHistory: SprintVelocity[] }, timestamp: string): JiraAnomaly[] {
|
|
const anomalies: JiraAnomaly[] = [];
|
|
const { sprintHistory } = velocityMetrics;
|
|
|
|
if (sprintHistory.length === 0) return anomalies;
|
|
|
|
// Analyse des 3 derniers sprints
|
|
const recentSprints = sprintHistory.slice(-3);
|
|
const avgCompletionRate = recentSprints.reduce((sum: number, sprint: SprintVelocity) =>
|
|
sum + sprint.completionRate, 0) / recentSprints.length;
|
|
|
|
if (avgCompletionRate < (this.config.completionRateThreshold ?? this.defaultConfig.completionRateThreshold)) {
|
|
anomalies.push({
|
|
id: `low-completion-rate-${Date.now()}`,
|
|
type: 'completion',
|
|
severity: avgCompletionRate < 50 ? 'critical' : 'high',
|
|
title: 'Taux de completion faible',
|
|
description: `Taux de completion moyen de ${avgCompletionRate.toFixed(1)}% sur les derniers sprints`,
|
|
value: avgCompletionRate,
|
|
threshold: this.config.completionRateThreshold ?? this.defaultConfig.completionRateThreshold,
|
|
recommendation: 'Revoyez la planification et l\'estimation des sprints',
|
|
affectedItems: recentSprints.map((s: SprintVelocity) => `${s.sprintName}: ${s.completionRate.toFixed(1)}%`),
|
|
timestamp
|
|
});
|
|
}
|
|
|
|
return anomalies;
|
|
}
|
|
|
|
/**
|
|
* Calcule la variance d'un tableau de nombres
|
|
*/
|
|
private calculateVariance(numbers: number[]): number {
|
|
const mean = numbers.reduce((sum, num) => sum + num, 0) / numbers.length;
|
|
const squaredDiffs = numbers.map(num => Math.pow(num - mean, 2));
|
|
return squaredDiffs.reduce((sum, diff) => sum + diff, 0) / numbers.length;
|
|
}
|
|
|
|
/**
|
|
* Retourne le poids numérique d'une sévérité pour le tri
|
|
*/
|
|
private getSeverityWeight(severity: string): number {
|
|
switch (severity) {
|
|
case 'critical': return 4;
|
|
case 'high': return 3;
|
|
case 'medium': return 2;
|
|
case 'low': return 1;
|
|
default: return 0;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Met à jour la configuration de détection
|
|
*/
|
|
updateConfig(newConfig: Partial<AnomalyDetectionConfig>): void {
|
|
this.config = { ...this.config, ...newConfig };
|
|
}
|
|
|
|
/**
|
|
* Retourne la configuration actuelle
|
|
*/
|
|
getConfig(): AnomalyDetectionConfig {
|
|
return { ...this.defaultConfig, ...this.config };
|
|
}
|
|
}
|
|
|
|
export const jiraAnomalyDetection = new JiraAnomalyDetectionService();
|