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.
This commit is contained in:
Julien Froidefond
2025-10-03 08:37:43 +02:00
parent 735070dd6f
commit 1dfb8f8ac1
9 changed files with 572 additions and 48 deletions

View File

@@ -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<TagDistributionMetrics> {
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<string, string>) {
const tagCounts = new Map<string, { color: string; tasks: Task[] }>();
// 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<string, string>) {
const tagMetrics = new Map<string, {
color: string;
totalTasks: number;
completedTasks: number;
prioritySum: number;
priorityCount: number;
}>();
// 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<string, Map<string, number>>();
// 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<string>();
const tagUsage = new Map<string, number>();
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
};
}
}