diff --git a/components/dashboard/ManagerWeeklySummary.tsx b/components/dashboard/ManagerWeeklySummary.tsx index 93dd36e..127dda6 100644 --- a/components/dashboard/ManagerWeeklySummary.tsx +++ b/components/dashboard/ManagerWeeklySummary.tsx @@ -7,6 +7,7 @@ import { Button } from '@/components/ui/Button'; import { TagDisplay } from '@/components/ui/TagDisplay'; import { getPriorityConfig } from '@/lib/status-config'; import { useTasksContext } from '@/contexts/TasksContext'; +import { MetricsTab } from './MetricsTab'; import { format } from 'date-fns'; import { fr } from 'date-fns/locale'; @@ -16,7 +17,7 @@ interface ManagerWeeklySummaryProps { export default function ManagerWeeklySummary({ initialSummary }: ManagerWeeklySummaryProps) { const [summary] = useState(initialSummary); - const [activeView, setActiveView] = useState<'narrative' | 'accomplishments' | 'challenges'>('narrative'); + const [activeView, setActiveView] = useState<'narrative' | 'accomplishments' | 'challenges' | 'metrics'>('narrative'); const { tags: availableTags } = useTasksContext(); const handleRefresh = () => { @@ -98,6 +99,16 @@ export default function ManagerWeeklySummary({ initialSummary }: ManagerWeeklySu > 🎯 Enjeux à venir ({summary.upcomingChallenges.length}) + @@ -195,7 +206,7 @@ export default function ManagerWeeklySummary({ initialSummary }: ManagerWeeklySu summary.keyAccomplishments.slice(0, 6).map((accomplishment, index) => (
{/* Barre colorée gauche */}
@@ -269,7 +280,7 @@ export default function ManagerWeeklySummary({ initialSummary }: ManagerWeeklySu summary.upcomingChallenges.slice(0, 6).map((challenge, index) => (
{/* Barre colorée gauche */}
@@ -345,7 +356,7 @@ export default function ManagerWeeklySummary({ initialSummary }: ManagerWeeklySu {summary.keyAccomplishments.map((accomplishment, index) => (
{/* Barre colorée gauche */}
@@ -417,7 +428,7 @@ export default function ManagerWeeklySummary({ initialSummary }: ManagerWeeklySu {summary.upcomingChallenges.map((challenge, index) => (
{/* Barre colorée gauche */}
@@ -476,6 +487,11 @@ export default function ManagerWeeklySummary({ initialSummary }: ManagerWeeklySu )} + + {/* Vue Métriques */} + {activeView === 'metrics' && ( + + )}
); } diff --git a/components/dashboard/MetricsTab.tsx b/components/dashboard/MetricsTab.tsx new file mode 100644 index 0000000..d75bb16 --- /dev/null +++ b/components/dashboard/MetricsTab.tsx @@ -0,0 +1,245 @@ +'use client'; + +import { useState } from 'react'; +import { useWeeklyMetrics, useVelocityTrends } from '@/hooks/use-metrics'; +import { Card, CardHeader, CardContent } from '@/components/ui/Card'; +import { Button } from '@/components/ui/Button'; +import { DailyStatusChart } from './charts/DailyStatusChart'; +import { CompletionRateChart } from './charts/CompletionRateChart'; +import { StatusDistributionChart } from './charts/StatusDistributionChart'; +import { PriorityBreakdownChart } from './charts/PriorityBreakdownChart'; +import { VelocityTrendChart } from './charts/VelocityTrendChart'; +import { WeeklyActivityHeatmap } from './charts/WeeklyActivityHeatmap'; +import { ProductivityInsights } from './charts/ProductivityInsights'; +import { format } from 'date-fns'; +import { fr } from 'date-fns/locale'; + +interface MetricsTabProps { + className?: string; +} + +export function MetricsTab({ className }: MetricsTabProps) { + const [selectedDate] = useState(new Date()); + const [weeksBack, setWeeksBack] = useState(4); + + const { metrics, loading: metricsLoading, error: metricsError, refetch: refetchMetrics } = useWeeklyMetrics(selectedDate); + const { trends, loading: trendsLoading, error: trendsError, refetch: refetchTrends } = useVelocityTrends(weeksBack); + + const handleRefresh = () => { + refetchMetrics(); + refetchTrends(); + }; + + const formatPeriod = () => { + if (!metrics) return ''; + return `Semaine du ${format(metrics.period.start, 'dd MMM', { locale: fr })} au ${format(metrics.period.end, 'dd MMM yyyy', { locale: fr })}`; + }; + + const getTrendIcon = (trend: string) => { + switch (trend) { + case 'improving': return '📈'; + case 'declining': return '📉'; + case 'stable': return '➡️'; + default: return '📊'; + } + }; + + const getPatternIcon = (pattern: string) => { + switch (pattern) { + case 'consistent': return '🎯'; + case 'variable': return '📊'; + case 'weekend-heavy': return '📅'; + default: return '📋'; + } + }; + + if (metricsError || trendsError) { + return ( +
+ + +

+ ❌ Erreur lors du chargement des métriques +

+

+ {metricsError || trendsError} +

+ +
+
+
+ ); + } + + return ( +
+ {/* Header avec période et contrôles */} +
+
+

📊 Métriques & Analytics

+

{formatPeriod()}

+
+
+ +
+
+ + {metricsLoading || trendsLoading ? ( + + +
+
+
+
+

Chargement des métriques...

+
+
+ ) : metrics ? ( +
+ {/* Vue d'ensemble rapide */} + + +

🎯 Vue d'ensemble

+
+ +
+
+
+ {metrics.summary.totalTasksCompleted} +
+
Terminées
+
+ +
+
+ {metrics.summary.totalTasksCreated} +
+
Créées
+
+ +
+
+ {metrics.summary.averageCompletionRate.toFixed(1)}% +
+
Taux moyen
+
+ +
+
+ {getTrendIcon(metrics.summary.trendsAnalysis.completionTrend)} +
+
+ {metrics.summary.trendsAnalysis.completionTrend} +
+
+ +
+
+ {getPatternIcon(metrics.summary.trendsAnalysis.productivityPattern)} +
+
+ {metrics.summary.trendsAnalysis.productivityPattern === 'consistent' ? 'Régulier' : + metrics.summary.trendsAnalysis.productivityPattern === 'variable' ? 'Variable' : 'Weekend+'} +
+
+
+
+
+ + {/* Graphiques principaux */} +
+ + +

📈 Évolution quotidienne des statuts

+
+ + + +
+ + + +

🎯 Taux de completion quotidien

+
+ + + +
+
+ + {/* Distribution et priorités */} +
+ + +

🍰 Répartition des statuts

+
+ + + +
+ + + +

⚡ Performance par priorité

+
+ + + +
+ + + +

🔥 Heatmap d'activité

+
+ + + +
+
+ + {/* Tendances de vélocité */} + {trends.length > 0 && ( + + +
+

🚀 Tendances de vélocité

+ +
+
+ + + +
+ )} + + {/* Analyses de productivité */} + + +

💡 Analyses de productivité

+
+ + + +
+
+ ) : null} +
+ ); +} diff --git a/components/dashboard/charts/CompletionRateChart.tsx b/components/dashboard/charts/CompletionRateChart.tsx new file mode 100644 index 0000000..6f9d3a6 --- /dev/null +++ b/components/dashboard/charts/CompletionRateChart.tsx @@ -0,0 +1,97 @@ +'use client'; + +import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'; +import { DailyMetrics } from '@/services/metrics'; + +interface CompletionRateChartProps { + data: DailyMetrics[]; + className?: string; +} + +export function CompletionRateChart({ data, className }: CompletionRateChartProps) { + // Transformer les données pour le graphique + const chartData = data.map(day => ({ + day: day.dayName.substring(0, 3), // Lun, Mar, etc. + date: new Date(day.date).toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit' }), + completionRate: day.completionRate, + completed: day.completed, + total: day.totalTasks + })); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const CustomTooltip = ({ active, payload, label }: { active?: boolean; payload?: any[]; label?: string }) => { + if (active && payload && payload.length) { + const data = payload[0].payload; + return ( +
+

{`${label} (${data.date})`}

+

+ Taux de completion: {data.completionRate.toFixed(1)}% +

+

+ {data.completed} / {data.total} tâches +

+
+ ); + } + return null; + }; + + // Calculer la moyenne pour la ligne de référence + const averageRate = data.reduce((sum, day) => sum + day.completionRate, 0) / data.length; + + return ( +
+ + + + + `${value}%`} + /> + } /> + + {/* Ligne de moyenne */} + averageRate} + stroke="#94a3b8" + strokeWidth={1} + strokeDasharray="5 5" + dot={false} + activeDot={false} + /> + + + + {/* Légende */} +
+
+
+ Taux quotidien +
+
+
+ Moyenne ({averageRate.toFixed(1)}%) +
+
+
+ ); +} diff --git a/components/dashboard/charts/DailyStatusChart.tsx b/components/dashboard/charts/DailyStatusChart.tsx new file mode 100644 index 0000000..449a893 --- /dev/null +++ b/components/dashboard/charts/DailyStatusChart.tsx @@ -0,0 +1,68 @@ +'use client'; + +import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts'; +import { DailyMetrics } from '@/services/metrics'; + +interface DailyStatusChartProps { + data: DailyMetrics[]; + className?: string; +} + +export function DailyStatusChart({ data, className }: DailyStatusChartProps) { + // Transformer les données pour le graphique + const chartData = data.map(day => ({ + day: day.dayName.substring(0, 3), // Lun, Mar, etc. + date: new Date(day.date).toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit' }), + 'Complétées': day.completed, + 'En cours': day.inProgress, + 'Bloquées': day.blocked, + 'En attente': day.pending, + 'Nouvelles': day.newTasks + })); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const CustomTooltip = ({ active, payload, label }: { active?: boolean; payload?: any[]; label?: string }) => { + if (active && payload && payload.length) { + return ( +
+

{`${label} (${payload[0]?.payload?.date})`}

+ {payload.map((entry: { dataKey: string; value: number; color: string }, index: number) => ( +

+ {`${entry.dataKey}: ${entry.value}`} +

+ ))} +
+ ); + } + return null; + }; + + return ( +
+ + + + + + } /> + + + + + + + + +
+ ); +} diff --git a/components/dashboard/charts/PriorityBreakdownChart.tsx b/components/dashboard/charts/PriorityBreakdownChart.tsx new file mode 100644 index 0000000..6fb1689 --- /dev/null +++ b/components/dashboard/charts/PriorityBreakdownChart.tsx @@ -0,0 +1,112 @@ +'use client'; + +import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts'; + +interface PriorityData { + priority: string; + completed: number; + pending: number; + total: number; + completionRate: number; + color: string; +} + +interface PriorityBreakdownChartProps { + data: PriorityData[]; + className?: string; +} + +export function PriorityBreakdownChart({ data, className }: PriorityBreakdownChartProps) { + // Transformer les données pour l'affichage + const getPriorityLabel = (priority: string) => { + const labels: { [key: string]: string } = { + 'high': 'Haute', + 'medium': 'Moyenne', + 'low': 'Basse' + }; + return labels[priority] || priority; + }; + + const chartData = data.map(item => ({ + priority: getPriorityLabel(item.priority), + 'Terminées': item.completed, + 'En cours': item.pending, + completionRate: item.completionRate, + total: item.total + })); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const CustomTooltip = ({ active, payload, label }: { active?: boolean; payload?: any[]; label?: string }) => { + if (active && payload && payload.length) { + const data = payload[0].payload; + return ( +
+

{`Priorité ${label}`}

+

+ Terminées: {data['Terminées']} +

+

+ En cours: {data['En cours']} +

+

+ Taux: {data.completionRate.toFixed(1)}% ({data.total} total) +

+
+ ); + } + return null; + }; + + return ( +
+ + + + + + } /> + + + + + + + {/* Affichage des taux de completion */} +
+ {data.map((item, index) => ( +
+
+ {getPriorityLabel(item.priority)} +
+
+ {item.completionRate.toFixed(0)}% +
+
+ {item.completed}/{item.total} +
+
+ ))} +
+
+ ); +} diff --git a/components/dashboard/charts/ProductivityInsights.tsx b/components/dashboard/charts/ProductivityInsights.tsx new file mode 100644 index 0000000..909b0a3 --- /dev/null +++ b/components/dashboard/charts/ProductivityInsights.tsx @@ -0,0 +1,190 @@ +'use client'; + +import { DailyMetrics } from '@/services/metrics'; + +interface ProductivityInsightsProps { + data: DailyMetrics[]; + className?: string; +} + +export function ProductivityInsights({ data, className }: ProductivityInsightsProps) { + // Calculer les insights + const totalCompleted = data.reduce((sum, day) => sum + day.completed, 0); + const totalCreated = data.reduce((sum, day) => sum + day.newTasks, 0); + // const averageCompletion = data.reduce((sum, day) => sum + day.completionRate, 0) / data.length; + + // Trouver le jour le plus productif + const mostProductiveDay = data.reduce((best, day) => + day.completed > best.completed ? day : best + ); + + // Trouver le jour avec le plus de nouvelles tâches + const mostCreativeDay = data.reduce((best, day) => + day.newTasks > best.newTasks ? day : best + ); + + // Analyser la tendance + const firstHalf = data.slice(0, Math.ceil(data.length / 2)); + const secondHalf = data.slice(Math.ceil(data.length / 2)); + + const firstHalfAvg = firstHalf.reduce((sum, day) => sum + day.completed, 0) / firstHalf.length; + const secondHalfAvg = secondHalf.reduce((sum, day) => sum + day.completed, 0) / secondHalf.length; + + const trend = secondHalfAvg > firstHalfAvg ? 'up' : secondHalfAvg < firstHalfAvg ? 'down' : 'stable'; + + // Calculer la consistance (écart-type faible = plus consistant) + const avgCompleted = totalCompleted / data.length; + const variance = data.reduce((sum, day) => { + const diff = day.completed - avgCompleted; + return sum + diff * diff; + }, 0) / data.length; + const standardDeviation = Math.sqrt(variance); + const consistencyScore = Math.max(0, 100 - (standardDeviation * 10)); // Score sur 100 + + // Ratio création/completion + const creationRatio = totalCreated > 0 ? (totalCompleted / totalCreated) * 100 : 0; + + const getTrendIcon = () => { + switch (trend) { + case 'up': return { icon: '📈', color: 'text-green-600', label: 'En amélioration' }; + case 'down': return { icon: '📉', color: 'text-red-600', label: 'En baisse' }; + default: return { icon: '➡️', color: 'text-blue-600', label: 'Stable' }; + } + }; + + const getConsistencyLevel = () => { + if (consistencyScore >= 80) return { label: 'Très régulier', color: 'text-green-600', icon: '🎯' }; + if (consistencyScore >= 60) return { label: 'Assez régulier', color: 'text-blue-600', icon: '📊' }; + if (consistencyScore >= 40) return { label: 'Variable', color: 'text-yellow-600', icon: '📊' }; + return { label: 'Très variable', color: 'text-red-600', icon: '📊' }; + }; + + const getRatioStatus = () => { + if (creationRatio >= 100) return { label: 'Équilibré+', color: 'text-green-600', icon: '⚖️' }; + if (creationRatio >= 80) return { label: 'Bien équilibré', color: 'text-blue-600', icon: '⚖️' }; + if (creationRatio >= 60) return { label: 'Légèrement en retard', color: 'text-yellow-600', icon: '⚖️' }; + return { label: 'Accumulation', color: 'text-red-600', icon: '⚖️' }; + }; + + const trendInfo = getTrendIcon(); + const consistencyInfo = getConsistencyLevel(); + const ratioInfo = getRatioStatus(); + + return ( +
+
+ {/* Insights principaux */} +
+ {/* Jour le plus productif */} +
+
+

+ 🏆 Jour champion +

+ + {mostProductiveDay.completed} + +
+

+ {mostProductiveDay.dayName} - {mostProductiveDay.completed} tâches terminées +

+

+ Taux: {mostProductiveDay.completionRate.toFixed(1)}% +

+
+ + {/* Jour le plus créatif */} +
+
+

+ 💡 Jour créatif +

+ + {mostCreativeDay.newTasks} + +
+

+ {mostCreativeDay.dayName} - {mostCreativeDay.newTasks} nouvelles tâches +

+

+ {mostCreativeDay.dayName === mostProductiveDay.dayName ? + 'Également jour le plus productif!' : + 'Journée de planification'} +

+
+
+ + {/* Analyses comportementales */} +
+ {/* Tendance */} +
+
+ {trendInfo.icon} +

Tendance

+
+

+ {trendInfo.label} +

+

+ {secondHalfAvg > firstHalfAvg ? + `+${(((secondHalfAvg - firstHalfAvg) / firstHalfAvg) * 100).toFixed(1)}%` : + `${(((secondHalfAvg - firstHalfAvg) / firstHalfAvg) * 100).toFixed(1)}%`} +

+
+ + {/* Consistance */} +
+
+ {consistencyInfo.icon} +

Régularité

+
+

+ {consistencyInfo.label} +

+

+ Score: {consistencyScore.toFixed(0)}/100 +

+
+ + {/* Ratio Création/Completion */} +
+
+ {ratioInfo.icon} +

Équilibre

+
+

+ {ratioInfo.label} +

+

+ {creationRatio.toFixed(0)}% de completion +

+
+
+ + {/* Recommandations */} +
+

+ 💡 Recommandations +

+
+ {trend === 'down' && ( +

• Essayez de retrouver votre rythme du début de semaine

+ )} + {consistencyScore < 60 && ( +

• Essayez de maintenir un rythme plus régulier

+ )} + {creationRatio < 80 && ( +

• Concentrez-vous plus sur terminer les tâches existantes

+ )} + {creationRatio > 120 && ( +

• Excellent rythme! Peut-être ralentir la création de nouvelles tâches

+ )} + {mostProductiveDay.dayName === mostCreativeDay.dayName && ( +

• Excellente synergie création/exécution le {mostProductiveDay.dayName}

+ )} +
+
+
+
+ ); +} diff --git a/components/dashboard/charts/StatusDistributionChart.tsx b/components/dashboard/charts/StatusDistributionChart.tsx new file mode 100644 index 0000000..ea347af --- /dev/null +++ b/components/dashboard/charts/StatusDistributionChart.tsx @@ -0,0 +1,109 @@ +'use client'; + +import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip, Legend } from 'recharts'; + +interface StatusDistributionData { + status: string; + count: number; + percentage: number; + color: string; +} + +interface StatusDistributionChartProps { + data: StatusDistributionData[]; + className?: string; +} + +export function StatusDistributionChart({ data, className }: StatusDistributionChartProps) { + // Transformer les statuts pour l'affichage + const getStatusLabel = (status: string) => { + const labels: { [key: string]: string } = { + 'pending': 'En attente', + 'in_progress': 'En cours', + 'blocked': 'Bloquées', + 'done': 'Terminées', + 'archived': 'Archivées' + }; + return labels[status] || status; + }; + + const chartData = data.map(item => ({ + ...item, + name: getStatusLabel(item.status), + value: item.count + })); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const CustomTooltip = ({ active, payload }: { active?: boolean; payload?: any[] }) => { + if (active && payload && payload.length) { + const data = payload[0].payload; + return ( +
+

{data.name}

+

+ {data.count} tâches ({data.percentage}%) +

+
+ ); + } + return null; + }; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const CustomLabel = (props: any) => { + const { cx, cy, midAngle, innerRadius, outerRadius, percent } = props; + if (percent < 0.05) return null; // Ne pas afficher les labels pour les petites sections + + const RADIAN = Math.PI / 180; + const radius = innerRadius + (outerRadius - innerRadius) * 0.5; + const x = cx + radius * Math.cos(-midAngle * RADIAN); + const y = cy + radius * Math.sin(-midAngle * RADIAN); + + return ( + cx ? 'start' : 'end'} + dominantBaseline="central" + fontSize={12} + fontWeight="medium" + > + {`${(percent * 100).toFixed(0)}%`} + + ); + }; + + return ( +
+ + + + {chartData.map((entry, index) => ( + + ))} + + } /> + ( + + {value} + + )} + /> + + +
+ ); +} diff --git a/components/dashboard/charts/VelocityTrendChart.tsx b/components/dashboard/charts/VelocityTrendChart.tsx new file mode 100644 index 0000000..cf2ce65 --- /dev/null +++ b/components/dashboard/charts/VelocityTrendChart.tsx @@ -0,0 +1,95 @@ +'use client'; + +import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts'; +import { VelocityTrend } from '@/services/metrics'; + +interface VelocityTrendChartProps { + data: VelocityTrend[]; + className?: string; +} + +export function VelocityTrendChart({ data, className }: VelocityTrendChartProps) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const CustomTooltip = ({ active, payload, label }: { active?: boolean; payload?: any[]; label?: string }) => { + if (active && payload && payload.length) { + const data = payload[0].payload; + return ( +
+

{`Semaine du ${label}`}

+

+ Terminées: {data.completed} +

+

+ Créées: {data.created} +

+

+ Vélocité: {data.velocity.toFixed(1)}% +

+
+ ); + } + return null; + }; + + return ( +
+ + + + + + `${value}%`} + /> + } /> + + + + + + +
+ ); +} diff --git a/components/dashboard/charts/WeeklyActivityHeatmap.tsx b/components/dashboard/charts/WeeklyActivityHeatmap.tsx new file mode 100644 index 0000000..1cfbc57 --- /dev/null +++ b/components/dashboard/charts/WeeklyActivityHeatmap.tsx @@ -0,0 +1,123 @@ +'use client'; + +import { DailyMetrics } from '@/services/metrics'; + +interface WeeklyActivityHeatmapProps { + data: DailyMetrics[]; + className?: string; +} + +export function WeeklyActivityHeatmap({ data, className }: WeeklyActivityHeatmapProps) { + // Calculer l'intensité max pour la normalisation + const maxActivity = Math.max(...data.map(day => day.completed + day.newTasks)); + + // Obtenir l'intensité relative (0-1) + const getIntensity = (day: DailyMetrics) => { + const activity = day.completed + day.newTasks; + return maxActivity > 0 ? activity / maxActivity : 0; + }; + + // Obtenir la couleur basée sur l'intensité + const getColorClass = (intensity: number) => { + if (intensity === 0) return 'bg-gray-100 dark:bg-gray-800'; + if (intensity < 0.2) return 'bg-green-100 dark:bg-green-900/30'; + if (intensity < 0.4) return 'bg-green-200 dark:bg-green-800/50'; + if (intensity < 0.6) return 'bg-green-300 dark:bg-green-700/70'; + if (intensity < 0.8) return 'bg-green-400 dark:bg-green-600/80'; + return 'bg-green-500 dark:bg-green-500'; + }; + + return ( +
+
+ {/* Titre */} +
+

+ Heatmap d'activité hebdomadaire +

+

+ Intensité basée sur les tâches complétées + nouvelles tâches +

+
+ + {/* Heatmap */} +
+
+ {data.map((day, index) => { + const intensity = getIntensity(day); + const colorClass = getColorClass(intensity); + const totalActivity = day.completed + day.newTasks; + + return ( +
+ {/* Carré de couleur */} +
+ {/* Tooltip au hover */} +
+
{day.dayName}
+
+ {day.completed} terminées, {day.newTasks} créées +
+
+ Taux: {day.completionRate.toFixed(1)}% +
+
+ + {/* Indicator si jour actuel */} + {new Date(day.date).toDateString() === new Date().toDateString() && ( +
+ )} +
+ + {/* Label du jour */} +
+ {day.dayName.substring(0, 3)} +
+
+ ); + })} +
+
+ + {/* Légende */} +
+ Moins +
+
+
+
+
+
+
+
+ Plus +
+ + {/* Stats rapides */} +
+
+
+ {data.reduce((sum, day) => sum + day.completed, 0)} +
+
Terminées
+
+
+
+ {data.reduce((sum, day) => sum + day.newTasks, 0)} +
+
Créées
+
+
+
+ {(data.reduce((sum, day) => sum + day.completionRate, 0) / data.length).toFixed(1)}% +
+
Taux moyen
+
+
+
+
+ ); +} diff --git a/hooks/use-metrics.ts b/hooks/use-metrics.ts new file mode 100644 index 0000000..688bbc9 --- /dev/null +++ b/hooks/use-metrics.ts @@ -0,0 +1,63 @@ +import { useState, useEffect, useTransition, useCallback } from 'react'; +import { getWeeklyMetrics, getVelocityTrends } from '@/actions/metrics'; +import { WeeklyMetricsOverview, VelocityTrend } from '@/services/metrics'; + +export function useWeeklyMetrics(date?: Date) { + const [metrics, setMetrics] = useState(null); + const [error, setError] = useState(null); + const [isPending, startTransition] = useTransition(); + + const fetchMetrics = useCallback(() => { + startTransition(async () => { + setError(null); + const result = await getWeeklyMetrics(date); + + if (result.success && result.data) { + setMetrics(result.data); + } else { + setError(result.error || 'Failed to fetch metrics'); + } + }); + }, [date, startTransition]); + + useEffect(() => { + fetchMetrics(); + }, [date, fetchMetrics]); + + return { + metrics, + loading: isPending, + error, + refetch: fetchMetrics + }; +} + +export function useVelocityTrends(weeksBack: number = 4) { + const [trends, setTrends] = useState([]); + const [error, setError] = useState(null); + const [isPending, startTransition] = useTransition(); + + const fetchTrends = useCallback(() => { + startTransition(async () => { + setError(null); + const result = await getVelocityTrends(weeksBack); + + if (result.success && result.data) { + setTrends(result.data); + } else { + setError(result.error || 'Failed to fetch velocity trends'); + } + }); + }, [weeksBack, startTransition]); + + useEffect(() => { + fetchTrends(); + }, [weeksBack, fetchTrends]); + + return { + trends, + loading: isPending, + error, + refetch: fetchTrends + }; +} diff --git a/services/metrics.ts b/services/metrics.ts new file mode 100644 index 0000000..7057b4b --- /dev/null +++ b/services/metrics.ts @@ -0,0 +1,362 @@ +import { prisma } from './database'; +import { startOfWeek, endOfWeek, eachDayOfInterval, format, startOfDay, endOfDay } from 'date-fns'; +import { fr } from 'date-fns/locale'; + +export interface DailyMetrics { + date: string; // Format ISO + dayName: string; // Lundi, Mardi, etc. + completed: number; + inProgress: number; + blocked: number; + pending: number; + newTasks: number; + totalTasks: number; + completionRate: number; +} + +export interface VelocityTrend { + date: string; + completed: number; + created: number; + velocity: number; +} + +export interface WeeklyMetricsOverview { + period: { + start: Date; + end: Date; + }; + dailyBreakdown: DailyMetrics[]; + summary: { + totalTasksCompleted: number; + totalTasksCreated: number; + averageCompletionRate: number; + peakProductivityDay: string; + lowProductivityDay: string; + trendsAnalysis: { + completionTrend: 'improving' | 'declining' | 'stable'; + productivityPattern: 'consistent' | 'variable' | 'weekend-heavy'; + }; + }; + statusDistribution: { + status: string; + count: number; + percentage: number; + color: string; + }[]; + priorityBreakdown: { + priority: string; + completed: number; + pending: number; + total: number; + completionRate: number; + color: string; + }[]; +} + +export class MetricsService { + /** + * Récupère les métriques journalières de la semaine + */ + static async getWeeklyMetrics(date: Date = new Date()): Promise { + 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 { + const dayStart = startOfDay(date); + const dayEnd = endOfDay(date); + + // Compter les tâches par statut à la fin de la journée + const [completed, inProgress, blocked, pending, newTasks, totalTasks] = await Promise.all([ + // Tâches complétées ce jour + prisma.task.count({ + where: { + OR: [ + { + completedAt: { + gte: dayStart, + lte: dayEnd + } + }, + { + status: 'done', + updatedAt: { + gte: dayStart, + lte: dayEnd + } + } + ] + } + }), + + // Tâches en cours (status = in_progress à ce moment) + prisma.task.count({ + where: { + status: 'in_progress', + createdAt: { lte: dayEnd } + } + }), + + // Tâches bloquées + prisma.task.count({ + where: { + status: 'blocked', + createdAt: { lte: dayEnd } + } + }), + + // Tâches en attente + prisma.task.count({ + where: { + status: 'pending', + createdAt: { lte: dayEnd } + } + }), + + // Nouvelles tâches créées ce jour + prisma.task.count({ + where: { + createdAt: { + gte: dayStart, + lte: dayEnd + } + } + }), + + // Total des tâches existantes ce jour + prisma.task.count({ + where: { + createdAt: { lte: dayEnd } + } + }) + ]); + + const completionRate = totalTasks > 0 ? (completed / totalTasks) * 100 : 0; + + return { + date: date.toISOString(), + dayName: format(date, 'EEEE', { locale: fr }), + completed, + inProgress, + blocked, + pending, + newTasks, + totalTasks, + completionRate: Math.round(completionRate * 100) / 100 + }; + } + + /** + * Calcule le résumé hebdomadaire + */ + private static calculateWeeklySummary(dailyBreakdown: DailyMetrics[]) { + const totalTasksCompleted = dailyBreakdown.reduce((sum, day) => sum + day.completed, 0); + const totalTasksCreated = dailyBreakdown.reduce((sum, day) => sum + day.newTasks, 0); + const averageCompletionRate = dailyBreakdown.reduce((sum, day) => sum + day.completionRate, 0) / dailyBreakdown.length; + + // Identifier les jours de pic et de creux + const peakDay = dailyBreakdown.reduce((peak, day) => + day.completed > peak.completed ? day : peak + ); + const lowDay = dailyBreakdown.reduce((low, day) => + day.completed < low.completed ? day : low + ); + + // Analyser les tendances + const firstHalf = dailyBreakdown.slice(0, 3); + const secondHalf = dailyBreakdown.slice(4); + const firstHalfAvg = firstHalf.reduce((sum, day) => sum + day.completed, 0) / firstHalf.length; + const secondHalfAvg = secondHalf.reduce((sum, day) => sum + day.completed, 0) / secondHalf.length; + + let completionTrend: 'improving' | 'declining' | 'stable'; + if (secondHalfAvg > firstHalfAvg * 1.1) { + completionTrend = 'improving'; + } else if (secondHalfAvg < firstHalfAvg * 0.9) { + completionTrend = 'declining'; + } else { + completionTrend = 'stable'; + } + + // Analyser le pattern de productivité + const weekendDays = dailyBreakdown.slice(5); // Samedi et dimanche + const weekdayDays = dailyBreakdown.slice(0, 5); + const weekendAvg = weekendDays.reduce((sum, day) => sum + day.completed, 0) / weekendDays.length; + const weekdayAvg = weekdayDays.reduce((sum, day) => sum + day.completed, 0) / weekdayDays.length; + + let productivityPattern: 'consistent' | 'variable' | 'weekend-heavy'; + if (weekendAvg > weekdayAvg * 1.2) { + productivityPattern = 'weekend-heavy'; + } else { + const variance = dailyBreakdown.reduce((sum, day) => { + const diff = day.completed - (totalTasksCompleted / dailyBreakdown.length); + return sum + diff * diff; + }, 0) / dailyBreakdown.length; + productivityPattern = variance > 4 ? 'variable' : 'consistent'; + } + + return { + totalTasksCompleted, + totalTasksCreated, + averageCompletionRate: Math.round(averageCompletionRate * 100) / 100, + peakProductivityDay: peakDay.dayName, + lowProductivityDay: lowDay.dayName, + trendsAnalysis: { + completionTrend, + productivityPattern + } + }; + } + + /** + * Récupère la distribution des statuts pour la période + */ + private static async getStatusDistribution(start: Date, end: Date) { + const statusCounts = await prisma.task.groupBy({ + by: ['status'], + _count: { + status: true + }, + where: { + createdAt: { + gte: start, + lte: end + } + } + }); + + const total = statusCounts.reduce((sum, item) => sum + item._count.status, 0); + + const statusColors: { [key: string]: string } = { + pending: '#94a3b8', // gray + in_progress: '#3b82f6', // blue + blocked: '#ef4444', // red + done: '#10b981', // green + archived: '#6b7280' // gray-500 + }; + + return statusCounts.map(item => ({ + status: item.status, + count: item._count.status, + percentage: Math.round((item._count.status / total) * 100 * 100) / 100, + color: statusColors[item.status] || '#6b7280' + })); + } + + /** + * Récupère la répartition par priorité avec taux de completion + */ + private static async getPriorityBreakdown(start: Date, end: Date) { + const priorities = ['high', 'medium', 'low']; + + const priorityData = await Promise.all( + priorities.map(async (priority) => { + const [completed, total] = await Promise.all([ + prisma.task.count({ + where: { + priority, + completedAt: { + gte: start, + lte: end + } + } + }), + prisma.task.count({ + where: { + priority, + createdAt: { + gte: start, + lte: end + } + } + }) + ]); + + const pending = total - completed; + const completionRate = total > 0 ? (completed / total) * 100 : 0; + + return { + priority, + completed, + pending, + total, + completionRate: Math.round(completionRate * 100) / 100, + color: priority === 'high' ? '#ef4444' : + priority === 'medium' ? '#f59e0b' : '#10b981' + }; + }) + ); + + return priorityData; + } + + /** + * Récupère les métriques de vélocité d'équipe (pour graphiques de tendance) + */ + static async getVelocityTrends(weeksBack: number = 4): Promise { + const trends = []; + + for (let i = weeksBack - 1; i >= 0; i--) { + const weekStart = startOfWeek(new Date(Date.now() - i * 7 * 24 * 60 * 60 * 1000), { weekStartsOn: 1 }); + const weekEnd = endOfWeek(weekStart, { weekStartsOn: 1 }); + + const [completed, created] = await Promise.all([ + prisma.task.count({ + where: { + completedAt: { + gte: weekStart, + lte: weekEnd + } + } + }), + prisma.task.count({ + where: { + createdAt: { + gte: weekStart, + lte: weekEnd + } + } + }) + ]); + + const velocity = created > 0 ? (completed / created) * 100 : 0; + + trends.push({ + date: format(weekStart, 'dd/MM', { locale: fr }), + completed, + created, + velocity: Math.round(velocity * 100) / 100 + }); + } + + return trends; + } +} diff --git a/src/actions/metrics.ts b/src/actions/metrics.ts new file mode 100644 index 0000000..71b4322 --- /dev/null +++ b/src/actions/metrics.ts @@ -0,0 +1,78 @@ +'use server'; + +import { MetricsService, WeeklyMetricsOverview, VelocityTrend } from '@/services/metrics'; +import { revalidatePath } from 'next/cache'; + +/** + * Récupère les métriques hebdomadaires pour une date donnée + */ +export async function getWeeklyMetrics(date?: Date): Promise<{ + success: boolean; + data?: WeeklyMetricsOverview; + error?: string; +}> { + try { + const targetDate = date || new Date(); + const metrics = await MetricsService.getWeeklyMetrics(targetDate); + + return { + success: true, + data: metrics + }; + } catch (error) { + console.error('Error fetching weekly metrics:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to fetch weekly metrics' + }; + } +} + +/** + * Récupère les tendances de vélocité sur plusieurs semaines + */ +export async function getVelocityTrends(weeksBack: number = 4): Promise<{ + success: boolean; + data?: VelocityTrend[]; + error?: string; +}> { + try { + if (weeksBack < 1 || weeksBack > 12) { + return { + success: false, + error: 'Invalid weeksBack parameter (must be 1-12)' + }; + } + + const trends = await MetricsService.getVelocityTrends(weeksBack); + + return { + success: true, + data: trends + }; + } catch (error) { + console.error('Error fetching velocity trends:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to fetch velocity trends' + }; + } +} + +/** + * Rafraîchir les données de métriques (invalide le cache) + */ +export async function refreshMetrics(): Promise<{ + success: boolean; + error?: string; +}> { + try { + revalidatePath('/manager'); + return { success: true }; + } catch { + return { + success: false, + error: 'Failed to refresh metrics' + }; + } +}