Files
towercontrol/services/metrics.ts
2025-09-19 17:05:13 +02:00

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;
}
}