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:
Julien Froidefond
2025-09-23 10:15:13 +02:00
parent f88954bf81
commit ed16e2bb80
16 changed files with 23 additions and 22 deletions

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

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

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