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:
@@ -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 {
|
||||||
|
|||||||
155
services/jira-analytics-cache.ts
Normal file
155
services/jira-analytics-cache.ts
Normal 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();
|
||||||
@@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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
98
src/actions/jira-cache.ts
Normal 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'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -154,6 +154,12 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{analytics && (
|
||||||
|
<div className="text-xs text-[var(--muted-foreground)] px-2 py-1 bg-[var(--card)] border border-[var(--border)] rounded">
|
||||||
|
💾 Données en cache
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
onClick={refreshAnalytics}
|
onClick={refreshAnalytics}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
@@ -163,6 +169,7 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Contenu principal */}
|
{/* Contenu principal */}
|
||||||
{error && (
|
{error && (
|
||||||
|
|||||||
Reference in New Issue
Block a user