feat: complete Phase 2 of service refactoring
- Marked tasks in `TODO.md` as completed for moving analytics-related files to the `analytics` directory and correcting imports across the codebase. - Updated imports in `src/actions/analytics.ts`, `src/actions/metrics.ts`, and various components to reflect the new structure. - Removed unused `analytics.ts`, `manager-summary.ts`, and `metrics.ts` files to streamline the codebase.
This commit is contained in:
293
src/services/analytics/analytics.ts
Normal file
293
src/services/analytics/analytics.ts
Normal file
@@ -0,0 +1,293 @@
|
||||
import { Task, TaskStatus, TaskPriority, TaskSource } from '@/lib/types';
|
||||
import { prisma } from '../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;
|
||||
}>;
|
||||
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 }> = [];
|
||||
const completedTasks = tasks.filter(task => task.completedAt);
|
||||
|
||||
// Grouper par semaine
|
||||
const weekGroups = new Map<string, number>();
|
||||
|
||||
completedTasks.forEach(task => {
|
||||
if (task.completedAt && task.completedAt >= start && task.completedAt <= end) {
|
||||
const weekStart = this.getWeekStart(task.completedAt);
|
||||
const weekKey = weekStart.toISOString().split('T')[0];
|
||||
weekGroups.set(weekKey, (weekGroups.get(weekKey) || 0) + 1);
|
||||
}
|
||||
});
|
||||
|
||||
// Calculer la moyenne mobile
|
||||
const values = Array.from(weekGroups.values());
|
||||
const average = values.length > 0 ? values.reduce((a, b) => a + b, 0) / values.length : 0;
|
||||
|
||||
// Convertir en format pour le graphique
|
||||
weekGroups.forEach((count, weekKey) => {
|
||||
const weekDate = parseDate(weekKey);
|
||||
weeklyData.push({
|
||||
week: `Sem. ${this.getWeekNumber(weekDate)}`,
|
||||
completed: count,
|
||||
average: Math.round(average * 10) / 10
|
||||
});
|
||||
});
|
||||
|
||||
return weeklyData.sort((a, b) => a.week.localeCompare(b.week));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
564
src/services/analytics/manager-summary.ts
Normal file
564
src/services/analytics/manager-summary.ts
Normal file
@@ -0,0 +1,564 @@
|
||||
import { prisma } from '../core/database';
|
||||
import { startOfWeek, endOfWeek } from 'date-fns';
|
||||
import { getToday } from '@/lib/date-utils';
|
||||
|
||||
type TaskType = {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
priority: string; // high, medium, low
|
||||
completedAt?: Date | null;
|
||||
createdAt: Date;
|
||||
taskTags?: {
|
||||
tag: {
|
||||
name: string;
|
||||
}
|
||||
}[];
|
||||
};
|
||||
|
||||
type CheckboxType = {
|
||||
id: string;
|
||||
text: string;
|
||||
isChecked: boolean;
|
||||
type: string; // task, meeting
|
||||
date: Date;
|
||||
createdAt: Date;
|
||||
task?: {
|
||||
id: string;
|
||||
title: string;
|
||||
priority: string;
|
||||
taskTags?: {
|
||||
tag: {
|
||||
name: string;
|
||||
}
|
||||
}[];
|
||||
} | null;
|
||||
};
|
||||
|
||||
export interface KeyAccomplishment {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
tags: string[];
|
||||
impact: 'high' | 'medium' | 'low';
|
||||
completedAt: Date;
|
||||
relatedItems: string[]; // IDs des tâches/checkboxes liées
|
||||
todosCount: number; // Nombre de todos associés
|
||||
}
|
||||
|
||||
export interface UpcomingChallenge {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
tags: string[];
|
||||
priority: 'high' | 'medium' | 'low';
|
||||
estimatedEffort: 'days' | 'weeks' | 'hours';
|
||||
blockers: string[];
|
||||
deadline?: Date;
|
||||
relatedItems: string[]; // IDs des tâches/checkboxes liées
|
||||
todosCount: number; // Nombre de todos associés
|
||||
}
|
||||
|
||||
export interface ManagerSummary {
|
||||
period: {
|
||||
start: Date;
|
||||
end: Date;
|
||||
};
|
||||
keyAccomplishments: KeyAccomplishment[];
|
||||
upcomingChallenges: UpcomingChallenge[];
|
||||
metrics: {
|
||||
totalTasksCompleted: number;
|
||||
totalCheckboxesCompleted: number;
|
||||
highPriorityTasksCompleted: number;
|
||||
meetingCheckboxesCompleted: number;
|
||||
completionRate: number;
|
||||
focusAreas: { [category: string]: number };
|
||||
};
|
||||
narrative: {
|
||||
weekHighlight: string;
|
||||
mainChallenges: string;
|
||||
nextWeekFocus: string;
|
||||
};
|
||||
}
|
||||
|
||||
export class ManagerSummaryService {
|
||||
/**
|
||||
* Génère un résumé orienté manager pour la semaine
|
||||
*/
|
||||
static async getManagerSummary(date: Date = getToday()): Promise<ManagerSummary> {
|
||||
const weekStart = startOfWeek(date, { weekStartsOn: 1 }); // Lundi
|
||||
const weekEnd = endOfWeek(date, { weekStartsOn: 1 }); // Dimanche
|
||||
|
||||
// Récupérer les données de base
|
||||
const [tasks, checkboxes] = await Promise.all([
|
||||
this.getCompletedTasks(weekStart, weekEnd),
|
||||
this.getCompletedCheckboxes(weekStart, weekEnd)
|
||||
]);
|
||||
|
||||
// Analyser et extraire les accomplissements clés
|
||||
const keyAccomplishments = this.extractKeyAccomplishments(tasks, checkboxes);
|
||||
|
||||
// Identifier les défis à venir
|
||||
const upcomingChallenges = await this.identifyUpcomingChallenges();
|
||||
|
||||
// Calculer les métriques
|
||||
const metrics = this.calculateMetrics(tasks, checkboxes);
|
||||
|
||||
// Générer le narratif
|
||||
const narrative = this.generateNarrative(keyAccomplishments, upcomingChallenges);
|
||||
|
||||
return {
|
||||
period: { start: weekStart, end: weekEnd },
|
||||
keyAccomplishments,
|
||||
upcomingChallenges,
|
||||
metrics,
|
||||
narrative
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère les tâches complétées de la semaine
|
||||
*/
|
||||
private static async getCompletedTasks(startDate: Date, endDate: Date) {
|
||||
const tasks = await prisma.task.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
// Tâches avec completedAt dans la période (priorité)
|
||||
{
|
||||
completedAt: {
|
||||
gte: startDate,
|
||||
lte: endDate
|
||||
}
|
||||
},
|
||||
// Tâches avec status 'done' et updatedAt dans la période
|
||||
{
|
||||
status: 'done',
|
||||
updatedAt: {
|
||||
gte: startDate,
|
||||
lte: endDate
|
||||
}
|
||||
},
|
||||
// Tâches avec status 'archived' récemment (aussi des accomplissements)
|
||||
{
|
||||
status: 'archived',
|
||||
updatedAt: {
|
||||
gte: startDate,
|
||||
lte: endDate
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
orderBy: {
|
||||
completedAt: 'desc'
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
description: true,
|
||||
priority: true,
|
||||
completedAt: true,
|
||||
createdAt: true,
|
||||
taskTags: {
|
||||
select: {
|
||||
tag: {
|
||||
select: {
|
||||
name: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return tasks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère les checkboxes complétées de la semaine
|
||||
*/
|
||||
private static async getCompletedCheckboxes(startDate: Date, endDate: Date) {
|
||||
const checkboxes = await prisma.dailyCheckbox.findMany({
|
||||
where: {
|
||||
isChecked: true,
|
||||
date: {
|
||||
gte: startDate,
|
||||
lte: endDate
|
||||
}
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
text: true,
|
||||
isChecked: true,
|
||||
type: true,
|
||||
date: true,
|
||||
createdAt: true,
|
||||
task: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
priority: true,
|
||||
taskTags: {
|
||||
select: {
|
||||
tag: {
|
||||
select: {
|
||||
name: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
orderBy: {
|
||||
date: 'desc'
|
||||
}
|
||||
});
|
||||
|
||||
return checkboxes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrait les accomplissements clés basés sur la priorité
|
||||
*/
|
||||
private static extractKeyAccomplishments(tasks: TaskType[], checkboxes: CheckboxType[]): KeyAccomplishment[] {
|
||||
const accomplishments: KeyAccomplishment[] = [];
|
||||
|
||||
// Tâches: prendre toutes les high/medium priority, et quelques low si significatives
|
||||
tasks.forEach(task => {
|
||||
const priority = task.priority.toLowerCase();
|
||||
|
||||
// Convertir priorité task en impact accomplissement
|
||||
let impact: 'high' | 'medium' | 'low';
|
||||
if (priority === 'high') {
|
||||
impact = 'high';
|
||||
} else if (priority === 'medium') {
|
||||
impact = 'medium';
|
||||
} else {
|
||||
// Pour les low priority, ne garder que si c'est vraiment significatif
|
||||
if (!this.isSignificantTask(task.title)) {
|
||||
return;
|
||||
}
|
||||
impact = 'low';
|
||||
}
|
||||
|
||||
// Compter les todos (checkboxes) associés à cette tâche
|
||||
const relatedTodos = checkboxes.filter(cb => cb.task?.id === task.id);
|
||||
|
||||
accomplishments.push({
|
||||
id: `task-${task.id}`,
|
||||
title: task.title,
|
||||
description: task.description || undefined,
|
||||
tags: task.taskTags?.map(tt => tt.tag.name) || [],
|
||||
impact,
|
||||
completedAt: task.completedAt || getToday(),
|
||||
relatedItems: [task.id, ...relatedTodos.map(t => t.id)],
|
||||
todosCount: relatedTodos.length // Nombre réel de todos associés
|
||||
});
|
||||
});
|
||||
|
||||
// AJOUTER SEULEMENT les meetings importants standalone (non liés à une tâche)
|
||||
const standaloneMeetings = checkboxes.filter(checkbox =>
|
||||
checkbox.type === 'meeting' && !checkbox.task // Meetings non liés à une tâche
|
||||
);
|
||||
|
||||
standaloneMeetings.forEach(meeting => {
|
||||
accomplishments.push({
|
||||
id: `meeting-${meeting.id}`,
|
||||
title: `📅 ${meeting.text}`,
|
||||
tags: [], // Meetings n'ont pas de tags par défaut
|
||||
impact: 'medium', // Meetings sont importants
|
||||
completedAt: meeting.date,
|
||||
relatedItems: [meeting.id],
|
||||
todosCount: 1 // Un meeting = 1 todo
|
||||
});
|
||||
});
|
||||
|
||||
// Trier par impact puis par date
|
||||
return accomplishments
|
||||
.sort((a, b) => {
|
||||
const impactOrder = { high: 3, medium: 2, low: 1 };
|
||||
if (impactOrder[a.impact] !== impactOrder[b.impact]) {
|
||||
return impactOrder[b.impact] - impactOrder[a.impact];
|
||||
}
|
||||
return b.completedAt.getTime() - a.completedAt.getTime();
|
||||
})
|
||||
.slice(0, 12); // Plus d'items maintenant qu'on filtre mieux
|
||||
}
|
||||
|
||||
/**
|
||||
* Identifie les défis et enjeux à venir
|
||||
*/
|
||||
private static async identifyUpcomingChallenges(): Promise<UpcomingChallenge[]> {
|
||||
|
||||
// Récupérer les tâches à venir (priorité high/medium en premier)
|
||||
const upcomingTasks = await prisma.task.findMany({
|
||||
where: {
|
||||
completedAt: null
|
||||
},
|
||||
orderBy: [
|
||||
{ priority: 'asc' }, // high < medium < low
|
||||
{ createdAt: 'desc' }
|
||||
],
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
description: true,
|
||||
priority: true,
|
||||
createdAt: true,
|
||||
taskTags: {
|
||||
select: {
|
||||
tag: {
|
||||
select: {
|
||||
name: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
take: 30
|
||||
});
|
||||
|
||||
// Récupérer les checkboxes récurrentes non complétées (meetings + tâches prioritaires)
|
||||
const upcomingCheckboxes = await prisma.dailyCheckbox.findMany({
|
||||
where: {
|
||||
isChecked: false,
|
||||
date: {
|
||||
gte: getToday()
|
||||
},
|
||||
OR: [
|
||||
{ type: 'meeting' },
|
||||
{
|
||||
task: {
|
||||
priority: {
|
||||
in: ['high', 'medium']
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
text: true,
|
||||
isChecked: true,
|
||||
type: true,
|
||||
date: true,
|
||||
createdAt: true,
|
||||
task: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
priority: true,
|
||||
taskTags: {
|
||||
select: {
|
||||
tag: {
|
||||
select: {
|
||||
name: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
orderBy: [
|
||||
{ date: 'asc' },
|
||||
{ createdAt: 'asc' }
|
||||
],
|
||||
take: 20
|
||||
});
|
||||
|
||||
const challenges: UpcomingChallenge[] = [];
|
||||
|
||||
// Analyser les tâches - se baser sur la priorité réelle
|
||||
upcomingTasks.forEach((task) => {
|
||||
const taskPriority = task.priority.toLowerCase();
|
||||
|
||||
// Convertir priorité task en priorité challenge
|
||||
let priority: 'high' | 'medium' | 'low';
|
||||
if (taskPriority === 'high') {
|
||||
priority = 'high';
|
||||
} else if (taskPriority === 'medium') {
|
||||
priority = 'medium';
|
||||
} else {
|
||||
// Pour les low priority, ne garder que si c'est vraiment challengeant
|
||||
if (!this.isChallengingTask(task.title)) {
|
||||
return;
|
||||
}
|
||||
priority = 'low';
|
||||
}
|
||||
|
||||
const estimatedEffort = this.estimateEffort(task.title, task.description || undefined);
|
||||
|
||||
challenges.push({
|
||||
id: `task-${task.id}`,
|
||||
title: task.title,
|
||||
description: task.description || undefined,
|
||||
tags: task.taskTags?.map(tt => tt.tag.name) || [],
|
||||
priority,
|
||||
estimatedEffort,
|
||||
blockers: this.identifyBlockers(task.title, task.description || undefined),
|
||||
relatedItems: [task.id],
|
||||
todosCount: 0 // TODO: compter les todos associés à cette tâche
|
||||
});
|
||||
});
|
||||
|
||||
// Ajouter les meetings importants comme challenges
|
||||
upcomingCheckboxes.forEach(checkbox => {
|
||||
if (checkbox.type === 'meeting') {
|
||||
challenges.push({
|
||||
id: `checkbox-${checkbox.id}`,
|
||||
title: checkbox.text,
|
||||
tags: checkbox.task?.taskTags?.map(tt => tt.tag.name) || [],
|
||||
priority: 'medium', // Meetings sont medium par défaut
|
||||
estimatedEffort: 'hours',
|
||||
blockers: [],
|
||||
relatedItems: [checkbox.id],
|
||||
todosCount: 1 // Une checkbox = 1 todo
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return challenges
|
||||
.sort((a, b) => {
|
||||
const priorityOrder = { high: 3, medium: 2, low: 1 };
|
||||
return priorityOrder[b.priority] - priorityOrder[a.priority];
|
||||
})
|
||||
.slice(0, 10); // Plus d'items maintenant qu'on filtre mieux
|
||||
}
|
||||
|
||||
/**
|
||||
* Estime l'effort requis
|
||||
*/
|
||||
private static estimateEffort(title: string, description?: string): 'days' | 'weeks' | 'hours' {
|
||||
const content = `${title} ${description || ''}`.toLowerCase();
|
||||
|
||||
if (content.includes('architecture') || content.includes('migration') || content.includes('refactor')) {
|
||||
return 'weeks';
|
||||
}
|
||||
if (content.includes('feature') || content.includes('implement') || content.includes('integration')) {
|
||||
return 'days';
|
||||
}
|
||||
return 'hours';
|
||||
}
|
||||
|
||||
/**
|
||||
* Identifie les blockers potentiels
|
||||
*/
|
||||
private static identifyBlockers(title: string, description?: string): string[] {
|
||||
const content = `${title} ${description || ''}`.toLowerCase();
|
||||
const blockers: string[] = [];
|
||||
|
||||
if (content.includes('depends') || content.includes('waiting')) {
|
||||
blockers.push('Dépendances externes');
|
||||
}
|
||||
if (content.includes('approval') || content.includes('review')) {
|
||||
blockers.push('Validation requise');
|
||||
}
|
||||
if (content.includes('design') && !content.includes('implement')) {
|
||||
blockers.push('Spécifications incomplètes');
|
||||
}
|
||||
|
||||
return blockers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Détermine si une tâche est significative
|
||||
*/
|
||||
private static isSignificantTask(title: string): boolean {
|
||||
const significantKeywords = [
|
||||
'release', 'deploy', 'launch', 'milestone',
|
||||
'architecture', 'design', 'strategy',
|
||||
'integration', 'migration', 'optimization'
|
||||
];
|
||||
return significantKeywords.some(keyword => title.toLowerCase().includes(keyword));
|
||||
}
|
||||
|
||||
/**
|
||||
* Détermine si une checkbox est significative
|
||||
*/
|
||||
private static isSignificantCheckbox(text: string): boolean {
|
||||
const content = text.toLowerCase();
|
||||
return content.length > 30 || // Checkboxes détaillées
|
||||
content.includes('meeting') ||
|
||||
content.includes('review') ||
|
||||
content.includes('call') ||
|
||||
content.includes('presentation');
|
||||
}
|
||||
|
||||
/**
|
||||
* Détermine si une tâche représente un défi
|
||||
*/
|
||||
private static isChallengingTask(title: string): boolean {
|
||||
const challengingKeywords = [
|
||||
'complex', 'difficult', 'challenge',
|
||||
'architecture', 'performance', 'security',
|
||||
'integration', 'migration', 'optimization'
|
||||
];
|
||||
return challengingKeywords.some(keyword => title.toLowerCase().includes(keyword));
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyse les patterns dans les checkboxes pour identifier des enjeux
|
||||
*/
|
||||
private static analyzeCheckboxPatterns(): UpcomingChallenge[] {
|
||||
// Pour l'instant, retourner un array vide
|
||||
// À implémenter selon les besoins spécifiques
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcule les métriques résumées
|
||||
*/
|
||||
private static calculateMetrics(tasks: TaskType[], checkboxes: CheckboxType[]) {
|
||||
const totalTasksCompleted = tasks.length;
|
||||
const totalCheckboxesCompleted = checkboxes.length;
|
||||
|
||||
// Calculer les métriques détaillées
|
||||
const highPriorityTasksCompleted = tasks.filter(t => t.priority.toLowerCase() === 'high').length;
|
||||
const meetingCheckboxesCompleted = checkboxes.filter(c => c.type === 'meeting').length;
|
||||
|
||||
// Analyser la répartition par catégorie
|
||||
const focusAreas: { [category: string]: number } = {};
|
||||
|
||||
return {
|
||||
totalTasksCompleted,
|
||||
totalCheckboxesCompleted,
|
||||
highPriorityTasksCompleted,
|
||||
meetingCheckboxesCompleted,
|
||||
completionRate: 0, // À calculer par rapport aux objectifs
|
||||
focusAreas
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Génère le narratif pour le manager
|
||||
*/
|
||||
private static generateNarrative(
|
||||
accomplishments: KeyAccomplishment[],
|
||||
challenges: UpcomingChallenge[]
|
||||
) {
|
||||
// Points forts de la semaine
|
||||
const topAccomplishments = accomplishments.slice(0, 3);
|
||||
const weekHighlight = topAccomplishments.length > 0
|
||||
? `Cette semaine, j'ai principalement progressé sur ${topAccomplishments.map(a => a.title).join(', ')}.`
|
||||
: 'Semaine focalisée sur l\'exécution des tâches quotidiennes.';
|
||||
|
||||
// Défis rencontrés
|
||||
const highImpactItems = accomplishments.filter(a => a.impact === 'high');
|
||||
const mainChallenges = highImpactItems.length > 0
|
||||
? `Les principaux enjeux traités ont été liés aux ${[...new Set(highImpactItems.flatMap(a => a.tags))].join(', ')}.`
|
||||
: 'Pas de blockers majeurs rencontrés cette semaine.';
|
||||
|
||||
// Focus semaine prochaine
|
||||
const topChallenges = challenges.slice(0, 3);
|
||||
const nextWeekFocus = topChallenges.length > 0
|
||||
? `La semaine prochaine sera concentrée sur ${topChallenges.map(c => c.title).join(', ')}.`
|
||||
: 'Continuation du travail en cours selon les priorités établies.';
|
||||
|
||||
return {
|
||||
weekHighlight,
|
||||
mainChallenges,
|
||||
nextWeekFocus
|
||||
};
|
||||
}
|
||||
}
|
||||
363
src/services/analytics/metrics.ts
Normal file
363
src/services/analytics/metrics.ts
Normal file
@@ -0,0 +1,363 @@
|
||||
import { prisma } from '../core/database';
|
||||
import { startOfWeek, endOfWeek, eachDayOfInterval, format, startOfDay, endOfDay } from 'date-fns';
|
||||
import { fr } from 'date-fns/locale';
|
||||
import { formatDateForAPI, getDayName, getToday, subtractDays } from '@/lib/date-utils';
|
||||
|
||||
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 = getToday()): 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: formatDateForAPI(date),
|
||||
dayName: getDayName(date),
|
||||
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(subtractDays(getToday(), i * 7), { 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