diff --git a/hooks/useJiraAnalytics.ts b/hooks/useJiraAnalytics.ts index 2170be6..1f0376b 100644 --- a/hooks/useJiraAnalytics.ts +++ b/hooks/useJiraAnalytics.ts @@ -9,13 +9,13 @@ export function useJiraAnalytics() { const [error, setError] = useState(null); const [isPending, startTransition] = useTransition(); - const loadAnalytics = useCallback(() => { + const loadAnalytics = useCallback((forceRefresh = false) => { startTransition(async () => { try { setError(null); - - const result = await getJiraAnalytics(); - + + const result = await getJiraAnalytics(forceRefresh); + if (result.success && result.data) { setAnalytics(result.data); } else { @@ -30,7 +30,7 @@ export function useJiraAnalytics() { }, []); const refreshAnalytics = useCallback(() => { - loadAnalytics(); + loadAnalytics(true); // Force refresh quand on actualise manuellement }, [loadAnalytics]); return { diff --git a/services/jira-analytics-cache.ts b/services/jira-analytics-cache.ts new file mode 100644 index 0000000..0a66327 --- /dev/null +++ b/services/jira-analytics-cache.ts @@ -0,0 +1,155 @@ +/** + * Service de cache pour les analytics Jira + * Cache en mémoire avec invalidation manuelle + */ + +import { JiraAnalytics } from '@/lib/types'; + +interface CacheEntry { + data: JiraAnalytics; + timestamp: number; + projectKey: string; + configHash: string; // Hash de la config Jira pour détecter les changements +} + +class JiraAnalyticsCacheService { + private cache = new Map(); + private readonly CACHE_KEY_PREFIX = 'jira-analytics:'; + + /** + * Génère une clé de cache basée sur la config Jira + */ + private getCacheKey(projectKey: string, configHash: string): string { + return `${this.CACHE_KEY_PREFIX}${projectKey}:${configHash}`; + } + + /** + * Génère un hash de la configuration Jira pour détecter les changements + */ + private generateConfigHash(config: { baseUrl: string; email: string; apiToken: string; projectKey: string }): string { + const configString = `${config.baseUrl}|${config.email}|${config.apiToken}|${config.projectKey}`; + // Simple hash (pour production, utiliser crypto.createHash) + let hash = 0; + for (let i = 0; i < configString.length; i++) { + const char = configString.charCodeAt(i); + hash = ((hash << 5) - hash) + char; + hash = hash & hash; // Convert to 32-bit integer + } + return hash.toString(); + } + + /** + * Récupère les analytics depuis le cache si disponible + */ + get(config: { baseUrl: string; email: string; apiToken: string; projectKey: string }): JiraAnalytics | null { + const configHash = this.generateConfigHash(config); + const cacheKey = this.getCacheKey(config.projectKey, configHash); + + const entry = this.cache.get(cacheKey); + + if (!entry) { + console.log(`📋 Cache MISS pour projet ${config.projectKey}`); + return null; + } + + // Vérifier que la config n'a pas changé + if (entry.configHash !== configHash) { + console.log(`🔄 Config changée pour projet ${config.projectKey}, invalidation du cache`); + this.cache.delete(cacheKey); + return null; + } + + console.log(`✅ Cache HIT pour projet ${config.projectKey} (${this.getAgeDescription(entry.timestamp)})`); + return entry.data; + } + + /** + * Stocke les analytics dans le cache + */ + set(config: { baseUrl: string; email: string; apiToken: string; projectKey: string }, data: JiraAnalytics): void { + const configHash = this.generateConfigHash(config); + const cacheKey = this.getCacheKey(config.projectKey, configHash); + + const entry: CacheEntry = { + data, + timestamp: Date.now(), + projectKey: config.projectKey, + configHash + }; + + this.cache.set(cacheKey, entry); + console.log(`💾 Analytics mises en cache pour projet ${config.projectKey}`); + } + + /** + * Invalide le cache pour un projet spécifique + */ + invalidate(config: { baseUrl: string; email: string; apiToken: string; projectKey: string }): void { + const configHash = this.generateConfigHash(config); + const cacheKey = this.getCacheKey(config.projectKey, configHash); + + const deleted = this.cache.delete(cacheKey); + if (deleted) { + console.log(`🗑️ Cache invalidé pour projet ${config.projectKey}`); + } else { + console.log(`ℹ️ Aucun cache à invalider pour projet ${config.projectKey}`); + } + } + + /** + * Invalide tout le cache + */ + invalidateAll(): void { + const size = this.cache.size; + this.cache.clear(); + console.log(`🗑️ Tout le cache analytics invalidé (${size} entrées supprimées)`); + } + + /** + * Retourne les statistiques du cache + */ + getStats(): { + totalEntries: number; + projects: Array<{ projectKey: string; age: string; size: number }>; + } { + const projects = Array.from(this.cache.entries()).map(([key, entry]) => ({ + projectKey: entry.projectKey, + age: this.getAgeDescription(entry.timestamp), + size: JSON.stringify(entry.data).length + })); + + return { + totalEntries: this.cache.size, + projects + }; + } + + /** + * Formate l'âge d'une entrée de cache + */ + private getAgeDescription(timestamp: number): string { + const ageMs = Date.now() - timestamp; + const ageMinutes = Math.floor(ageMs / (1000 * 60)); + const ageHours = Math.floor(ageMinutes / 60); + + if (ageHours > 0) { + return `il y a ${ageHours}h${ageMinutes % 60}m`; + } else if (ageMinutes > 0) { + return `il y a ${ageMinutes}m`; + } else { + return 'maintenant'; + } + } + + /** + * Vérifie si une entrée existe pour un projet + */ + has(config: { baseUrl: string; email: string; apiToken: string; projectKey: string }): boolean { + const configHash = this.generateConfigHash(config); + const cacheKey = this.getCacheKey(config.projectKey, configHash); + return this.cache.has(cacheKey); + } +} + +// Instance singleton +export const jiraAnalyticsCache = new JiraAnalyticsCacheService(); diff --git a/services/jira-analytics.ts b/services/jira-analytics.ts index 9c2b626..f175b4b 100644 --- a/services/jira-analytics.ts +++ b/services/jira-analytics.ts @@ -4,14 +4,15 @@ */ import { JiraService } from './jira'; -import { - JiraAnalytics, +import { jiraAnalyticsCache } from './jira-analytics-cache'; +import { + JiraAnalytics, JiraTask, - AssigneeDistribution, - SprintVelocity, - CycleTimeByType, - StatusDistribution, - AssigneeWorkload + AssigneeDistribution, + SprintVelocity, + CycleTimeByType, + StatusDistribution, + AssigneeWorkload } from '@/lib/types'; export interface JiraAnalyticsConfig { @@ -24,18 +25,28 @@ export interface JiraAnalyticsConfig { export class JiraAnalyticsService { private jiraService: JiraService; private projectKey: string; + private config: JiraAnalyticsConfig; constructor(config: JiraAnalyticsConfig) { this.jiraService = new JiraService(config); this.projectKey = config.projectKey; + this.config = config; } /** - * Récupère toutes les analytics du projet + * Récupère toutes les analytics du projet avec cache */ - async getProjectAnalytics(): Promise { + async getProjectAnalytics(forceRefresh = false): Promise { try { - console.log(`📊 Début de l'analyse du projet ${this.projectKey}...`); + // Vérifier le cache d'abord (sauf si forceRefresh) + if (!forceRefresh) { + const cachedAnalytics = jiraAnalyticsCache.get(this.config); + if (cachedAnalytics) { + return cachedAnalytics; + } + } + + console.log(`🔄 Calcul des analytics Jira pour projet ${this.projectKey} ${forceRefresh ? '(actualisation forcée)' : '(cache manquant)'}`); // Récupérer les informations du projet const projectInfo = await this.getProjectInfo(); @@ -57,7 +68,7 @@ export class JiraAnalyticsService { this.calculateWorkInProgress(allIssues) ]); - return { + const analytics: JiraAnalytics = { project: { key: this.projectKey, name: projectInfo.name, @@ -69,12 +80,24 @@ export class JiraAnalyticsService { workInProgress }; + // Mettre en cache le résultat + jiraAnalyticsCache.set(this.config, analytics); + + return analytics; + } catch (error) { console.error('Erreur lors du calcul des analytics:', error); throw error; } } + /** + * Invalide le cache pour ce projet + */ + invalidateCache(): void { + jiraAnalyticsCache.invalidate(this.config); + } + /** * Récupère les informations de base du projet */ diff --git a/src/actions/jira-analytics.ts b/src/actions/jira-analytics.ts index 26e3e91..10ca38a 100644 --- a/src/actions/jira-analytics.ts +++ b/src/actions/jira-analytics.ts @@ -13,7 +13,7 @@ export type JiraAnalyticsResult = { /** * Server Action pour récupérer les analytics Jira du projet configuré */ -export async function getJiraAnalytics(): Promise { +export async function getJiraAnalytics(forceRefresh = false): Promise { try { // Récupérer la config Jira depuis la base de données const jiraConfig = await userPreferencesService.getJiraConfig(); @@ -40,8 +40,8 @@ export async function getJiraAnalytics(): Promise { projectKey: jiraConfig.projectKey }); - // Récupérer les analytics - const analytics = await analyticsService.getProjectAnalytics(); + // Récupérer les analytics (avec cache ou actualisation forcée) + const analytics = await analyticsService.getProjectAnalytics(forceRefresh); return { success: true, diff --git a/src/actions/jira-cache.ts b/src/actions/jira-cache.ts new file mode 100644 index 0000000..0833ace --- /dev/null +++ b/src/actions/jira-cache.ts @@ -0,0 +1,98 @@ +'use server'; + +import { jiraAnalyticsCache } from '@/services/jira-analytics-cache'; +import { userPreferencesService } from '@/services/user-preferences'; + +export type CacheStatsResult = { + success: boolean; + data?: { + totalEntries: number; + projects: Array<{ projectKey: string; age: string; size: number }>; + }; + error?: string; +}; + +export type CacheActionResult = { + success: boolean; + message?: string; + error?: string; +}; + +/** + * Server Action pour récupérer les statistiques du cache + */ +export async function getJiraCacheStats(): Promise { + try { + const stats = jiraAnalyticsCache.getStats(); + + return { + success: true, + data: stats + }; + } catch (error) { + console.error('❌ Erreur lors de la récupération des stats du cache:', error); + + return { + success: false, + error: error instanceof Error ? error.message : 'Erreur inconnue' + }; + } +} + +/** + * Server Action pour invalider le cache du projet configuré + */ +export async function invalidateJiraCache(): Promise { + try { + // Récupérer la config Jira actuelle + const jiraConfig = await userPreferencesService.getJiraConfig(); + + if (!jiraConfig.enabled || !jiraConfig.baseUrl || !jiraConfig.email || !jiraConfig.apiToken || !jiraConfig.projectKey) { + return { + success: false, + error: 'Configuration Jira incomplète' + }; + } + + // Invalider le cache pour ce projet + jiraAnalyticsCache.invalidate({ + baseUrl: jiraConfig.baseUrl, + email: jiraConfig.email, + apiToken: jiraConfig.apiToken, + projectKey: jiraConfig.projectKey + }); + + return { + success: true, + message: `Cache invalidé pour le projet ${jiraConfig.projectKey}` + }; + } catch (error) { + console.error('❌ Erreur lors de l\'invalidation du cache:', error); + + return { + success: false, + error: error instanceof Error ? error.message : 'Erreur inconnue' + }; + } +} + +/** + * Server Action pour invalider tout le cache analytics + */ +export async function invalidateAllJiraCache(): Promise { + try { + jiraAnalyticsCache.invalidateAll(); + + return { + success: true, + message: 'Tout le cache analytics a été invalidé' + }; + } catch (error) { + console.error('❌ Erreur lors de l\'invalidation totale du cache:', error); + + return { + success: false, + error: error instanceof Error ? error.message : 'Erreur inconnue' + }; + } +} diff --git a/src/app/jira-dashboard/JiraDashboardPageClient.tsx b/src/app/jira-dashboard/JiraDashboardPageClient.tsx index b092d75..af3e3bd 100644 --- a/src/app/jira-dashboard/JiraDashboardPageClient.tsx +++ b/src/app/jira-dashboard/JiraDashboardPageClient.tsx @@ -154,13 +154,20 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage ))} - +
+ {analytics && ( +
+ 💾 Données en cache +
+ )} + +