feat: metrics on Manager page
This commit is contained in:
362
services/metrics.ts
Normal file
362
services/metrics.ts
Normal file
@@ -0,0 +1,362 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user