feat: enhance Jira analytics with caching and force refresh

- Updated `getJiraAnalytics` to accept a `forceRefresh` parameter for optional cache bypass.
- Modified `getProjectAnalytics` to check the cache and return cached data unless forced to refresh.
- Adjusted `loadAnalytics` in `useJiraAnalytics` to trigger a forced refresh on manual updates.
- Improved UI in `JiraDashboardPageClient` to indicate when data is served from cache.
This commit is contained in:
Julien Froidefond
2025-09-18 22:28:34 +02:00
parent 5d73a6c279
commit 4c03ae946f
6 changed files with 309 additions and 26 deletions

View File

@@ -9,12 +9,12 @@ export function useJiraAnalytics() {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
const loadAnalytics = useCallback(() => { const loadAnalytics = useCallback((forceRefresh = false) => {
startTransition(async () => { startTransition(async () => {
try { try {
setError(null); setError(null);
const result = await getJiraAnalytics(); const result = await getJiraAnalytics(forceRefresh);
if (result.success && result.data) { if (result.success && result.data) {
setAnalytics(result.data); setAnalytics(result.data);
@@ -30,7 +30,7 @@ export function useJiraAnalytics() {
}, []); }, []);
const refreshAnalytics = useCallback(() => { const refreshAnalytics = useCallback(() => {
loadAnalytics(); loadAnalytics(true); // Force refresh quand on actualise manuellement
}, [loadAnalytics]); }, [loadAnalytics]);
return { return {

View File

@@ -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<string, CacheEntry>();
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();

View File

@@ -4,6 +4,7 @@
*/ */
import { JiraService } from './jira'; import { JiraService } from './jira';
import { jiraAnalyticsCache } from './jira-analytics-cache';
import { import {
JiraAnalytics, JiraAnalytics,
JiraTask, JiraTask,
@@ -24,18 +25,28 @@ export interface JiraAnalyticsConfig {
export class JiraAnalyticsService { export class JiraAnalyticsService {
private jiraService: JiraService; private jiraService: JiraService;
private projectKey: string; private projectKey: string;
private config: JiraAnalyticsConfig;
constructor(config: JiraAnalyticsConfig) { constructor(config: JiraAnalyticsConfig) {
this.jiraService = new JiraService(config); this.jiraService = new JiraService(config);
this.projectKey = config.projectKey; 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<JiraAnalytics> { async getProjectAnalytics(forceRefresh = false): Promise<JiraAnalytics> {
try { 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 // Récupérer les informations du projet
const projectInfo = await this.getProjectInfo(); const projectInfo = await this.getProjectInfo();
@@ -57,7 +68,7 @@ export class JiraAnalyticsService {
this.calculateWorkInProgress(allIssues) this.calculateWorkInProgress(allIssues)
]); ]);
return { const analytics: JiraAnalytics = {
project: { project: {
key: this.projectKey, key: this.projectKey,
name: projectInfo.name, name: projectInfo.name,
@@ -69,12 +80,24 @@ export class JiraAnalyticsService {
workInProgress workInProgress
}; };
// Mettre en cache le résultat
jiraAnalyticsCache.set(this.config, analytics);
return analytics;
} catch (error) { } catch (error) {
console.error('Erreur lors du calcul des analytics:', error); console.error('Erreur lors du calcul des analytics:', error);
throw error; throw error;
} }
} }
/**
* Invalide le cache pour ce projet
*/
invalidateCache(): void {
jiraAnalyticsCache.invalidate(this.config);
}
/** /**
* Récupère les informations de base du projet * Récupère les informations de base du projet
*/ */

View File

@@ -13,7 +13,7 @@ export type JiraAnalyticsResult = {
/** /**
* Server Action pour récupérer les analytics Jira du projet configuré * Server Action pour récupérer les analytics Jira du projet configuré
*/ */
export async function getJiraAnalytics(): Promise<JiraAnalyticsResult> { export async function getJiraAnalytics(forceRefresh = false): Promise<JiraAnalyticsResult> {
try { try {
// Récupérer la config Jira depuis la base de données // Récupérer la config Jira depuis la base de données
const jiraConfig = await userPreferencesService.getJiraConfig(); const jiraConfig = await userPreferencesService.getJiraConfig();
@@ -40,8 +40,8 @@ export async function getJiraAnalytics(): Promise<JiraAnalyticsResult> {
projectKey: jiraConfig.projectKey projectKey: jiraConfig.projectKey
}); });
// Récupérer les analytics // Récupérer les analytics (avec cache ou actualisation forcée)
const analytics = await analyticsService.getProjectAnalytics(); const analytics = await analyticsService.getProjectAnalytics(forceRefresh);
return { return {
success: true, success: true,

98
src/actions/jira-cache.ts Normal file
View File

@@ -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<CacheStatsResult> {
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<CacheActionResult> {
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<CacheActionResult> {
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'
};
}
}

View File

@@ -154,13 +154,20 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
))} ))}
</div> </div>
<Button <div className="flex items-center gap-2">
onClick={refreshAnalytics} {analytics && (
disabled={isLoading} <div className="text-xs text-[var(--muted-foreground)] px-2 py-1 bg-[var(--card)] border border-[var(--border)] rounded">
variant="secondary" 💾 Données en cache
> </div>
{isLoading ? '🔄 Actualisation...' : '🔄 Actualiser'} )}
</Button> <Button
onClick={refreshAnalytics}
disabled={isLoading}
variant="secondary"
>
{isLoading ? '🔄 Actualisation...' : '🔄 Actualiser'}
</Button>
</div>
</div> </div>
</div> </div>