Files
towercontrol/src/services/analytics/manager-summary.ts
Julien Froidefond 3fcada65f6 fix: update todosCount checks and refactor key accomplishments extraction
- Changed todosCount checks in AchievementCard and ChallengeCard to ensure proper handling of undefined values.
- Updated extractKeyAccomplishments method to be asynchronous and count all related todos using Prisma, improving accuracy in task completion metrics.
- Refactored relatedItems and todosCount handling for better clarity and functionality in ManagerSummaryService.
2025-09-29 16:20:35 +02:00

600 lines
17 KiB
TypeScript

import { prisma } from '@/services/core/database';
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 les 7 derniers jours
*/
static async getManagerSummary(date: Date = getToday()): Promise<ManagerSummary> {
// Fenêtre glissante de 7 jours au lieu de semaine calendaire
const weekEnd = new Date(date);
const weekStart = new Date(date);
weekStart.setDate(weekStart.getDate() - 6); // 7 jours en arrière (incluant aujourd'hui)
// 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 = await 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 async extractKeyAccomplishments(tasks: TaskType[], checkboxes: CheckboxType[]): Promise<KeyAccomplishment[]> {
const accomplishments: KeyAccomplishment[] = [];
// Tâches: prendre toutes les high/medium priority, et quelques low si significatives
for (const task of tasks) {
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)) {
continue;
}
impact = 'low';
}
// Compter TOUS les todos associés à cette tâche (pas seulement ceux de la période)
// car l'accomplissement c'est la tâche complétée, pas seulement les todos de la période
const allRelatedTodos = await prisma.dailyCheckbox.count({
where: {
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],
todosCount: allRelatedTodos // Nombre total de todos associés à cette tâche
});
}
// AJOUTER les todos standalone avec la nouvelle règle de priorité
// Exclure les todos déjà comptés dans les tâches complétées
const standaloneTodos = checkboxes.filter(checkbox =>
!checkbox.task // Todos non liés à une tâche
);
standaloneTodos.forEach(todo => {
// Appliquer la nouvelle règle de priorité :
// Si pas de tâche associée, priorité faible (même pour les meetings)
const impact: 'high' | 'medium' | 'low' = 'low';
accomplishments.push({
id: `todo-${todo.id}`,
title: todo.type === 'meeting' ? `📅 ${todo.text}` : todo.text,
tags: [], // Todos standalone n'ont pas de tags par défaut
impact,
completedAt: todo.date,
relatedItems: [todo.id],
todosCount: 1 // Un todo = 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);
// Compter les todos associés à cette tâche
const relatedTodos = upcomingCheckboxes.filter(cb => cb.task?.id === task.id);
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, ...relatedTodos.map(t => t.id)],
todosCount: relatedTodos.length // Nombre réel de todos associés
});
});
// Ajouter les todos importants comme challenges
upcomingCheckboxes.forEach(checkbox => {
// Déterminer la priorité selon la nouvelle règle :
// Si le todo est associé à une tâche, prendre la priorité de la tâche
// Sinon, priorité faible par défaut (même pour les meetings)
let priority: 'high' | 'medium' | 'low';
if (checkbox.task?.priority) {
const taskPriority = checkbox.task.priority.toLowerCase();
if (taskPriority === 'high') {
priority = 'high';
} else if (taskPriority === 'medium') {
priority = 'medium';
} else {
priority = 'low';
}
} else {
// Pas de tâche associée = priorité faible (même pour les meetings)
priority = 'low';
}
// Inclure tous les todos (toutes priorités, y compris faible, sont maintenant visibles)
// La priorité est déterminée par la nouvelle règle et affichée visuellement
challenges.push({
id: `checkbox-${checkbox.id}`,
title: checkbox.text,
tags: checkbox.task?.taskTags?.map(tt => tt.tag.name) || [],
priority,
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 des 7 derniers jours
const topAccomplishments = accomplishments.slice(0, 3);
const weekHighlight = topAccomplishments.length > 0
? `Ces 7 derniers jours, j'ai principalement progressé sur ${topAccomplishments.map(a => a.title).join(', ')}.`
: 'Période 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 sur cette période.';
// Focus 7 prochains jours
const topChallenges = challenges.slice(0, 3);
const nextWeekFocus = topChallenges.length > 0
? `Les 7 prochains jours seront concentrés sur ${topChallenges.map(c => c.title).join(', ')}.`
: 'Continuation du travail en cours selon les priorités établies.';
return {
weekHighlight,
mainChallenges,
nextWeekFocus
};
}
}