feat: enhance Jira dashboard with advanced filtering and sprint details

- 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.
This commit is contained in:
Julien Froidefond
2025-09-19 10:13:48 +02:00
parent b7707d7651
commit 3dd6e0fd1c
17 changed files with 2879 additions and 68 deletions

View File

@@ -0,0 +1,88 @@
'use server';
import { jiraAnomalyDetection, JiraAnomaly, AnomalyDetectionConfig } from '@/services/jira-anomaly-detection';
import { JiraAnalyticsService, JiraAnalyticsConfig } from '@/services/jira-analytics';
import { userPreferencesService } from '@/services/user-preferences';
export interface AnomalyDetectionResult {
success: boolean;
data?: JiraAnomaly[];
error?: string;
}
/**
* Détecte les anomalies dans les métriques Jira actuelles
*/
export async function detectJiraAnomalies(forceRefresh = false): Promise<AnomalyDetectionResult> {
try {
// Récupérer la config Jira
const jiraConfig = await userPreferencesService.getJiraConfig();
if (!jiraConfig?.baseUrl || !jiraConfig?.email || !jiraConfig?.apiToken || !jiraConfig?.projectKey) {
return {
success: false,
error: 'Configuration Jira incomplète'
};
}
// Récupérer les analytics actuelles
if (!jiraConfig.baseUrl || !jiraConfig.projectKey) {
return { success: false, error: 'Configuration Jira incomplète' };
}
const analyticsService = new JiraAnalyticsService(jiraConfig as JiraAnalyticsConfig);
const analytics = await analyticsService.getProjectAnalytics(forceRefresh);
// Détecter les anomalies
const anomalies = await jiraAnomalyDetection.detectAnomalies(analytics);
return {
success: true,
data: anomalies
};
} catch (error) {
console.error('❌ Erreur lors de la détection d\'anomalies:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Erreur inconnue'
};
}
}
/**
* Met à jour la configuration de détection d'anomalies
*/
export async function updateAnomalyDetectionConfig(config: Partial<AnomalyDetectionConfig>) {
try {
jiraAnomalyDetection.updateConfig(config);
return {
success: true,
data: jiraAnomalyDetection.getConfig()
};
} catch (error) {
console.error('❌ Erreur lors de la mise à jour de la config:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Erreur inconnue'
};
}
}
/**
* Récupère la configuration actuelle de détection d'anomalies
*/
export async function getAnomalyDetectionConfig() {
try {
return {
success: true,
data: jiraAnomalyDetection.getConfig()
};
} catch (error) {
console.error('❌ Erreur lors de la récupération de la config:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Erreur inconnue'
};
}
}

113
src/actions/jira-filters.ts Normal file
View File

@@ -0,0 +1,113 @@
'use server';
import { JiraAnalyticsService, JiraAnalyticsConfig } from '@/services/jira-analytics';
import { JiraAdvancedFiltersService } from '@/services/jira-advanced-filters';
import { userPreferencesService } from '@/services/user-preferences';
import { AvailableFilters, JiraAnalyticsFilters, JiraAnalytics } from '@/lib/types';
export interface FiltersResult {
success: boolean;
data?: AvailableFilters;
error?: string;
}
export interface FilteredAnalyticsResult {
success: boolean;
data?: JiraAnalytics;
error?: string;
}
/**
* Récupère les filtres disponibles depuis les données Jira
*/
export async function getAvailableJiraFilters(): Promise<FiltersResult> {
try {
// Récupérer la config Jira
const jiraConfig = await userPreferencesService.getJiraConfig();
if (!jiraConfig?.baseUrl || !jiraConfig?.email || !jiraConfig?.apiToken || !jiraConfig?.projectKey) {
return {
success: false,
error: 'Configuration Jira incomplète'
};
}
// Récupérer toutes les issues du projet pour extraire les filtres
if (!jiraConfig.baseUrl || !jiraConfig.projectKey) {
return { success: false, error: 'Configuration Jira incomplète' };
}
const analyticsService = new JiraAnalyticsService(jiraConfig as JiraAnalyticsConfig);
// Récupérer la liste des issues pour extraire les filtres
const allIssues = await analyticsService.getAllProjectIssues();
// Extraire les filtres disponibles
const availableFilters = JiraAdvancedFiltersService.extractAvailableFilters(allIssues);
return {
success: true,
data: availableFilters
};
} catch (error) {
console.error('❌ Erreur lors de la récupération des filtres:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Erreur inconnue'
};
}
}
/**
* Applique des filtres aux analytics et retourne les données filtrées
*/
export async function getFilteredJiraAnalytics(filters: Partial<JiraAnalyticsFilters>): Promise<FilteredAnalyticsResult> {
try {
// Récupérer la config Jira
const jiraConfig = await userPreferencesService.getJiraConfig();
if (!jiraConfig?.baseUrl || !jiraConfig?.email || !jiraConfig?.apiToken || !jiraConfig?.projectKey) {
return {
success: false,
error: 'Configuration Jira incomplète'
};
}
// Récupérer les analytics originales
if (!jiraConfig.baseUrl || !jiraConfig.projectKey) {
return { success: false, error: 'Configuration Jira incomplète' };
}
const analyticsService = new JiraAnalyticsService(jiraConfig as JiraAnalyticsConfig);
const originalAnalytics = await analyticsService.getProjectAnalytics();
// Si aucun filtre actif, retourner les données originales
if (!JiraAdvancedFiltersService.hasActiveFilters(filters)) {
return {
success: true,
data: originalAnalytics
};
}
// Récupérer toutes les issues pour appliquer les filtres
const allIssues = await analyticsService.getAllProjectIssues();
// Appliquer les filtres
const filteredAnalytics = JiraAdvancedFiltersService.applyFiltersToAnalytics(
originalAnalytics,
filters,
allIssues
);
return {
success: true,
data: filteredAnalytics
};
} catch (error) {
console.error('❌ Erreur lors du filtrage des analytics:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Erreur inconnue'
};
}
}

View File

@@ -0,0 +1,191 @@
'use server';
import { JiraAnalyticsService, JiraAnalyticsConfig } from '@/services/jira-analytics';
import { userPreferencesService } from '@/services/user-preferences';
import { SprintDetails } from '@/components/jira/SprintDetailModal';
import { JiraTask, AssigneeDistribution, StatusDistribution, SprintVelocity } from '@/lib/types';
export interface SprintDetailsResult {
success: boolean;
data?: SprintDetails;
error?: string;
}
/**
* Récupère les détails d'un sprint spécifique
*/
export async function getSprintDetails(sprintName: string): Promise<SprintDetailsResult> {
try {
// Récupérer la config Jira
const jiraConfig = await userPreferencesService.getJiraConfig();
if (!jiraConfig?.baseUrl || !jiraConfig?.email || !jiraConfig?.apiToken || !jiraConfig?.projectKey) {
return {
success: false,
error: 'Configuration Jira incomplète'
};
}
// Récupérer les analytics générales pour trouver le sprint
if (!jiraConfig.baseUrl || !jiraConfig.projectKey) {
return { success: false, error: 'Configuration Jira incomplète' };
}
const analyticsService = new JiraAnalyticsService(jiraConfig as JiraAnalyticsConfig);
const analytics = await analyticsService.getProjectAnalytics();
const sprint = analytics.velocityMetrics.sprintHistory.find(s => s.sprintName === sprintName);
if (!sprint) {
return {
success: false,
error: `Sprint "${sprintName}" introuvable`
};
}
// Récupérer toutes les issues du projet pour filtrer par sprint
const allIssues = await analyticsService.getAllProjectIssues();
// Filtrer les issues pour ce sprint spécifique
// Note: En réalité, il faudrait une requête JQL plus précise pour récupérer les issues d'un sprint
// Pour simplifier, on prend les issues dans la période du sprint
const sprintStart = new Date(sprint.startDate);
const sprintEnd = new Date(sprint.endDate);
const sprintIssues = allIssues.filter(issue => {
const issueDate = new Date(issue.created);
return issueDate >= sprintStart && issueDate <= sprintEnd;
});
// Calculer les métriques du sprint
const sprintMetrics = calculateSprintMetrics(sprintIssues, sprint);
// Calculer la distribution par assigné pour ce sprint
const assigneeDistribution = calculateAssigneeDistribution(sprintIssues);
// Calculer la distribution par statut pour ce sprint
const statusDistribution = calculateStatusDistribution(sprintIssues);
const sprintDetails: SprintDetails = {
sprint,
issues: sprintIssues,
assigneeDistribution,
statusDistribution,
metrics: sprintMetrics
};
return {
success: true,
data: sprintDetails
};
} catch (error) {
console.error('❌ Erreur lors de la récupération des détails du sprint:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Erreur inconnue'
};
}
}
/**
* Calcule les métriques spécifiques au sprint
*/
function calculateSprintMetrics(issues: JiraTask[], sprint: SprintVelocity) {
const totalIssues = issues.length;
const completedIssues = issues.filter(issue =>
issue.status.category === 'Done' ||
issue.status.name.toLowerCase().includes('done') ||
issue.status.name.toLowerCase().includes('closed')
).length;
const inProgressIssues = issues.filter(issue =>
issue.status.category === 'In Progress' ||
issue.status.name.toLowerCase().includes('progress') ||
issue.status.name.toLowerCase().includes('review')
).length;
const blockedIssues = issues.filter(issue =>
issue.status.name.toLowerCase().includes('blocked') ||
issue.status.name.toLowerCase().includes('waiting')
).length;
// Calcul du cycle time moyen pour ce sprint
const completedIssuesWithDates = issues.filter(issue =>
issue.status.category === 'Done' && issue.created && issue.updated
);
let averageCycleTime = 0;
if (completedIssuesWithDates.length > 0) {
const totalCycleTime = completedIssuesWithDates.reduce((total, issue) => {
const created = new Date(issue.created);
const updated = new Date(issue.updated);
const cycleTime = (updated.getTime() - created.getTime()) / (1000 * 60 * 60 * 24); // en jours
return total + cycleTime;
}, 0);
averageCycleTime = totalCycleTime / completedIssuesWithDates.length;
}
// Déterminer la tendance de vélocité (simplifié)
let velocityTrend: 'up' | 'down' | 'stable' = 'stable';
if (sprint.completedPoints > sprint.plannedPoints * 0.9) {
velocityTrend = 'up';
} else if (sprint.completedPoints < sprint.plannedPoints * 0.7) {
velocityTrend = 'down';
}
return {
totalIssues,
completedIssues,
inProgressIssues,
blockedIssues,
averageCycleTime,
velocityTrend
};
}
/**
* Calcule la distribution par assigné pour le sprint
*/
function calculateAssigneeDistribution(issues: JiraTask[]): AssigneeDistribution[] {
const assigneeMap = new Map<string, { total: number; completed: number; inProgress: number }>();
issues.forEach(issue => {
const assigneeName = issue.assignee?.displayName || 'Non assigné';
const current = assigneeMap.get(assigneeName) || { total: 0, completed: 0, inProgress: 0 };
current.total++;
if (issue.status.category === 'Done') {
current.completed++;
} else if (issue.status.category === 'In Progress') {
current.inProgress++;
}
assigneeMap.set(assigneeName, current);
});
return Array.from(assigneeMap.entries()).map(([displayName, stats]) => ({
assignee: displayName === 'Non assigné' ? '' : displayName,
displayName,
totalIssues: stats.total,
completedIssues: stats.completed,
inProgressIssues: stats.inProgress,
percentage: issues.length > 0 ? (stats.total / issues.length) * 100 : 0
})).sort((a, b) => b.totalIssues - a.totalIssues);
}
/**
* Calcule la distribution par statut pour le sprint
*/
function calculateStatusDistribution(issues: JiraTask[]): StatusDistribution[] {
const statusMap = new Map<string, number>();
issues.forEach(issue => {
statusMap.set(issue.status.name, (statusMap.get(issue.status.name) || 0) + 1);
});
return Array.from(statusMap.entries()).map(([status, count]) => ({
status,
count,
percentage: issues.length > 0 ? (count / issues.length) * 100 : 0
})).sort((a, b) => b.count - a.count);
}