From 1dfb8f8ac1ebacb4e7d0a5ec3c7b7a26f39d12cc Mon Sep 17 00:00:00 2001 From: Julien Froidefond Date: Fri, 3 Oct 2025 08:37:43 +0200 Subject: [PATCH] feat: enhance HomePage with tag metrics and analytics integration - Added TagAnalyticsService to fetch tag distribution metrics for the HomePage. - Updated HomePageClient and ProductivityAnalytics components to utilize new tag metrics. - Refactored TagsClient to use utility functions for color validation and generation. - Simplified TagForm to use centralized tag colors from TAG_COLORS. --- src/app/page.tsx | 7 +- src/clients/tags-client.ts | 18 +- src/components/HomePageClient.tsx | 10 +- .../dashboard/ProductivityAnalytics.tsx | 8 +- .../dashboard/TagDistributionChart.tsx | 204 +++++++++++++ src/components/forms/TagForm.tsx | 16 +- src/lib/tag-colors.ts | 67 +++++ src/services/analytics/tag-analytics.ts | 274 ++++++++++++++++++ src/services/task-management/tasks.ts | 16 +- 9 files changed, 572 insertions(+), 48 deletions(-) create mode 100644 src/components/dashboard/TagDistributionChart.tsx create mode 100644 src/lib/tag-colors.ts create mode 100644 src/services/analytics/tag-analytics.ts diff --git a/src/app/page.tsx b/src/app/page.tsx index 66801d0..36a73d2 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -2,6 +2,7 @@ import { tasksService } from '@/services/task-management/tasks'; import { tagsService } from '@/services/task-management/tags'; import { AnalyticsService } from '@/services/analytics/analytics'; import { DeadlineAnalyticsService } from '@/services/analytics/deadline-analytics'; +import { TagAnalyticsService } from '@/services/analytics/tag-analytics'; import { HomePageClient } from '@/components/HomePageClient'; // Force dynamic rendering (no static generation) @@ -9,12 +10,13 @@ export const dynamic = 'force-dynamic'; export default async function HomePage() { // SSR - Récupération des données côté serveur - const [initialTasks, initialTags, initialStats, productivityMetrics, deadlineMetrics] = await Promise.all([ + const [initialTasks, initialTags, initialStats, productivityMetrics, deadlineMetrics, tagMetrics] = await Promise.all([ tasksService.getTasks(), tagsService.getTags(), tasksService.getTaskStats(), AnalyticsService.getProductivityMetrics(), - DeadlineAnalyticsService.getDeadlineMetrics() + DeadlineAnalyticsService.getDeadlineMetrics(), + TagAnalyticsService.getTagDistributionMetrics() ]); return ( @@ -24,6 +26,7 @@ export default async function HomePage() { initialStats={initialStats} productivityMetrics={productivityMetrics} deadlineMetrics={deadlineMetrics} + tagMetrics={tagMetrics} /> ); } diff --git a/src/clients/tags-client.ts b/src/clients/tags-client.ts index f2a2a49..6e52bda 100644 --- a/src/clients/tags-client.ts +++ b/src/clients/tags-client.ts @@ -1,5 +1,6 @@ import { HttpClient } from './base/http-client'; import { Tag } from '@/lib/types'; +import { generateRandomTagColor, isValidTagColor } from '@/lib/tag-colors'; // Types pour les requêtes (now only used for validation - CRUD operations moved to server actions) @@ -85,27 +86,14 @@ export class TagsClient extends HttpClient { * Valide le format d'une couleur hexadécimale */ static isValidColor(color: string): boolean { - return /^#[0-9A-F]{6}$/i.test(color); + return isValidTagColor(color); } /** * Génère une couleur aléatoire pour un nouveau tag */ static generateRandomColor(): string { - const colors = [ - '#3B82F6', // Blue - '#EF4444', // Red - '#10B981', // Green - '#F59E0B', // Yellow - '#8B5CF6', // Purple - '#EC4899', // Pink - '#06B6D4', // Cyan - '#84CC16', // Lime - '#F97316', // Orange - '#6366F1', // Indigo - ]; - - return colors[Math.floor(Math.random() * colors.length)]; + return generateRandomTagColor(); } /** diff --git a/src/components/HomePageClient.tsx b/src/components/HomePageClient.tsx index 78f459c..ad74edb 100644 --- a/src/components/HomePageClient.tsx +++ b/src/components/HomePageClient.tsx @@ -13,6 +13,7 @@ import { WelcomeSection } from '@/components/dashboard/WelcomeSection'; import { IntegrationFilter } from '@/components/dashboard/IntegrationFilter'; import { ProductivityMetrics } from '@/services/analytics/analytics'; import { DeadlineMetrics } from '@/services/analytics/deadline-analytics'; +import { TagDistributionMetrics } from '@/services/analytics/tag-analytics'; import { useGlobalKeyboardShortcuts } from '@/hooks/useGlobalKeyboardShortcuts'; interface HomePageClientProps { @@ -21,12 +22,14 @@ interface HomePageClientProps { initialStats: TaskStats; productivityMetrics: ProductivityMetrics; deadlineMetrics: DeadlineMetrics; + tagMetrics: TagDistributionMetrics; } -function HomePageContent({ productivityMetrics, deadlineMetrics }: { +function HomePageContent({ productivityMetrics, deadlineMetrics, tagMetrics }: { productivityMetrics: ProductivityMetrics; deadlineMetrics: DeadlineMetrics; + tagMetrics: TagDistributionMetrics; }) { const { syncing, createTask, tasks } = useTasksContext(); const [selectedSources, setSelectedSources] = useState([]); @@ -83,6 +86,7 @@ function HomePageContent({ productivityMetrics, deadlineMetrics }: { @@ -99,7 +103,8 @@ export function HomePageClient({ initialTags, initialStats, productivityMetrics, - deadlineMetrics + deadlineMetrics, + tagMetrics }: HomePageClientProps) { return ( ); diff --git a/src/components/dashboard/ProductivityAnalytics.tsx b/src/components/dashboard/ProductivityAnalytics.tsx index 334ca38..dc8d97e 100644 --- a/src/components/dashboard/ProductivityAnalytics.tsx +++ b/src/components/dashboard/ProductivityAnalytics.tsx @@ -1,21 +1,24 @@ import { useMemo } from 'react'; import { ProductivityMetrics } from '@/services/analytics/analytics'; import { DeadlineMetrics } from '@/services/analytics/deadline-analytics'; +import { TagDistributionMetrics } from '@/services/analytics/tag-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 { TagDistributionChart } from '@/components/dashboard/TagDistributionChart'; import { Card, MetricCard } from '@/components/ui'; import { DeadlineOverview } from '@/components/deadline/DeadlineOverview'; interface ProductivityAnalyticsProps { metrics: ProductivityMetrics; deadlineMetrics: DeadlineMetrics; + tagMetrics: TagDistributionMetrics; selectedSources: string[]; hiddenSources?: string[]; } -export function ProductivityAnalytics({ metrics, deadlineMetrics, selectedSources, hiddenSources = [] }: ProductivityAnalyticsProps) { +export function ProductivityAnalytics({ metrics, deadlineMetrics, tagMetrics, selectedSources, hiddenSources = [] }: ProductivityAnalyticsProps) { // Filtrer les métriques selon les sources sélectionnées const filteredMetrics = useMemo(() => { @@ -133,6 +136,9 @@ export function ProductivityAnalytics({ metrics, deadlineMetrics, selectedSource + {/* Distribution par Tags */} + + {/* Insights automatiques */}

💡 Insights

diff --git a/src/components/dashboard/TagDistributionChart.tsx b/src/components/dashboard/TagDistributionChart.tsx new file mode 100644 index 0000000..f31440a --- /dev/null +++ b/src/components/dashboard/TagDistributionChart.tsx @@ -0,0 +1,204 @@ +'use client'; + +import { Card } from '@/components/ui/Card'; +import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip, Legend, BarChart, Bar, XAxis, YAxis, CartesianGrid, PieLabelRenderProps } from 'recharts'; +import { TagDistributionMetrics } from '@/services/analytics/tag-analytics'; + +interface TagDistributionChartProps { + metrics: TagDistributionMetrics; + className?: string; +} + +export function TagDistributionChart({ metrics, className }: TagDistributionChartProps) { + // Préparer les données pour le graphique en camembert + const pieData = metrics.tagDistribution.slice(0, 8).map((tag) => ({ + name: tag.tagName, + value: tag.count, + percentage: tag.percentage, + color: tag.tagColor + })); + + // Préparer les données pour le graphique en barres (top tags) + const barData = metrics.topTags.slice(0, 10).map(tag => ({ + name: tag.tagName.length > 12 ? `${tag.tagName.substring(0, 12)}...` : tag.tagName, + usage: tag.usage, + completionRate: tag.completionRate, + avgPriority: tag.avgPriority, + color: tag.tagColor + })); + + // Tooltip personnalisé pour le camembert + const PieTooltip = ({ active, payload }: { active?: boolean; payload?: Array<{ payload: { name: string; value: number; percentage: number } }> }) => { + if (active && payload && payload.length) { + const data = payload[0].payload; + return ( +
+

{data.name}

+

+ {data.value} tâches ({data.percentage.toFixed(1)}%) +

+
+ ); + } + return null; + }; + + // Tooltip personnalisé pour les barres + const BarTooltip = ({ active, payload }: { active?: boolean; payload?: Array<{ payload: { name: string; usage: number; completionRate: number } }> }) => { + if (active && payload && payload.length) { + const data = payload[0].payload; + return ( +
+

{data.name}

+

+ {data.usage} tâches +

+

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

+
+ ); + } + return null; + }; + + // Légende personnalisée + const CustomLegend = ({ payload }: { payload?: Array<{ value: string; color: string }> }) => { + if (!payload) return null; + + return ( +
+ {payload.map((entry, index) => ( +
+
+ {entry.value} +
+ ))} +
+ ); + }; + + return ( +
+
+ {/* Distribution par tags - Camembert */} + +

🏷️ Distribution par Tags

+ +
+ + + { + const { name, percent } = props; + const percentValue = typeof percent === 'number' ? percent : 0; + return percentValue > 0.05 ? `${name}: ${(percentValue * 100).toFixed(1)}%` : ''; + }} + outerRadius={80} + fill="#8884d8" + dataKey="value" + nameKey="name" + > + {pieData.map((entry, index) => ( + + ))} + + } /> + } /> + + +
+
+ + {/* Top tags - Barres */} + +

📊 Top Tags par Usage

+ +
+ + + + + + } /> + + {barData.map((entry, index) => ( + + ))} + + + +
+
+
+ + {/* Statistiques des tags */} + +

📈 Statistiques des Tags

+ +
+
+
+ {metrics.tagStats.totalTags} +
+
+ Tags totaux +
+
+ +
+
+ {metrics.tagStats.activeTags} +
+
+ Tags actifs +
+
+ +
+
+ {metrics.tagStats.mostUsedTag} +
+
+ Plus utilisé +
+
+ +
+
+ {metrics.tagStats.leastUsedTag} +
+
+ Moins utilisé +
+
+ +
+
+ {metrics.tagStats.avgTasksPerTag.toFixed(1)} +
+
+ Moy. tâches/tag +
+
+
+
+
+ ); +} diff --git a/src/components/forms/TagForm.tsx b/src/components/forms/TagForm.tsx index 65eb23e..e328296 100644 --- a/src/components/forms/TagForm.tsx +++ b/src/components/forms/TagForm.tsx @@ -3,6 +3,7 @@ import { useState, useEffect, useTransition } from 'react'; import { Tag } from '@/lib/types'; import { TagsClient } from '@/clients/tags-client'; +import { TAG_COLORS } from '@/lib/tag-colors'; import { Modal } from '@/components/ui/Modal'; import { Input } from '@/components/ui/Input'; import { Button } from '@/components/ui/Button'; @@ -15,20 +16,7 @@ interface TagFormProps { tag?: Tag | null; // Si fourni, mode édition } -const PRESET_COLORS = [ - '#3B82F6', // Blue - '#EF4444', // Red - '#10B981', // Green - '#F59E0B', // Yellow - '#8B5CF6', // Purple - '#EC4899', // Pink - '#06B6D4', // Cyan - '#84CC16', // Lime - '#F97316', // Orange - '#6366F1', // Indigo - '#14B8A6', // Teal - '#F43F5E', // Rose -]; +const PRESET_COLORS = TAG_COLORS; export function TagForm({ isOpen, onClose, onSuccess, tag }: TagFormProps) { const [isPending, startTransition] = useTransition(); diff --git a/src/lib/tag-colors.ts b/src/lib/tag-colors.ts new file mode 100644 index 0000000..9625e97 --- /dev/null +++ b/src/lib/tag-colors.ts @@ -0,0 +1,67 @@ +/** + * Lib centralisée pour la gestion des couleurs des tags + */ + +// Couleurs prédéfinies pour les tags (cohérentes avec TagForm et TagsClient) +export const TAG_COLORS = [ + '#3B82F6', // Blue + '#EF4444', // Red + '#10B981', // Green + '#F59E0B', // Yellow + '#8B5CF6', // Purple + '#EC4899', // Pink + '#06B6D4', // Cyan + '#84CC16', // Lime + '#F97316', // Orange + '#6366F1', // Indigo + '#14B8A6', // Teal + '#F43F5E', // Rose + '#22C55E', // Emerald + '#EAB308', // Amber + '#A855F7', // Violet + '#D946EF', // Fuchsia +] as const; + +/** + * Génère une couleur pour un tag basée sur son nom (hash déterministe) + */ +export function generateTagColor(tagName: string): string { + // Hash simple du nom pour choisir une couleur + let hash = 0; + for (let i = 0; i < tagName.length; i++) { + hash = tagName.charCodeAt(i) + ((hash << 5) - hash); + } + + return TAG_COLORS[Math.abs(hash) % TAG_COLORS.length]; +} + +/** + * Génère une couleur aléatoire pour un nouveau tag + */ +export function generateRandomTagColor(): string { + return TAG_COLORS[Math.floor(Math.random() * TAG_COLORS.length)]; +} + +/** + * Valide si une couleur est valide (format hex) + */ +export function isValidTagColor(color: string): boolean { + return /^#[0-9A-F]{6}$/i.test(color); +} + +/** + * Récupère la couleur d'un tag, en générant une couleur par défaut si nécessaire + */ +export function getTagColor(tagName: string, existingColor?: string): string { + if (existingColor && isValidTagColor(existingColor)) { + return existingColor; + } + return generateTagColor(tagName); +} + +/** + * Génère un tableau de couleurs pour les graphiques en respectant les couleurs des tags + */ +export function generateChartColors(tags: Array<{ name: string; color?: string }>): string[] { + return tags.map(tag => getTagColor(tag.name, tag.color)); +} diff --git a/src/services/analytics/tag-analytics.ts b/src/services/analytics/tag-analytics.ts new file mode 100644 index 0000000..9f3f063 --- /dev/null +++ b/src/services/analytics/tag-analytics.ts @@ -0,0 +1,274 @@ +import { prisma } from '@/services/core/database'; +import { Task, TaskStatus, TaskPriority, TaskSource } from '@/lib/types'; +import { getToday, subtractDays } from '@/lib/date-utils'; +import { getTagColor } from '@/lib/tag-colors'; + +export interface TagDistributionMetrics { + tagDistribution: Array<{ + tagName: string; + tagColor: string; + count: number; + percentage: number; + tasks: Array<{ + id: string; + title: string; + status: TaskStatus; + priority: TaskPriority; + }>; + }>; + topTags: Array<{ + tagName: string; + tagColor: string; + usage: number; + completionRate: number; + avgPriority: number; + }>; + tagTrends: Array<{ + date: string; + tagName: string; + count: number; + }>; + tagStats: { + totalTags: number; + activeTags: number; + mostUsedTag: string; + leastUsedTag: string; + avgTasksPerTag: number; + }; +} + +export interface TimeRange { + start: Date; + end: Date; +} + +export class TagAnalyticsService { + /** + * Calcule les métriques de distribution par tags + */ + static async getTagDistributionMetrics( + timeRange?: TimeRange, + sources?: string[] + ): Promise { + try { + const now = getToday(); + const defaultStart = subtractDays(now, 30); // 30 jours + + const start = timeRange?.start || defaultStart; + const end = timeRange?.end || now; + + // Récupérer toutes les tâches avec leurs tags + const dbTasks = await prisma.task.findMany({ + where: { + createdAt: { + gte: start, + lte: end + } + }, + include: { + taskTags: { + include: { + tag: true + } + } + } + }); + + // Récupérer aussi tous les tags pour avoir leurs couleurs + const allTags = await prisma.tag.findMany({ + select: { + name: true, + color: true + } + }); + + const tagColorMap = new Map( + allTags.map(tag => [tag.name, tag.color]) + ); + + // Convertir en format Task + let 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 + })); + + // Filtrer par sources si spécifié + if (sources && sources.length > 0) { + tasks = tasks.filter(task => sources.includes(task.source)); + } + + return { + tagDistribution: this.calculateTagDistribution(tasks, tagColorMap), + topTags: this.calculateTopTags(tasks, tagColorMap), + tagTrends: this.calculateTagTrends(tasks), + tagStats: this.calculateTagStats(tasks) + }; + } catch (error) { + console.error('Erreur lors du calcul des métriques de tags:', error); + throw new Error('Impossible de calculer les métriques de distribution par tags'); + } + } + + /** + * Calcule la distribution des tags + */ + private static calculateTagDistribution(tasks: Task[], tagColorMap: Map) { + const tagCounts = new Map(); + + // Compter les tâches par tag + tasks.forEach(task => { + task.tags.forEach(tagName => { + if (!tagCounts.has(tagName)) { + const tagColor = getTagColor(tagName, tagColorMap.get(tagName)); + tagCounts.set(tagName, { color: tagColor, tasks: [] }); + } + tagCounts.get(tagName)!.tasks.push(task); + }); + }); + + const totalTasks = tasks.length; + + return Array.from(tagCounts.entries()) + .map(([tagName, data]) => ({ + tagName, + tagColor: data.color, + count: data.tasks.length, + percentage: totalTasks > 0 ? (data.tasks.length / totalTasks) * 100 : 0, + tasks: data.tasks.map(task => ({ + id: task.id, + title: task.title, + status: task.status, + priority: task.priority + })) + })) + .sort((a, b) => b.count - a.count); + } + + /** + * Calcule les tags les plus utilisés avec métriques + */ + private static calculateTopTags(tasks: Task[], tagColorMap: Map) { + const tagMetrics = new Map(); + + // Calculer les métriques par tag + tasks.forEach(task => { + task.tags.forEach(tagName => { + if (!tagMetrics.has(tagName)) { + const tagColor = getTagColor(tagName, tagColorMap.get(tagName)); + tagMetrics.set(tagName, { + color: tagColor, + totalTasks: 0, + completedTasks: 0, + prioritySum: 0, + priorityCount: 0 + }); + } + + const metrics = tagMetrics.get(tagName)!; + metrics.totalTasks++; + + if (task.status === 'done') { + metrics.completedTasks++; + } + + // Convertir la priorité en nombre pour le calcul de moyenne + const priorityValue = task.priority === 'high' ? 3 : + task.priority === 'medium' ? 2 : 1; + metrics.prioritySum += priorityValue; + metrics.priorityCount++; + }); + }); + + return Array.from(tagMetrics.entries()) + .map(([tagName, metrics]) => ({ + tagName, + tagColor: metrics.color, + usage: metrics.totalTasks, + completionRate: metrics.totalTasks > 0 ? + (metrics.completedTasks / metrics.totalTasks) * 100 : 0, + avgPriority: metrics.priorityCount > 0 ? + metrics.prioritySum / metrics.priorityCount : 0 + })) + .sort((a, b) => b.usage - a.usage) + .slice(0, 10); // Top 10 + } + + /** + * Calcule les tendances des tags dans le temps + */ + private static calculateTagTrends(tasks: Task[]) { + const trends = new Map>(); + + // Grouper par jour et par tag + tasks.forEach(task => { + const day = task.createdAt.toISOString().split('T')[0]; + task.tags.forEach(tagName => { + if (!trends.has(tagName)) { + trends.set(tagName, new Map()); + } + const dayMap = trends.get(tagName)!; + dayMap.set(day, (dayMap.get(day) || 0) + 1); + }); + }); + + // Convertir en format pour le graphique + const result: Array<{ date: string; tagName: string; count: number }> = []; + trends.forEach((dayMap, tagName) => { + dayMap.forEach((count, date) => { + result.push({ date, tagName, count }); + }); + }); + + return result.sort((a, b) => a.date.localeCompare(b.date)); + } + + /** + * Calcule les statistiques générales des tags + */ + private static calculateTagStats(tasks: Task[]) { + const allTags = new Set(); + const tagUsage = new Map(); + + tasks.forEach(task => { + task.tags.forEach(tagName => { + allTags.add(tagName); + tagUsage.set(tagName, (tagUsage.get(tagName) || 0) + 1); + }); + }); + + const usageValues = Array.from(tagUsage.values()); + const mostUsedTag = Array.from(tagUsage.entries()) + .sort((a, b) => b[1] - a[1])[0]?.[0] || 'Aucun'; + const leastUsedTag = Array.from(tagUsage.entries()) + .sort((a, b) => a[1] - b[1])[0]?.[0] || 'Aucun'; + + return { + totalTags: allTags.size, + activeTags: Array.from(tagUsage.values()).filter(count => count > 0).length, + mostUsedTag, + leastUsedTag, + avgTasksPerTag: allTags.size > 0 ? + usageValues.reduce((sum, count) => sum + count, 0) / allTags.size : 0 + }; + } +} diff --git a/src/services/task-management/tasks.ts b/src/services/task-management/tasks.ts index df8c211..f6b8181 100644 --- a/src/services/task-management/tasks.ts +++ b/src/services/task-management/tasks.ts @@ -2,6 +2,7 @@ import { prisma } from '@/services/core/database'; import { Task, TaskStatus, TaskPriority, TaskSource, BusinessError, DailyCheckbox, DailyCheckboxType, Tag } from '@/lib/types'; import { Prisma } from '@prisma/client'; import { getToday } from '@/lib/date-utils'; +import { generateTagColor } from '@/lib/tag-colors'; /** * Service pour la gestion des tâches (version standalone) @@ -336,20 +337,7 @@ export class TasksService { * Génère une couleur pour un tag basée sur son nom */ private generateTagColor(tagName: string): string { - const colors = [ - '#ef4444', '#f97316', '#f59e0b', '#eab308', - '#84cc16', '#22c55e', '#10b981', '#14b8a6', - '#06b6d4', '#0ea5e9', '#3b82f6', '#6366f1', - '#8b5cf6', '#a855f7', '#d946ef', '#ec4899' - ]; - - // Hash simple du nom pour choisir une couleur - let hash = 0; - for (let i = 0; i < tagName.length; i++) { - hash = tagName.charCodeAt(i) + ((hash << 5) - hash); - } - - return colors[Math.abs(hash) % colors.length]; + return generateTagColor(tagName); } /**