363 lines
10 KiB
TypeScript
363 lines
10 KiB
TypeScript
import { prisma } from './database';
|
|
import { startOfWeek, endOfWeek, eachDayOfInterval, format, startOfDay, endOfDay } from 'date-fns';
|
|
import { fr } from 'date-fns/locale';
|
|
|
|
export interface DailyMetrics {
|
|
date: string; // Format ISO
|
|
dayName: string; // Lundi, Mardi, etc.
|
|
completed: number;
|
|
inProgress: number;
|
|
blocked: number;
|
|
pending: number;
|
|
newTasks: number;
|
|
totalTasks: number;
|
|
completionRate: number;
|
|
}
|
|
|
|
export interface VelocityTrend {
|
|
date: string;
|
|
completed: number;
|
|
created: number;
|
|
velocity: number;
|
|
}
|
|
|
|
export interface WeeklyMetricsOverview {
|
|
period: {
|
|
start: Date;
|
|
end: Date;
|
|
};
|
|
dailyBreakdown: DailyMetrics[];
|
|
summary: {
|
|
totalTasksCompleted: number;
|
|
totalTasksCreated: number;
|
|
averageCompletionRate: number;
|
|
peakProductivityDay: string;
|
|
lowProductivityDay: string;
|
|
trendsAnalysis: {
|
|
completionTrend: 'improving' | 'declining' | 'stable';
|
|
productivityPattern: 'consistent' | 'variable' | 'weekend-heavy';
|
|
};
|
|
};
|
|
statusDistribution: {
|
|
status: string;
|
|
count: number;
|
|
percentage: number;
|
|
color: string;
|
|
}[];
|
|
priorityBreakdown: {
|
|
priority: string;
|
|
completed: number;
|
|
pending: number;
|
|
total: number;
|
|
completionRate: number;
|
|
color: string;
|
|
}[];
|
|
}
|
|
|
|
export class MetricsService {
|
|
/**
|
|
* Récupère les métriques journalières de la semaine
|
|
*/
|
|
static async getWeeklyMetrics(date: Date = new Date()): Promise<WeeklyMetricsOverview> {
|
|
const weekStart = startOfWeek(date, { weekStartsOn: 1 }); // Lundi
|
|
const weekEnd = endOfWeek(date, { weekStartsOn: 1 }); // Dimanche
|
|
|
|
// Générer tous les jours de la semaine
|
|
const daysOfWeek = eachDayOfInterval({ start: weekStart, end: weekEnd });
|
|
|
|
// Récupérer les données pour chaque jour
|
|
const dailyBreakdown = await Promise.all(
|
|
daysOfWeek.map(day => this.getDailyMetrics(day))
|
|
);
|
|
|
|
// Calculer les métriques de résumé
|
|
const summary = this.calculateWeeklySummary(dailyBreakdown);
|
|
|
|
// Récupérer la distribution des statuts pour la semaine
|
|
const statusDistribution = await this.getStatusDistribution(weekStart, weekEnd);
|
|
|
|
// Récupérer la répartition par priorité
|
|
const priorityBreakdown = await this.getPriorityBreakdown(weekStart, weekEnd);
|
|
|
|
return {
|
|
period: { start: weekStart, end: weekEnd },
|
|
dailyBreakdown,
|
|
summary,
|
|
statusDistribution,
|
|
priorityBreakdown
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Récupère les métriques pour un jour donné
|
|
*/
|
|
private static async getDailyMetrics(date: Date): Promise<DailyMetrics> {
|
|
const dayStart = startOfDay(date);
|
|
const dayEnd = endOfDay(date);
|
|
|
|
// Compter les tâches par statut à la fin de la journée
|
|
const [completed, inProgress, blocked, pending, newTasks, totalTasks] = await Promise.all([
|
|
// Tâches complétées ce jour
|
|
prisma.task.count({
|
|
where: {
|
|
OR: [
|
|
{
|
|
completedAt: {
|
|
gte: dayStart,
|
|
lte: dayEnd
|
|
}
|
|
},
|
|
{
|
|
status: 'done',
|
|
updatedAt: {
|
|
gte: dayStart,
|
|
lte: dayEnd
|
|
}
|
|
}
|
|
]
|
|
}
|
|
}),
|
|
|
|
// Tâches en cours (status = in_progress à ce moment)
|
|
prisma.task.count({
|
|
where: {
|
|
status: 'in_progress',
|
|
createdAt: { lte: dayEnd }
|
|
}
|
|
}),
|
|
|
|
// Tâches bloquées
|
|
prisma.task.count({
|
|
where: {
|
|
status: 'blocked',
|
|
createdAt: { lte: dayEnd }
|
|
}
|
|
}),
|
|
|
|
// Tâches en attente
|
|
prisma.task.count({
|
|
where: {
|
|
status: 'pending',
|
|
createdAt: { lte: dayEnd }
|
|
}
|
|
}),
|
|
|
|
// Nouvelles tâches créées ce jour
|
|
prisma.task.count({
|
|
where: {
|
|
createdAt: {
|
|
gte: dayStart,
|
|
lte: dayEnd
|
|
}
|
|
}
|
|
}),
|
|
|
|
// Total des tâches existantes ce jour
|
|
prisma.task.count({
|
|
where: {
|
|
createdAt: { lte: dayEnd }
|
|
}
|
|
})
|
|
]);
|
|
|
|
const completionRate = totalTasks > 0 ? (completed / totalTasks) * 100 : 0;
|
|
|
|
return {
|
|
date: date.toISOString(),
|
|
dayName: format(date, 'EEEE', { locale: fr }),
|
|
completed,
|
|
inProgress,
|
|
blocked,
|
|
pending,
|
|
newTasks,
|
|
totalTasks,
|
|
completionRate: Math.round(completionRate * 100) / 100
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Calcule le résumé hebdomadaire
|
|
*/
|
|
private static calculateWeeklySummary(dailyBreakdown: DailyMetrics[]) {
|
|
const totalTasksCompleted = dailyBreakdown.reduce((sum, day) => sum + day.completed, 0);
|
|
const totalTasksCreated = dailyBreakdown.reduce((sum, day) => sum + day.newTasks, 0);
|
|
const averageCompletionRate = dailyBreakdown.reduce((sum, day) => sum + day.completionRate, 0) / dailyBreakdown.length;
|
|
|
|
// Identifier les jours de pic et de creux
|
|
const peakDay = dailyBreakdown.reduce((peak, day) =>
|
|
day.completed > peak.completed ? day : peak
|
|
);
|
|
const lowDay = dailyBreakdown.reduce((low, day) =>
|
|
day.completed < low.completed ? day : low
|
|
);
|
|
|
|
// Analyser les tendances
|
|
const firstHalf = dailyBreakdown.slice(0, 3);
|
|
const secondHalf = dailyBreakdown.slice(4);
|
|
const firstHalfAvg = firstHalf.reduce((sum, day) => sum + day.completed, 0) / firstHalf.length;
|
|
const secondHalfAvg = secondHalf.reduce((sum, day) => sum + day.completed, 0) / secondHalf.length;
|
|
|
|
let completionTrend: 'improving' | 'declining' | 'stable';
|
|
if (secondHalfAvg > firstHalfAvg * 1.1) {
|
|
completionTrend = 'improving';
|
|
} else if (secondHalfAvg < firstHalfAvg * 0.9) {
|
|
completionTrend = 'declining';
|
|
} else {
|
|
completionTrend = 'stable';
|
|
}
|
|
|
|
// Analyser le pattern de productivité
|
|
const weekendDays = dailyBreakdown.slice(5); // Samedi et dimanche
|
|
const weekdayDays = dailyBreakdown.slice(0, 5);
|
|
const weekendAvg = weekendDays.reduce((sum, day) => sum + day.completed, 0) / weekendDays.length;
|
|
const weekdayAvg = weekdayDays.reduce((sum, day) => sum + day.completed, 0) / weekdayDays.length;
|
|
|
|
let productivityPattern: 'consistent' | 'variable' | 'weekend-heavy';
|
|
if (weekendAvg > weekdayAvg * 1.2) {
|
|
productivityPattern = 'weekend-heavy';
|
|
} else {
|
|
const variance = dailyBreakdown.reduce((sum, day) => {
|
|
const diff = day.completed - (totalTasksCompleted / dailyBreakdown.length);
|
|
return sum + diff * diff;
|
|
}, 0) / dailyBreakdown.length;
|
|
productivityPattern = variance > 4 ? 'variable' : 'consistent';
|
|
}
|
|
|
|
return {
|
|
totalTasksCompleted,
|
|
totalTasksCreated,
|
|
averageCompletionRate: Math.round(averageCompletionRate * 100) / 100,
|
|
peakProductivityDay: peakDay.dayName,
|
|
lowProductivityDay: lowDay.dayName,
|
|
trendsAnalysis: {
|
|
completionTrend,
|
|
productivityPattern
|
|
}
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Récupère la distribution des statuts pour la période
|
|
*/
|
|
private static async getStatusDistribution(start: Date, end: Date) {
|
|
const statusCounts = await prisma.task.groupBy({
|
|
by: ['status'],
|
|
_count: {
|
|
status: true
|
|
},
|
|
where: {
|
|
createdAt: {
|
|
gte: start,
|
|
lte: end
|
|
}
|
|
}
|
|
});
|
|
|
|
const total = statusCounts.reduce((sum, item) => sum + item._count.status, 0);
|
|
|
|
const statusColors: { [key: string]: string } = {
|
|
pending: '#94a3b8', // gray
|
|
in_progress: '#3b82f6', // blue
|
|
blocked: '#ef4444', // red
|
|
done: '#10b981', // green
|
|
archived: '#6b7280' // gray-500
|
|
};
|
|
|
|
return statusCounts.map(item => ({
|
|
status: item.status,
|
|
count: item._count.status,
|
|
percentage: Math.round((item._count.status / total) * 100 * 100) / 100,
|
|
color: statusColors[item.status] || '#6b7280'
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* Récupère la répartition par priorité avec taux de completion
|
|
*/
|
|
private static async getPriorityBreakdown(start: Date, end: Date) {
|
|
const priorities = ['high', 'medium', 'low'];
|
|
|
|
const priorityData = await Promise.all(
|
|
priorities.map(async (priority) => {
|
|
const [completed, total] = await Promise.all([
|
|
prisma.task.count({
|
|
where: {
|
|
priority,
|
|
completedAt: {
|
|
gte: start,
|
|
lte: end
|
|
}
|
|
}
|
|
}),
|
|
prisma.task.count({
|
|
where: {
|
|
priority,
|
|
createdAt: {
|
|
gte: start,
|
|
lte: end
|
|
}
|
|
}
|
|
})
|
|
]);
|
|
|
|
const pending = total - completed;
|
|
const completionRate = total > 0 ? (completed / total) * 100 : 0;
|
|
|
|
return {
|
|
priority,
|
|
completed,
|
|
pending,
|
|
total,
|
|
completionRate: Math.round(completionRate * 100) / 100,
|
|
color: priority === 'high' ? '#ef4444' :
|
|
priority === 'medium' ? '#f59e0b' : '#10b981'
|
|
};
|
|
})
|
|
);
|
|
|
|
return priorityData;
|
|
}
|
|
|
|
/**
|
|
* Récupère les métriques de vélocité d'équipe (pour graphiques de tendance)
|
|
*/
|
|
static async getVelocityTrends(weeksBack: number = 4): Promise<VelocityTrend[]> {
|
|
const trends = [];
|
|
|
|
for (let i = weeksBack - 1; i >= 0; i--) {
|
|
const weekStart = startOfWeek(new Date(Date.now() - i * 7 * 24 * 60 * 60 * 1000), { weekStartsOn: 1 });
|
|
const weekEnd = endOfWeek(weekStart, { weekStartsOn: 1 });
|
|
|
|
const [completed, created] = await Promise.all([
|
|
prisma.task.count({
|
|
where: {
|
|
completedAt: {
|
|
gte: weekStart,
|
|
lte: weekEnd
|
|
}
|
|
}
|
|
}),
|
|
prisma.task.count({
|
|
where: {
|
|
createdAt: {
|
|
gte: weekStart,
|
|
lte: weekEnd
|
|
}
|
|
}
|
|
})
|
|
]);
|
|
|
|
const velocity = created > 0 ? (completed / created) * 100 : 0;
|
|
|
|
trends.push({
|
|
date: format(weekStart, 'dd/MM', { locale: fr }),
|
|
completed,
|
|
created,
|
|
velocity: Math.round(velocity * 100) / 100
|
|
});
|
|
}
|
|
|
|
return trends;
|
|
}
|
|
}
|