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 */}
+
+
+
+
+ >
)}
+