diff --git a/TODO.md b/TODO.md index 29cc77a..5f80c39 100644 --- a/TODO.md +++ b/TODO.md @@ -298,7 +298,8 @@ Endpoints complexes → API Routes conservées ### 5.5 Fonctionnalités de surveillance - [x] **Cache serveur intelligent** : Cache en mémoire avec invalidation manuelle -- [ ] Comparaison inter-sprints et tendances +- [x] **Export des métriques** : Export CSV/JSON avec téléchargement automatique +- [x] **Comparaison inter-sprints** : Tendances, prédictions et recommandations - [ ] Détection automatique d'anomalies (alertes) - [ ] Filtrage par composant, version, type de ticket - [ ] Vue détaillée par sprint avec drill-down diff --git a/components/jira/SprintComparison.tsx b/components/jira/SprintComparison.tsx new file mode 100644 index 0000000..77b5f2d --- /dev/null +++ b/components/jira/SprintComparison.tsx @@ -0,0 +1,336 @@ +'use client'; + +import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, BarChart, Bar, Cell } from 'recharts'; +import { SprintVelocity } from '@/lib/types'; +import { Card, CardContent, CardHeader } from '@/components/ui/Card'; + +interface SprintComparisonProps { + sprintHistory: SprintVelocity[]; + className?: string; +} + +interface ComparisonMetrics { + velocityTrend: 'improving' | 'declining' | 'stable'; + avgCompletion: number; + consistency: 'high' | 'medium' | 'low'; + bestSprint: SprintVelocity; + worstSprint: SprintVelocity; + predictions: { + nextSprintEstimate: number; + confidenceLevel: 'high' | 'medium' | 'low'; + }; +} + +export function SprintComparison({ sprintHistory, className }: SprintComparisonProps) { + // Analyser les tendances + const metrics = analyzeSprintTrends(sprintHistory); + + // Données pour les graphiques + const comparisonData = sprintHistory.map(sprint => ({ + name: sprint.sprintName.replace('Sprint ', ''), + completion: sprint.completionRate, + velocity: sprint.completedPoints, + planned: sprint.plannedPoints, + variance: sprint.plannedPoints > 0 + ? ((sprint.completedPoints - sprint.plannedPoints) / sprint.plannedPoints) * 100 + : 0 + })); + + const CustomTooltip = ({ active, payload, label }: { + active?: boolean; + payload?: Array<{ value: number; name: string; color: string }>; + label?: string + }) => { + if (active && payload && payload.length) { + return ( +
+

Sprint {label}

+
+ {payload.map((item, index) => ( +
+ {item.name}: + + {typeof item.value === 'number' ? + (item.name.includes('%') ? `${item.value}%` : item.value) : + item.value + } + +
+ ))} +
+
+ ); + } + return null; + }; + + return ( +
+
+ {/* Graphique de comparaison des taux de complétion */} +
+

Évolution des taux de complétion

+
+ + + + + + } /> + + + +
+
+ + {/* Graphique de comparaison des vélocités */} +
+

Comparaison planifié vs réalisé

+
+ + + + + + } /> + + + + +
+
+
+ + {/* Métriques de comparaison */} +
+ +
+
+ {metrics.velocityTrend === 'improving' ? '📈' : + metrics.velocityTrend === 'declining' ? '📉' : '➡️'} +
+
+ Tendance générale +
+
+
+ + +
+
80 ? 'text-green-500' : + metrics.avgCompletion > 60 ? 'text-orange-500' : 'text-red-500' + }`}> + {Math.round(metrics.avgCompletion)}% +
+
+ Complétion moyenne +
+
+
+ + +
+
+ {metrics.consistency === 'high' ? 'Haute' : + metrics.consistency === 'medium' ? 'Moyenne' : 'Faible'} +
+
+ Consistance +
+
+
+ + +
+
+ {metrics.predictions.nextSprintEstimate} +
+
+ Prédiction suivante +
+
+
+
+ + {/* Insights et recommandations */} +
+ +

🏆 Meilleur sprint

+
+
+ Sprint: + {metrics.bestSprint.sprintName} +
+
+ Points complétés: + {metrics.bestSprint.completedPoints} +
+
+ Taux de complétion: + {metrics.bestSprint.completionRate}% +
+
+
+ + +

📉 Sprint à améliorer

+
+
+ Sprint: + {metrics.worstSprint.sprintName} +
+
+ Points complétés: + {metrics.worstSprint.completedPoints} +
+
+ Taux de complétion: + {metrics.worstSprint.completionRate}% +
+
+
+
+ + {/* Recommandations */} + +

💡 Recommandations

+
+ {getRecommendations(metrics).map((recommendation, index) => ( +
+ + {recommendation} +
+ ))} +
+
+
+ ); +} + +/** + * Analyse les tendances des sprints + */ +function analyzeSprintTrends(sprintHistory: SprintVelocity[]): ComparisonMetrics { + if (sprintHistory.length === 0) { + return { + velocityTrend: 'stable', + avgCompletion: 0, + consistency: 'low', + bestSprint: sprintHistory[0], + worstSprint: sprintHistory[0], + predictions: { nextSprintEstimate: 0, confidenceLevel: 'low' } + }; + } + + // Tendance de vélocité (comparer premiers vs derniers sprints) + const firstHalf = sprintHistory.slice(0, Math.ceil(sprintHistory.length / 2)); + const secondHalf = sprintHistory.slice(Math.floor(sprintHistory.length / 2)); + + const firstHalfAvg = firstHalf.reduce((sum, s) => sum + s.completedPoints, 0) / firstHalf.length; + const secondHalfAvg = secondHalf.reduce((sum, s) => sum + s.completedPoints, 0) / secondHalf.length; + + const improvementRate = (secondHalfAvg - firstHalfAvg) / firstHalfAvg * 100; + const velocityTrend: 'improving' | 'declining' | 'stable' = + improvementRate > 10 ? 'improving' : + improvementRate < -10 ? 'declining' : 'stable'; + + // Complétion moyenne + const avgCompletion = sprintHistory.reduce((sum, s) => sum + s.completionRate, 0) / sprintHistory.length; + + // Consistance (variance des taux de complétion) + const completionRates = sprintHistory.map(s => s.completionRate); + const variance = completionRates.reduce((sum, rate) => sum + Math.pow(rate - avgCompletion, 2), 0) / completionRates.length; + const standardDeviation = Math.sqrt(variance); + + const consistency: 'high' | 'medium' | 'low' = + standardDeviation < 10 ? 'high' : + standardDeviation < 20 ? 'medium' : 'low'; + + // Meilleur et pire sprint + const bestSprint = sprintHistory.reduce((best, current) => + current.completionRate > best.completionRate ? current : best); + const worstSprint = sprintHistory.reduce((worst, current) => + current.completionRate < worst.completionRate ? current : worst); + + // Prédiction pour le prochain sprint + const recentSprints = sprintHistory.slice(-3); // 3 derniers sprints + const recentAvg = recentSprints.reduce((sum, s) => sum + s.completedPoints, 0) / recentSprints.length; + const nextSprintEstimate = Math.round(recentAvg); + + const confidenceLevel: 'high' | 'medium' | 'low' = + consistency === 'high' && velocityTrend !== 'declining' ? 'high' : + consistency === 'medium' ? 'medium' : 'low'; + + return { + velocityTrend, + avgCompletion, + consistency, + bestSprint, + worstSprint, + predictions: { nextSprintEstimate, confidenceLevel } + }; +} + +/** + * Génère des recommandations basées sur l'analyse + */ +function getRecommendations(metrics: ComparisonMetrics): string[] { + const recommendations: string[] = []; + + if (metrics.velocityTrend === 'declining') { + recommendations.push("Tendance en baisse détectée - Identifier les blockers récurrents"); + recommendations.push("Revoir les estimations ou la complexité des tâches récentes"); + } else if (metrics.velocityTrend === 'improving') { + recommendations.push("Excellente progression ! Maintenir les bonnes pratiques actuelles"); + } + + if (metrics.avgCompletion < 60) { + recommendations.push("Taux de complétion faible - Considérer des sprints plus courts ou moins ambitieux"); + } else if (metrics.avgCompletion > 90) { + recommendations.push("Taux de complétion très élevé - L'équipe pourrait prendre plus d'engagements"); + } + + if (metrics.consistency === 'low') { + recommendations.push("Consistance faible - Améliorer la prévisibilité des estimations"); + recommendations.push("Organiser des rétrospectives pour identifier les causes de variabilité"); + } + + if (recommendations.length === 0) { + recommendations.push("Performance stable et prévisible - Continuer sur cette lancée !"); + } + + return recommendations; +} diff --git a/hooks/useJiraExport.ts b/hooks/useJiraExport.ts new file mode 100644 index 0000000..4e2e55d --- /dev/null +++ b/hooks/useJiraExport.ts @@ -0,0 +1,58 @@ +'use client'; + +import { useState, useTransition } from 'react'; +import { exportJiraAnalytics, ExportFormat } from '@/actions/jira-export'; + +export function useJiraExport() { + const [isExporting, startTransition] = useTransition(); + const [error, setError] = useState(null); + + const exportAnalytics = (format: ExportFormat = 'csv') => { + startTransition(async () => { + try { + setError(null); + + const result = await exportJiraAnalytics(format); + + if (result.success && result.data && result.filename) { + // Créer un blob et déclencher le téléchargement + const mimeType = format === 'json' ? 'application/json' : 'text/csv'; + const blob = new Blob([result.data], { type: mimeType }); + + // Créer un lien temporaire pour le téléchargement + const url = window.URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = result.filename; + + // Déclencher le téléchargement + document.body.appendChild(link); + link.click(); + + // Nettoyer + document.body.removeChild(link); + window.URL.revokeObjectURL(url); + + console.log(`✅ Export ${format.toUpperCase()} réussi: ${result.filename}`); + } else { + setError(result.error || 'Erreur lors de l\'export'); + } + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Erreur lors de l\'export'; + setError(errorMessage); + console.error('Erreur export analytics:', err); + } + }); + }; + + const exportCSV = () => exportAnalytics('csv'); + const exportJSON = () => exportAnalytics('json'); + + return { + isExporting, + error, + exportCSV, + exportJSON, + exportAnalytics + }; +} diff --git a/src/actions/jira-export.ts b/src/actions/jira-export.ts new file mode 100644 index 0000000..b62d0cd --- /dev/null +++ b/src/actions/jira-export.ts @@ -0,0 +1,183 @@ +'use server'; + +import { getJiraAnalytics } from './jira-analytics'; + +export type ExportFormat = 'csv' | 'json'; + +export type ExportResult = { + success: boolean; + data?: string; + filename?: string; + error?: string; +}; + +/** + * Server Action pour exporter les analytics Jira au format CSV ou JSON + */ +export async function exportJiraAnalytics(format: ExportFormat = 'csv'): Promise { + try { + // Récupérer les analytics (force refresh pour avoir les données les plus récentes) + const analyticsResult = await getJiraAnalytics(true); + + if (!analyticsResult.success || !analyticsResult.data) { + return { + success: false, + error: analyticsResult.error || 'Impossible de récupérer les analytics' + }; + } + + const analytics = analyticsResult.data; + const timestamp = new Date().toISOString().slice(0, 16).replace(/:/g, '-'); + const projectKey = analytics.project.key; + + if (format === 'json') { + return { + success: true, + data: JSON.stringify(analytics, null, 2), + filename: `jira-analytics-${projectKey}-${timestamp}.json` + }; + } + + // Format CSV + const csvData = generateCSV(analytics); + + return { + success: true, + data: csvData, + filename: `jira-analytics-${projectKey}-${timestamp}.csv` + }; + + } catch (error) { + console.error('❌ Erreur lors de l\'export des analytics:', error); + + return { + success: false, + error: error instanceof Error ? error.message : 'Erreur inconnue' + }; + } +} + +/** + * Génère un CSV à partir des analytics Jira + */ +function generateCSV(analytics: any): string { + const lines: string[] = []; + + // Header du rapport + lines.push('# Rapport Analytics Jira'); + lines.push(`# Projet: ${analytics.project.name} (${analytics.project.key})`); + lines.push(`# Généré le: ${new Date().toLocaleString('fr-FR')}`); + lines.push(`# Total tickets: ${analytics.project.totalIssues}`); + lines.push(''); + + // Section 1: Métriques d'équipe + lines.push('## Répartition de l\'équipe'); + lines.push('Assignee,Nom,Total Tickets,Tickets Complétés,Tickets En Cours,Pourcentage'); + analytics.teamMetrics.issuesDistribution.forEach((assignee: any) => { + lines.push([ + escapeCsv(assignee.assignee), + escapeCsv(assignee.displayName), + assignee.totalIssues, + assignee.completedIssues, + assignee.inProgressIssues, + assignee.percentage.toFixed(1) + '%' + ].join(',')); + }); + lines.push(''); + + // Section 2: Historique des sprints + lines.push('## Historique des sprints'); + lines.push('Sprint,Date Début,Date Fin,Points Planifiés,Points Complétés,Taux de Complétion'); + analytics.velocityMetrics.sprintHistory.forEach((sprint: any) => { + lines.push([ + escapeCsv(sprint.sprintName), + sprint.startDate.slice(0, 10), + sprint.endDate.slice(0, 10), + sprint.plannedPoints, + sprint.completedPoints, + sprint.completionRate + '%' + ].join(',')); + }); + lines.push(''); + + // Section 3: Cycle time par type + lines.push('## Cycle Time par type de ticket'); + lines.push('Type de Ticket,Temps Moyen (jours),Temps Médian (jours),Échantillons'); + analytics.cycleTimeMetrics.cycleTimeByType.forEach((type: any) => { + lines.push([ + escapeCsv(type.issueType), + type.averageDays, + type.medianDays, + type.samples + ].join(',')); + }); + lines.push(''); + + // Section 4: Work in Progress + lines.push('## Work in Progress par statut'); + lines.push('Statut,Nombre,Pourcentage'); + analytics.workInProgress.byStatus.forEach((status: any) => { + lines.push([ + escapeCsv(status.status), + status.count, + status.percentage + '%' + ].join(',')); + }); + lines.push(''); + + // Section 5: Charge de travail par assignee + lines.push('## Charge de travail par assignee'); + lines.push('Assignee,Nom,À Faire,En Cours,En Revue,Total Actif'); + analytics.workInProgress.byAssignee.forEach((assignee: any) => { + lines.push([ + escapeCsv(assignee.assignee), + escapeCsv(assignee.displayName), + assignee.todoCount, + assignee.inProgressCount, + assignee.reviewCount, + assignee.totalActive + ].join(',')); + }); + lines.push(''); + + // Section 6: Métriques résumé + lines.push('## Métriques de résumé'); + lines.push('Métrique,Valeur'); + lines.push([ + 'Total membres équipe', + analytics.teamMetrics.totalAssignees + ].join(',')); + lines.push([ + 'Membres actifs', + analytics.teamMetrics.activeAssignees + ].join(',')); + lines.push([ + 'Points complétés sprint actuel', + analytics.velocityMetrics.currentSprintPoints + ].join(',')); + lines.push([ + 'Vélocité moyenne', + analytics.velocityMetrics.averageVelocity + ].join(',')); + lines.push([ + 'Cycle time moyen (jours)', + analytics.cycleTimeMetrics.averageCycleTime + ].join(',')); + + return lines.join('\n'); +} + +/** + * Échappe les valeurs CSV (guillemets, virgules, retours à la ligne) + */ +function escapeCsv(value: string): string { + if (typeof value !== 'string') return String(value); + + // Si la valeur contient des guillemets, virgules ou retours à la ligne + if (value.includes('"') || value.includes(',') || value.includes('\n')) { + // Doubler les guillemets et entourer de guillemets + return '"' + value.replace(/"/g, '""') + '"'; + } + + return value; +} diff --git a/src/app/jira-dashboard/JiraDashboardPageClient.tsx b/src/app/jira-dashboard/JiraDashboardPageClient.tsx index aa457d6..78cfb5d 100644 --- a/src/app/jira-dashboard/JiraDashboardPageClient.tsx +++ b/src/app/jira-dashboard/JiraDashboardPageClient.tsx @@ -3,6 +3,7 @@ import { useState, useEffect } from 'react'; import { JiraConfig } from '@/lib/types'; import { useJiraAnalytics } from '@/hooks/useJiraAnalytics'; +import { useJiraExport } from '@/hooks/useJiraExport'; import { Header } from '@/components/ui/Header'; import { Card, CardHeader, CardContent } from '@/components/ui/Card'; import { Button } from '@/components/ui/Button'; @@ -15,6 +16,7 @@ import { ThroughputChart } from '@/components/jira/ThroughputChart'; import { QualityMetrics } from '@/components/jira/QualityMetrics'; import { PredictabilityMetrics } from '@/components/jira/PredictabilityMetrics'; import { CollaborationMatrix } from '@/components/jira/CollaborationMatrix'; +import { SprintComparison } from '@/components/jira/SprintComparison'; import Link from 'next/link'; interface JiraDashboardPageClientProps { @@ -23,6 +25,7 @@ interface JiraDashboardPageClientProps { export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPageClientProps) { const { analytics, isLoading, error, loadAnalytics, refreshAnalytics } = useJiraAnalytics(); + const { isExporting, error: exportError, exportCSV, exportJSON } = useJiraExport(); const [selectedPeriod, setSelectedPeriod] = useState<'7d' | '30d' | '3m' | 'current'>('current'); useEffect(() => { @@ -158,10 +161,33 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
{analytics && ( -
- 💾 Données en cache -
+ <> +
+ 💾 Données en cache +
+ + {/* Boutons d'export */} +
+ + +
+ )} +