diff --git a/TODO.md b/TODO.md index 78a61c9..42bc37d 100644 --- a/TODO.md +++ b/TODO.md @@ -132,17 +132,21 @@ - [x] Déplacer kanban vers /kanban et créer nouveau dashboard à la racine - [x] Actions rapides vers les différentes sections - [x] Affichage des tâches récentes -- [ ] Graphiques de productivité (tâches complétées par jour/semaine) -- [ ] Indicateurs de performance personnels +- [x] Graphiques de productivité (tâches complétées par jour/semaine) +- [x] Indicateurs de performance personnels +- [x] Intégration des analytics dans le dashboard ### 3.4 Analytics et métriques -- [ ] `services/analytics.ts` - Calculs statistiques -- [ ] Métriques de productivité (vélocité, temps moyen, etc.) -- [ ] Graphiques avec Chart.js ou Recharts +- [x] `services/analytics.ts` - Calculs statistiques +- [x] Métriques de productivité (vélocité, temps moyen, etc.) +- [x] Graphiques avec Recharts (tendances, vélocité, distribution) +- [x] Composants de graphiques (CompletionTrend, Velocity, Priority, Weekly) +- [x] Insights automatiques et métriques visuelles - [ ] Export des données en CSV/JSON ## Autre Todo - [x] Avoir un bouton pour réduire/agrandir la font des taches dans les kanban (swimlane et classique) +- [ ] Refactorer les couleurs des priorités dans un seul endroit - [ ] Système de sauvegarde automatique base de données - [ ] Sauvegarde automatique toutes les 6 heures (configurable) - [ ] Configuration dans les paramètres (intervalle de temps + bouton sauvegarde manuelle) diff --git a/components/HomePageClient.tsx b/components/HomePageClient.tsx index d82a937..4cf8711 100644 --- a/components/HomePageClient.tsx +++ b/components/HomePageClient.tsx @@ -8,6 +8,7 @@ import { CreateTaskData } from '@/clients/tasks-client'; import { DashboardStats } from '@/components/dashboard/DashboardStats'; import { QuickActions } from '@/components/dashboard/QuickActions'; import { RecentTasks } from '@/components/dashboard/RecentTasks'; +import { ProductivityAnalytics } from '@/components/dashboard/ProductivityAnalytics'; interface HomePageClientProps { initialTasks: Task[]; @@ -41,6 +42,9 @@ function HomePageContent() { {/* Actions rapides */} + {/* Analytics et métriques */} + + {/* Tâches récentes */} diff --git a/components/charts/CompletionTrendChart.tsx b/components/charts/CompletionTrendChart.tsx new file mode 100644 index 0000000..885a556 --- /dev/null +++ b/components/charts/CompletionTrendChart.tsx @@ -0,0 +1,108 @@ +'use client'; + +import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'; +import { Card } from '@/components/ui/Card'; + +interface CompletionTrendData { + date: string; + completed: number; + created: number; + total: number; +} + +interface CompletionTrendChartProps { + data: CompletionTrendData[]; + title?: string; +} + +export function CompletionTrendChart({ data, title = "Tendance de Completion" }: CompletionTrendChartProps) { + // Formatter pour les dates + const formatDate = (dateStr: string) => { + const date = new Date(dateStr); + return date.toLocaleDateString('fr-FR', { + day: 'numeric', + month: 'short' + }); + }; + + // Tooltip personnalisé + const CustomTooltip = ({ active, payload, label }: { + active?: boolean; + payload?: Array<{ name: string; value: number; color: string }>; + label?: string + }) => { + if (active && payload && payload.length) { + return ( +
+

{label ? formatDate(label) : ''}

+ {payload.map((entry, index: number) => ( +

+ {entry.name === 'completed' ? 'Terminées' : + entry.name === 'created' ? 'Créées' : 'Total'}: {entry.value} +

+ ))} +
+ ); + } + return null; + }; + + return ( + +

{title}

+
+ + + + + + } /> + + + + +
+ + {/* Légende */} +
+
+
+ Tâches terminées +
+
+
+ Tâches créées +
+
+
+ ); +} diff --git a/components/charts/PriorityDistributionChart.tsx b/components/charts/PriorityDistributionChart.tsx new file mode 100644 index 0000000..738b086 --- /dev/null +++ b/components/charts/PriorityDistributionChart.tsx @@ -0,0 +1,100 @@ +'use client'; + +import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip, Legend, PieLabelRenderProps } from 'recharts'; +import { Card } from '@/components/ui/Card'; + +interface PriorityData { + priority: string; + count: number; + percentage: number; + [key: string]: string | number; // Index signature pour Recharts +} + +interface PriorityDistributionChartProps { + data: PriorityData[]; + title?: string; +} + +// Couleurs pour chaque priorité +const PRIORITY_COLORS = { + 'Faible': '#10b981', // green-500 + 'Moyenne': '#f59e0b', // amber-500 + 'Élevée': '#8b5cf6', // violet-500 + 'Urgente': '#ef4444', // red-500 + 'Non définie': '#6b7280' // gray-500 +}; + +export function PriorityDistributionChart({ data, title = "Distribution des Priorités" }: PriorityDistributionChartProps) { + // Tooltip personnalisé + const CustomTooltip = ({ active, payload }: { active?: boolean; payload?: Array<{ payload: PriorityData }> }) => { + if (active && payload && payload.length) { + const data = payload[0].payload; + return ( +
+

{data.priority}

+

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

+
+ ); + } + return null; + }; + + // Légende personnalisée + const CustomLegend = ({ payload }: { payload?: Array<{ value: string; color: string }> }) => { + return ( +
+ {payload?.map((entry, index: number) => ( +
+
+ + {entry.value} + +
+ ))} +
+ ); + }; + + // Label personnalisé pour afficher les pourcentages + const renderLabel = (props: PieLabelRenderProps) => { + const percentage = typeof props.percent === 'number' ? props.percent * 100 : 0; + return percentage > 5 ? `${Math.round(percentage)}%` : ''; + }; + + return ( + +

{title}

+
+ + + + {data.map((entry, index) => ( + + ))} + + } /> + } /> + + +
+
+ ); +} diff --git a/components/charts/VelocityChart.tsx b/components/charts/VelocityChart.tsx new file mode 100644 index 0000000..41913b1 --- /dev/null +++ b/components/charts/VelocityChart.tsx @@ -0,0 +1,94 @@ +'use client'; + +import { Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Line, ComposedChart } from 'recharts'; +import { Card } from '@/components/ui/Card'; + +interface VelocityData { + week: string; + completed: number; + average: number; +} + +interface VelocityChartProps { + data: VelocityData[]; + title?: string; +} + +export function VelocityChart({ data, title = "Vélocité Hebdomadaire" }: VelocityChartProps) { + // Tooltip personnalisé + const CustomTooltip = ({ active, payload, label }: { + active?: boolean; + payload?: Array<{ dataKey: string; value: number; color: string }>; + label?: string + }) => { + if (active && payload && payload.length) { + return ( +
+

{label}

+ {payload.map((entry, index: number) => ( +

+ {entry.dataKey === 'completed' ? 'Terminées' : 'Moyenne'}: {entry.value} + {entry.dataKey === 'completed' ? ' tâches' : ' tâches/sem'} +

+ ))} +
+ ); + } + return null; + }; + + return ( + +

{title}

+
+ + + + + + } /> + + + + +
+ + {/* Légende */} +
+
+
+ Tâches terminées +
+
+
+ Moyenne mobile +
+
+
+ ); +} diff --git a/components/charts/WeeklyStatsCard.tsx b/components/charts/WeeklyStatsCard.tsx new file mode 100644 index 0000000..fcb0f65 --- /dev/null +++ b/components/charts/WeeklyStatsCard.tsx @@ -0,0 +1,78 @@ +'use client'; + +import { Card } from '@/components/ui/Card'; + +interface WeeklyStats { + thisWeek: number; + lastWeek: number; + change: number; + changePercent: number; +} + +interface WeeklyStatsCardProps { + stats: WeeklyStats; + title?: string; +} + +export function WeeklyStatsCard({ stats, title = "Performance Hebdomadaire" }: WeeklyStatsCardProps) { + const isPositive = stats.change >= 0; + const changeColor = isPositive ? 'text-[var(--success)]' : 'text-[var(--destructive)]'; + const changeIcon = isPositive ? '↗️' : '↘️'; + const changeBg = isPositive + ? 'bg-[var(--success)]/10 border border-[var(--success)]/20' + : 'bg-[var(--destructive)]/10 border border-[var(--destructive)]/20'; + + return ( + +

{title}

+ +
+ {/* Cette semaine */} +
+
+ {stats.thisWeek} +
+
+ Cette semaine +
+
+ + {/* Semaine dernière */} +
+
+ {stats.lastWeek} +
+
+ Semaine dernière +
+
+
+ + {/* Changement */} +
+
+ {changeIcon} +
+
+ {isPositive ? '+' : ''}{stats.change} tâches +
+
+ {isPositive ? '+' : ''}{stats.changePercent}% vs semaine dernière +
+
+
+
+ + {/* Insight */} +
+

+ {stats.changePercent > 20 ? 'Excellente progression ! 🚀' : + stats.changePercent > 0 ? 'Bonne progression 👍' : + stats.changePercent === 0 ? 'Performance stable 📊' : + stats.changePercent > -20 ? 'Légère baisse, restez motivé 💪' : + 'Focus sur la productivité cette semaine 🎯'} +

+
+
+ ); +} diff --git a/components/dashboard/ProductivityAnalytics.tsx b/components/dashboard/ProductivityAnalytics.tsx new file mode 100644 index 0000000..6fc15bb --- /dev/null +++ b/components/dashboard/ProductivityAnalytics.tsx @@ -0,0 +1,162 @@ +'use client'; + +import { useState, useEffect, useTransition } from 'react'; +import { ProductivityMetrics } from '@/services/analytics'; +import { getProductivityMetrics } from '@/actions/analytics'; +import { CompletionTrendChart } from '@/components/charts/CompletionTrendChart'; +import { VelocityChart } from '@/components/charts/VelocityChart'; +import { PriorityDistributionChart } from '@/components/charts/PriorityDistributionChart'; +import { WeeklyStatsCard } from '@/components/charts/WeeklyStatsCard'; +import { Card } from '@/components/ui/Card'; + +export function ProductivityAnalytics() { + const [metrics, setMetrics] = useState(null); + const [error, setError] = useState(null); + const [isPending, startTransition] = useTransition(); + + useEffect(() => { + const loadMetrics = () => { + startTransition(async () => { + try { + setError(null); + const response = await getProductivityMetrics(); + + if (response.success && response.data) { + setMetrics(response.data); + } else { + setError(response.error || 'Erreur lors du chargement des métriques'); + } + } catch (err) { + setError(err instanceof Error ? err.message : 'Erreur lors du chargement des métriques'); + console.error('Erreur analytics:', err); + } + }); + }; + + loadMetrics(); + }, []); + + if (isPending) { + return ( +
+ {Array.from({ length: 4 }).map((_, i) => ( + +
+
+
+ ))} +
+ ); + } + + if (error) { + return ( + +
+
⚠️
+

Erreur de chargement

+

{error}

+
+
+ ); + } + + if (!metrics) { + return null; + } + + return ( +
+ {/* Titre de section */} +
+

📊 Analytics & Métriques

+
+ Derniers 30 jours +
+
+ + {/* Performance hebdomadaire */} + + + {/* Graphiques principaux */} +
+ + +
+ + {/* Distributions */} +
+ + + {/* Status Flow - Graphique simple en barres horizontales */} + +

Répartition par Statut

+
+ {metrics.statusFlow.map((item, index) => ( +
+
+ {item.status} +
+
+
+
+
+ {item.count} +
+
+ {item.percentage}% +
+
+ ))} +
+
+
+ + {/* Insights automatiques */} + +

💡 Insights

+
+
+
+ Vélocité Moyenne +
+
+ {metrics.velocityData.length > 0 + ? Math.round(metrics.velocityData.reduce((acc, item) => acc + item.completed, 0) / metrics.velocityData.length) + : 0 + } tâches/sem +
+
+ +
+
+ Priorité Principale +
+
+ {metrics.priorityDistribution.reduce((max, item) => + item.count > max.count ? item : max, + metrics.priorityDistribution[0] + )?.priority || 'N/A'} +
+
+ +
+
+ Taux de Completion +
+
+ {(() => { + const completed = metrics.statusFlow.find(s => s.status === 'Terminé')?.count || 0; + const total = metrics.statusFlow.reduce((acc, s) => acc + s.count, 0); + return total > 0 ? Math.round((completed / total) * 100) : 0; + })()}% +
+
+
+
+
+ ); +} diff --git a/components/dashboard/RecentTasks.tsx b/components/dashboard/RecentTasks.tsx index f4f20b8..be0b79a 100644 --- a/components/dashboard/RecentTasks.tsx +++ b/components/dashboard/RecentTasks.tsx @@ -6,6 +6,7 @@ import { TagDisplay } from '@/components/ui/TagDisplay'; import { Badge } from '@/components/ui/Badge'; import { useTasksContext } from '@/contexts/TasksContext'; import { getPriorityConfig, getPriorityColorHex } from '@/lib/status-config'; +import { TaskPriority } from '@/lib/types'; import Link from 'next/link'; interface RecentTasksProps { @@ -40,7 +41,7 @@ export function RecentTasks({ tasks }: RecentTasksProps) { const getPriorityStyle = (priority: string) => { try { - const config = getPriorityConfig(priority as any); + const config = getPriorityConfig(priority as TaskPriority); const hexColor = getPriorityColorHex(config.color); return { color: hexColor }; } catch { @@ -49,7 +50,7 @@ export function RecentTasks({ tasks }: RecentTasksProps) { }; return ( - +

Tâches Récentes

@@ -103,7 +104,7 @@ export function RecentTasks({ tasks }: RecentTasksProps) { > {(() => { try { - return getPriorityConfig(task.priority as any).label; + return getPriorityConfig(task.priority as TaskPriority).label; } catch { return task.priority; } diff --git a/package-lock.json b/package-lock.json index 28f51c7..ed82429 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,11 +1,11 @@ { - "name": "towercontrol-temp", + "name": "towercontrol", "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "towercontrol-temp", + "name": "towercontrol", "version": "0.1.0", "dependencies": { "@dnd-kit/core": "^6.3.1", @@ -18,6 +18,7 @@ "prisma": "^6.16.1", "react": "19.1.0", "react-dom": "19.1.0", + "recharts": "^3.2.1", "sqlite3": "^5.1.7", "tailwind-merge": "^3.3.1" }, @@ -1149,6 +1150,32 @@ "@prisma/debug": "6.16.1" } }, + "node_modules/@reduxjs/toolkit": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.9.0.tgz", + "integrity": "sha512-fSfQlSRu9Z5yBkvsNhYF2rPS8cGXn/TZVrlwN1948QyZ8xMZ0JvP50S2acZNaf+o63u6aEeMjipFyksjIcWrog==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^10.0.3", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -1169,6 +1196,12 @@ "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==", "license": "MIT" }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@swc/helpers": { "version": "0.5.15", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", @@ -1475,6 +1508,69 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz", + "integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -1510,7 +1606,7 @@ "version": "19.1.13", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.13.tgz", "integrity": "sha512-hHkbU/eoO3EG5/MZkuFSKmYqPbSVk5byPFa3e7y/8TybHiLMACgI8seVYlicwk7H5K/rI2px9xrQp/C+AUDTiQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.0.2" @@ -1526,6 +1622,12 @@ "@types/react": "^19.0.0" } }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.43.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.43.0.tgz", @@ -2887,9 +2989,130 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "dev": true, + "devOptional": true, "license": "MIT" }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz", + "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/damerau-levenshtein": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", @@ -2979,6 +3202,12 @@ } } }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, "node_modules/decompress-response": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", @@ -3376,6 +3605,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/es-toolkit": { + "version": "1.39.10", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.39.10.tgz", + "integrity": "sha512-E0iGnTtbDhkeczB0T+mxmoVlT4YNweEKBLq7oaU4p11mecdsZpNWOglI4895Vh4usbQ+LsJiuLuI2L0Vdmfm2w==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -3862,6 +4101,12 @@ "node": ">=0.10.0" } }, + "node_modules/eventemitter3": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", + "license": "MIT" + }, "node_modules/expand-template": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", @@ -4515,6 +4760,16 @@ "node": ">= 4" } }, + "node_modules/immer": { + "version": "10.1.3", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.1.3.tgz", + "integrity": "sha512-tmjF/k8QDKydUlm3mZU+tjM6zeq9/fFpPqH9SzWmBnVVKsPBg/V66qsMwb3/Bo90cgUN+ghdVBess+hPsxUyRw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -4598,6 +4853,15 @@ "node": ">= 0.4" } }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/ip-address": { "version": "10.0.1", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz", @@ -6560,9 +6824,31 @@ "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "dev": true, "license": "MIT" }, + "node_modules/react-redux": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", + "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, "node_modules/readable-stream": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", @@ -6590,6 +6876,48 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/recharts": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.2.1.tgz", + "integrity": "sha512-0JKwHRiFZdmLq/6nmilxEZl3pqb4T+aKkOkOi/ZISRZwfBhVMgInxzlYU9D4KnCH3KINScLy68m/OvMXoYGZUw==", + "license": "MIT", + "dependencies": { + "@reduxjs/toolkit": "1.x.x || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^10.1.1", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.1.1", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -6634,6 +6962,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, "node_modules/resolve": { "version": "1.22.10", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", @@ -7618,6 +7952,12 @@ "node": ">=18" } }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, "node_modules/tinyexec": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.1.tgz", @@ -7925,12 +8265,43 @@ "punycode": "^2.1.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz", + "integrity": "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index ac836f7..384aa8f 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "prisma": "^6.16.1", "react": "19.1.0", "react-dom": "19.1.0", + "recharts": "^3.2.1", "sqlite3": "^5.1.7", "tailwind-merge": "^3.3.1" }, diff --git a/services/analytics.ts b/services/analytics.ts new file mode 100644 index 0000000..61878b4 --- /dev/null +++ b/services/analytics.ts @@ -0,0 +1,292 @@ +import { Task, TaskStatus, TaskPriority, TaskSource } from '@/lib/types'; +import { prisma } from './database'; + +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 { + try { + const now = new Date(); + const defaultStart = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); // 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); + 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 => + new Date(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(); + + 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 = new Date(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(); + 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(); + 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 = new Date(); + const thisWeekStart = this.getWeekStart(now); + const lastWeekStart = new Date(thisWeekStart.getTime() - 7 * 24 * 60 * 60 * 1000); + const lastWeekEnd = new Date(thisWeekStart.getTime() - 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); + 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 = { + '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 = { + 'backlog': 'Backlog', + 'todo': 'À faire', + 'in_progress': 'En cours', + 'done': 'Terminé', + 'cancelled': 'Annulé', + 'freeze': 'Gelé', + 'archived': 'Archivé' + }; + return labels[status] || status; + } +} diff --git a/src/actions/analytics.ts b/src/actions/analytics.ts new file mode 100644 index 0000000..dc1e957 --- /dev/null +++ b/src/actions/analytics.ts @@ -0,0 +1,25 @@ +'use server'; + +import { AnalyticsService, ProductivityMetrics, TimeRange } from '@/services/analytics'; + +export async function getProductivityMetrics(timeRange?: TimeRange): Promise<{ + success: boolean; + data?: ProductivityMetrics; + error?: string; +}> { + try { + const metrics = await AnalyticsService.getProductivityMetrics(timeRange); + + return { + success: true, + data: metrics + }; + } catch (error) { + console.error('Erreur lors de la récupération des métriques:', error); + + return { + success: false, + error: error instanceof Error ? error.message : 'Erreur inconnue' + }; + } +}