feat: add weekly summary features and components

- Introduced `CategoryBreakdown`, `JiraWeeklyMetrics`, `PeriodSelector`, and `VelocityMetrics` components to enhance the weekly summary dashboard.
- Updated `WeeklySummaryClient` to manage period selection and PDF export functionality.
- Enhanced `WeeklySummaryService` to support period comparisons and activity categorization.
- Added new API route for fetching weekly summary data based on selected period.
- Updated `package.json` and `package-lock.json` to include `jspdf` and related types for PDF generation.
- Marked several tasks as complete in `TODO.md` to reflect progress on summary features.
This commit is contained in:
Julien Froidefond
2025-09-19 12:28:11 +02:00
parent f9c0035c82
commit fded7d0078
14 changed files with 2028 additions and 111 deletions

180
services/jira-summary.ts Normal file
View File

@@ -0,0 +1,180 @@
import type { JiraConfig } from './jira';
import { Task } from '@/lib/types';
export interface JiraWeeklyMetrics {
totalJiraTasks: number;
completedJiraTasks: number;
totalStoryPoints: number; // Estimation basée sur le type de ticket
projectsContributed: string[];
ticketTypes: { [type: string]: number };
jiraLinks: Array<{
key: string;
title: string;
status: string;
type: string;
url: string;
estimatedPoints: number;
}>;
}
export class JiraSummaryService {
/**
* Enrichit les tâches hebdomadaires avec des métriques Jira
*/
static async getJiraWeeklyMetrics(
weeklyTasks: Task[],
jiraConfig?: JiraConfig
): Promise<JiraWeeklyMetrics | null> {
if (!jiraConfig?.baseUrl || !jiraConfig?.email || !jiraConfig?.apiToken) {
return null;
}
const jiraTasks = weeklyTasks.filter(task =>
task.source === 'jira' && task.jiraKey && task.jiraProject
);
if (jiraTasks.length === 0) {
return {
totalJiraTasks: 0,
completedJiraTasks: 0,
totalStoryPoints: 0,
projectsContributed: [],
ticketTypes: {},
jiraLinks: []
};
}
// Calculer les métriques basiques
const completedJiraTasks = jiraTasks.filter(task => task.status === 'done');
const projectsContributed = [...new Set(jiraTasks.map(task => task.jiraProject).filter((project): project is string => Boolean(project)))];
// Analyser les types de tickets
const ticketTypes: { [type: string]: number } = {};
jiraTasks.forEach(task => {
const type = task.jiraType || 'Unknown';
ticketTypes[type] = (ticketTypes[type] || 0) + 1;
});
// Estimer les story points basés sur le type de ticket
const estimateStoryPoints = (type: string): number => {
const typeMapping: { [key: string]: number } = {
'Story': 3,
'Task': 2,
'Bug': 1,
'Epic': 8,
'Sub-task': 1,
'Improvement': 2,
'New Feature': 5,
'défaut': 1, // French
'amélioration': 2, // French
'nouvelle fonctionnalité': 5, // French
};
return typeMapping[type] || typeMapping[type?.toLowerCase()] || 2; // Défaut: 2 points
};
const totalStoryPoints = jiraTasks.reduce((sum, task) => {
return sum + estimateStoryPoints(task.jiraType || '');
}, 0);
// Créer les liens Jira
const jiraLinks = jiraTasks.map(task => ({
key: task.jiraKey || '',
title: task.title,
status: task.status,
type: task.jiraType || 'Unknown',
url: `${jiraConfig.baseUrl.replace('/rest/api/3', '')}/browse/${task.jiraKey}`,
estimatedPoints: estimateStoryPoints(task.jiraType || '')
}));
return {
totalJiraTasks: jiraTasks.length,
completedJiraTasks: completedJiraTasks.length,
totalStoryPoints,
projectsContributed,
ticketTypes,
jiraLinks
};
}
/**
* Récupère la configuration Jira depuis les préférences utilisateur
*/
static async getJiraConfig(): Promise<JiraConfig | null> {
try {
// Import dynamique pour éviter les cycles de dépendance
const { userPreferencesService } = await import('./user-preferences');
const preferences = await userPreferencesService.getAllPreferences();
if (!preferences.jiraConfig?.baseUrl ||
!preferences.jiraConfig?.email ||
!preferences.jiraConfig?.apiToken) {
return null;
}
return {
baseUrl: preferences.jiraConfig.baseUrl,
email: preferences.jiraConfig.email,
apiToken: preferences.jiraConfig.apiToken,
projectKey: preferences.jiraConfig.projectKey,
ignoredProjects: preferences.jiraConfig.ignoredProjects
};
} catch (error) {
console.error('Erreur lors de la récupération de la config Jira:', error);
return null;
}
}
/**
* Génère des insights business basés sur les métriques Jira
*/
static generateBusinessInsights(jiraMetrics: JiraWeeklyMetrics): string[] {
const insights: string[] = [];
if (jiraMetrics.totalJiraTasks === 0) {
insights.push("Aucune tâche Jira cette semaine. Concentré sur des tâches internes ?");
return insights;
}
// Insights sur la completion
const completionRate = (jiraMetrics.completedJiraTasks / jiraMetrics.totalJiraTasks) * 100;
if (completionRate >= 80) {
insights.push(`🎯 Excellent taux de completion Jira: ${completionRate.toFixed(0)}%`);
} else if (completionRate < 50) {
insights.push(`⚠️ Taux de completion Jira faible: ${completionRate.toFixed(0)}%. Revoir les estimations ?`);
}
// Insights sur les story points
if (jiraMetrics.totalStoryPoints > 0) {
insights.push(`📊 Estimation: ${jiraMetrics.totalStoryPoints} story points traités cette semaine`);
const avgPointsPerTask = jiraMetrics.totalStoryPoints / jiraMetrics.totalJiraTasks;
if (avgPointsPerTask > 4) {
insights.push(`🏋️ Travail sur des tâches complexes (${avgPointsPerTask.toFixed(1)} pts/tâche en moyenne)`);
}
}
// Insights sur les projets
if (jiraMetrics.projectsContributed.length > 1) {
insights.push(`🤝 Contribution multi-projets: ${jiraMetrics.projectsContributed.join(', ')}`);
} else if (jiraMetrics.projectsContributed.length === 1) {
insights.push(`🎯 Focus sur le projet ${jiraMetrics.projectsContributed[0]}`);
}
// Insights sur les types de tickets
const bugCount = jiraMetrics.ticketTypes['Bug'] || jiraMetrics.ticketTypes['défaut'] || 0;
const totalTickets = Object.values(jiraMetrics.ticketTypes).reduce((sum, count) => sum + count, 0);
if (bugCount > 0) {
const bugRatio = (bugCount / totalTickets) * 100;
if (bugRatio > 50) {
insights.push(`🐛 Semaine focalisée sur la correction de bugs (${bugRatio.toFixed(0)}%)`);
} else if (bugRatio < 20) {
insights.push(`✨ Semaine productive avec peu de bugs (${bugRatio.toFixed(0)}%)`);
}
}
return insights;
}
}

193
services/pdf-export.ts Normal file
View File

@@ -0,0 +1,193 @@
import jsPDF from 'jspdf';
import { WeeklySummary } from './weekly-summary';
export class PDFExportService {
/**
* Génère un PDF du résumé hebdomadaire
*/
static async exportWeeklySummary(summary: WeeklySummary): Promise<void> {
const pdf = new jsPDF();
const pageWidth = pdf.internal.pageSize.getWidth();
const margin = 20;
let yPosition = margin + 10;
// Header avec logo/titre
pdf.setFontSize(24);
pdf.setFont('helvetica', 'bold');
pdf.text('📊 RÉSUMÉ HEBDOMADAIRE', pageWidth / 2, yPosition, { align: 'center' });
yPosition += 15;
pdf.setFontSize(12);
pdf.setFont('helvetica', 'normal');
const dateRange = `Du ${this.formatDate(summary.period.start)} au ${this.formatDate(summary.period.end)}`;
pdf.text(dateRange, pageWidth / 2, yPosition, { align: 'center' });
yPosition += 20;
// Section métriques principales
pdf.setFontSize(16);
pdf.setFont('helvetica', 'bold');
pdf.text('🎯 MÉTRIQUES PRINCIPALES', margin, yPosition);
yPosition += 15;
pdf.setFontSize(11);
pdf.setFont('helvetica', 'normal');
// Statistiques en deux colonnes
const statsLeft = [
`Tâches complétées: ${summary.stats.completedTasks}/${summary.stats.totalTasks}`,
`Taux de réussite tâches: ${summary.stats.taskCompletionRate.toFixed(1)}%`,
`Daily items complétés: ${summary.stats.completedCheckboxes}/${summary.stats.totalCheckboxes}`,
`Taux de réussite daily: ${summary.stats.checkboxCompletionRate.toFixed(1)}%`
];
const statsRight = [
`Vélocité actuelle: ${summary.velocity.currentWeekTasks} tâches`,
`Semaine précédente: ${summary.velocity.previousWeekTasks} tâches`,
`Moyenne 4 semaines: ${summary.velocity.fourWeekAverage.toFixed(1)}`,
`Tendance: ${this.formatTrend(summary.velocity.weeklyTrend)}`
];
// Colonne gauche
statsLeft.forEach((stat, index) => {
pdf.text(`${stat}`, margin, yPosition + (index * 8));
});
// Colonne droite
statsRight.forEach((stat, index) => {
pdf.text(`${stat}`, pageWidth / 2 + 10, yPosition + (index * 8));
});
yPosition += 40;
// Section jour le plus productif
pdf.setFontSize(14);
pdf.setFont('helvetica', 'bold');
pdf.text('⭐ INSIGHTS', margin, yPosition);
yPosition += 12;
pdf.setFontSize(11);
pdf.setFont('helvetica', 'normal');
pdf.text(`• Jour le plus productif: ${summary.stats.mostProductiveDay}`, margin + 5, yPosition);
yPosition += 8;
// Tendance insight
let trendInsight = '';
if (summary.velocity.weeklyTrend > 10) {
trendInsight = `• Excellente progression ! Amélioration de ${summary.velocity.weeklyTrend.toFixed(1)}% cette semaine.`;
} else if (summary.velocity.weeklyTrend < -10) {
trendInsight = `• Baisse d'activité de ${Math.abs(summary.velocity.weeklyTrend).toFixed(1)}%. Temps de revoir le planning ?`;
} else {
trendInsight = '• Rythme stable maintenu cette semaine.';
}
pdf.text(trendInsight, margin + 5, yPosition);
yPosition += 12;
// Section breakdown par jour
pdf.setFontSize(14);
pdf.setFont('helvetica', 'bold');
pdf.text('📅 RÉPARTITION PAR JOUR', margin, yPosition);
yPosition += 12;
pdf.setFontSize(10);
pdf.setFont('helvetica', 'normal');
// Headers du tableau
const colWidth = (pageWidth - 2 * margin) / 4;
pdf.text('Jour', margin, yPosition);
pdf.text('Tâches', margin + colWidth, yPosition);
pdf.text('Daily Items', margin + 2 * colWidth, yPosition);
pdf.text('Total', margin + 3 * colWidth, yPosition);
yPosition += 8;
// Ligne de séparation
pdf.line(margin, yPosition - 2, pageWidth - margin, yPosition - 2);
// Données du tableau
summary.stats.dailyBreakdown.forEach((day) => {
pdf.text(day.dayName, margin, yPosition);
pdf.text(`${day.completedTasks}/${day.tasks}`, margin + colWidth, yPosition);
pdf.text(`${day.completedCheckboxes}/${day.checkboxes}`, margin + 2 * colWidth, yPosition);
pdf.text(`${day.completedTasks + day.completedCheckboxes}`, margin + 3 * colWidth, yPosition);
yPosition += 6;
});
yPosition += 10;
// Section principales réalisations
pdf.setFontSize(14);
pdf.setFont('helvetica', 'bold');
pdf.text('✅ PRINCIPALES RÉALISATIONS', margin, yPosition);
yPosition += 12;
pdf.setFontSize(10);
pdf.setFont('helvetica', 'normal');
const completedActivities = summary.activities
.filter(a => a.completed)
.slice(0, 8); // Top 8 réalisations
if (completedActivities.length === 0) {
pdf.text('Aucune réalisation complétée cette semaine.', margin + 5, yPosition);
yPosition += 8;
} else {
completedActivities.forEach((activity) => {
const truncatedTitle = activity.title.length > 60
? activity.title.substring(0, 57) + '...'
: activity.title;
const icon = activity.type === 'task' ? '🎯' : '✅';
pdf.text(`${icon} ${truncatedTitle}`, margin + 5, yPosition);
yPosition += 6;
// Nouvelle page si nécessaire
if (yPosition > pdf.internal.pageSize.getHeight() - 30) {
pdf.addPage();
yPosition = margin + 10;
}
});
}
// Footer
const totalPages = pdf.internal.getNumberOfPages();
for (let i = 1; i <= totalPages; i++) {
pdf.setPage(i);
pdf.setFontSize(8);
pdf.setFont('helvetica', 'normal');
const footerText = `TowerControl - Généré le ${new Date().toLocaleDateString('fr-FR')} - Page ${i}/${totalPages}`;
pdf.text(footerText, pageWidth / 2, pdf.internal.pageSize.getHeight() - 10, { align: 'center' });
}
// Télécharger le PDF
const fileName = `weekly-summary-${this.formatDateForFilename(summary.period.start)}_${this.formatDateForFilename(summary.period.end)}.pdf`;
pdf.save(fileName);
}
/**
* Formate une date pour l'affichage
*/
private static formatDate(date: Date): string {
return new Date(date).toLocaleDateString('fr-FR', {
weekday: 'long',
day: 'numeric',
month: 'long',
year: 'numeric'
});
}
/**
* Formate une date pour le nom de fichier
*/
private static formatDateForFilename(date: Date): string {
return new Date(date).toISOString().split('T')[0];
}
/**
* Formate le pourcentage de tendance
*/
private static formatTrend(trend: number): string {
const sign = trend > 0 ? '+' : '';
return `${sign}${trend.toFixed(1)}%`;
}
}

View File

@@ -0,0 +1,185 @@
export interface PredefinedCategory {
name: string;
color: string;
keywords: string[];
icon: string;
}
export const PREDEFINED_CATEGORIES: PredefinedCategory[] = [
{
name: 'Dev',
color: '#3b82f6', // Blue
icon: '💻',
keywords: [
'code', 'coding', 'development', 'develop', 'dev', 'programming', 'program',
'bug', 'fix', 'debug', 'feature', 'implement', 'refactor', 'review',
'api', 'database', 'db', 'frontend', 'backend', 'ui', 'ux',
'component', 'service', 'function', 'method', 'class',
'git', 'commit', 'merge', 'pull request', 'pr', 'deploy', 'deployment',
'test', 'testing', 'unit test', 'integration'
]
},
{
name: 'Meeting',
color: '#8b5cf6', // Purple
icon: '🤝',
keywords: [
'meeting', 'réunion', 'call', 'standup', 'daily', 'retrospective', 'retro',
'planning', 'demo', 'presentation', 'sync', 'catch up', 'catchup',
'interview', 'discussion', 'brainstorm', 'workshop', 'session',
'one on one', '1on1', 'review meeting', 'sprint planning'
]
},
{
name: 'Admin',
color: '#6b7280', // Gray
icon: '📋',
keywords: [
'admin', 'administration', 'paperwork', 'documentation', 'doc', 'docs',
'report', 'reporting', 'timesheet', 'expense', 'invoice',
'email', 'mail', 'communication', 'update', 'status',
'config', 'configuration', 'setup', 'installation', 'maintenance',
'backup', 'security', 'permission', 'user management'
]
},
{
name: 'Learning',
color: '#10b981', // Green
icon: '📚',
keywords: [
'learning', 'learn', 'study', 'training', 'course', 'tutorial',
'research', 'reading', 'documentation', 'knowledge', 'skill',
'certification', 'workshop', 'seminar', 'conference',
'practice', 'exercise', 'experiment', 'exploration', 'investigate'
]
}
];
export class TaskCategorizationService {
/**
* Suggère une catégorie basée sur le titre et la description d'une tâche
*/
static suggestCategory(title: string, description?: string): PredefinedCategory | null {
const text = `${title} ${description || ''}`.toLowerCase();
// Compte les matches pour chaque catégorie
const categoryScores = PREDEFINED_CATEGORIES.map(category => {
const matches = category.keywords.filter(keyword =>
text.includes(keyword.toLowerCase())
).length;
return {
category,
score: matches
};
});
// Trouve la meilleure catégorie
const bestMatch = categoryScores.reduce((best, current) =>
current.score > best.score ? current : best
);
// Retourne la catégorie seulement s'il y a au moins un match
return bestMatch.score > 0 ? bestMatch.category : null;
}
/**
* Suggère plusieurs catégories avec leur score de confiance
*/
static suggestCategoriesWithScore(title: string, description?: string): Array<{
category: PredefinedCategory;
score: number;
confidence: number;
}> {
const text = `${title} ${description || ''}`.toLowerCase();
const categoryScores = PREDEFINED_CATEGORIES.map(category => {
const matches = category.keywords.filter(keyword =>
text.includes(keyword.toLowerCase())
);
const score = matches.length;
const confidence = Math.min((score / 3) * 100, 100); // Max 100% de confiance avec 3+ mots-clés
return {
category,
score,
confidence
};
});
return categoryScores
.filter(item => item.score > 0)
.sort((a, b) => b.score - a.score);
}
/**
* Analyse les activités et retourne la répartition par catégorie
*/
static analyzeActivitiesByCategory(activities: Array<{ title: string; description?: string }>): {
[categoryName: string]: {
count: number;
percentage: number;
color: string;
icon: string;
}
} {
const categoryCounts: { [key: string]: number } = {};
const uncategorized = { count: 0 };
// Initialiser les compteurs
PREDEFINED_CATEGORIES.forEach(cat => {
categoryCounts[cat.name] = 0;
});
// Analyser chaque activité
activities.forEach(activity => {
const suggestedCategory = this.suggestCategory(activity.title, activity.description);
if (suggestedCategory) {
categoryCounts[suggestedCategory.name]++;
} else {
uncategorized.count++;
}
});
const total = activities.length;
const result: { [categoryName: string]: { count: number; percentage: number; color: string; icon: string } } = {};
// Ajouter les catégories prédéfinies
PREDEFINED_CATEGORIES.forEach(category => {
const count = categoryCounts[category.name];
result[category.name] = {
count,
percentage: total > 0 ? (count / total) * 100 : 0,
color: category.color,
icon: category.icon
};
});
// Ajouter "Autre" si nécessaire
if (uncategorized.count > 0) {
result['Autre'] = {
count: uncategorized.count,
percentage: total > 0 ? (uncategorized.count / total) * 100 : 0,
color: '#d1d5db',
icon: '❓'
};
}
return result;
}
/**
* Retourne les tags suggérés pour une tâche
*/
static getSuggestedTags(title: string, description?: string): string[] {
const suggestions = this.suggestCategoriesWithScore(title, description);
return suggestions
.filter(s => s.confidence >= 30) // Seulement les suggestions avec 30%+ de confiance
.slice(0, 2) // Maximum 2 suggestions
.map(s => s.category.name);
}
}

View File

@@ -1,5 +1,6 @@
import { prisma } from './database';
import { Task, TaskStatus, TaskPriority, TaskSource } from '@/lib/types';
import { TaskCategorizationService } from './task-categorization';
export interface DailyItem {
id: string;
@@ -39,44 +40,99 @@ export interface WeeklyActivity {
dayName: string;
}
export interface VelocityMetrics {
currentWeekTasks: number;
previousWeekTasks: number;
weeklyTrend: number; // Pourcentage d'amélioration/détérioration
fourWeekAverage: number;
weeklyData: Array<{
weekStart: Date;
weekEnd: Date;
completedTasks: number;
completedCheckboxes: number;
totalActivities: number;
}>;
}
export interface PeriodComparison {
currentPeriod: {
tasks: number;
checkboxes: number;
total: number;
};
previousPeriod: {
tasks: number;
checkboxes: number;
total: number;
};
changes: {
tasks: number; // pourcentage de changement
checkboxes: number;
total: number;
};
}
export interface WeeklySummary {
stats: WeeklyStats;
activities: WeeklyActivity[];
velocity: VelocityMetrics;
categoryBreakdown: { [categoryName: string]: { count: number; percentage: number; color: string; icon: string } };
periodComparison: PeriodComparison | null;
period: {
start: Date;
end: Date;
};
}
export interface PeriodOption {
label: string;
days: number;
key: string;
}
export const PERIOD_OPTIONS: PeriodOption[] = [
{ label: 'Dernière semaine', days: 7, key: 'week' },
{ label: 'Dernières 2 semaines', days: 14, key: '2weeks' },
{ label: 'Dernier mois', days: 30, key: 'month' }
];
export class WeeklySummaryService {
/**
* Récupère le résumé complet de la semaine écoulée
*/
static async getWeeklySummary(): Promise<WeeklySummary> {
static async getWeeklySummary(periodDays: number = 7): Promise<WeeklySummary> {
const now = new Date();
const startOfWeek = new Date(now);
startOfWeek.setDate(now.getDate() - 7);
startOfWeek.setHours(0, 0, 0, 0);
const startOfPeriod = new Date(now);
startOfPeriod.setDate(now.getDate() - periodDays);
startOfPeriod.setHours(0, 0, 0, 0);
const endOfWeek = new Date(now);
endOfWeek.setHours(23, 59, 59, 999);
const endOfPeriod = new Date(now);
endOfPeriod.setHours(23, 59, 59, 999);
console.log(`📊 Génération du résumé hebdomadaire du ${startOfWeek.toLocaleDateString()} au ${endOfWeek.toLocaleDateString()}`);
console.log(`📊 Génération du résumé (${periodDays} jours) du ${startOfPeriod.toLocaleDateString()} au ${endOfPeriod.toLocaleDateString()}`);
const [checkboxes, tasks] = await Promise.all([
this.getWeeklyCheckboxes(startOfWeek, endOfWeek),
this.getWeeklyTasks(startOfWeek, endOfWeek)
const [checkboxes, tasks, velocity] = await Promise.all([
this.getWeeklyCheckboxes(startOfPeriod, endOfPeriod),
this.getWeeklyTasks(startOfPeriod, endOfPeriod),
this.calculateVelocityMetrics(endOfPeriod)
]);
const stats = this.calculateStats(checkboxes, tasks, startOfWeek, endOfWeek);
const stats = this.calculateStats(checkboxes, tasks, startOfPeriod, endOfPeriod);
const activities = this.mergeActivities(checkboxes, tasks);
const categoryBreakdown = this.analyzeCategorization(checkboxes, tasks);
// Calculer la comparaison avec la période précédente
const periodComparison = await this.calculatePeriodComparison(startOfPeriod, endOfPeriod, periodDays);
return {
stats,
activities,
velocity,
categoryBreakdown,
periodComparison,
period: {
start: startOfWeek,
end: endOfWeek
start: startOfPeriod,
end: endOfPeriod
}
};
}
@@ -214,6 +270,128 @@ export class WeeklySummaryService {
};
}
/**
* Calcule les métriques de vélocité sur 4 semaines
*/
private static async calculateVelocityMetrics(currentEndDate: Date): Promise<VelocityMetrics> {
const weeks = [];
const currentDate = new Date(currentEndDate);
// Générer les 4 dernières semaines
for (let i = 0; i < 4; i++) {
const weekEnd = new Date(currentDate);
weekEnd.setDate(weekEnd.getDate() - (i * 7));
weekEnd.setHours(23, 59, 59, 999);
const weekStart = new Date(weekEnd);
weekStart.setDate(weekEnd.getDate() - 6);
weekStart.setHours(0, 0, 0, 0);
const [weekCheckboxes, weekTasks] = await Promise.all([
this.getWeeklyCheckboxes(weekStart, weekEnd),
this.getWeeklyTasks(weekStart, weekEnd)
]);
const completedTasks = weekTasks.filter(t => t.status === 'done').length;
const completedCheckboxes = weekCheckboxes.filter(c => c.isChecked).length;
weeks.push({
weekStart,
weekEnd,
completedTasks,
completedCheckboxes,
totalActivities: completedTasks + completedCheckboxes
});
}
// Calculer les métriques
const currentWeek = weeks[0];
const previousWeek = weeks[1];
const fourWeekAverage = weeks.reduce((sum, week) => sum + week.totalActivities, 0) / weeks.length;
let weeklyTrend = 0;
if (previousWeek.totalActivities > 0) {
weeklyTrend = ((currentWeek.totalActivities - previousWeek.totalActivities) / previousWeek.totalActivities) * 100;
} else if (currentWeek.totalActivities > 0) {
weeklyTrend = 100; // 100% d'amélioration si on passe de 0 à quelque chose
}
return {
currentWeekTasks: currentWeek.completedTasks,
previousWeekTasks: previousWeek.completedTasks,
weeklyTrend,
fourWeekAverage,
weeklyData: weeks.reverse() // Plus ancien en premier pour l'affichage du graphique
};
}
/**
* Calcule la comparaison avec la période précédente
*/
private static async calculatePeriodComparison(
currentStart: Date,
currentEnd: Date,
periodDays: number
): Promise<PeriodComparison> {
// Période précédente
const previousEnd = new Date(currentStart);
previousEnd.setHours(23, 59, 59, 999);
const previousStart = new Date(previousEnd);
previousStart.setDate(previousEnd.getDate() - periodDays);
previousStart.setHours(0, 0, 0, 0);
const [currentCheckboxes, currentTasks, previousCheckboxes, previousTasks] = await Promise.all([
this.getWeeklyCheckboxes(currentStart, currentEnd),
this.getWeeklyTasks(currentStart, currentEnd),
this.getWeeklyCheckboxes(previousStart, previousEnd),
this.getWeeklyTasks(previousStart, previousEnd)
]);
const currentCompletedTasks = currentTasks.filter(t => t.status === 'done').length;
const currentCompletedCheckboxes = currentCheckboxes.filter(c => c.isChecked).length;
const currentTotal = currentCompletedTasks + currentCompletedCheckboxes;
const previousCompletedTasks = previousTasks.filter(t => t.status === 'done').length;
const previousCompletedCheckboxes = previousCheckboxes.filter(c => c.isChecked).length;
const previousTotal = previousCompletedTasks + previousCompletedCheckboxes;
const calculateChange = (current: number, previous: number): number => {
if (previous === 0) return current > 0 ? 100 : 0;
return ((current - previous) / previous) * 100;
};
return {
currentPeriod: {
tasks: currentCompletedTasks,
checkboxes: currentCompletedCheckboxes,
total: currentTotal
},
previousPeriod: {
tasks: previousCompletedTasks,
checkboxes: previousCompletedCheckboxes,
total: previousTotal
},
changes: {
tasks: calculateChange(currentCompletedTasks, previousCompletedTasks),
checkboxes: calculateChange(currentCompletedCheckboxes, previousCompletedCheckboxes),
total: calculateChange(currentTotal, previousTotal)
}
};
}
/**
* Analyse la catégorisation des activités
*/
private static analyzeCategorization(checkboxes: DailyItem[], tasks: Task[]): { [categoryName: string]: { count: number; percentage: number; color: string; icon: string } } {
const allActivities = [
...checkboxes.map(c => ({ title: c.text, description: '' })),
...tasks.map(t => ({ title: t.title, description: t.description || '' }))
];
return TaskCategorizationService.analyzeActivitiesByCategory(allActivities);
}
/**
* Fusionne les activités (checkboxes + tâches) en une timeline
*/