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:
@@ -2,6 +2,7 @@ import { tasksService } from '@/services/task-management/tasks';
|
|||||||
import { tagsService } from '@/services/task-management/tags';
|
import { tagsService } from '@/services/task-management/tags';
|
||||||
import { AnalyticsService } from '@/services/analytics/analytics';
|
import { AnalyticsService } from '@/services/analytics/analytics';
|
||||||
import { DeadlineAnalyticsService } from '@/services/analytics/deadline-analytics';
|
import { DeadlineAnalyticsService } from '@/services/analytics/deadline-analytics';
|
||||||
|
import { TagAnalyticsService } from '@/services/analytics/tag-analytics';
|
||||||
import { HomePageClient } from '@/components/HomePageClient';
|
import { HomePageClient } from '@/components/HomePageClient';
|
||||||
|
|
||||||
// Force dynamic rendering (no static generation)
|
// Force dynamic rendering (no static generation)
|
||||||
@@ -9,12 +10,13 @@ export const dynamic = 'force-dynamic';
|
|||||||
|
|
||||||
export default async function HomePage() {
|
export default async function HomePage() {
|
||||||
// SSR - Récupération des données côté serveur
|
// 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(),
|
tasksService.getTasks(),
|
||||||
tagsService.getTags(),
|
tagsService.getTags(),
|
||||||
tasksService.getTaskStats(),
|
tasksService.getTaskStats(),
|
||||||
AnalyticsService.getProductivityMetrics(),
|
AnalyticsService.getProductivityMetrics(),
|
||||||
DeadlineAnalyticsService.getDeadlineMetrics()
|
DeadlineAnalyticsService.getDeadlineMetrics(),
|
||||||
|
TagAnalyticsService.getTagDistributionMetrics()
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -24,6 +26,7 @@ export default async function HomePage() {
|
|||||||
initialStats={initialStats}
|
initialStats={initialStats}
|
||||||
productivityMetrics={productivityMetrics}
|
productivityMetrics={productivityMetrics}
|
||||||
deadlineMetrics={deadlineMetrics}
|
deadlineMetrics={deadlineMetrics}
|
||||||
|
tagMetrics={tagMetrics}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { HttpClient } from './base/http-client';
|
import { HttpClient } from './base/http-client';
|
||||||
import { Tag } from '@/lib/types';
|
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)
|
// 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
|
* Valide le format d'une couleur hexadécimale
|
||||||
*/
|
*/
|
||||||
static isValidColor(color: string): boolean {
|
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
|
* Génère une couleur aléatoire pour un nouveau tag
|
||||||
*/
|
*/
|
||||||
static generateRandomColor(): string {
|
static generateRandomColor(): string {
|
||||||
const colors = [
|
return generateRandomTagColor();
|
||||||
'#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)];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { WelcomeSection } from '@/components/dashboard/WelcomeSection';
|
|||||||
import { IntegrationFilter } from '@/components/dashboard/IntegrationFilter';
|
import { IntegrationFilter } from '@/components/dashboard/IntegrationFilter';
|
||||||
import { ProductivityMetrics } from '@/services/analytics/analytics';
|
import { ProductivityMetrics } from '@/services/analytics/analytics';
|
||||||
import { DeadlineMetrics } from '@/services/analytics/deadline-analytics';
|
import { DeadlineMetrics } from '@/services/analytics/deadline-analytics';
|
||||||
|
import { TagDistributionMetrics } from '@/services/analytics/tag-analytics';
|
||||||
import { useGlobalKeyboardShortcuts } from '@/hooks/useGlobalKeyboardShortcuts';
|
import { useGlobalKeyboardShortcuts } from '@/hooks/useGlobalKeyboardShortcuts';
|
||||||
|
|
||||||
interface HomePageClientProps {
|
interface HomePageClientProps {
|
||||||
@@ -21,12 +22,14 @@ interface HomePageClientProps {
|
|||||||
initialStats: TaskStats;
|
initialStats: TaskStats;
|
||||||
productivityMetrics: ProductivityMetrics;
|
productivityMetrics: ProductivityMetrics;
|
||||||
deadlineMetrics: DeadlineMetrics;
|
deadlineMetrics: DeadlineMetrics;
|
||||||
|
tagMetrics: TagDistributionMetrics;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function HomePageContent({ productivityMetrics, deadlineMetrics }: {
|
function HomePageContent({ productivityMetrics, deadlineMetrics, tagMetrics }: {
|
||||||
productivityMetrics: ProductivityMetrics;
|
productivityMetrics: ProductivityMetrics;
|
||||||
deadlineMetrics: DeadlineMetrics;
|
deadlineMetrics: DeadlineMetrics;
|
||||||
|
tagMetrics: TagDistributionMetrics;
|
||||||
}) {
|
}) {
|
||||||
const { syncing, createTask, tasks } = useTasksContext();
|
const { syncing, createTask, tasks } = useTasksContext();
|
||||||
const [selectedSources, setSelectedSources] = useState<string[]>([]);
|
const [selectedSources, setSelectedSources] = useState<string[]>([]);
|
||||||
@@ -83,6 +86,7 @@ function HomePageContent({ productivityMetrics, deadlineMetrics }: {
|
|||||||
<ProductivityAnalytics
|
<ProductivityAnalytics
|
||||||
metrics={productivityMetrics}
|
metrics={productivityMetrics}
|
||||||
deadlineMetrics={deadlineMetrics}
|
deadlineMetrics={deadlineMetrics}
|
||||||
|
tagMetrics={tagMetrics}
|
||||||
selectedSources={selectedSources}
|
selectedSources={selectedSources}
|
||||||
hiddenSources={hiddenSources}
|
hiddenSources={hiddenSources}
|
||||||
/>
|
/>
|
||||||
@@ -99,7 +103,8 @@ export function HomePageClient({
|
|||||||
initialTags,
|
initialTags,
|
||||||
initialStats,
|
initialStats,
|
||||||
productivityMetrics,
|
productivityMetrics,
|
||||||
deadlineMetrics
|
deadlineMetrics,
|
||||||
|
tagMetrics
|
||||||
}: HomePageClientProps) {
|
}: HomePageClientProps) {
|
||||||
return (
|
return (
|
||||||
<TasksProvider
|
<TasksProvider
|
||||||
@@ -110,6 +115,7 @@ export function HomePageClient({
|
|||||||
<HomePageContent
|
<HomePageContent
|
||||||
productivityMetrics={productivityMetrics}
|
productivityMetrics={productivityMetrics}
|
||||||
deadlineMetrics={deadlineMetrics}
|
deadlineMetrics={deadlineMetrics}
|
||||||
|
tagMetrics={tagMetrics}
|
||||||
/>
|
/>
|
||||||
</TasksProvider>
|
</TasksProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,21 +1,24 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { ProductivityMetrics } from '@/services/analytics/analytics';
|
import { ProductivityMetrics } from '@/services/analytics/analytics';
|
||||||
import { DeadlineMetrics } from '@/services/analytics/deadline-analytics';
|
import { DeadlineMetrics } from '@/services/analytics/deadline-analytics';
|
||||||
|
import { TagDistributionMetrics } from '@/services/analytics/tag-analytics';
|
||||||
import { CompletionTrendChart } from '@/components/charts/CompletionTrendChart';
|
import { CompletionTrendChart } from '@/components/charts/CompletionTrendChart';
|
||||||
import { VelocityChart } from '@/components/charts/VelocityChart';
|
import { VelocityChart } from '@/components/charts/VelocityChart';
|
||||||
import { PriorityDistributionChart } from '@/components/charts/PriorityDistributionChart';
|
import { PriorityDistributionChart } from '@/components/charts/PriorityDistributionChart';
|
||||||
import { WeeklyStatsCard } from '@/components/charts/WeeklyStatsCard';
|
import { WeeklyStatsCard } from '@/components/charts/WeeklyStatsCard';
|
||||||
|
import { TagDistributionChart } from '@/components/dashboard/TagDistributionChart';
|
||||||
import { Card, MetricCard } from '@/components/ui';
|
import { Card, MetricCard } from '@/components/ui';
|
||||||
import { DeadlineOverview } from '@/components/deadline/DeadlineOverview';
|
import { DeadlineOverview } from '@/components/deadline/DeadlineOverview';
|
||||||
|
|
||||||
interface ProductivityAnalyticsProps {
|
interface ProductivityAnalyticsProps {
|
||||||
metrics: ProductivityMetrics;
|
metrics: ProductivityMetrics;
|
||||||
deadlineMetrics: DeadlineMetrics;
|
deadlineMetrics: DeadlineMetrics;
|
||||||
|
tagMetrics: TagDistributionMetrics;
|
||||||
selectedSources: string[];
|
selectedSources: string[];
|
||||||
hiddenSources?: 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
|
// Filtrer les métriques selon les sources sélectionnées
|
||||||
const filteredMetrics = useMemo(() => {
|
const filteredMetrics = useMemo(() => {
|
||||||
@@ -133,6 +136,9 @@ export function ProductivityAnalytics({ metrics, deadlineMetrics, selectedSource
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Distribution par Tags */}
|
||||||
|
<TagDistributionChart metrics={tagMetrics} />
|
||||||
|
|
||||||
{/* Insights automatiques */}
|
{/* Insights automatiques */}
|
||||||
<Card className="p-6">
|
<Card className="p-6">
|
||||||
<h3 className="text-lg font-semibold mb-4">💡 Insights</h3>
|
<h3 className="text-lg font-semibold mb-4">💡 Insights</h3>
|
||||||
|
|||||||
204
src/components/dashboard/TagDistributionChart.tsx
Normal file
204
src/components/dashboard/TagDistributionChart.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="bg-[var(--card)] border border-[var(--border)] rounded-lg p-3 shadow-lg">
|
||||||
|
<p className="font-medium mb-1">{data.name}</p>
|
||||||
|
<p className="text-sm text-[var(--foreground)]">
|
||||||
|
{data.value} tâches ({data.percentage.toFixed(1)}%)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
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 (
|
||||||
|
<div className="bg-[var(--card)] border border-[var(--border)] rounded-lg p-3 shadow-lg">
|
||||||
|
<p className="font-medium mb-1">{data.name}</p>
|
||||||
|
<p className="text-sm text-[var(--foreground)]">
|
||||||
|
{data.usage} tâches
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-[var(--muted-foreground)]">
|
||||||
|
Taux de completion: {data.completionRate.toFixed(1)}%
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Légende personnalisée
|
||||||
|
const CustomLegend = ({ payload }: { payload?: Array<{ value: string; color: string }> }) => {
|
||||||
|
if (!payload) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-2 mt-4">
|
||||||
|
{payload.map((entry, index) => (
|
||||||
|
<div key={index} className="flex items-center gap-2 text-sm">
|
||||||
|
<div
|
||||||
|
className="w-3 h-3 rounded-full"
|
||||||
|
style={{ backgroundColor: entry.color }}
|
||||||
|
/>
|
||||||
|
<span className="text-[var(--foreground)]">{entry.value}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
{/* Distribution par tags - Camembert */}
|
||||||
|
<Card className="p-6">
|
||||||
|
<h3 className="text-lg font-semibold mb-4">🏷️ Distribution par Tags</h3>
|
||||||
|
|
||||||
|
<div className="h-64">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={pieData}
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
labelLine={false}
|
||||||
|
label={(props: PieLabelRenderProps) => {
|
||||||
|
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) => (
|
||||||
|
<Cell key={`cell-${index}`} fill={entry.color} />
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip content={<PieTooltip />} />
|
||||||
|
<Legend content={<CustomLegend />} />
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Top tags - Barres */}
|
||||||
|
<Card className="p-6">
|
||||||
|
<h3 className="text-lg font-semibold mb-4">📊 Top Tags par Usage</h3>
|
||||||
|
|
||||||
|
<div className="h-64">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart data={barData} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="name"
|
||||||
|
tick={{ fontSize: 12 }}
|
||||||
|
angle={-45}
|
||||||
|
textAnchor="end"
|
||||||
|
height={80}
|
||||||
|
/>
|
||||||
|
<YAxis tick={{ fontSize: 12 }} />
|
||||||
|
<Tooltip content={<BarTooltip />} />
|
||||||
|
<Bar
|
||||||
|
dataKey="usage"
|
||||||
|
radius={[4, 4, 0, 0]}
|
||||||
|
>
|
||||||
|
{barData.map((entry, index) => (
|
||||||
|
<Cell key={`cell-${index}`} fill={entry.color} />
|
||||||
|
))}
|
||||||
|
</Bar>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Statistiques des tags */}
|
||||||
|
<Card className="p-6 mt-6">
|
||||||
|
<h3 className="text-lg font-semibold mb-4">📈 Statistiques des Tags</h3>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-2xl font-bold text-[var(--primary)]">
|
||||||
|
{metrics.tagStats.totalTags}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-[var(--muted-foreground)]">
|
||||||
|
Tags totaux
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-2xl font-bold text-[var(--success)]">
|
||||||
|
{metrics.tagStats.activeTags}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-[var(--muted-foreground)]">
|
||||||
|
Tags actifs
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-lg font-semibold text-[var(--foreground)] truncate">
|
||||||
|
{metrics.tagStats.mostUsedTag}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-[var(--muted-foreground)]">
|
||||||
|
Plus utilisé
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-lg font-semibold text-[var(--foreground)] truncate">
|
||||||
|
{metrics.tagStats.leastUsedTag}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-[var(--muted-foreground)]">
|
||||||
|
Moins utilisé
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-2xl font-bold text-[var(--accent)]">
|
||||||
|
{metrics.tagStats.avgTasksPerTag.toFixed(1)}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-[var(--muted-foreground)]">
|
||||||
|
Moy. tâches/tag
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useState, useEffect, useTransition } from 'react';
|
import { useState, useEffect, useTransition } from 'react';
|
||||||
import { Tag } from '@/lib/types';
|
import { Tag } from '@/lib/types';
|
||||||
import { TagsClient } from '@/clients/tags-client';
|
import { TagsClient } from '@/clients/tags-client';
|
||||||
|
import { TAG_COLORS } from '@/lib/tag-colors';
|
||||||
import { Modal } from '@/components/ui/Modal';
|
import { Modal } from '@/components/ui/Modal';
|
||||||
import { Input } from '@/components/ui/Input';
|
import { Input } from '@/components/ui/Input';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
@@ -15,20 +16,7 @@ interface TagFormProps {
|
|||||||
tag?: Tag | null; // Si fourni, mode édition
|
tag?: Tag | null; // Si fourni, mode édition
|
||||||
}
|
}
|
||||||
|
|
||||||
const PRESET_COLORS = [
|
const PRESET_COLORS = 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
|
|
||||||
];
|
|
||||||
|
|
||||||
export function TagForm({ isOpen, onClose, onSuccess, tag }: TagFormProps) {
|
export function TagForm({ isOpen, onClose, onSuccess, tag }: TagFormProps) {
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|||||||
67
src/lib/tag-colors.ts
Normal file
67
src/lib/tag-colors.ts
Normal file
@@ -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));
|
||||||
|
}
|
||||||
274
src/services/analytics/tag-analytics.ts
Normal file
274
src/services/analytics/tag-analytics.ts
Normal 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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { prisma } from '@/services/core/database';
|
|||||||
import { Task, TaskStatus, TaskPriority, TaskSource, BusinessError, DailyCheckbox, DailyCheckboxType, Tag } from '@/lib/types';
|
import { Task, TaskStatus, TaskPriority, TaskSource, BusinessError, DailyCheckbox, DailyCheckboxType, Tag } from '@/lib/types';
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import { getToday } from '@/lib/date-utils';
|
import { getToday } from '@/lib/date-utils';
|
||||||
|
import { generateTagColor } from '@/lib/tag-colors';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Service pour la gestion des tâches (version standalone)
|
* 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
|
* Génère une couleur pour un tag basée sur son nom
|
||||||
*/
|
*/
|
||||||
private generateTagColor(tagName: string): string {
|
private generateTagColor(tagName: string): string {
|
||||||
const colors = [
|
return generateTagColor(tagName);
|
||||||
'#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];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user