Files
towercontrol/src/services/analytics/analytics.ts
Julien Froidefond fd3827214f feat: update dashboard components and analytics for 7-day summaries
- Modified `ManagerWeeklySummary`, `MetricsTab`, and `ProductivityAnalytics` to reflect a focus on the last 7 days instead of the current week.
- Enhanced `ManagerSummaryService` and `MetricsService` to calculate metrics over a sliding 7-day window, improving data relevance.
- Added a new utility function `formatDistanceToNow` for better date formatting in French.
- Updated comments and documentation to clarify changes in timeframes.
2025-09-23 21:22:59 +02:00

302 lines
9.1 KiB
TypeScript

import { Task, TaskStatus, TaskPriority, TaskSource } from '@/lib/types';
import { prisma } from '@/services/core/database';
import { getToday, parseDate, subtractDays } from '@/lib/date-utils';
export interface ProductivityMetrics {
completionTrend: Array<{
date: string;
completed: number;
created: number;
total: number;
}>;
velocityData: Array<{
week: string;
completed: number;
average: number;
weekNumber?: number;
weekStart?: Date;
}>;
priorityDistribution: Array<{
priority: string;
count: number;
percentage: number;
}>;
statusFlow: Array<{
status: string;
count: number;
percentage: number;
}>;
weeklyStats: {
thisWeek: number;
lastWeek: number;
change: number;
changePercent: number;
};
}
export interface TimeRange {
start: Date;
end: Date;
}
export class AnalyticsService {
/**
* Calcule les métriques de productivité pour une période donnée
*/
static async getProductivityMetrics(timeRange?: TimeRange): Promise<ProductivityMetrics> {
try {
const now = getToday();
const defaultStart = subtractDays(now, 30); // 30 jours
const start = timeRange?.start || defaultStart;
const end = timeRange?.end || now;
// Récupérer toutes les tâches depuis la base de données avec leurs tags
const dbTasks = await prisma.task.findMany({
include: {
taskTags: {
include: {
tag: true
}
}
}
});
// Convertir en format Task
const tasks: Task[] = dbTasks.map(task => ({
id: task.id,
title: task.title,
description: task.description || undefined,
status: task.status as TaskStatus,
priority: task.priority as TaskPriority,
source: task.source as TaskSource,
sourceId: task.sourceId || undefined,
tags: task.taskTags.map(taskTag => taskTag.tag.name),
dueDate: task.dueDate || undefined,
completedAt: task.completedAt || undefined,
createdAt: task.createdAt,
updatedAt: task.updatedAt,
jiraProject: task.jiraProject || undefined,
jiraKey: task.jiraKey || undefined,
jiraType: task.jiraType || undefined,
assignee: task.assignee || undefined
}));
return {
completionTrend: this.calculateCompletionTrend(tasks, start, end),
velocityData: this.calculateVelocity(tasks, start, end),
priorityDistribution: this.calculatePriorityDistribution(tasks),
statusFlow: this.calculateStatusFlow(tasks),
weeklyStats: this.calculateWeeklyStats(tasks)
};
} catch (error) {
console.error('Erreur lors du calcul des métriques:', error);
throw new Error('Impossible de calculer les métriques de productivité');
}
}
/**
* Calcule la tendance de completion des tâches par jour
*/
private static calculateCompletionTrend(tasks: Task[], start: Date, end: Date) {
const trend: Array<{ date: string; completed: number; created: number; total: number }> = [];
// Générer les dates pour la période
const currentDate = new Date(start.getTime());
while (currentDate <= end) {
const dateStr = currentDate.toISOString().split('T')[0];
// Compter les tâches terminées ce jour
const completedThisDay = tasks.filter(task =>
task.completedAt &&
task.completedAt.toISOString().split('T')[0] === dateStr
).length;
// Compter les tâches créées ce jour
const createdThisDay = tasks.filter(task =>
task.createdAt.toISOString().split('T')[0] === dateStr
).length;
// Total cumulé jusqu'à ce jour
const totalUntilThisDay = tasks.filter(task =>
task.createdAt <= currentDate
).length;
trend.push({
date: dateStr,
completed: completedThisDay,
created: createdThisDay,
total: totalUntilThisDay
});
currentDate.setDate(currentDate.getDate() + 1);
}
return trend;
}
/**
* Calcule la vélocité (tâches terminées par semaine)
*/
private static calculateVelocity(tasks: Task[], start: Date, end: Date) {
const weeklyData: Array<{ week: string; completed: number; average: number; weekNumber: number; weekStart: Date }> = [];
const completedTasks = tasks.filter(task => task.completedAt);
// Grouper par semaine en utilisant le numéro de semaine comme clé
const weekGroups = new Map<number, { count: number; weekStart: Date }>();
completedTasks.forEach(task => {
if (task.completedAt && task.completedAt >= start && task.completedAt <= end) {
const weekStart = this.getWeekStart(task.completedAt);
const weekNumber = this.getWeekNumber(weekStart);
if (!weekGroups.has(weekNumber)) {
weekGroups.set(weekNumber, { count: 0, weekStart });
}
weekGroups.get(weekNumber)!.count++;
}
});
// Calculer la moyenne mobile
const values = Array.from(weekGroups.values()).map(w => w.count);
const average = values.length > 0 ? values.reduce((a, b) => a + b, 0) / values.length : 0;
// Convertir en format pour le graphique
weekGroups.forEach((data, weekNumber) => {
weeklyData.push({
week: `Sem. ${weekNumber}`,
completed: data.count,
average: Math.round(average * 10) / 10,
weekNumber,
weekStart: data.weekStart
});
});
// Trier par numéro de semaine (pas alphabétiquement)
return weeklyData.sort((a, b) => a.weekNumber - b.weekNumber);
}
/**
* Calcule la distribution des priorités
*/
private static calculatePriorityDistribution(tasks: Task[]) {
const priorityCounts = new Map<string, number>();
const total = tasks.length;
tasks.forEach(task => {
const priority = task.priority || 'non-définie';
priorityCounts.set(priority, (priorityCounts.get(priority) || 0) + 1);
});
return Array.from(priorityCounts.entries()).map(([priority, count]) => ({
priority: this.getPriorityLabel(priority),
count,
percentage: Math.round((count / total) * 100)
}));
}
/**
* Calcule la distribution des statuts
*/
private static calculateStatusFlow(tasks: Task[]) {
const statusCounts = new Map<string, number>();
const total = tasks.length;
tasks.forEach(task => {
const status = task.status;
statusCounts.set(status, (statusCounts.get(status) || 0) + 1);
});
return Array.from(statusCounts.entries()).map(([status, count]) => ({
status: this.getStatusLabel(status),
count,
percentage: Math.round((count / total) * 100)
}));
}
/**
* Calcule les statistiques hebdomadaires
*/
private static calculateWeeklyStats(tasks: Task[]) {
const now = getToday();
const thisWeekStart = this.getWeekStart(now);
const lastWeekStart = subtractDays(thisWeekStart, 7);
const lastWeekEnd = subtractDays(thisWeekStart, 1);
const thisWeekCompleted = tasks.filter(task =>
task.completedAt &&
task.completedAt >= thisWeekStart &&
task.completedAt <= now
).length;
const lastWeekCompleted = tasks.filter(task =>
task.completedAt &&
task.completedAt >= lastWeekStart &&
task.completedAt <= lastWeekEnd
).length;
const change = thisWeekCompleted - lastWeekCompleted;
const changePercent = lastWeekCompleted > 0
? Math.round((change / lastWeekCompleted) * 100)
: thisWeekCompleted > 0 ? 100 : 0;
return {
thisWeek: thisWeekCompleted,
lastWeek: lastWeekCompleted,
change,
changePercent
};
}
/**
* Obtient le début de la semaine pour une date
*/
private static getWeekStart(date: Date): Date {
const d = new Date(date.getTime());
const day = d.getDay();
const diff = d.getDate() - day + (day === 0 ? -6 : 1); // Lundi = début de semaine
return new Date(d.setDate(diff));
}
/**
* Obtient le numéro de la semaine
*/
private static getWeekNumber(date: Date): number {
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
const dayNum = d.getUTCDay() || 7;
d.setUTCDate(d.getUTCDate() + 4 - dayNum);
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
return Math.ceil((((d.getTime() - yearStart.getTime()) / 86400000) + 1) / 7);
}
/**
* Convertit le code priorité en label français
*/
private static getPriorityLabel(priority: string): string {
const labels: Record<string, string> = {
'low': 'Faible',
'medium': 'Moyenne',
'high': 'Élevée',
'urgent': 'Urgente',
'non-définie': 'Non définie'
};
return labels[priority] || priority;
}
/**
* Convertit le code statut en label français
*/
private static getStatusLabel(status: string): string {
const labels: Record<string, string> = {
'backlog': 'Backlog',
'todo': 'À faire',
'in_progress': 'En cours',
'done': 'Terminé',
'cancelled': 'Annulé',
'freeze': 'Gelé',
'archived': 'Archivé'
};
return labels[status] || status;
}
}