9 Commits

Author SHA1 Message Date
Julien Froidefond
339661aa13 feat: metrics on Manager page 2025-09-19 17:05:13 +02:00
Julien Froidefond
9d0b6da3a0 refactor: remove deprecated weekly summary components and related services
- Deleted `WeeklySummaryClient`, `VelocityMetrics`, `PeriodSelector`, and associated services to streamline the codebase.
- Removed the `weekly-summary` API route and related PDF export functionality, as these features are no longer in use.
- Updated `TODO.md` to reflect the removal of these components and their functionalities.
2025-09-19 15:26:20 +02:00
Julien Froidefond
888e81d15d feat: add 'Manager' link to Header component
- Introduced a new navigation link for the 'Manager' page in the Header component, improving user access to management features.
2025-09-19 15:07:04 +02:00
Julien Froidefond
c4d8bacd97 feat: enhance GeneralSettingsPage with tag management
- Added tag management functionality to the `GeneralSettingsPageClient`, including filtering, sorting, and CRUD operations for tags.
- Integrated a modal for creating and editing tags, improving user experience in managing task labels.
- Updated the `Header` component to replace the 'Tags' link with 'Manager'.
- Removed the deprecated `TagsPage` and `TagsPageClient` components to streamline the codebase.
- Adjusted data fetching in `page.tsx` to include initial tags alongside user preferences.
2025-09-19 13:34:15 +02:00
Julien Froidefond
d6722e90d1 fix: correct date formatting in VelocityMetrics component
- Updated date formatting in the `VelocityMetrics` component to ensure proper conversion of `week.weekStart` to a Date object before calling `toLocaleDateString`. This change improves the reliability of date display in the dashboard.
2025-09-19 12:37:15 +02:00
Julien Froidefond
f16ae2e017 chore: add backups directory to docker-compose
- Included a volume mapping for the `./backups` directory in the docker-compose file to facilitate backup management.
2025-09-19 12:30:17 +02:00
Julien Froidefond
fded7d0078 feat: add weekly summary features and components
- Introduced `CategoryBreakdown`, `JiraWeeklyMetrics`, `PeriodSelector`, and `VelocityMetrics` components to enhance the weekly summary dashboard.
- Updated `WeeklySummaryClient` to manage period selection and PDF export functionality.
- Enhanced `WeeklySummaryService` to support period comparisons and activity categorization.
- Added new API route for fetching weekly summary data based on selected period.
- Updated `package.json` and `package-lock.json` to include `jspdf` and related types for PDF generation.
- Marked several tasks as complete in `TODO.md` to reflect progress on summary features.
2025-09-19 12:28:11 +02:00
Julien Froidefond
f9c0035c82 chore : no db in git by default 2025-09-19 10:51:35 +02:00
Julien Froidefond
dfeac94993 chore: clean up seed files with generic data 2025-09-19 10:36:28 +02:00
35 changed files with 3957 additions and 873 deletions

1
.gitignore vendored
View File

@@ -41,5 +41,6 @@ yarn-error.log*
next-env.d.ts
/src/generated/prisma
/prisma/dev.db
backups/

128
TODO.md
View File

@@ -305,134 +305,6 @@ Endpoints complexes → API Routes conservées
- [x] Vue détaillée par sprint avec drill-down
- [x] ~~Intégration avec les daily notes (mentions des blockers)~~ (supprimé)
## 📊 Phase 5.6: Résumé hebdomadaire pour Individual Review (EN COURS)
### 5.6.1 Fonctionnalités de base (TERMINÉ)
- [x] Vue résumé des 7 derniers jours (daily items + tâches)
- [x] Statistiques globales (completion rates, jour le plus productif)
- [x] Timeline chronologique des activités
- [x] Filtrage par jour de la semaine
- [x] Architecture SSR pour performance optimale
### 5.6.2 Améliorations pour l'Individual Review Manager 🎯
- [ ] **Métriques de performance personnelles**
- [ ] Vélocité hebdomadaire (tasks completed/week)
- [ ] Temps moyen de completion des tâches
- [ ] Répartition par priorité (high/medium/low tasks)
- [ ] Taux de respect des deadlines
- [ ] Evolution des performances sur 4 semaines (tendance)
- [ ] **Catégorisation des activités professionnelles**
- [ ] Auto-tagging par type : "Development", "Meetings", "Documentation", "Code Review"
- [ ] Répartition temps par catégorie (% dev vs meetings vs admin)
- [ ] Identification des "deep work" sessions vs interruptions
- [ ] Tracking des objectifs OKRs/KPIs assignés
- [ ] **Visualisations pour manager**
- [ ] Graphique en aires : progression hebdomadaire
- [ ] Heatmap de productivité : heures/jours les plus productifs
- [ ] Radar chart : compétences/domaines travaillés
- [ ] Burndown chart personnel : objectifs vs réalisé
- [ ] **Rapport automatique formaté**
- [ ] Export PDF professionnel avec métriques
- [ ] Template "Weekly Accomplishments" pré-rempli
- [ ] Bullet points des principales réalisations
- [ ] Section "Challenges & Blockers" automatique
- [ ] Recommandations d'amélioration basées sur les patterns
- [ ] **Contexte business et impact**
- [ ] Liaison tâches → tickets Jira → business value
- [ ] Calcul d'impact estimé (story points, business priority)
- [ ] Suivi des initiatives stratégiques
- [ ] Corrélation avec les métriques d'équipe
- [ ] **Intelligence et insights**
- [ ] Détection patterns de productivité personnels
- [ ] Suggestions d'optimisation du planning
- [ ] Alertes sur la charge de travail excessive
- [ ] Comparaison avec moyennes d'équipe (anonyme)
- [ ] Prédiction de capacity pour la semaine suivante
- [ ] **Fonctionnalités avancées pour 1-on-1**
- [ ] Mode "Manager View" : vue consolidée pour discussions
- [ ] Annotations et notes privées sur les réalisations
- [ ] Objectifs SMART tracking avec progress bars
- [ ] Archivage des reviews précédentes pour suivi long terme
- [ ] Templates de questions pour auto-reflection
### 5.6.3 Intégrations externes pour contexte pro
- [ ] **Import calendrier** : Meetings duration & frequency
- [ ] **GitHub/GitLab integration** : Commits, PRs, code reviews
- [ ] **Slack integration** : Messages envoyés, réactions, temps de réponse
- [ ] **Confluence/Notion** : Documents créés/édités
- [ ] **Time tracking tools** : Import depuis Toggl, Clockify, etc.
### 5.6.4 Machine Learning & Predictions
- [ ] **Modèle de productivité personnelle**
- [ ] Prédiction des jours de forte/faible productivité
- [ ] Recommandations de planning optimal
- [ ] Détection automatique de burnout patterns
- [ ] Suggestions de breaks et équilibre work-life
- [ ] **Insights business automatiques**
- [ ] "Cette semaine, tu as contribué à 3 initiatives stratégiques"
- [ ] "Ton focus sur la qualité (code reviews) est 20% au-dessus de la moyenne"
- [ ] "Suggestion: bloquer 2h demain pour deep work sur Project X"
### 🚀 Quick Wins pour démarrer (Priorité 1)
- [ ] **Métriques de vélocité personnelle** (1-2h)
- [ ] Calcul tâches complétées par jour/semaine
- [ ] Graphique simple ligne de tendance sur 4 semaines
- [ ] Comparaison semaine actuelle vs semaine précédente
- [ ] **Export PDF basique** (2-3h)
- [ ] Génération PDF simple avec statistiques actuelles
- [ ] Template "Weekly Summary" avec logo/header pro
- [ ] Liste des principales réalisations de la semaine
- [ ] **Catégorisation simple par tags** (1h)
- [ ] Tags prédéfinis : "Dev", "Meeting", "Admin", "Learning"
- [ ] Auto-suggestion basée sur mots-clés dans les titres
- [ ] Répartition en camembert par catégorie
- [ ] **Connexion Jira pour contexte business** (3-4h)
- [ ] Affichage des story points complétés
- [ ] Lien vers les tickets Jira depuis les tâches
- [ ] Récap des sprints/epics contributés
- [ ] **Période flexible** (1h)
- [ ] Sélecteur de période : dernière semaine, 2 semaines, mois
- [ ] Comparaison période courante vs période précédente
- [ ] Sauvegarde de la période préférée
### 💡 Idées spécifiques pour Individual Review
#### **Sections du rapport idéal :**
1. **Executive Summary** (3-4 bullet points impact business)
2. **Quantified Achievements** (metrics, numbers, scope)
3. **Technical Contributions** (code, architecture, tools)
4. **Collaboration Impact** (reviews, mentoring, knowledge sharing)
5. **Process Improvements** (efficiency gains, automation)
6. **Learning & Growth** (new skills, certifications, initiatives)
7. **Challenges & Solutions** (blockers overcome, lessons learned)
8. **Next Period Goals** (SMART objectives, capacity planning)
#### **Métriques qui impressionnent un manager :**
- **Velocity & Consistency** : "Completed 23 tasks with 94% on-time delivery"
- **Quality Focus** : "15 code reviews provided, 0 production bugs"
- **Initiative** : "Automated deployment reducing release time by 30%"
- **Business Impact** : "Features delivered serve 10K+ users daily"
- **Collaboration** : "Mentored 2 junior devs, led 3 technical sessions"
- **Efficiency** : "Process optimization saved team 5h/week"
#### **Questions auto-reflection intégrées :**
- "What was your biggest technical achievement this week?"
- "Which tasks had the highest business impact?"
- "What blockers did you encounter and how did you solve them?"
- "What did you learn that you can share with the team?"
- "What would you do differently next week?"
## Autre Todos #2
- [ ] Synchro Jira auto en background timé comme pour la synchro de sauvegarde
- [ ] refacto des allpreferences : ca devrait eter un contexte dans le layout qui balance serverside dans le hook

View File

@@ -0,0 +1,146 @@
'use client';
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
import { Badge } from '@/components/ui/Badge';
interface CategoryData {
count: number;
percentage: number;
color: string;
icon: string;
}
interface CategoryBreakdownProps {
categoryData: { [categoryName: string]: CategoryData };
totalActivities: number;
}
export function CategoryBreakdown({ categoryData, totalActivities }: CategoryBreakdownProps) {
const categories = Object.entries(categoryData)
.filter(([, data]) => data.count > 0)
.sort((a, b) => b[1].count - a[1].count);
if (categories.length === 0) {
return (
<Card>
<CardHeader>
<h3 className="text-lg font-semibold">📊 Répartition par catégorie</h3>
</CardHeader>
<CardContent>
<p className="text-center text-[var(--muted-foreground)]">
Aucune activité à catégoriser
</p>
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader>
<h3 className="text-lg font-semibold">📊 Répartition par catégorie</h3>
<p className="text-sm text-[var(--muted-foreground)]">
Analyse automatique de vos {totalActivities} activités
</p>
</CardHeader>
<CardContent className="space-y-6">
{/* Légende des catégories */}
<div className="flex flex-wrap gap-3 justify-center">
{categories.map(([categoryName, data]) => (
<div
key={categoryName}
className="flex items-center gap-2 bg-[var(--card)] border border-[var(--border)] rounded-lg px-3 py-2 hover:border-[var(--primary)]/50 transition-colors"
>
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: data.color }}
/>
<span className="text-sm font-medium text-[var(--foreground)]">
{data.icon} {categoryName}
</span>
<Badge className="bg-[var(--primary)]/10 text-[var(--primary)] text-xs">
{data.count}
</Badge>
</div>
))}
</div>
{/* Barres de progression */}
<div className="space-y-3">
{categories.map(([categoryName, data]) => (
<div key={categoryName} className="space-y-1">
<div className="flex items-center justify-between text-sm">
<span className="flex items-center gap-2">
<span>{data.icon}</span>
<span className="font-medium">{categoryName}</span>
</span>
<span className="text-[var(--muted-foreground)]">
{data.count} ({data.percentage.toFixed(1)}%)
</span>
</div>
<div className="w-full bg-[var(--border)] rounded-full h-2">
<div
className="h-2 rounded-full transition-all duration-500"
style={{
backgroundColor: data.color,
width: `${data.percentage}%`
}}
/>
</div>
</div>
))}
</div>
{/* Insights */}
<div className="bg-[var(--card)] p-4 rounded-lg border border-[var(--border)]">
<h4 className="font-medium mb-2">💡 Insights</h4>
<div className="text-sm text-[var(--muted-foreground)] space-y-1">
{categories.length > 0 && (
<>
<p>
🏆 <strong>{categories[0][0]}</strong> est votre activité principale
({categories[0][1].percentage.toFixed(1)}% de votre temps).
</p>
{categories.length > 1 && (
<p>
📈 Vous avez une bonne diversité avec {categories.length} catégories d&apos;activités.
</p>
)}
{/* Suggestions basées sur la répartition */}
{categories.some(([, data]) => data.percentage > 70) && (
<p>
Forte concentration sur une seule catégorie.
Pensez à diversifier vos activités pour un meilleur équilibre.
</p>
)}
{(() => {
const learningCategory = categories.find(([name]) => name === 'Learning');
return learningCategory && learningCategory[1].percentage > 0 && (
<p>
🎓 Excellent ! Vous consacrez du temps à l&apos;apprentissage
({learningCategory[1].percentage.toFixed(1)}%).
</p>
);
})()}
{(() => {
const devCategory = categories.find(([name]) => name === 'Dev');
return devCategory && devCategory[1].percentage > 50 && (
<p>
💻 Focus développement intense. N&apos;oubliez pas les pauses et la collaboration !
</p>
);
})()}
</>
)}
</div>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,193 @@
'use client';
import type { JiraWeeklyMetrics } from '@/services/jira-summary';
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
import { Badge } from '@/components/ui/Badge';
import { JiraSummaryService } from '@/services/jira-summary';
interface JiraWeeklyMetricsProps {
jiraMetrics: JiraWeeklyMetrics | null;
}
export function JiraWeeklyMetrics({ jiraMetrics }: JiraWeeklyMetricsProps) {
if (!jiraMetrics) {
return (
<Card>
<CardHeader>
<h3 className="text-lg font-semibold">🔗 Contexte business Jira</h3>
</CardHeader>
<CardContent>
<p className="text-center text-[var(--muted-foreground)]">
Configuration Jira non disponible
</p>
</CardContent>
</Card>
);
}
if (jiraMetrics.totalJiraTasks === 0) {
return (
<Card>
<CardHeader>
<h3 className="text-lg font-semibold">🔗 Contexte business Jira</h3>
</CardHeader>
<CardContent>
<p className="text-center text-[var(--muted-foreground)]">
Aucune tâche Jira cette semaine
</p>
</CardContent>
</Card>
);
}
const completionRate = (jiraMetrics.completedJiraTasks / jiraMetrics.totalJiraTasks) * 100;
const insights = JiraSummaryService.generateBusinessInsights(jiraMetrics);
return (
<Card>
<CardHeader>
<h3 className="text-lg font-semibold">🔗 Contexte business Jira</h3>
<p className="text-sm text-[var(--muted-foreground)]">
Impact business et métriques projet
</p>
</CardHeader>
<CardContent className="space-y-6">
{/* Métriques principales */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<div className="bg-[var(--card)] p-4 rounded-lg border border-[var(--border)] hover:border-[var(--primary)]/50 transition-colors text-center">
<div className="text-2xl font-bold text-[var(--primary)]">
{jiraMetrics.totalJiraTasks}
</div>
<div className="text-sm text-[var(--muted-foreground)]">Tickets Jira</div>
</div>
<div className="bg-[var(--card)] p-4 rounded-lg border border-[var(--border)] hover:border-[var(--success)]/50 transition-colors text-center">
<div className="text-2xl font-bold text-[var(--success)]">
{completionRate.toFixed(0)}%
</div>
<div className="text-sm text-[var(--muted-foreground)]">Taux completion</div>
</div>
<div className="bg-[var(--card)] p-4 rounded-lg border border-[var(--border)] hover:border-[var(--accent)]/50 transition-colors text-center">
<div className="text-2xl font-bold text-[var(--accent)]">
{jiraMetrics.totalStoryPoints}
</div>
<div className="text-sm text-[var(--muted-foreground)]">Story Points*</div>
</div>
<div className="bg-[var(--card)] p-4 rounded-lg border border-[var(--border)] hover:border-[var(--warning)]/50 transition-colors text-center">
<div className="text-2xl font-bold text-[var(--warning)]">
{jiraMetrics.projectsContributed.length}
</div>
<div className="text-sm text-[var(--muted-foreground)]">Projet(s)</div>
</div>
</div>
{/* Projets contributés */}
{jiraMetrics.projectsContributed.length > 0 && (
<div>
<h4 className="font-medium mb-2">📂 Projets contributés</h4>
<div className="flex flex-wrap gap-2">
{jiraMetrics.projectsContributed.map(project => (
<Badge key={project} className="bg-[var(--primary)]/10 text-[var(--primary)]">
{project}
</Badge>
))}
</div>
</div>
)}
{/* Types de tickets */}
<div>
<h4 className="font-medium mb-3">🎯 Types de tickets</h4>
<div className="space-y-2">
{Object.entries(jiraMetrics.ticketTypes)
.sort(([,a], [,b]) => b - a)
.map(([type, count]) => {
const percentage = (count / jiraMetrics.totalJiraTasks) * 100;
return (
<div key={type} className="flex items-center justify-between">
<span className="text-sm text-[var(--foreground)]">{type}</span>
<div className="flex items-center gap-2">
<div className="w-20 bg-[var(--border)] rounded-full h-2">
<div
className="h-2 bg-[var(--primary)] rounded-full transition-all"
style={{ width: `${percentage}%` }}
/>
</div>
<span className="text-sm text-[var(--muted-foreground)] w-8">
{count}
</span>
</div>
</div>
);
})}
</div>
</div>
{/* Liens vers les tickets */}
<div>
<h4 className="font-medium mb-3">🎫 Tickets traités</h4>
<div className="space-y-2 max-h-40 overflow-y-auto">
{jiraMetrics.jiraLinks.map((link) => (
<div
key={link.key}
className="flex items-center justify-between p-2 rounded border hover:bg-[var(--muted)] transition-colors"
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<a
href={link.url}
target="_blank"
rel="noopener noreferrer"
className="text-[var(--primary)] hover:underline font-medium text-sm"
>
{link.key}
</a>
<Badge
className={`text-xs ${
link.status === 'done'
? 'bg-[var(--success)]/10 text-[var(--success)]'
: 'bg-[var(--muted)]/50 text-[var(--muted-foreground)]'
}`}
>
{link.status}
</Badge>
</div>
<p className="text-xs text-[var(--muted-foreground)] truncate">
{link.title}
</p>
</div>
<div className="flex items-center gap-2 text-xs text-[var(--muted-foreground)]">
<span>{link.type}</span>
<span>{link.estimatedPoints}pts</span>
</div>
</div>
))}
</div>
</div>
{/* Insights business */}
{insights.length > 0 && (
<div className="bg-[var(--card)] p-4 rounded-lg border border-[var(--border)]">
<h4 className="font-medium mb-2">💡 Insights business</h4>
<div className="text-sm text-[var(--muted-foreground)] space-y-1">
{insights.map((insight, index) => (
<p key={index}>{insight}</p>
))}
</div>
</div>
)}
{/* Note sur les story points */}
<div className="text-xs text-[var(--muted-foreground)] bg-[var(--card)] border border-[var(--border)] p-2 rounded">
<p>
* Story Points estimés automatiquement basés sur le type de ticket
(Epic: 8pts, Story: 3pts, Task: 2pts, Bug: 1pt)
</p>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,497 @@
'use client';
import { useState } from 'react';
import { ManagerSummary } from '@/services/manager-summary';
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { TagDisplay } from '@/components/ui/TagDisplay';
import { getPriorityConfig } from '@/lib/status-config';
import { useTasksContext } from '@/contexts/TasksContext';
import { MetricsTab } from './MetricsTab';
import { format } from 'date-fns';
import { fr } from 'date-fns/locale';
interface ManagerWeeklySummaryProps {
initialSummary: ManagerSummary;
}
export default function ManagerWeeklySummary({ initialSummary }: ManagerWeeklySummaryProps) {
const [summary] = useState<ManagerSummary>(initialSummary);
const [activeView, setActiveView] = useState<'narrative' | 'accomplishments' | 'challenges' | 'metrics'>('narrative');
const { tags: availableTags } = useTasksContext();
const handleRefresh = () => {
// SSR - refresh via page reload
window.location.reload();
};
const formatPeriod = () => {
return `Semaine du ${format(summary.period.start, 'dd MMM', { locale: fr })} au ${format(summary.period.end, 'dd MMM yyyy', { locale: fr })}`;
};
const getPriorityBadgeStyle = (priority: 'low' | 'medium' | 'high') => {
const config = getPriorityConfig(priority);
const baseClasses = 'text-xs px-2 py-0.5 rounded font-medium';
switch (config.color) {
case 'blue':
return `${baseClasses} bg-blue-100 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400`;
case 'yellow':
return `${baseClasses} bg-yellow-100 dark:bg-yellow-900/20 text-yellow-600 dark:text-yellow-400`;
case 'purple':
return `${baseClasses} bg-purple-100 dark:bg-purple-900/20 text-purple-600 dark:text-purple-400`;
case 'red':
return `${baseClasses} bg-red-100 dark:bg-red-900/20 text-red-600 dark:text-red-400`;
default:
return `${baseClasses} bg-gray-100 dark:bg-gray-900/20 text-gray-600 dark:text-gray-400`;
}
};
return (
<div className="space-y-6">
{/* Header avec navigation */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-[var(--foreground)]">👔 Résumé Manager</h1>
<p className="text-[var(--muted-foreground)]">{formatPeriod()}</p>
</div>
<Button
onClick={handleRefresh}
variant="secondary"
size="sm"
>
🔄 Actualiser
</Button>
</div>
{/* Navigation des vues */}
<div className="border-b border-[var(--border)]">
<nav className="flex space-x-8">
<button
onClick={() => setActiveView('narrative')}
className={`py-3 px-1 border-b-2 font-medium text-sm transition-colors ${
activeView === 'narrative'
? 'border-[var(--primary)] text-[var(--primary)]'
: 'border-transparent text-[var(--muted-foreground)] hover:text-[var(--foreground)]'
}`}
>
📝 Vue Executive
</button>
<button
onClick={() => setActiveView('accomplishments')}
className={`py-3 px-1 border-b-2 font-medium text-sm transition-colors ${
activeView === 'accomplishments'
? 'border-[var(--primary)] text-[var(--primary)]'
: 'border-transparent text-[var(--muted-foreground)] hover:text-[var(--foreground)]'
}`}
>
Accomplissements ({summary.keyAccomplishments.length})
</button>
<button
onClick={() => setActiveView('challenges')}
className={`py-3 px-1 border-b-2 font-medium text-sm transition-colors ${
activeView === 'challenges'
? 'border-[var(--primary)] text-[var(--primary)]'
: 'border-transparent text-[var(--muted-foreground)] hover:text-[var(--foreground)]'
}`}
>
🎯 Enjeux à venir ({summary.upcomingChallenges.length})
</button>
<button
onClick={() => setActiveView('metrics')}
className={`py-3 px-1 border-b-2 font-medium text-sm transition-colors ${
activeView === 'metrics'
? 'border-[var(--primary)] text-[var(--primary)]'
: 'border-transparent text-[var(--muted-foreground)] hover:text-[var(--foreground)]'
}`}
>
📊 Métriques
</button>
</nav>
</div>
{/* Vue Executive / Narrative */}
{activeView === 'narrative' && (
<div className="space-y-6">
{/* Résumé narratif */}
<Card>
<CardHeader>
<h2 className="text-lg font-semibold flex items-center gap-2">
📊 Résumé de la semaine
</h2>
</CardHeader>
<CardContent className="space-y-4">
<div className="bg-blue-50 p-4 rounded-lg border-l-4 border-blue-400">
<h3 className="font-medium text-blue-900 mb-2">🎯 Points clés accomplis</h3>
<p className="text-blue-800">{summary.narrative.weekHighlight}</p>
</div>
<div className="bg-yellow-50 p-4 rounded-lg border-l-4 border-yellow-400">
<h3 className="font-medium text-yellow-900 mb-2"> Défis traités</h3>
<p className="text-yellow-800">{summary.narrative.mainChallenges}</p>
</div>
<div className="bg-green-50 p-4 rounded-lg border-l-4 border-green-400">
<h3 className="font-medium text-green-900 mb-2">🔮 Focus semaine prochaine</h3>
<p className="text-green-800">{summary.narrative.nextWeekFocus}</p>
</div>
</CardContent>
</Card>
{/* Métriques rapides */}
<Card>
<CardHeader>
<h2 className="text-lg font-semibold">📈 Métriques en bref</h2>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<div className="text-center p-4 bg-blue-50 rounded-lg">
<div className="text-2xl font-bold text-blue-600">
{summary.metrics.totalTasksCompleted}
</div>
<div className="text-sm text-blue-600">Tâches complétées</div>
<div className="text-xs text-blue-500">
dont {summary.metrics.highPriorityTasksCompleted} priorité haute
</div>
</div>
<div className="text-center p-4 bg-green-50 rounded-lg">
<div className="text-2xl font-bold text-green-600">
{summary.metrics.totalCheckboxesCompleted}
</div>
<div className="text-sm text-green-600">Todos complétés</div>
<div className="text-xs text-green-500">
dont {summary.metrics.meetingCheckboxesCompleted} meetings
</div>
</div>
<div className="text-center p-4 bg-purple-50 rounded-lg">
<div className="text-2xl font-bold text-purple-600">
{summary.keyAccomplishments.filter(a => a.impact === 'high').length}
</div>
<div className="text-sm text-purple-600">Items à fort impact</div>
<div className="text-xs text-purple-500">
/ {summary.keyAccomplishments.length} accomplissements
</div>
</div>
<div className="text-center p-4 bg-orange-50 rounded-lg">
<div className="text-2xl font-bold text-orange-600">
{summary.upcomingChallenges.filter(c => c.priority === 'high').length}
</div>
<div className="text-sm text-orange-600">Priorités critiques</div>
<div className="text-xs text-orange-500">
/ {summary.upcomingChallenges.length} enjeux
</div>
</div>
</div>
</CardContent>
</Card>
{/* Top accomplissements */}
<Card>
<CardHeader>
<h2 className="text-lg font-semibold">🏆 Top accomplissements</h2>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{summary.keyAccomplishments.length === 0 ? (
<div className="col-span-3 text-center py-8 text-[var(--muted-foreground)]">
<p>Aucun accomplissement significatif trouvé cette semaine.</p>
<p className="text-sm mt-2">Ajoutez des tâches avec priorité haute/medium ou des meetings.</p>
</div>
) : (
summary.keyAccomplishments.slice(0, 6).map((accomplishment, index) => (
<div
key={accomplishment.id}
className="relative bg-[var(--card)] border border-[var(--border)] rounded-lg p-3 transition-all duration-200 group"
>
{/* Barre colorée gauche */}
<div className="absolute left-0 top-0 bottom-0 w-1 bg-green-500 rounded-l-lg"></div>
{/* Header compact */}
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<span className="w-5 h-5 bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300 rounded-full text-xs font-bold flex items-center justify-center">
#{index + 1}
</span>
<span className={getPriorityBadgeStyle(accomplishment.impact)}>
{getPriorityConfig(accomplishment.impact).label}
</span>
</div>
<span className="text-xs text-[var(--muted-foreground)]">
{format(accomplishment.completedAt, 'dd/MM', { locale: fr })}
</span>
</div>
{/* Titre */}
<h4 className="font-semibold text-sm text-[var(--foreground)] mb-2 line-clamp-2">
{accomplishment.title}
</h4>
{/* Tags */}
{accomplishment.tags && accomplishment.tags.length > 0 && (
<div className="mb-2">
<TagDisplay
tags={accomplishment.tags}
availableTags={availableTags}
size="sm"
maxTags={2}
/>
</div>
)}
{/* Description si disponible */}
{accomplishment.description && (
<p className="text-xs text-[var(--muted-foreground)] line-clamp-2 leading-relaxed mb-2">
{accomplishment.description}
</p>
)}
{/* Count de todos */}
{accomplishment.todosCount > 0 && (
<div className="flex items-center gap-1 text-xs text-[var(--muted-foreground)]">
<span>📋</span>
<span>{accomplishment.todosCount} todo{accomplishment.todosCount > 1 ? 's' : ''}</span>
</div>
)}
</div>
))
)}
</div>
</CardContent>
</Card>
{/* Top challenges */}
<Card>
<CardHeader>
<h2 className="text-lg font-semibold">🎯 Top enjeux à venir</h2>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{summary.upcomingChallenges.length === 0 ? (
<div className="col-span-3 text-center py-8 text-[var(--muted-foreground)]">
<p>Aucun enjeu prioritaire trouvé.</p>
<p className="text-sm mt-2">Ajoutez des tâches non complétées avec priorité haute/medium.</p>
</div>
) : (
summary.upcomingChallenges.slice(0, 6).map((challenge, index) => (
<div
key={challenge.id}
className="relative bg-[var(--card)] border border-[var(--border)] rounded-lg p-3 transition-all duration-200 group"
>
{/* Barre colorée gauche */}
<div className="absolute left-0 top-0 bottom-0 w-1 bg-orange-500 rounded-l-lg"></div>
{/* Header compact */}
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<span className="w-5 h-5 bg-orange-100 dark:bg-orange-900/30 text-orange-700 dark:text-orange-300 rounded-full text-xs font-bold flex items-center justify-center">
#{index + 1}
</span>
<span className={getPriorityBadgeStyle(challenge.priority)}>
{getPriorityConfig(challenge.priority).label}
</span>
</div>
{challenge.deadline && (
<span className="text-xs text-[var(--muted-foreground)]">
{format(challenge.deadline, 'dd/MM', { locale: fr })}
</span>
)}
</div>
{/* Titre */}
<h4 className="font-semibold text-sm text-[var(--foreground)] mb-2 line-clamp-2">
{challenge.title}
</h4>
{/* Tags */}
{challenge.tags && challenge.tags.length > 0 && (
<div className="mb-2">
<TagDisplay
tags={challenge.tags}
availableTags={availableTags}
size="sm"
maxTags={2}
/>
</div>
)}
{/* Description si disponible */}
{challenge.description && (
<p className="text-xs text-[var(--muted-foreground)] line-clamp-2 leading-relaxed mb-2">
{challenge.description}
</p>
)}
{/* Count de todos */}
{challenge.todosCount > 0 && (
<div className="flex items-center gap-1 text-xs text-[var(--muted-foreground)]">
<span>📋</span>
<span>{challenge.todosCount} todo{challenge.todosCount > 1 ? 's' : ''}</span>
</div>
)}
</div>
))
)}
</div>
</CardContent>
</Card>
</div>
)}
{/* Vue détaillée des accomplissements */}
{activeView === 'accomplishments' && (
<Card>
<CardHeader>
<h2 className="text-lg font-semibold"> Accomplissements de la semaine</h2>
<p className="text-sm text-[var(--muted-foreground)]">
{summary.keyAccomplishments.length} accomplissements significatifs {summary.metrics.totalTasksCompleted} tâches {summary.metrics.totalCheckboxesCompleted} todos complétés
</p>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{summary.keyAccomplishments.map((accomplishment, index) => (
<div
key={accomplishment.id}
className="relative bg-[var(--card)] border border-[var(--border)] rounded-lg p-3 transition-all duration-200 group"
>
{/* Barre colorée gauche */}
<div className="absolute left-0 top-0 bottom-0 w-1 bg-green-500 rounded-l-lg"></div>
{/* Header compact */}
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<span className="w-5 h-5 bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300 rounded-full text-xs font-bold flex items-center justify-center">
#{index + 1}
</span>
<span className={getPriorityBadgeStyle(accomplishment.impact)}>
{getPriorityConfig(accomplishment.impact).label}
</span>
</div>
<span className="text-xs text-[var(--muted-foreground)]">
{format(accomplishment.completedAt, 'dd/MM', { locale: fr })}
</span>
</div>
{/* Titre */}
<h4 className="font-semibold text-sm text-[var(--foreground)] mb-2 line-clamp-2">
{accomplishment.title}
</h4>
{/* Tags */}
{accomplishment.tags && accomplishment.tags.length > 0 && (
<div className="mb-2">
<TagDisplay
tags={accomplishment.tags}
availableTags={availableTags}
size="sm"
maxTags={3}
/>
</div>
)}
{/* Description si disponible */}
{accomplishment.description && (
<p className="text-xs text-[var(--muted-foreground)] line-clamp-3 leading-relaxed mb-2">
{accomplishment.description}
</p>
)}
{/* Count de todos */}
{accomplishment.todosCount > 0 && (
<div className="flex items-center gap-1 text-xs text-[var(--muted-foreground)]">
<span>📋</span>
<span>{accomplishment.todosCount} todo{accomplishment.todosCount > 1 ? 's' : ''}</span>
</div>
)}
</div>
))}
</div>
</CardContent>
</Card>
)}
{/* Vue détaillée des challenges */}
{activeView === 'challenges' && (
<Card>
<CardHeader>
<h2 className="text-lg font-semibold">🎯 Enjeux et défis à venir</h2>
<p className="text-sm text-[var(--muted-foreground)]">
{summary.upcomingChallenges.length} défis identifiés {summary.upcomingChallenges.filter(c => c.priority === 'high').length} priorité haute {summary.upcomingChallenges.filter(c => c.blockers.length > 0).length} avec blockers
</p>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{summary.upcomingChallenges.map((challenge, index) => (
<div
key={challenge.id}
className="relative bg-[var(--card)] border border-[var(--border)] rounded-lg p-3 transition-all duration-200 group"
>
{/* Barre colorée gauche */}
<div className="absolute left-0 top-0 bottom-0 w-1 bg-orange-500 rounded-l-lg"></div>
{/* Header compact */}
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<span className="w-5 h-5 bg-orange-100 dark:bg-orange-900/30 text-orange-700 dark:text-orange-300 rounded-full text-xs font-bold flex items-center justify-center">
#{index + 1}
</span>
<span className={getPriorityBadgeStyle(challenge.priority)}>
{getPriorityConfig(challenge.priority).label}
</span>
</div>
{challenge.deadline && (
<span className="text-xs text-[var(--muted-foreground)]">
{format(challenge.deadline, 'dd/MM', { locale: fr })}
</span>
)}
</div>
{/* Titre */}
<h4 className="font-semibold text-sm text-[var(--foreground)] mb-2 line-clamp-2">
{challenge.title}
</h4>
{/* Tags */}
{challenge.tags && challenge.tags.length > 0 && (
<div className="mb-2">
<TagDisplay
tags={challenge.tags}
availableTags={availableTags}
size="sm"
maxTags={3}
/>
</div>
)}
{/* Description si disponible */}
{challenge.description && (
<p className="text-xs text-[var(--muted-foreground)] line-clamp-3 leading-relaxed mb-2">
{challenge.description}
</p>
)}
{/* Count de todos */}
{challenge.todosCount > 0 && (
<div className="flex items-center gap-1 text-xs text-[var(--muted-foreground)]">
<span>📋</span>
<span>{challenge.todosCount} todo{challenge.todosCount > 1 ? 's' : ''}</span>
</div>
)}
</div>
))}
</div>
</CardContent>
</Card>
)}
{/* Vue Métriques */}
{activeView === 'metrics' && (
<MetricsTab />
)}
</div>
);
}

View File

@@ -0,0 +1,245 @@
'use client';
import { useState } from 'react';
import { useWeeklyMetrics, useVelocityTrends } from '@/hooks/use-metrics';
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { DailyStatusChart } from './charts/DailyStatusChart';
import { CompletionRateChart } from './charts/CompletionRateChart';
import { StatusDistributionChart } from './charts/StatusDistributionChart';
import { PriorityBreakdownChart } from './charts/PriorityBreakdownChart';
import { VelocityTrendChart } from './charts/VelocityTrendChart';
import { WeeklyActivityHeatmap } from './charts/WeeklyActivityHeatmap';
import { ProductivityInsights } from './charts/ProductivityInsights';
import { format } from 'date-fns';
import { fr } from 'date-fns/locale';
interface MetricsTabProps {
className?: string;
}
export function MetricsTab({ className }: MetricsTabProps) {
const [selectedDate] = useState<Date>(new Date());
const [weeksBack, setWeeksBack] = useState(4);
const { metrics, loading: metricsLoading, error: metricsError, refetch: refetchMetrics } = useWeeklyMetrics(selectedDate);
const { trends, loading: trendsLoading, error: trendsError, refetch: refetchTrends } = useVelocityTrends(weeksBack);
const handleRefresh = () => {
refetchMetrics();
refetchTrends();
};
const formatPeriod = () => {
if (!metrics) return '';
return `Semaine du ${format(metrics.period.start, 'dd MMM', { locale: fr })} au ${format(metrics.period.end, 'dd MMM yyyy', { locale: fr })}`;
};
const getTrendIcon = (trend: string) => {
switch (trend) {
case 'improving': return '📈';
case 'declining': return '📉';
case 'stable': return '➡️';
default: return '📊';
}
};
const getPatternIcon = (pattern: string) => {
switch (pattern) {
case 'consistent': return '🎯';
case 'variable': return '📊';
case 'weekend-heavy': return '📅';
default: return '📋';
}
};
if (metricsError || trendsError) {
return (
<div className={className}>
<Card>
<CardContent className="p-6 text-center">
<p className="text-red-500 mb-4">
Erreur lors du chargement des métriques
</p>
<p className="text-sm text-[var(--muted-foreground)] mb-4">
{metricsError || trendsError}
</p>
<Button onClick={handleRefresh} variant="secondary" size="sm">
🔄 Réessayer
</Button>
</CardContent>
</Card>
</div>
);
}
return (
<div className={className}>
{/* Header avec période et contrôles */}
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-xl font-bold text-[var(--foreground)]">📊 Métriques & Analytics</h2>
<p className="text-[var(--muted-foreground)]">{formatPeriod()}</p>
</div>
<div className="flex items-center gap-2">
<Button
onClick={handleRefresh}
variant="secondary"
size="sm"
disabled={metricsLoading || trendsLoading}
>
🔄 Actualiser
</Button>
</div>
</div>
{metricsLoading || trendsLoading ? (
<Card>
<CardContent className="p-6 text-center">
<div className="animate-pulse">
<div className="h-4 bg-[var(--border)] rounded w-1/4 mx-auto mb-4"></div>
<div className="h-32 bg-[var(--border)] rounded"></div>
</div>
<p className="text-[var(--muted-foreground)] mt-4">Chargement des métriques...</p>
</CardContent>
</Card>
) : metrics ? (
<div className="space-y-6">
{/* Vue d'ensemble rapide */}
<Card>
<CardHeader>
<h3 className="text-lg font-semibold">🎯 Vue d&apos;ensemble</h3>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 lg:grid-cols-5 gap-4">
<div className="text-center p-4 bg-green-50 dark:bg-green-950/20 rounded-lg">
<div className="text-2xl font-bold text-green-600">
{metrics.summary.totalTasksCompleted}
</div>
<div className="text-sm text-green-600">Terminées</div>
</div>
<div className="text-center p-4 bg-blue-50 dark:bg-blue-950/20 rounded-lg">
<div className="text-2xl font-bold text-blue-600">
{metrics.summary.totalTasksCreated}
</div>
<div className="text-sm text-blue-600">Créées</div>
</div>
<div className="text-center p-4 bg-purple-50 dark:bg-purple-950/20 rounded-lg">
<div className="text-2xl font-bold text-purple-600">
{metrics.summary.averageCompletionRate.toFixed(1)}%
</div>
<div className="text-sm text-purple-600">Taux moyen</div>
</div>
<div className="text-center p-4 bg-orange-50 dark:bg-orange-950/20 rounded-lg">
<div className="text-2xl font-bold text-orange-600">
{getTrendIcon(metrics.summary.trendsAnalysis.completionTrend)}
</div>
<div className="text-sm text-orange-600 capitalize">
{metrics.summary.trendsAnalysis.completionTrend}
</div>
</div>
<div className="text-center p-4 bg-gray-50 dark:bg-gray-950/20 rounded-lg">
<div className="text-2xl font-bold text-gray-600">
{getPatternIcon(metrics.summary.trendsAnalysis.productivityPattern)}
</div>
<div className="text-sm text-gray-600">
{metrics.summary.trendsAnalysis.productivityPattern === 'consistent' ? 'Régulier' :
metrics.summary.trendsAnalysis.productivityPattern === 'variable' ? 'Variable' : 'Weekend+'}
</div>
</div>
</div>
</CardContent>
</Card>
{/* Graphiques principaux */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card>
<CardHeader>
<h3 className="text-lg font-semibold">📈 Évolution quotidienne des statuts</h3>
</CardHeader>
<CardContent>
<DailyStatusChart data={metrics.dailyBreakdown} />
</CardContent>
</Card>
<Card>
<CardHeader>
<h3 className="text-lg font-semibold">🎯 Taux de completion quotidien</h3>
</CardHeader>
<CardContent>
<CompletionRateChart data={metrics.dailyBreakdown} />
</CardContent>
</Card>
</div>
{/* Distribution et priorités */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<Card>
<CardHeader>
<h3 className="text-lg font-semibold">🍰 Répartition des statuts</h3>
</CardHeader>
<CardContent>
<StatusDistributionChart data={metrics.statusDistribution} />
</CardContent>
</Card>
<Card>
<CardHeader>
<h3 className="text-lg font-semibold"> Performance par priorité</h3>
</CardHeader>
<CardContent>
<PriorityBreakdownChart data={metrics.priorityBreakdown} />
</CardContent>
</Card>
<Card>
<CardHeader>
<h3 className="text-lg font-semibold">🔥 Heatmap d&apos;activité</h3>
</CardHeader>
<CardContent>
<WeeklyActivityHeatmap data={metrics.dailyBreakdown} />
</CardContent>
</Card>
</div>
{/* Tendances de vélocité */}
{trends.length > 0 && (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold">🚀 Tendances de vélocité</h3>
<select
value={weeksBack}
onChange={(e) => setWeeksBack(parseInt(e.target.value))}
className="text-sm border border-[var(--border)] rounded px-2 py-1 bg-[var(--background)]"
>
<option value={4}>4 semaines</option>
<option value={8}>8 semaines</option>
<option value={12}>12 semaines</option>
</select>
</div>
</CardHeader>
<CardContent>
<VelocityTrendChart data={trends} />
</CardContent>
</Card>
)}
{/* Analyses de productivité */}
<Card>
<CardHeader>
<h3 className="text-lg font-semibold">💡 Analyses de productivité</h3>
</CardHeader>
<CardContent>
<ProductivityInsights data={metrics.dailyBreakdown} />
</CardContent>
</Card>
</div>
) : null}
</div>
);
}

View File

@@ -1,200 +0,0 @@
'use client';
import { useState } from 'react';
import { WeeklySummary, WeeklyActivity } from '@/services/weekly-summary';
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
interface WeeklySummaryClientProps {
initialSummary: WeeklySummary;
}
export default function WeeklySummaryClient({ initialSummary }: WeeklySummaryClientProps) {
const [summary] = useState<WeeklySummary>(initialSummary);
const [selectedDay, setSelectedDay] = useState<string | null>(null);
const [isRefreshing, setIsRefreshing] = useState(false);
const handleRefresh = async () => {
setIsRefreshing(true);
// Recharger la page pour refaire le fetch côté serveur
window.location.reload();
};
const formatDate = (date: Date) => {
return new Date(date).toLocaleDateString('fr-FR', {
weekday: 'long',
day: 'numeric',
month: 'long'
});
};
const getActivityIcon = (activity: WeeklyActivity) => {
if (activity.type === 'checkbox') {
return activity.completed ? '✅' : '☐';
}
return activity.completed ? '🎯' : '📝';
};
const getActivityTypeLabel = (type: 'checkbox' | 'task') => {
return type === 'checkbox' ? 'Daily' : 'Tâche';
};
const filteredActivities = selectedDay
? summary.activities.filter(a => a.dayName === selectedDay)
: summary.activities;
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold">📅 Résumé de la semaine</h2>
<p className="text-sm text-[var(--muted-foreground)]">
Du {formatDate(summary.period.start)} au {formatDate(summary.period.end)}
</p>
</div>
<Button
onClick={handleRefresh}
variant="secondary"
size="sm"
disabled={isRefreshing}
>
{isRefreshing ? '🔄' : '🔄'} {isRefreshing ? 'Actualisation...' : 'Actualiser'}
</Button>
</div>
</CardHeader>
<CardContent className="space-y-6">
{/* Statistiques globales */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<div className="bg-blue-50 rounded-lg p-4 text-center">
<div className="text-2xl font-bold text-blue-600">
{summary.stats.completedCheckboxes}
</div>
<div className="text-sm text-blue-600">Daily items</div>
<div className="text-xs text-[var(--muted-foreground)]">
sur {summary.stats.totalCheckboxes} ({summary.stats.checkboxCompletionRate.toFixed(0)}%)
</div>
</div>
<div className="bg-green-50 rounded-lg p-4 text-center">
<div className="text-2xl font-bold text-green-600">
{summary.stats.completedTasks}
</div>
<div className="text-sm text-green-600">Tâches</div>
<div className="text-xs text-[var(--muted-foreground)]">
sur {summary.stats.totalTasks} ({summary.stats.taskCompletionRate.toFixed(0)}%)
</div>
</div>
<div className="bg-purple-50 rounded-lg p-4 text-center">
<div className="text-2xl font-bold text-purple-600">
{summary.stats.completedCheckboxes + summary.stats.completedTasks}
</div>
<div className="text-sm text-purple-600">Total complété</div>
<div className="text-xs text-[var(--muted-foreground)]">
sur {summary.stats.totalCheckboxes + summary.stats.totalTasks}
</div>
</div>
<div className="bg-orange-50 rounded-lg p-4 text-center">
<div className="text-lg font-bold text-orange-600">
{summary.stats.mostProductiveDay}
</div>
<div className="text-sm text-orange-600">Jour le plus productif</div>
</div>
</div>
{/* Breakdown par jour */}
<div>
<h3 className="font-medium mb-3">📊 Répartition par jour</h3>
<div className="grid grid-cols-7 gap-2 mb-4">
{summary.stats.dailyBreakdown.map((day) => (
<button
key={day.date}
onClick={() => setSelectedDay(selectedDay === day.dayName ? null : day.dayName)}
className={`p-2 rounded-lg text-center transition-colors ${
selectedDay === day.dayName
? 'bg-blue-100 border-2 border-blue-300'
: 'bg-[var(--muted)] hover:bg-[var(--muted)]/80'
}`}
>
<div className="text-xs font-medium">
{day.dayName.slice(0, 3)}
</div>
<div className="text-sm font-bold">
{day.completedCheckboxes + day.completedTasks}
</div>
<div className="text-xs text-[var(--muted-foreground)]">
/{day.checkboxes + day.tasks}
</div>
</button>
))}
</div>
{selectedDay && (
<div className="text-sm text-[var(--muted-foreground)] mb-4">
📍 Filtré sur: <strong>{selectedDay}</strong>
<button
onClick={() => setSelectedDay(null)}
className="ml-2 text-blue-600 hover:underline"
>
(voir tout)
</button>
</div>
)}
</div>
{/* Timeline des activités */}
<div>
<h3 className="font-medium mb-3">
🕒 Timeline des activités
<span className="text-sm font-normal text-[var(--muted-foreground)]">
({filteredActivities.length} items)
</span>
</h3>
{filteredActivities.length === 0 ? (
<div className="text-center py-8 text-[var(--muted-foreground)]">
{selectedDay ? 'Aucune activité ce jour-là' : 'Aucune activité cette semaine'}
</div>
) : (
<div className="space-y-2 max-h-96 overflow-y-auto">
{filteredActivities.map((activity) => (
<div
key={activity.id}
className={`flex items-center gap-3 p-3 rounded-lg border transition-colors ${
activity.completed
? 'bg-green-50 border-green-200'
: 'bg-[var(--card)] border-[var(--border)]'
}`}
>
<span className="text-lg flex-shrink-0">
{getActivityIcon(activity)}
</span>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className={`text-sm ${activity.completed ? 'line-through text-[var(--muted-foreground)]' : ''}`}>
{activity.title}
</span>
<Badge className="text-xs bg-[var(--muted)] text-[var(--muted-foreground)]">
{getActivityTypeLabel(activity.type)}
</Badge>
</div>
<div className="text-xs text-[var(--muted-foreground)]">
{activity.dayName} {new Date(activity.createdAt).toLocaleDateString('fr-FR')}
{activity.completedAt && (
<span> Complété le {new Date(activity.completedAt).toLocaleDateString('fr-FR')}</span>
)}
</div>
</div>
</div>
))}
</div>
)}
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,97 @@
'use client';
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
import { DailyMetrics } from '@/services/metrics';
interface CompletionRateChartProps {
data: DailyMetrics[];
className?: string;
}
export function CompletionRateChart({ data, className }: CompletionRateChartProps) {
// Transformer les données pour le graphique
const chartData = data.map(day => ({
day: day.dayName.substring(0, 3), // Lun, Mar, etc.
date: new Date(day.date).toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit' }),
completionRate: day.completionRate,
completed: day.completed,
total: day.totalTasks
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const CustomTooltip = ({ active, payload, label }: { active?: boolean; payload?: any[]; label?: string }) => {
if (active && payload && payload.length) {
const data = payload[0].payload;
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-lg p-3 shadow-lg">
<p className="font-medium mb-2">{`${label} (${data.date})`}</p>
<p className="text-sm text-[var(--foreground)]">
Taux de completion: {data.completionRate.toFixed(1)}%
</p>
<p className="text-sm text-[var(--muted-foreground)]">
{data.completed} / {data.total} tâches
</p>
</div>
);
}
return null;
};
// Calculer la moyenne pour la ligne de référence
const averageRate = data.reduce((sum, day) => sum + day.completionRate, 0) / data.length;
return (
<div className={className}>
<ResponsiveContainer width="100%" height={250}>
<LineChart
data={chartData}
margin={{ top: 20, right: 30, left: 20, bottom: 5 }}
>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
<XAxis
dataKey="day"
stroke="var(--muted-foreground)"
fontSize={12}
/>
<YAxis
stroke="var(--muted-foreground)"
fontSize={12}
domain={[0, 100]}
tickFormatter={(value) => `${value}%`}
/>
<Tooltip content={<CustomTooltip />} />
<Line
type="monotone"
dataKey="completionRate"
stroke="#10b981"
strokeWidth={3}
dot={{ fill: "#10b981", strokeWidth: 2, r: 4 }}
activeDot={{ r: 6, stroke: "#10b981", strokeWidth: 2 }}
/>
{/* Ligne de moyenne */}
<Line
type="monotone"
dataKey={() => averageRate}
stroke="#94a3b8"
strokeWidth={1}
strokeDasharray="5 5"
dot={false}
activeDot={false}
/>
</LineChart>
</ResponsiveContainer>
{/* Légende */}
<div className="flex items-center justify-center gap-4 mt-2 text-xs text-[var(--muted-foreground)]">
<div className="flex items-center gap-1">
<div className="w-3 h-0.5 bg-green-500"></div>
<span>Taux quotidien</span>
</div>
<div className="flex items-center gap-1">
<div className="w-3 h-0.5 bg-gray-400 border-dashed"></div>
<span>Moyenne ({averageRate.toFixed(1)}%)</span>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,68 @@
'use client';
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts';
import { DailyMetrics } from '@/services/metrics';
interface DailyStatusChartProps {
data: DailyMetrics[];
className?: string;
}
export function DailyStatusChart({ data, className }: DailyStatusChartProps) {
// Transformer les données pour le graphique
const chartData = data.map(day => ({
day: day.dayName.substring(0, 3), // Lun, Mar, etc.
date: new Date(day.date).toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit' }),
'Complétées': day.completed,
'En cours': day.inProgress,
'Bloquées': day.blocked,
'En attente': day.pending,
'Nouvelles': day.newTasks
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const CustomTooltip = ({ active, payload, label }: { active?: boolean; payload?: any[]; label?: string }) => {
if (active && payload && payload.length) {
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-lg p-3 shadow-lg">
<p className="font-medium mb-2">{`${label} (${payload[0]?.payload?.date})`}</p>
{payload.map((entry: { dataKey: string; value: number; color: string }, index: number) => (
<p key={index} style={{ color: entry.color }} className="text-sm">
{`${entry.dataKey}: ${entry.value}`}
</p>
))}
</div>
);
}
return null;
};
return (
<div className={className}>
<ResponsiveContainer width="100%" height={300}>
<BarChart
data={chartData}
margin={{ top: 20, right: 30, left: 20, bottom: 5 }}
>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
<XAxis
dataKey="day"
stroke="var(--muted-foreground)"
fontSize={12}
/>
<YAxis
stroke="var(--muted-foreground)"
fontSize={12}
/>
<Tooltip content={<CustomTooltip />} />
<Legend />
<Bar dataKey="Complétées" fill="#10b981" radius={[2, 2, 0, 0]} />
<Bar dataKey="En cours" fill="#3b82f6" radius={[2, 2, 0, 0]} />
<Bar dataKey="Bloquées" fill="#ef4444" radius={[2, 2, 0, 0]} />
<Bar dataKey="En attente" fill="#94a3b8" radius={[2, 2, 0, 0]} />
<Bar dataKey="Nouvelles" fill="#8b5cf6" radius={[2, 2, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
);
}

View File

@@ -0,0 +1,112 @@
'use client';
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts';
interface PriorityData {
priority: string;
completed: number;
pending: number;
total: number;
completionRate: number;
color: string;
}
interface PriorityBreakdownChartProps {
data: PriorityData[];
className?: string;
}
export function PriorityBreakdownChart({ data, className }: PriorityBreakdownChartProps) {
// Transformer les données pour l'affichage
const getPriorityLabel = (priority: string) => {
const labels: { [key: string]: string } = {
'high': 'Haute',
'medium': 'Moyenne',
'low': 'Basse'
};
return labels[priority] || priority;
};
const chartData = data.map(item => ({
priority: getPriorityLabel(item.priority),
'Terminées': item.completed,
'En cours': item.pending,
completionRate: item.completionRate,
total: item.total
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const CustomTooltip = ({ active, payload, label }: { active?: boolean; payload?: any[]; label?: string }) => {
if (active && payload && payload.length) {
const data = payload[0].payload;
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-lg p-3 shadow-lg">
<p className="font-medium mb-2">{`Priorité ${label}`}</p>
<p className="text-sm text-green-600">
Terminées: {data['Terminées']}
</p>
<p className="text-sm text-blue-600">
En cours: {data['En cours']}
</p>
<p className="text-sm text-[var(--muted-foreground)] mt-1">
Taux: {data.completionRate.toFixed(1)}% ({data.total} total)
</p>
</div>
);
}
return null;
};
return (
<div className={className}>
<ResponsiveContainer width="100%" height={250}>
<BarChart
data={chartData}
margin={{ top: 20, right: 30, left: 20, bottom: 5 }}
>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
<XAxis
dataKey="priority"
stroke="var(--muted-foreground)"
fontSize={12}
/>
<YAxis
stroke="var(--muted-foreground)"
fontSize={12}
/>
<Tooltip content={<CustomTooltip />} />
<Legend />
<Bar
dataKey="Terminées"
stackId="a"
fill="#10b981"
radius={[0, 0, 0, 0]}
/>
<Bar
dataKey="En cours"
stackId="a"
fill="#3b82f6"
radius={[2, 2, 0, 0]}
/>
</BarChart>
</ResponsiveContainer>
{/* Affichage des taux de completion */}
<div className="mt-4 grid grid-cols-3 gap-4 text-center">
{data.map((item, index) => (
<div key={index} className="p-2 bg-[var(--card)] rounded border">
<div className="text-xs text-[var(--muted-foreground)] mb-1">
{getPriorityLabel(item.priority)}
</div>
<div className="text-lg font-bold" style={{ color: item.color }}>
{item.completionRate.toFixed(0)}%
</div>
<div className="text-xs text-[var(--muted-foreground)]">
{item.completed}/{item.total}
</div>
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,190 @@
'use client';
import { DailyMetrics } from '@/services/metrics';
interface ProductivityInsightsProps {
data: DailyMetrics[];
className?: string;
}
export function ProductivityInsights({ data, className }: ProductivityInsightsProps) {
// Calculer les insights
const totalCompleted = data.reduce((sum, day) => sum + day.completed, 0);
const totalCreated = data.reduce((sum, day) => sum + day.newTasks, 0);
// const averageCompletion = data.reduce((sum, day) => sum + day.completionRate, 0) / data.length;
// Trouver le jour le plus productif
const mostProductiveDay = data.reduce((best, day) =>
day.completed > best.completed ? day : best
);
// Trouver le jour avec le plus de nouvelles tâches
const mostCreativeDay = data.reduce((best, day) =>
day.newTasks > best.newTasks ? day : best
);
// Analyser la tendance
const firstHalf = data.slice(0, Math.ceil(data.length / 2));
const secondHalf = data.slice(Math.ceil(data.length / 2));
const firstHalfAvg = firstHalf.reduce((sum, day) => sum + day.completed, 0) / firstHalf.length;
const secondHalfAvg = secondHalf.reduce((sum, day) => sum + day.completed, 0) / secondHalf.length;
const trend = secondHalfAvg > firstHalfAvg ? 'up' : secondHalfAvg < firstHalfAvg ? 'down' : 'stable';
// Calculer la consistance (écart-type faible = plus consistant)
const avgCompleted = totalCompleted / data.length;
const variance = data.reduce((sum, day) => {
const diff = day.completed - avgCompleted;
return sum + diff * diff;
}, 0) / data.length;
const standardDeviation = Math.sqrt(variance);
const consistencyScore = Math.max(0, 100 - (standardDeviation * 10)); // Score sur 100
// Ratio création/completion
const creationRatio = totalCreated > 0 ? (totalCompleted / totalCreated) * 100 : 0;
const getTrendIcon = () => {
switch (trend) {
case 'up': return { icon: '📈', color: 'text-green-600', label: 'En amélioration' };
case 'down': return { icon: '📉', color: 'text-red-600', label: 'En baisse' };
default: return { icon: '➡️', color: 'text-blue-600', label: 'Stable' };
}
};
const getConsistencyLevel = () => {
if (consistencyScore >= 80) return { label: 'Très régulier', color: 'text-green-600', icon: '🎯' };
if (consistencyScore >= 60) return { label: 'Assez régulier', color: 'text-blue-600', icon: '📊' };
if (consistencyScore >= 40) return { label: 'Variable', color: 'text-yellow-600', icon: '📊' };
return { label: 'Très variable', color: 'text-red-600', icon: '📊' };
};
const getRatioStatus = () => {
if (creationRatio >= 100) return { label: 'Équilibré+', color: 'text-green-600', icon: '⚖️' };
if (creationRatio >= 80) return { label: 'Bien équilibré', color: 'text-blue-600', icon: '⚖️' };
if (creationRatio >= 60) return { label: 'Légèrement en retard', color: 'text-yellow-600', icon: '⚖️' };
return { label: 'Accumulation', color: 'text-red-600', icon: '⚖️' };
};
const trendInfo = getTrendIcon();
const consistencyInfo = getConsistencyLevel();
const ratioInfo = getRatioStatus();
return (
<div className={className}>
<div className="space-y-4">
{/* Insights principaux */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Jour le plus productif */}
<div className="p-4 bg-green-50 dark:bg-green-950/20 rounded-lg">
<div className="flex items-center justify-between mb-2">
<h4 className="font-medium text-green-900 dark:text-green-100">
🏆 Jour champion
</h4>
<span className="text-2xl font-bold text-green-600">
{mostProductiveDay.completed}
</span>
</div>
<p className="text-sm text-green-800 dark:text-green-200">
{mostProductiveDay.dayName} - {mostProductiveDay.completed} tâches terminées
</p>
<p className="text-xs text-green-600 mt-1">
Taux: {mostProductiveDay.completionRate.toFixed(1)}%
</p>
</div>
{/* Jour le plus créatif */}
<div className="p-4 bg-blue-50 dark:bg-blue-950/20 rounded-lg">
<div className="flex items-center justify-between mb-2">
<h4 className="font-medium text-blue-900 dark:text-blue-100">
💡 Jour créatif
</h4>
<span className="text-2xl font-bold text-blue-600">
{mostCreativeDay.newTasks}
</span>
</div>
<p className="text-sm text-blue-800 dark:text-blue-200">
{mostCreativeDay.dayName} - {mostCreativeDay.newTasks} nouvelles tâches
</p>
<p className="text-xs text-blue-600 mt-1">
{mostCreativeDay.dayName === mostProductiveDay.dayName ?
'Également jour le plus productif!' :
'Journée de planification'}
</p>
</div>
</div>
{/* Analyses comportementales */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{/* Tendance */}
<div className="p-4 bg-[var(--card)] border border-[var(--border)] rounded-lg">
<div className="flex items-center gap-2 mb-2">
<span className="text-2xl">{trendInfo.icon}</span>
<h4 className="font-medium text-[var(--foreground)]">Tendance</h4>
</div>
<p className={`text-sm font-medium ${trendInfo.color}`}>
{trendInfo.label}
</p>
<p className="text-xs text-[var(--muted-foreground)] mt-1">
{secondHalfAvg > firstHalfAvg ?
`+${(((secondHalfAvg - firstHalfAvg) / firstHalfAvg) * 100).toFixed(1)}%` :
`${(((secondHalfAvg - firstHalfAvg) / firstHalfAvg) * 100).toFixed(1)}%`}
</p>
</div>
{/* Consistance */}
<div className="p-4 bg-[var(--card)] border border-[var(--border)] rounded-lg">
<div className="flex items-center gap-2 mb-2">
<span className="text-2xl">{consistencyInfo.icon}</span>
<h4 className="font-medium text-[var(--foreground)]">Régularité</h4>
</div>
<p className={`text-sm font-medium ${consistencyInfo.color}`}>
{consistencyInfo.label}
</p>
<p className="text-xs text-[var(--muted-foreground)] mt-1">
Score: {consistencyScore.toFixed(0)}/100
</p>
</div>
{/* Ratio Création/Completion */}
<div className="p-4 bg-[var(--card)] border border-[var(--border)] rounded-lg">
<div className="flex items-center gap-2 mb-2">
<span className="text-2xl">{ratioInfo.icon}</span>
<h4 className="font-medium text-[var(--foreground)]">Équilibre</h4>
</div>
<p className={`text-sm font-medium ${ratioInfo.color}`}>
{ratioInfo.label}
</p>
<p className="text-xs text-[var(--muted-foreground)] mt-1">
{creationRatio.toFixed(0)}% de completion
</p>
</div>
</div>
{/* Recommandations */}
<div className="p-4 bg-yellow-50 dark:bg-yellow-950/20 rounded-lg">
<h4 className="font-medium text-yellow-900 dark:text-yellow-100 mb-2 flex items-center gap-2">
💡 Recommandations
</h4>
<div className="space-y-1 text-sm text-yellow-800 dark:text-yellow-200">
{trend === 'down' && (
<p> Essayez de retrouver votre rythme du début de semaine</p>
)}
{consistencyScore < 60 && (
<p> Essayez de maintenir un rythme plus régulier</p>
)}
{creationRatio < 80 && (
<p> Concentrez-vous plus sur terminer les tâches existantes</p>
)}
{creationRatio > 120 && (
<p> Excellent rythme! Peut-être ralentir la création de nouvelles tâches</p>
)}
{mostProductiveDay.dayName === mostCreativeDay.dayName && (
<p> Excellente synergie création/exécution le {mostProductiveDay.dayName}</p>
)}
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,109 @@
'use client';
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip, Legend } from 'recharts';
interface StatusDistributionData {
status: string;
count: number;
percentage: number;
color: string;
}
interface StatusDistributionChartProps {
data: StatusDistributionData[];
className?: string;
}
export function StatusDistributionChart({ data, className }: StatusDistributionChartProps) {
// Transformer les statuts pour l'affichage
const getStatusLabel = (status: string) => {
const labels: { [key: string]: string } = {
'pending': 'En attente',
'in_progress': 'En cours',
'blocked': 'Bloquées',
'done': 'Terminées',
'archived': 'Archivées'
};
return labels[status] || status;
};
const chartData = data.map(item => ({
...item,
name: getStatusLabel(item.status),
value: item.count
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const CustomTooltip = ({ active, payload }: { active?: boolean; payload?: any[] }) => {
if (active && payload && payload.length) {
const data = payload[0].payload;
return (
<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.count} tâches ({data.percentage}%)
</p>
</div>
);
}
return null;
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const CustomLabel = (props: any) => {
const { cx, cy, midAngle, innerRadius, outerRadius, percent } = props;
if (percent < 0.05) return null; // Ne pas afficher les labels pour les petites sections
const RADIAN = Math.PI / 180;
const radius = innerRadius + (outerRadius - innerRadius) * 0.5;
const x = cx + radius * Math.cos(-midAngle * RADIAN);
const y = cy + radius * Math.sin(-midAngle * RADIAN);
return (
<text
x={x}
y={y}
fill="white"
textAnchor={x > cx ? 'start' : 'end'}
dominantBaseline="central"
fontSize={12}
fontWeight="medium"
>
{`${(percent * 100).toFixed(0)}%`}
</text>
);
};
return (
<div className={className}>
<ResponsiveContainer width="100%" height={250}>
<PieChart>
<Pie
data={chartData}
cx="50%"
cy="50%"
labelLine={false}
label={CustomLabel}
outerRadius={80}
fill="#8884d8"
dataKey="value"
>
{chartData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
))}
</Pie>
<Tooltip content={<CustomTooltip />} />
<Legend
verticalAlign="bottom"
height={36}
formatter={(value, entry: { color?: string }) => (
<span style={{ color: entry.color, fontSize: '12px' }}>
{value}
</span>
)}
/>
</PieChart>
</ResponsiveContainer>
</div>
);
}

View File

@@ -0,0 +1,95 @@
'use client';
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts';
import { VelocityTrend } from '@/services/metrics';
interface VelocityTrendChartProps {
data: VelocityTrend[];
className?: string;
}
export function VelocityTrendChart({ data, className }: VelocityTrendChartProps) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const CustomTooltip = ({ active, payload, label }: { active?: boolean; payload?: any[]; label?: string }) => {
if (active && payload && payload.length) {
const data = payload[0].payload;
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-lg p-3 shadow-lg">
<p className="font-medium mb-2">{`Semaine du ${label}`}</p>
<p className="text-sm text-green-600">
Terminées: {data.completed}
</p>
<p className="text-sm text-blue-600">
Créées: {data.created}
</p>
<p className="text-sm text-purple-600">
Vélocité: {data.velocity.toFixed(1)}%
</p>
</div>
);
}
return null;
};
return (
<div className={className}>
<ResponsiveContainer width="100%" height={300}>
<LineChart
data={data}
margin={{ top: 20, right: 30, left: 20, bottom: 5 }}
>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
<XAxis
dataKey="date"
stroke="var(--muted-foreground)"
fontSize={12}
/>
<YAxis
yAxisId="count"
stroke="var(--muted-foreground)"
fontSize={12}
orientation="left"
/>
<YAxis
yAxisId="velocity"
stroke="var(--muted-foreground)"
fontSize={12}
orientation="right"
domain={[0, 100]}
tickFormatter={(value) => `${value}%`}
/>
<Tooltip content={<CustomTooltip />} />
<Legend />
<Line
yAxisId="count"
type="monotone"
dataKey="completed"
stroke="#10b981"
strokeWidth={2}
dot={{ fill: "#10b981", strokeWidth: 2, r: 4 }}
name="Terminées"
/>
<Line
yAxisId="count"
type="monotone"
dataKey="created"
stroke="#3b82f6"
strokeWidth={2}
dot={{ fill: "#3b82f6", strokeWidth: 2, r: 4 }}
name="Créées"
/>
<Line
yAxisId="velocity"
type="monotone"
dataKey="velocity"
stroke="#8b5cf6"
strokeWidth={3}
dot={{ fill: "#8b5cf6", strokeWidth: 2, r: 5 }}
name="Vélocité (%)"
strokeDasharray="5 5"
/>
</LineChart>
</ResponsiveContainer>
</div>
);
}

View File

@@ -0,0 +1,123 @@
'use client';
import { DailyMetrics } from '@/services/metrics';
interface WeeklyActivityHeatmapProps {
data: DailyMetrics[];
className?: string;
}
export function WeeklyActivityHeatmap({ data, className }: WeeklyActivityHeatmapProps) {
// Calculer l'intensité max pour la normalisation
const maxActivity = Math.max(...data.map(day => day.completed + day.newTasks));
// Obtenir l'intensité relative (0-1)
const getIntensity = (day: DailyMetrics) => {
const activity = day.completed + day.newTasks;
return maxActivity > 0 ? activity / maxActivity : 0;
};
// Obtenir la couleur basée sur l'intensité
const getColorClass = (intensity: number) => {
if (intensity === 0) return 'bg-gray-100 dark:bg-gray-800';
if (intensity < 0.2) return 'bg-green-100 dark:bg-green-900/30';
if (intensity < 0.4) return 'bg-green-200 dark:bg-green-800/50';
if (intensity < 0.6) return 'bg-green-300 dark:bg-green-700/70';
if (intensity < 0.8) return 'bg-green-400 dark:bg-green-600/80';
return 'bg-green-500 dark:bg-green-500';
};
return (
<div className={className}>
<div className="space-y-4">
{/* Titre */}
<div className="text-center">
<h4 className="text-sm font-medium text-[var(--foreground)] mb-2">
Heatmap d&apos;activité hebdomadaire
</h4>
<p className="text-xs text-[var(--muted-foreground)]">
Intensité basée sur les tâches complétées + nouvelles tâches
</p>
</div>
{/* Heatmap */}
<div className="flex justify-center">
<div className="flex gap-1">
{data.map((day, index) => {
const intensity = getIntensity(day);
const colorClass = getColorClass(intensity);
const totalActivity = day.completed + day.newTasks;
return (
<div key={index} className="text-center">
{/* Carré de couleur */}
<div
className={`w-8 h-8 rounded ${colorClass} border border-[var(--border)] flex items-center justify-center transition-all hover:scale-110 cursor-help group relative`}
title={`${day.dayName}: ${totalActivity} activités (${day.completed} complétées, ${day.newTasks} créées)`}
>
{/* Tooltip au hover */}
<div className="opacity-0 group-hover:opacity-100 absolute bottom-10 left-1/2 transform -translate-x-1/2 bg-[var(--card)] border border-[var(--border)] rounded p-2 text-xs whitespace-nowrap z-10 shadow-lg transition-opacity">
<div className="font-medium">{day.dayName}</div>
<div className="text-[var(--muted-foreground)]">
{day.completed} terminées, {day.newTasks} créées
</div>
<div className="text-[var(--muted-foreground)]">
Taux: {day.completionRate.toFixed(1)}%
</div>
</div>
{/* Indicator si jour actuel */}
{new Date(day.date).toDateString() === new Date().toDateString() && (
<div className="w-2 h-2 bg-blue-500 rounded-full"></div>
)}
</div>
{/* Label du jour */}
<div className="text-xs text-[var(--muted-foreground)] mt-1">
{day.dayName.substring(0, 3)}
</div>
</div>
);
})}
</div>
</div>
{/* Légende */}
<div className="flex items-center justify-center gap-2 text-xs text-[var(--muted-foreground)]">
<span>Moins</span>
<div className="flex gap-1">
<div className="w-3 h-3 bg-gray-100 dark:bg-gray-800 border border-[var(--border)] rounded"></div>
<div className="w-3 h-3 bg-green-100 dark:bg-green-900/30 border border-[var(--border)] rounded"></div>
<div className="w-3 h-3 bg-green-200 dark:bg-green-800/50 border border-[var(--border)] rounded"></div>
<div className="w-3 h-3 bg-green-300 dark:bg-green-700/70 border border-[var(--border)] rounded"></div>
<div className="w-3 h-3 bg-green-400 dark:bg-green-600/80 border border-[var(--border)] rounded"></div>
<div className="w-3 h-3 bg-green-500 dark:bg-green-500 border border-[var(--border)] rounded"></div>
</div>
<span>Plus</span>
</div>
{/* Stats rapides */}
<div className="grid grid-cols-3 gap-2 text-center text-xs">
<div className="p-2 bg-[var(--card)] rounded border">
<div className="font-medium text-green-600">
{data.reduce((sum, day) => sum + day.completed, 0)}
</div>
<div className="text-[var(--muted-foreground)]">Terminées</div>
</div>
<div className="p-2 bg-[var(--card)] rounded border">
<div className="font-medium text-blue-600">
{data.reduce((sum, day) => sum + day.newTasks, 0)}
</div>
<div className="text-[var(--muted-foreground)]">Créées</div>
</div>
<div className="p-2 bg-[var(--card)] rounded border">
<div className="font-medium text-purple-600">
{(data.reduce((sum, day) => sum + day.completionRate, 0) / data.length).toFixed(1)}%
</div>
<div className="text-[var(--muted-foreground)]">Taux moyen</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,16 +1,85 @@
'use client';
import { UserPreferences } from '@/lib/types';
import { useState, useMemo } from 'react';
import { UserPreferences, Tag } from '@/lib/types';
import { useTags } from '@/hooks/useTags';
import { Header } from '@/components/ui/Header';
import { Card, CardContent } from '@/components/ui/Card';
import { Card, CardContent, CardHeader } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { TagForm } from '@/components/forms/TagForm';
import { UserPreferencesProvider } from '@/contexts/UserPreferencesContext';
import Link from 'next/link';
interface GeneralSettingsPageClientProps {
initialPreferences: UserPreferences;
initialTags: Tag[];
}
export function GeneralSettingsPageClient({ initialPreferences }: GeneralSettingsPageClientProps) {
export function GeneralSettingsPageClient({ initialPreferences, initialTags }: GeneralSettingsPageClientProps) {
const {
tags,
refreshTags,
deleteTag
} = useTags(initialTags as (Tag & { usage: number })[]);
const [searchQuery, setSearchQuery] = useState('');
const [showOnlyUnused, setShowOnlyUnused] = useState(false);
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [editingTag, setEditingTag] = useState<Tag | null>(null);
const [deletingTagId, setDeletingTagId] = useState<string | null>(null);
// Filtrer et trier les tags
const filteredTags = useMemo(() => {
let filtered = tags;
// Filtrer par recherche
if (searchQuery.trim()) {
const query = searchQuery.toLowerCase();
filtered = filtered.filter(tag =>
tag.name.toLowerCase().includes(query)
);
}
// Filtrer pour afficher seulement les non utilisés
if (showOnlyUnused) {
filtered = filtered.filter(tag => {
const usage = (tag as Tag & { usage?: number }).usage || 0;
return usage === 0;
});
}
const sorted = filtered.sort((a, b) => {
const usageA = (a as Tag & { usage?: number }).usage || 0;
const usageB = (b as Tag & { usage?: number }).usage || 0;
if (usageB !== usageA) return usageB - usageA;
return a.name.localeCompare(b.name);
});
// Limiter à 12 tags si pas de recherche ni filtre, sinon afficher tous les résultats
const hasFilters = searchQuery.trim() || showOnlyUnused;
return hasFilters ? sorted : sorted.slice(0, 12);
}, [tags, searchQuery, showOnlyUnused]);
const handleEditTag = (tag: Tag) => {
setEditingTag(tag);
};
const handleDeleteTag = async (tag: Tag) => {
if (!confirm(`Êtes-vous sûr de vouloir supprimer le tag "${tag.name}" ?`)) {
return;
}
setDeletingTagId(tag.id);
try {
await deleteTag(tag.id);
await refreshTags();
} catch (error) {
console.error('Erreur lors de la suppression:', error);
} finally {
setDeletingTagId(null);
}
};
return (
<UserPreferencesProvider initialPreferences={initialPreferences}>
<div className="min-h-screen bg-[var(--background)]">
@@ -41,6 +110,210 @@ export function GeneralSettingsPageClient({ initialPreferences }: GeneralSetting
</div>
<div className="space-y-6">
{/* Gestion des tags */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold flex items-center gap-2">
🏷 Gestion des tags
</h2>
<p className="text-sm text-[var(--muted-foreground)] mt-1">
Créer et organiser les étiquettes pour vos tâches
</p>
</div>
<Button
variant="primary"
size="sm"
onClick={() => setIsCreateModalOpen(true)}
className="flex items-center gap-2"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
Nouveau tag
</Button>
</div>
</CardHeader>
<CardContent>
{/* Stats des tags */}
<div className="grid grid-cols-3 gap-4 mb-4">
<div className="text-center p-3 bg-[var(--muted)]/20 rounded">
<div className="text-xl font-bold text-[var(--foreground)]">{tags.length}</div>
<div className="text-sm text-[var(--muted-foreground)]">Tags créés</div>
</div>
<div className="text-center p-3 bg-[var(--primary)]/10 rounded">
<div className="text-xl font-bold text-[var(--primary)]">
{tags.reduce((sum, tag) => sum + ((tag as Tag & { usage?: number }).usage || 0), 0)}
</div>
<div className="text-sm text-[var(--muted-foreground)]">Utilisations</div>
</div>
<div className="text-center p-3 bg-[var(--success)]/10 rounded">
<div className="text-xl font-bold text-[var(--success)]">
{tags.filter(tag => (tag as Tag & { usage?: number }).usage && (tag as Tag & { usage?: number }).usage! > 0).length}
</div>
<div className="text-sm text-[var(--muted-foreground)]">Actifs</div>
</div>
</div>
{/* Recherche et filtres */}
<div className="space-y-3 mb-4">
<Input
placeholder="Rechercher un tag..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full"
/>
{/* Filtres rapides */}
<div className="flex items-center gap-3">
<Button
variant={showOnlyUnused ? "primary" : "ghost"}
size="sm"
onClick={() => setShowOnlyUnused(!showOnlyUnused)}
className="flex items-center gap-2"
>
<span className="text-xs"></span>
Tags non utilisés ({tags.filter(tag => ((tag as Tag & { usage?: number }).usage || 0) === 0).length})
</Button>
{(searchQuery || showOnlyUnused) && (
<Button
variant="ghost"
size="sm"
onClick={() => {
setSearchQuery('');
setShowOnlyUnused(false);
}}
className="text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
>
Réinitialiser
</Button>
)}
</div>
</div>
{/* Liste des tags en grid */}
{filteredTags.length === 0 ? (
<div className="text-center py-8 text-[var(--muted-foreground)]">
{searchQuery && showOnlyUnused ? 'Aucun tag non utilisé trouvé avec cette recherche' :
searchQuery ? 'Aucun tag trouvé pour cette recherche' :
showOnlyUnused ? '🎉 Aucun tag non utilisé ! Tous vos tags sont actifs.' :
'Aucun tag créé'}
{!searchQuery && !showOnlyUnused && (
<div className="mt-2">
<Button
variant="ghost"
size="sm"
onClick={() => setIsCreateModalOpen(true)}
>
Créer votre premier tag
</Button>
</div>
)}
</div>
) : (
<div className="space-y-4">
{/* Grid des tags */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{filteredTags.map((tag) => {
const usage = (tag as Tag & { usage?: number }).usage || 0;
const isUnused = usage === 0;
return (
<div
key={tag.id}
className={`p-3 rounded-lg border transition-all hover:shadow-sm ${
isUnused
? 'border-[var(--destructive)]/30 bg-[var(--destructive)]/5 hover:border-[var(--destructive)]/50'
: 'border-[var(--border)] hover:border-[var(--primary)]/50'
}`}
>
{/* Header du tag */}
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2 flex-1 min-w-0">
<div
className="w-3 h-3 rounded-full flex-shrink-0"
style={{ backgroundColor: tag.color }}
/>
<span className="font-medium text-sm truncate">{tag.name}</span>
{tag.isPinned && (
<span className="text-xs px-1.5 py-0.5 bg-[var(--primary)]/20 text-[var(--primary)] rounded flex-shrink-0">
📌
</span>
)}
</div>
{/* Actions */}
<div className="flex items-center gap-1 flex-shrink-0">
<Button
variant="ghost"
size="sm"
onClick={() => handleEditTag(tag)}
className="h-7 w-7 p-0 text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteTag(tag)}
disabled={deletingTagId === tag.id}
className={`h-7 w-7 p-0 ${
isUnused
? 'text-[var(--destructive)] hover:text-[var(--destructive)] hover:bg-[var(--destructive)]/20'
: 'text-[var(--muted-foreground)] hover:text-[var(--destructive)] hover:bg-[var(--destructive)]/10'
}`}
>
{deletingTagId === tag.id ? (
<svg className="w-3 h-3 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="m4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
) : (
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
)}
</Button>
</div>
</div>
{/* Stats et warning */}
<div className="space-y-1">
<div className={`text-xs flex items-center justify-between ${
isUnused ? 'text-[var(--destructive)]' : 'text-[var(--muted-foreground)]'
}`}>
<span>{usage} utilisation{usage !== 1 ? 's' : ''}</span>
{isUnused && (
<span className="text-xs px-1.5 py-0.5 bg-[var(--destructive)]/20 text-[var(--destructive)] rounded">
Non utilisé
</span>
)}
</div>
{('createdAt' in tag && (tag as Tag & { createdAt: Date }).createdAt) && (
<div className="text-xs text-[var(--muted-foreground)]">
Créé le {new Date((tag as Tag & { createdAt: Date }).createdAt).toLocaleDateString('fr-FR')}
</div>
)}
</div>
</div>
);
})}
</div>
{/* Message si plus de tags */}
{tags.length > 12 && !searchQuery && !showOnlyUnused && (
<div className="text-center pt-2 text-sm text-[var(--muted-foreground)]">
Et {tags.length - 12} autres tags... (utilisez la recherche ou les filtres pour les voir)
</div>
)}
</div>
)}
</CardContent>
</Card>
{/* Note développement futur */}
<Card>
<CardContent className="p-4">
@@ -49,7 +322,7 @@ export function GeneralSettingsPageClient({ initialPreferences }: GeneralSetting
🚧 Interface de configuration en développement
</p>
<p className="text-xs text-[var(--muted-foreground)]">
Les contrôles interactifs pour modifier ces préférences seront disponibles dans une prochaine version.
Les contrôles interactifs pour modifier les autres préférences seront disponibles dans une prochaine version.
Pour l&apos;instant, les préférences sont modifiables via les boutons de l&apos;interface principale.
</p>
</div>
@@ -59,6 +332,30 @@ export function GeneralSettingsPageClient({ initialPreferences }: GeneralSetting
</div>
</div>
</div>
{/* Modals pour les tags */}
{isCreateModalOpen && (
<TagForm
isOpen={isCreateModalOpen}
onClose={() => setIsCreateModalOpen(false)}
onSuccess={async () => {
setIsCreateModalOpen(false);
await refreshTags();
}}
/>
)}
{editingTag && (
<TagForm
isOpen={!!editingTag}
tag={editingTag}
onClose={() => setEditingTag(null)}
onSuccess={async () => {
setEditingTag(null);
await refreshTags();
}}
/>
)}
</UserPreferencesProvider>
);
}

View File

@@ -53,8 +53,7 @@ export function Header({ title = "TowerControl", subtitle = "Task Management", s
{ href: '/', label: 'Dashboard' },
{ href: '/kanban', label: 'Kanban' },
{ href: '/daily', label: 'Daily' },
{ href: '/weekly-summary', label: 'Résumé' },
{ href: '/tags', label: 'Tags' },
{ href: '/weekly-manager', label: 'Manager' },
...(isJiraConfigured ? [{ href: '/jira-dashboard', label: `Jira${jiraConfig?.projectKey ? ` (${jiraConfig.projectKey})` : ''}` }] : []),
{ href: '/settings', label: 'Settings' }
];

0
dev.db Normal file
View File

View File

@@ -16,6 +16,7 @@ services:
- sqlite_data:/app/data
# Monter ta DB locale (décommente pour utiliser tes données locales)
- ./prisma/dev.db:/app/data/prod.db
- ./backups:/app/backups
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/api/health || exit 1"]

63
hooks/use-metrics.ts Normal file
View File

@@ -0,0 +1,63 @@
import { useState, useEffect, useTransition, useCallback } from 'react';
import { getWeeklyMetrics, getVelocityTrends } from '@/actions/metrics';
import { WeeklyMetricsOverview, VelocityTrend } from '@/services/metrics';
export function useWeeklyMetrics(date?: Date) {
const [metrics, setMetrics] = useState<WeeklyMetricsOverview | null>(null);
const [error, setError] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
const fetchMetrics = useCallback(() => {
startTransition(async () => {
setError(null);
const result = await getWeeklyMetrics(date);
if (result.success && result.data) {
setMetrics(result.data);
} else {
setError(result.error || 'Failed to fetch metrics');
}
});
}, [date, startTransition]);
useEffect(() => {
fetchMetrics();
}, [date, fetchMetrics]);
return {
metrics,
loading: isPending,
error,
refetch: fetchMetrics
};
}
export function useVelocityTrends(weeksBack: number = 4) {
const [trends, setTrends] = useState<VelocityTrend[]>([]);
const [error, setError] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
const fetchTrends = useCallback(() => {
startTransition(async () => {
setError(null);
const result = await getVelocityTrends(weeksBack);
if (result.success && result.data) {
setTrends(result.data);
} else {
setError(result.error || 'Failed to fetch velocity trends');
}
});
}, [weeksBack, startTransition]);
useEffect(() => {
fetchTrends();
}, [weeksBack, fetchTrends]);
return {
trends,
loading: isPending,
error,
refetch: fetchTrends
};
}

233
package-lock.json generated
View File

@@ -12,8 +12,10 @@
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@prisma/client": "^6.16.1",
"@types/jspdf": "^1.3.3",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"jspdf": "^3.0.3",
"next": "15.5.3",
"prisma": "^6.16.1",
"react": "19.1.0",
@@ -51,6 +53,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@babel/runtime": {
"version": "7.28.4",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz",
"integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@dnd-kit/accessibility": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
@@ -2035,6 +2046,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/jspdf": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@types/jspdf/-/jspdf-1.3.3.tgz",
"integrity": "sha512-DqwyAKpVuv+7DniCp2Deq1xGvfdnKSNgl9Agun2w6dFvR5UKamiv4VfYUgcypd8S9ojUyARFIlZqBrYrBMQlew==",
"license": "MIT"
},
"node_modules/@types/node": {
"version": "20.19.14",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.14.tgz",
@@ -2045,6 +2062,19 @@
"undici-types": "~6.21.0"
}
},
"node_modules/@types/pako": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz",
"integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==",
"license": "MIT"
},
"node_modules/@types/raf": {
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz",
"integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==",
"license": "MIT",
"optional": true
},
"node_modules/@types/react": {
"version": "19.1.13",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.13.tgz",
@@ -2065,6 +2095,13 @@
"@types/react": "^19.0.0"
}
},
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT",
"optional": true
},
"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",
@@ -3000,6 +3037,16 @@
"devOptional": true,
"license": "MIT"
},
"node_modules/base64-arraybuffer": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
"integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">= 0.6.0"
}
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
@@ -3254,6 +3301,26 @@
],
"license": "CC-BY-4.0"
},
"node_modules/canvg": {
"version": "3.0.11",
"resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz",
"integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==",
"license": "MIT",
"optional": true,
"dependencies": {
"@babel/runtime": "^7.12.5",
"@types/raf": "^3.4.0",
"core-js": "^3.8.3",
"raf": "^3.4.1",
"regenerator-runtime": "^0.13.7",
"rgbcolor": "^1.0.1",
"stackblur-canvas": "^2.0.0",
"svg-pathdata": "^6.0.3"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@@ -3413,6 +3480,18 @@
"license": "ISC",
"optional": true
},
"node_modules/core-js": {
"version": "3.45.1",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.45.1.tgz",
"integrity": "sha512-L4NPsJlCfZsPeXukyzHFlg/i7IIVwHSItR0wg0FLNqYClJ4MQYTYLbC7EkjKYRLZF2iof2MUgN0EGy7MdQFChg==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/core-js"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -3428,6 +3507,16 @@
"node": ">= 8"
}
},
"node_modules/css-line-break": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz",
"integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==",
"license": "MIT",
"optional": true,
"dependencies": {
"utrie": "^1.0.2"
}
},
"node_modules/csstype": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
@@ -3768,6 +3857,16 @@
"node": ">=0.10.0"
}
},
"node_modules/dompurify": {
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz",
"integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optional": true,
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/dotenv": {
"version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
@@ -4687,6 +4786,17 @@
"dev": true,
"license": "MIT"
},
"node_modules/fast-png": {
"version": "6.4.0",
"resolved": "https://registry.npmjs.org/fast-png/-/fast-png-6.4.0.tgz",
"integrity": "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==",
"license": "MIT",
"dependencies": {
"@types/pako": "^2.0.3",
"iobuffer": "^5.3.2",
"pako": "^2.1.0"
}
},
"node_modules/fastq": {
"version": "1.19.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz",
@@ -4697,6 +4807,12 @@
"reusify": "^1.0.4"
}
},
"node_modules/fflate": {
"version": "0.8.2",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
"integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==",
"license": "MIT"
},
"node_modules/file-entry-cache": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
@@ -5171,6 +5287,20 @@
"node": ">= 0.4"
}
},
"node_modules/html2canvas": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
"integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==",
"license": "MIT",
"optional": true,
"dependencies": {
"css-line-break": "^2.1.0",
"text-segmentation": "^1.0.3"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/http-cache-semantics": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz",
@@ -5362,6 +5492,12 @@
"node": ">=12"
}
},
"node_modules/iobuffer": {
"version": "5.4.0",
"resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz",
"integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==",
"license": "MIT"
},
"node_modules/ip-address": {
"version": "10.0.1",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz",
@@ -5892,6 +6028,23 @@
"json5": "lib/cli.js"
}
},
"node_modules/jspdf": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/jspdf/-/jspdf-3.0.3.tgz",
"integrity": "sha512-eURjAyz5iX1H8BOYAfzvdPfIKK53V7mCpBTe7Kb16PaM8JSXEcUQNBQaiWMI8wY5RvNOPj4GccMjTlfwRBd+oQ==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.26.9",
"fast-png": "^6.2.0",
"fflate": "^0.8.1"
},
"optionalDependencies": {
"canvg": "^3.0.11",
"core-js": "^3.6.0",
"dompurify": "^3.2.4",
"html2canvas": "^1.0.0-rc.5"
}
},
"node_modules/jsx-ast-utils": {
"version": "3.3.5",
"resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz",
@@ -6954,6 +7107,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/pako": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz",
"integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==",
"license": "(MIT AND Zlib)"
},
"node_modules/parent-module": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@@ -7016,6 +7175,13 @@
"integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==",
"license": "MIT"
},
"node_modules/performance-now": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
"integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==",
"license": "MIT",
"optional": true
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -7265,6 +7431,16 @@
],
"license": "MIT"
},
"node_modules/raf": {
"version": "3.4.1",
"resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz",
"integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==",
"license": "MIT",
"optional": true,
"dependencies": {
"performance-now": "^2.1.0"
}
},
"node_modules/rc": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
@@ -7441,6 +7617,13 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/regenerator-runtime": {
"version": "0.13.11",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
"license": "MIT",
"optional": true
},
"node_modules/regexp.prototype.flags": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz",
@@ -7530,6 +7713,16 @@
"node": ">=0.10.0"
}
},
"node_modules/rgbcolor": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz",
"integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==",
"license": "MIT OR SEE LICENSE IN FEEL-FREE.md",
"optional": true,
"engines": {
"node": ">= 0.8.15"
}
},
"node_modules/rimraf": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
@@ -8051,6 +8244,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/stackblur-canvas": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz",
"integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=0.1.14"
}
},
"node_modules/stop-iteration-iterator": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz",
@@ -8294,6 +8497,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/svg-pathdata": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz",
"integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/synckit": {
"version": "0.11.11",
"resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.11.tgz",
@@ -8452,6 +8665,16 @@
"node": ">=18"
}
},
"node_modules/text-segmentation": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
"integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==",
"license": "MIT",
"optional": true,
"dependencies": {
"utrie": "^1.0.2"
}
},
"node_modules/tiny-invariant": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
@@ -8800,6 +9023,16 @@
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/utrie": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz",
"integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==",
"license": "MIT",
"optional": true,
"dependencies": {
"base64-arraybuffer": "^1.0.2"
}
},
"node_modules/victory-vendor": {
"version": "37.3.6",
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",

View File

@@ -20,8 +20,10 @@
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@prisma/client": "^6.16.1",
"@types/jspdf": "^1.3.3",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"jspdf": "^3.0.3",
"next": "15.5.3",
"prisma": "^6.16.1",
"react": "19.1.0",

View File

@@ -10,28 +10,44 @@ async function seedTestData() {
const testTasks = [
{
title: '🎨 Redesign du dashboard',
description: 'Créer une interface moderne et intuitive pour le tableau de bord principal',
title: '🎨 Design System Implementation',
description: 'Create and implement a comprehensive design system with reusable components',
status: 'in_progress' as TaskStatus,
priority: 'high' as TaskPriority,
tags: ['design', 'ui', 'frontend'],
dueDate: new Date('2025-01-20')
dueDate: new Date('2025-12-31')
},
{
title: '🔧 Optimiser les performances API',
description: 'Améliorer les temps de réponse des endpoints et ajouter la pagination',
title: '🔧 API Performance Optimization',
description: 'Optimize API endpoints response time and implement pagination',
status: 'todo' as TaskStatus,
priority: 'medium' as TaskPriority,
tags: ['backend', 'performance', 'api'],
dueDate: new Date('2025-01-25')
dueDate: new Date('2025-12-15')
},
{
title: '✅ Tests unitaires composants',
description: 'Ajouter des tests Jest/RTL pour les composants principaux',
status: 'done' as TaskStatus,
title: '✅ Test Coverage Improvement',
description: 'Increase test coverage for core components and services',
status: 'todo' as TaskStatus,
priority: 'medium' as TaskPriority,
tags: ['testing', 'jest', 'quality'],
dueDate: new Date('2025-01-10')
tags: ['testing', 'quality'],
dueDate: new Date('2025-12-20')
},
{
title: '📱 Mobile Responsive Design',
description: 'Ensure all pages are fully responsive on mobile devices',
status: 'todo' as TaskStatus,
priority: 'high' as TaskPriority,
tags: ['frontend', 'mobile', 'ui'],
dueDate: new Date('2025-12-10')
},
{
title: '🔒 Security Audit',
description: 'Conduct a comprehensive security audit of the application',
status: 'backlog' as TaskStatus,
priority: 'urgent' as TaskPriority,
tags: ['security', 'audit'],
dueDate: new Date('2026-01-15')
}
];

View File

@@ -4,14 +4,17 @@ async function seedTags() {
console.log('🏷️ Création des tags de test...');
const testTags = [
{ name: 'Frontend', color: '#3B82F6' },
{ name: 'Backend', color: '#EF4444' },
{ name: 'Bug', color: '#F59E0B' },
{ name: 'Feature', color: '#10B981' },
{ name: 'Urgent', color: '#EC4899' },
{ name: 'Design', color: '#8B5CF6' },
{ name: 'API', color: '#06B6D4' },
{ name: 'Database', color: '#84CC16' },
{ name: 'frontend', color: '#3B82F6' },
{ name: 'backend', color: '#EF4444' },
{ name: 'ui', color: '#8B5CF6' },
{ name: 'design', color: '#EC4899' },
{ name: 'mobile', color: '#F59E0B' },
{ name: 'performance', color: '#10B981' },
{ name: 'api', color: '#06B6D4' },
{ name: 'testing', color: '#84CC16' },
{ name: 'quality', color: '#9333EA' },
{ name: 'security', color: '#DC2626' },
{ name: 'audit', color: '#2563EB' },
];
for (const tagData of testTags) {

180
services/jira-summary.ts Normal file
View File

@@ -0,0 +1,180 @@
import type { JiraConfig } from './jira';
import { Task } from '@/lib/types';
export interface JiraWeeklyMetrics {
totalJiraTasks: number;
completedJiraTasks: number;
totalStoryPoints: number; // Estimation basée sur le type de ticket
projectsContributed: string[];
ticketTypes: { [type: string]: number };
jiraLinks: Array<{
key: string;
title: string;
status: string;
type: string;
url: string;
estimatedPoints: number;
}>;
}
export class JiraSummaryService {
/**
* Enrichit les tâches hebdomadaires avec des métriques Jira
*/
static async getJiraWeeklyMetrics(
weeklyTasks: Task[],
jiraConfig?: JiraConfig
): Promise<JiraWeeklyMetrics | null> {
if (!jiraConfig?.baseUrl || !jiraConfig?.email || !jiraConfig?.apiToken) {
return null;
}
const jiraTasks = weeklyTasks.filter(task =>
task.source === 'jira' && task.jiraKey && task.jiraProject
);
if (jiraTasks.length === 0) {
return {
totalJiraTasks: 0,
completedJiraTasks: 0,
totalStoryPoints: 0,
projectsContributed: [],
ticketTypes: {},
jiraLinks: []
};
}
// Calculer les métriques basiques
const completedJiraTasks = jiraTasks.filter(task => task.status === 'done');
const projectsContributed = [...new Set(jiraTasks.map(task => task.jiraProject).filter((project): project is string => Boolean(project)))];
// Analyser les types de tickets
const ticketTypes: { [type: string]: number } = {};
jiraTasks.forEach(task => {
const type = task.jiraType || 'Unknown';
ticketTypes[type] = (ticketTypes[type] || 0) + 1;
});
// Estimer les story points basés sur le type de ticket
const estimateStoryPoints = (type: string): number => {
const typeMapping: { [key: string]: number } = {
'Story': 3,
'Task': 2,
'Bug': 1,
'Epic': 8,
'Sub-task': 1,
'Improvement': 2,
'New Feature': 5,
'défaut': 1, // French
'amélioration': 2, // French
'nouvelle fonctionnalité': 5, // French
};
return typeMapping[type] || typeMapping[type?.toLowerCase()] || 2; // Défaut: 2 points
};
const totalStoryPoints = jiraTasks.reduce((sum, task) => {
return sum + estimateStoryPoints(task.jiraType || '');
}, 0);
// Créer les liens Jira
const jiraLinks = jiraTasks.map(task => ({
key: task.jiraKey || '',
title: task.title,
status: task.status,
type: task.jiraType || 'Unknown',
url: `${jiraConfig.baseUrl.replace('/rest/api/3', '')}/browse/${task.jiraKey}`,
estimatedPoints: estimateStoryPoints(task.jiraType || '')
}));
return {
totalJiraTasks: jiraTasks.length,
completedJiraTasks: completedJiraTasks.length,
totalStoryPoints,
projectsContributed,
ticketTypes,
jiraLinks
};
}
/**
* Récupère la configuration Jira depuis les préférences utilisateur
*/
static async getJiraConfig(): Promise<JiraConfig | null> {
try {
// Import dynamique pour éviter les cycles de dépendance
const { userPreferencesService } = await import('./user-preferences');
const preferences = await userPreferencesService.getAllPreferences();
if (!preferences.jiraConfig?.baseUrl ||
!preferences.jiraConfig?.email ||
!preferences.jiraConfig?.apiToken) {
return null;
}
return {
baseUrl: preferences.jiraConfig.baseUrl,
email: preferences.jiraConfig.email,
apiToken: preferences.jiraConfig.apiToken,
projectKey: preferences.jiraConfig.projectKey,
ignoredProjects: preferences.jiraConfig.ignoredProjects
};
} catch (error) {
console.error('Erreur lors de la récupération de la config Jira:', error);
return null;
}
}
/**
* Génère des insights business basés sur les métriques Jira
*/
static generateBusinessInsights(jiraMetrics: JiraWeeklyMetrics): string[] {
const insights: string[] = [];
if (jiraMetrics.totalJiraTasks === 0) {
insights.push("Aucune tâche Jira cette semaine. Concentré sur des tâches internes ?");
return insights;
}
// Insights sur la completion
const completionRate = (jiraMetrics.completedJiraTasks / jiraMetrics.totalJiraTasks) * 100;
if (completionRate >= 80) {
insights.push(`🎯 Excellent taux de completion Jira: ${completionRate.toFixed(0)}%`);
} else if (completionRate < 50) {
insights.push(`⚠️ Taux de completion Jira faible: ${completionRate.toFixed(0)}%. Revoir les estimations ?`);
}
// Insights sur les story points
if (jiraMetrics.totalStoryPoints > 0) {
insights.push(`📊 Estimation: ${jiraMetrics.totalStoryPoints} story points traités cette semaine`);
const avgPointsPerTask = jiraMetrics.totalStoryPoints / jiraMetrics.totalJiraTasks;
if (avgPointsPerTask > 4) {
insights.push(`🏋️ Travail sur des tâches complexes (${avgPointsPerTask.toFixed(1)} pts/tâche en moyenne)`);
}
}
// Insights sur les projets
if (jiraMetrics.projectsContributed.length > 1) {
insights.push(`🤝 Contribution multi-projets: ${jiraMetrics.projectsContributed.join(', ')}`);
} else if (jiraMetrics.projectsContributed.length === 1) {
insights.push(`🎯 Focus sur le projet ${jiraMetrics.projectsContributed[0]}`);
}
// Insights sur les types de tickets
const bugCount = jiraMetrics.ticketTypes['Bug'] || jiraMetrics.ticketTypes['défaut'] || 0;
const totalTickets = Object.values(jiraMetrics.ticketTypes).reduce((sum, count) => sum + count, 0);
if (bugCount > 0) {
const bugRatio = (bugCount / totalTickets) * 100;
if (bugRatio > 50) {
insights.push(`🐛 Semaine focalisée sur la correction de bugs (${bugRatio.toFixed(0)}%)`);
} else if (bugRatio < 20) {
insights.push(`✨ Semaine productive avec peu de bugs (${bugRatio.toFixed(0)}%)`);
}
}
return insights;
}
}

563
services/manager-summary.ts Normal file
View File

@@ -0,0 +1,563 @@
import { prisma } from './database';
import { startOfWeek, endOfWeek } from 'date-fns';
type TaskType = {
id: string;
title: string;
description?: string | null;
priority: string; // high, medium, low
completedAt?: Date | null;
createdAt: Date;
taskTags?: {
tag: {
name: string;
}
}[];
};
type CheckboxType = {
id: string;
text: string;
isChecked: boolean;
type: string; // task, meeting
date: Date;
createdAt: Date;
task?: {
id: string;
title: string;
priority: string;
taskTags?: {
tag: {
name: string;
}
}[];
} | null;
};
export interface KeyAccomplishment {
id: string;
title: string;
description?: string;
tags: string[];
impact: 'high' | 'medium' | 'low';
completedAt: Date;
relatedItems: string[]; // IDs des tâches/checkboxes liées
todosCount: number; // Nombre de todos associés
}
export interface UpcomingChallenge {
id: string;
title: string;
description?: string;
tags: string[];
priority: 'high' | 'medium' | 'low';
estimatedEffort: 'days' | 'weeks' | 'hours';
blockers: string[];
deadline?: Date;
relatedItems: string[]; // IDs des tâches/checkboxes liées
todosCount: number; // Nombre de todos associés
}
export interface ManagerSummary {
period: {
start: Date;
end: Date;
};
keyAccomplishments: KeyAccomplishment[];
upcomingChallenges: UpcomingChallenge[];
metrics: {
totalTasksCompleted: number;
totalCheckboxesCompleted: number;
highPriorityTasksCompleted: number;
meetingCheckboxesCompleted: number;
completionRate: number;
focusAreas: { [category: string]: number };
};
narrative: {
weekHighlight: string;
mainChallenges: string;
nextWeekFocus: string;
};
}
export class ManagerSummaryService {
/**
* Génère un résumé orienté manager pour la semaine
*/
static async getManagerSummary(date: Date = new Date()): Promise<ManagerSummary> {
const weekStart = startOfWeek(date, { weekStartsOn: 1 }); // Lundi
const weekEnd = endOfWeek(date, { weekStartsOn: 1 }); // Dimanche
// Récupérer les données de base
const [tasks, checkboxes] = await Promise.all([
this.getCompletedTasks(weekStart, weekEnd),
this.getCompletedCheckboxes(weekStart, weekEnd)
]);
// Analyser et extraire les accomplissements clés
const keyAccomplishments = this.extractKeyAccomplishments(tasks, checkboxes);
// Identifier les défis à venir
const upcomingChallenges = await this.identifyUpcomingChallenges();
// Calculer les métriques
const metrics = this.calculateMetrics(tasks, checkboxes);
// Générer le narratif
const narrative = this.generateNarrative(keyAccomplishments, upcomingChallenges);
return {
period: { start: weekStart, end: weekEnd },
keyAccomplishments,
upcomingChallenges,
metrics,
narrative
};
}
/**
* Récupère les tâches complétées de la semaine
*/
private static async getCompletedTasks(startDate: Date, endDate: Date) {
const tasks = await prisma.task.findMany({
where: {
OR: [
// Tâches avec completedAt dans la période (priorité)
{
completedAt: {
gte: startDate,
lte: endDate
}
},
// Tâches avec status 'done' et updatedAt dans la période
{
status: 'done',
updatedAt: {
gte: startDate,
lte: endDate
}
},
// Tâches avec status 'archived' récemment (aussi des accomplissements)
{
status: 'archived',
updatedAt: {
gte: startDate,
lte: endDate
}
}
]
},
orderBy: {
completedAt: 'desc'
},
select: {
id: true,
title: true,
description: true,
priority: true,
completedAt: true,
createdAt: true,
taskTags: {
select: {
tag: {
select: {
name: true
}
}
}
}
}
});
return tasks;
}
/**
* Récupère les checkboxes complétées de la semaine
*/
private static async getCompletedCheckboxes(startDate: Date, endDate: Date) {
const checkboxes = await prisma.dailyCheckbox.findMany({
where: {
isChecked: true,
date: {
gte: startDate,
lte: endDate
}
},
select: {
id: true,
text: true,
isChecked: true,
type: true,
date: true,
createdAt: true,
task: {
select: {
id: true,
title: true,
priority: true,
taskTags: {
select: {
tag: {
select: {
name: true
}
}
}
}
}
}
},
orderBy: {
date: 'desc'
}
});
return checkboxes;
}
/**
* Extrait les accomplissements clés basés sur la priorité
*/
private static extractKeyAccomplishments(tasks: TaskType[], checkboxes: CheckboxType[]): KeyAccomplishment[] {
const accomplishments: KeyAccomplishment[] = [];
// Tâches: prendre toutes les high/medium priority, et quelques low si significatives
tasks.forEach(task => {
const priority = task.priority.toLowerCase();
// Convertir priorité task en impact accomplissement
let impact: 'high' | 'medium' | 'low';
if (priority === 'high') {
impact = 'high';
} else if (priority === 'medium') {
impact = 'medium';
} else {
// Pour les low priority, ne garder que si c'est vraiment significatif
if (!this.isSignificantTask(task.title)) {
return;
}
impact = 'low';
}
// Compter les todos (checkboxes) associés à cette tâche
const relatedTodos = checkboxes.filter(cb => cb.task?.id === task.id);
accomplishments.push({
id: `task-${task.id}`,
title: task.title,
description: task.description || undefined,
tags: task.taskTags?.map(tt => tt.tag.name) || [],
impact,
completedAt: task.completedAt || new Date(),
relatedItems: [task.id, ...relatedTodos.map(t => t.id)],
todosCount: relatedTodos.length // Nombre réel de todos associés
});
});
// AJOUTER SEULEMENT les meetings importants standalone (non liés à une tâche)
const standaloneMeetings = checkboxes.filter(checkbox =>
checkbox.type === 'meeting' && !checkbox.task // Meetings non liés à une tâche
);
standaloneMeetings.forEach(meeting => {
accomplishments.push({
id: `meeting-${meeting.id}`,
title: `📅 ${meeting.text}`,
tags: [], // Meetings n'ont pas de tags par défaut
impact: 'medium', // Meetings sont importants
completedAt: meeting.date,
relatedItems: [meeting.id],
todosCount: 1 // Un meeting = 1 todo
});
});
// Trier par impact puis par date
return accomplishments
.sort((a, b) => {
const impactOrder = { high: 3, medium: 2, low: 1 };
if (impactOrder[a.impact] !== impactOrder[b.impact]) {
return impactOrder[b.impact] - impactOrder[a.impact];
}
return b.completedAt.getTime() - a.completedAt.getTime();
})
.slice(0, 12); // Plus d'items maintenant qu'on filtre mieux
}
/**
* Identifie les défis et enjeux à venir
*/
private static async identifyUpcomingChallenges(): Promise<UpcomingChallenge[]> {
// Récupérer les tâches à venir (priorité high/medium en premier)
const upcomingTasks = await prisma.task.findMany({
where: {
completedAt: null
},
orderBy: [
{ priority: 'asc' }, // high < medium < low
{ createdAt: 'desc' }
],
select: {
id: true,
title: true,
description: true,
priority: true,
createdAt: true,
taskTags: {
select: {
tag: {
select: {
name: true
}
}
}
}
},
take: 30
});
// Récupérer les checkboxes récurrentes non complétées (meetings + tâches prioritaires)
const upcomingCheckboxes = await prisma.dailyCheckbox.findMany({
where: {
isChecked: false,
date: {
gte: new Date()
},
OR: [
{ type: 'meeting' },
{
task: {
priority: {
in: ['high', 'medium']
}
}
}
]
},
select: {
id: true,
text: true,
isChecked: true,
type: true,
date: true,
createdAt: true,
task: {
select: {
id: true,
title: true,
priority: true,
taskTags: {
select: {
tag: {
select: {
name: true
}
}
}
}
}
}
},
orderBy: [
{ date: 'asc' },
{ createdAt: 'asc' }
],
take: 20
});
const challenges: UpcomingChallenge[] = [];
// Analyser les tâches - se baser sur la priorité réelle
upcomingTasks.forEach((task) => {
const taskPriority = task.priority.toLowerCase();
// Convertir priorité task en priorité challenge
let priority: 'high' | 'medium' | 'low';
if (taskPriority === 'high') {
priority = 'high';
} else if (taskPriority === 'medium') {
priority = 'medium';
} else {
// Pour les low priority, ne garder que si c'est vraiment challengeant
if (!this.isChallengingTask(task.title)) {
return;
}
priority = 'low';
}
const estimatedEffort = this.estimateEffort(task.title, task.description || undefined);
challenges.push({
id: `task-${task.id}`,
title: task.title,
description: task.description || undefined,
tags: task.taskTags?.map(tt => tt.tag.name) || [],
priority,
estimatedEffort,
blockers: this.identifyBlockers(task.title, task.description || undefined),
relatedItems: [task.id],
todosCount: 0 // TODO: compter les todos associés à cette tâche
});
});
// Ajouter les meetings importants comme challenges
upcomingCheckboxes.forEach(checkbox => {
if (checkbox.type === 'meeting') {
challenges.push({
id: `checkbox-${checkbox.id}`,
title: checkbox.text,
tags: checkbox.task?.taskTags?.map(tt => tt.tag.name) || [],
priority: 'medium', // Meetings sont medium par défaut
estimatedEffort: 'hours',
blockers: [],
relatedItems: [checkbox.id],
todosCount: 1 // Une checkbox = 1 todo
});
}
});
return challenges
.sort((a, b) => {
const priorityOrder = { high: 3, medium: 2, low: 1 };
return priorityOrder[b.priority] - priorityOrder[a.priority];
})
.slice(0, 10); // Plus d'items maintenant qu'on filtre mieux
}
/**
* Estime l'effort requis
*/
private static estimateEffort(title: string, description?: string): 'days' | 'weeks' | 'hours' {
const content = `${title} ${description || ''}`.toLowerCase();
if (content.includes('architecture') || content.includes('migration') || content.includes('refactor')) {
return 'weeks';
}
if (content.includes('feature') || content.includes('implement') || content.includes('integration')) {
return 'days';
}
return 'hours';
}
/**
* Identifie les blockers potentiels
*/
private static identifyBlockers(title: string, description?: string): string[] {
const content = `${title} ${description || ''}`.toLowerCase();
const blockers: string[] = [];
if (content.includes('depends') || content.includes('waiting')) {
blockers.push('Dépendances externes');
}
if (content.includes('approval') || content.includes('review')) {
blockers.push('Validation requise');
}
if (content.includes('design') && !content.includes('implement')) {
blockers.push('Spécifications incomplètes');
}
return blockers;
}
/**
* Détermine si une tâche est significative
*/
private static isSignificantTask(title: string): boolean {
const significantKeywords = [
'release', 'deploy', 'launch', 'milestone',
'architecture', 'design', 'strategy',
'integration', 'migration', 'optimization'
];
return significantKeywords.some(keyword => title.toLowerCase().includes(keyword));
}
/**
* Détermine si une checkbox est significative
*/
private static isSignificantCheckbox(text: string): boolean {
const content = text.toLowerCase();
return content.length > 30 || // Checkboxes détaillées
content.includes('meeting') ||
content.includes('review') ||
content.includes('call') ||
content.includes('presentation');
}
/**
* Détermine si une tâche représente un défi
*/
private static isChallengingTask(title: string): boolean {
const challengingKeywords = [
'complex', 'difficult', 'challenge',
'architecture', 'performance', 'security',
'integration', 'migration', 'optimization'
];
return challengingKeywords.some(keyword => title.toLowerCase().includes(keyword));
}
/**
* Analyse les patterns dans les checkboxes pour identifier des enjeux
*/
private static analyzeCheckboxPatterns(): UpcomingChallenge[] {
// Pour l'instant, retourner un array vide
// À implémenter selon les besoins spécifiques
return [];
}
/**
* Calcule les métriques résumées
*/
private static calculateMetrics(tasks: TaskType[], checkboxes: CheckboxType[]) {
const totalTasksCompleted = tasks.length;
const totalCheckboxesCompleted = checkboxes.length;
// Calculer les métriques détaillées
const highPriorityTasksCompleted = tasks.filter(t => t.priority.toLowerCase() === 'high').length;
const meetingCheckboxesCompleted = checkboxes.filter(c => c.type === 'meeting').length;
// Analyser la répartition par catégorie
const focusAreas: { [category: string]: number } = {};
return {
totalTasksCompleted,
totalCheckboxesCompleted,
highPriorityTasksCompleted,
meetingCheckboxesCompleted,
completionRate: 0, // À calculer par rapport aux objectifs
focusAreas
};
}
/**
* Génère le narratif pour le manager
*/
private static generateNarrative(
accomplishments: KeyAccomplishment[],
challenges: UpcomingChallenge[]
) {
// Points forts de la semaine
const topAccomplishments = accomplishments.slice(0, 3);
const weekHighlight = topAccomplishments.length > 0
? `Cette semaine, j'ai principalement progressé sur ${topAccomplishments.map(a => a.title).join(', ')}.`
: 'Semaine focalisée sur l\'exécution des tâches quotidiennes.';
// Défis rencontrés
const highImpactItems = accomplishments.filter(a => a.impact === 'high');
const mainChallenges = highImpactItems.length > 0
? `Les principaux enjeux traités ont été liés aux ${[...new Set(highImpactItems.flatMap(a => a.tags))].join(', ')}.`
: 'Pas de blockers majeurs rencontrés cette semaine.';
// Focus semaine prochaine
const topChallenges = challenges.slice(0, 3);
const nextWeekFocus = topChallenges.length > 0
? `La semaine prochaine sera concentrée sur ${topChallenges.map(c => c.title).join(', ')}.`
: 'Continuation du travail en cours selon les priorités établies.';
return {
weekHighlight,
mainChallenges,
nextWeekFocus
};
}
}

362
services/metrics.ts Normal file
View File

@@ -0,0 +1,362 @@
import { prisma } from './database';
import { startOfWeek, endOfWeek, eachDayOfInterval, format, startOfDay, endOfDay } from 'date-fns';
import { fr } from 'date-fns/locale';
export interface DailyMetrics {
date: string; // Format ISO
dayName: string; // Lundi, Mardi, etc.
completed: number;
inProgress: number;
blocked: number;
pending: number;
newTasks: number;
totalTasks: number;
completionRate: number;
}
export interface VelocityTrend {
date: string;
completed: number;
created: number;
velocity: number;
}
export interface WeeklyMetricsOverview {
period: {
start: Date;
end: Date;
};
dailyBreakdown: DailyMetrics[];
summary: {
totalTasksCompleted: number;
totalTasksCreated: number;
averageCompletionRate: number;
peakProductivityDay: string;
lowProductivityDay: string;
trendsAnalysis: {
completionTrend: 'improving' | 'declining' | 'stable';
productivityPattern: 'consistent' | 'variable' | 'weekend-heavy';
};
};
statusDistribution: {
status: string;
count: number;
percentage: number;
color: string;
}[];
priorityBreakdown: {
priority: string;
completed: number;
pending: number;
total: number;
completionRate: number;
color: string;
}[];
}
export class MetricsService {
/**
* Récupère les métriques journalières de la semaine
*/
static async getWeeklyMetrics(date: Date = new Date()): Promise<WeeklyMetricsOverview> {
const weekStart = startOfWeek(date, { weekStartsOn: 1 }); // Lundi
const weekEnd = endOfWeek(date, { weekStartsOn: 1 }); // Dimanche
// Générer tous les jours de la semaine
const daysOfWeek = eachDayOfInterval({ start: weekStart, end: weekEnd });
// Récupérer les données pour chaque jour
const dailyBreakdown = await Promise.all(
daysOfWeek.map(day => this.getDailyMetrics(day))
);
// Calculer les métriques de résumé
const summary = this.calculateWeeklySummary(dailyBreakdown);
// Récupérer la distribution des statuts pour la semaine
const statusDistribution = await this.getStatusDistribution(weekStart, weekEnd);
// Récupérer la répartition par priorité
const priorityBreakdown = await this.getPriorityBreakdown(weekStart, weekEnd);
return {
period: { start: weekStart, end: weekEnd },
dailyBreakdown,
summary,
statusDistribution,
priorityBreakdown
};
}
/**
* Récupère les métriques pour un jour donné
*/
private static async getDailyMetrics(date: Date): Promise<DailyMetrics> {
const dayStart = startOfDay(date);
const dayEnd = endOfDay(date);
// Compter les tâches par statut à la fin de la journée
const [completed, inProgress, blocked, pending, newTasks, totalTasks] = await Promise.all([
// Tâches complétées ce jour
prisma.task.count({
where: {
OR: [
{
completedAt: {
gte: dayStart,
lte: dayEnd
}
},
{
status: 'done',
updatedAt: {
gte: dayStart,
lte: dayEnd
}
}
]
}
}),
// Tâches en cours (status = in_progress à ce moment)
prisma.task.count({
where: {
status: 'in_progress',
createdAt: { lte: dayEnd }
}
}),
// Tâches bloquées
prisma.task.count({
where: {
status: 'blocked',
createdAt: { lte: dayEnd }
}
}),
// Tâches en attente
prisma.task.count({
where: {
status: 'pending',
createdAt: { lte: dayEnd }
}
}),
// Nouvelles tâches créées ce jour
prisma.task.count({
where: {
createdAt: {
gte: dayStart,
lte: dayEnd
}
}
}),
// Total des tâches existantes ce jour
prisma.task.count({
where: {
createdAt: { lte: dayEnd }
}
})
]);
const completionRate = totalTasks > 0 ? (completed / totalTasks) * 100 : 0;
return {
date: date.toISOString(),
dayName: format(date, 'EEEE', { locale: fr }),
completed,
inProgress,
blocked,
pending,
newTasks,
totalTasks,
completionRate: Math.round(completionRate * 100) / 100
};
}
/**
* Calcule le résumé hebdomadaire
*/
private static calculateWeeklySummary(dailyBreakdown: DailyMetrics[]) {
const totalTasksCompleted = dailyBreakdown.reduce((sum, day) => sum + day.completed, 0);
const totalTasksCreated = dailyBreakdown.reduce((sum, day) => sum + day.newTasks, 0);
const averageCompletionRate = dailyBreakdown.reduce((sum, day) => sum + day.completionRate, 0) / dailyBreakdown.length;
// Identifier les jours de pic et de creux
const peakDay = dailyBreakdown.reduce((peak, day) =>
day.completed > peak.completed ? day : peak
);
const lowDay = dailyBreakdown.reduce((low, day) =>
day.completed < low.completed ? day : low
);
// Analyser les tendances
const firstHalf = dailyBreakdown.slice(0, 3);
const secondHalf = dailyBreakdown.slice(4);
const firstHalfAvg = firstHalf.reduce((sum, day) => sum + day.completed, 0) / firstHalf.length;
const secondHalfAvg = secondHalf.reduce((sum, day) => sum + day.completed, 0) / secondHalf.length;
let completionTrend: 'improving' | 'declining' | 'stable';
if (secondHalfAvg > firstHalfAvg * 1.1) {
completionTrend = 'improving';
} else if (secondHalfAvg < firstHalfAvg * 0.9) {
completionTrend = 'declining';
} else {
completionTrend = 'stable';
}
// Analyser le pattern de productivité
const weekendDays = dailyBreakdown.slice(5); // Samedi et dimanche
const weekdayDays = dailyBreakdown.slice(0, 5);
const weekendAvg = weekendDays.reduce((sum, day) => sum + day.completed, 0) / weekendDays.length;
const weekdayAvg = weekdayDays.reduce((sum, day) => sum + day.completed, 0) / weekdayDays.length;
let productivityPattern: 'consistent' | 'variable' | 'weekend-heavy';
if (weekendAvg > weekdayAvg * 1.2) {
productivityPattern = 'weekend-heavy';
} else {
const variance = dailyBreakdown.reduce((sum, day) => {
const diff = day.completed - (totalTasksCompleted / dailyBreakdown.length);
return sum + diff * diff;
}, 0) / dailyBreakdown.length;
productivityPattern = variance > 4 ? 'variable' : 'consistent';
}
return {
totalTasksCompleted,
totalTasksCreated,
averageCompletionRate: Math.round(averageCompletionRate * 100) / 100,
peakProductivityDay: peakDay.dayName,
lowProductivityDay: lowDay.dayName,
trendsAnalysis: {
completionTrend,
productivityPattern
}
};
}
/**
* Récupère la distribution des statuts pour la période
*/
private static async getStatusDistribution(start: Date, end: Date) {
const statusCounts = await prisma.task.groupBy({
by: ['status'],
_count: {
status: true
},
where: {
createdAt: {
gte: start,
lte: end
}
}
});
const total = statusCounts.reduce((sum, item) => sum + item._count.status, 0);
const statusColors: { [key: string]: string } = {
pending: '#94a3b8', // gray
in_progress: '#3b82f6', // blue
blocked: '#ef4444', // red
done: '#10b981', // green
archived: '#6b7280' // gray-500
};
return statusCounts.map(item => ({
status: item.status,
count: item._count.status,
percentage: Math.round((item._count.status / total) * 100 * 100) / 100,
color: statusColors[item.status] || '#6b7280'
}));
}
/**
* Récupère la répartition par priorité avec taux de completion
*/
private static async getPriorityBreakdown(start: Date, end: Date) {
const priorities = ['high', 'medium', 'low'];
const priorityData = await Promise.all(
priorities.map(async (priority) => {
const [completed, total] = await Promise.all([
prisma.task.count({
where: {
priority,
completedAt: {
gte: start,
lte: end
}
}
}),
prisma.task.count({
where: {
priority,
createdAt: {
gte: start,
lte: end
}
}
})
]);
const pending = total - completed;
const completionRate = total > 0 ? (completed / total) * 100 : 0;
return {
priority,
completed,
pending,
total,
completionRate: Math.round(completionRate * 100) / 100,
color: priority === 'high' ? '#ef4444' :
priority === 'medium' ? '#f59e0b' : '#10b981'
};
})
);
return priorityData;
}
/**
* Récupère les métriques de vélocité d'équipe (pour graphiques de tendance)
*/
static async getVelocityTrends(weeksBack: number = 4): Promise<VelocityTrend[]> {
const trends = [];
for (let i = weeksBack - 1; i >= 0; i--) {
const weekStart = startOfWeek(new Date(Date.now() - i * 7 * 24 * 60 * 60 * 1000), { weekStartsOn: 1 });
const weekEnd = endOfWeek(weekStart, { weekStartsOn: 1 });
const [completed, created] = await Promise.all([
prisma.task.count({
where: {
completedAt: {
gte: weekStart,
lte: weekEnd
}
}
}),
prisma.task.count({
where: {
createdAt: {
gte: weekStart,
lte: weekEnd
}
}
})
]);
const velocity = created > 0 ? (completed / created) * 100 : 0;
trends.push({
date: format(weekStart, 'dd/MM', { locale: fr }),
completed,
created,
velocity: Math.round(velocity * 100) / 100
});
}
return trends;
}
}

View File

@@ -0,0 +1,185 @@
export interface PredefinedCategory {
name: string;
color: string;
keywords: string[];
icon: string;
}
export const PREDEFINED_CATEGORIES: PredefinedCategory[] = [
{
name: 'Dev',
color: '#3b82f6', // Blue
icon: '💻',
keywords: [
'code', 'coding', 'development', 'develop', 'dev', 'programming', 'program',
'bug', 'fix', 'debug', 'feature', 'implement', 'refactor', 'review',
'api', 'database', 'db', 'frontend', 'backend', 'ui', 'ux',
'component', 'service', 'function', 'method', 'class',
'git', 'commit', 'merge', 'pull request', 'pr', 'deploy', 'deployment',
'test', 'testing', 'unit test', 'integration'
]
},
{
name: 'Meeting',
color: '#8b5cf6', // Purple
icon: '🤝',
keywords: [
'meeting', 'réunion', 'call', 'standup', 'daily', 'retrospective', 'retro',
'planning', 'demo', 'presentation', 'sync', 'catch up', 'catchup',
'interview', 'discussion', 'brainstorm', 'workshop', 'session',
'one on one', '1on1', 'review meeting', 'sprint planning'
]
},
{
name: 'Admin',
color: '#6b7280', // Gray
icon: '📋',
keywords: [
'admin', 'administration', 'paperwork', 'documentation', 'doc', 'docs',
'report', 'reporting', 'timesheet', 'expense', 'invoice',
'email', 'mail', 'communication', 'update', 'status',
'config', 'configuration', 'setup', 'installation', 'maintenance',
'backup', 'security', 'permission', 'user management'
]
},
{
name: 'Learning',
color: '#10b981', // Green
icon: '📚',
keywords: [
'learning', 'learn', 'study', 'training', 'course', 'tutorial',
'research', 'reading', 'documentation', 'knowledge', 'skill',
'certification', 'workshop', 'seminar', 'conference',
'practice', 'exercise', 'experiment', 'exploration', 'investigate'
]
}
];
export class TaskCategorizationService {
/**
* Suggère une catégorie basée sur le titre et la description d'une tâche
*/
static suggestCategory(title: string, description?: string): PredefinedCategory | null {
const text = `${title} ${description || ''}`.toLowerCase();
// Compte les matches pour chaque catégorie
const categoryScores = PREDEFINED_CATEGORIES.map(category => {
const matches = category.keywords.filter(keyword =>
text.includes(keyword.toLowerCase())
).length;
return {
category,
score: matches
};
});
// Trouve la meilleure catégorie
const bestMatch = categoryScores.reduce((best, current) =>
current.score > best.score ? current : best
);
// Retourne la catégorie seulement s'il y a au moins un match
return bestMatch.score > 0 ? bestMatch.category : null;
}
/**
* Suggère plusieurs catégories avec leur score de confiance
*/
static suggestCategoriesWithScore(title: string, description?: string): Array<{
category: PredefinedCategory;
score: number;
confidence: number;
}> {
const text = `${title} ${description || ''}`.toLowerCase();
const categoryScores = PREDEFINED_CATEGORIES.map(category => {
const matches = category.keywords.filter(keyword =>
text.includes(keyword.toLowerCase())
);
const score = matches.length;
const confidence = Math.min((score / 3) * 100, 100); // Max 100% de confiance avec 3+ mots-clés
return {
category,
score,
confidence
};
});
return categoryScores
.filter(item => item.score > 0)
.sort((a, b) => b.score - a.score);
}
/**
* Analyse les activités et retourne la répartition par catégorie
*/
static analyzeActivitiesByCategory(activities: Array<{ title: string; description?: string }>): {
[categoryName: string]: {
count: number;
percentage: number;
color: string;
icon: string;
}
} {
const categoryCounts: { [key: string]: number } = {};
const uncategorized = { count: 0 };
// Initialiser les compteurs
PREDEFINED_CATEGORIES.forEach(cat => {
categoryCounts[cat.name] = 0;
});
// Analyser chaque activité
activities.forEach(activity => {
const suggestedCategory = this.suggestCategory(activity.title, activity.description);
if (suggestedCategory) {
categoryCounts[suggestedCategory.name]++;
} else {
uncategorized.count++;
}
});
const total = activities.length;
const result: { [categoryName: string]: { count: number; percentage: number; color: string; icon: string } } = {};
// Ajouter les catégories prédéfinies
PREDEFINED_CATEGORIES.forEach(category => {
const count = categoryCounts[category.name];
result[category.name] = {
count,
percentage: total > 0 ? (count / total) * 100 : 0,
color: category.color,
icon: category.icon
};
});
// Ajouter "Autre" si nécessaire
if (uncategorized.count > 0) {
result['Autre'] = {
count: uncategorized.count,
percentage: total > 0 ? (uncategorized.count / total) * 100 : 0,
color: '#d1d5db',
icon: '❓'
};
}
return result;
}
/**
* Retourne les tags suggérés pour une tâche
*/
static getSuggestedTags(title: string, description?: string): string[] {
const suggestions = this.suggestCategoriesWithScore(title, description);
return suggestions
.filter(s => s.confidence >= 30) // Seulement les suggestions avec 30%+ de confiance
.slice(0, 2) // Maximum 2 suggestions
.map(s => s.category.name);
}
}

View File

@@ -1,260 +0,0 @@
import { prisma } from './database';
import { Task, TaskStatus, TaskPriority, TaskSource } from '@/lib/types';
export interface DailyItem {
id: string;
text: string;
isChecked: boolean;
createdAt: Date;
updatedAt: Date;
date: Date;
}
export interface WeeklyStats {
totalCheckboxes: number;
completedCheckboxes: number;
totalTasks: number;
completedTasks: number;
checkboxCompletionRate: number;
taskCompletionRate: number;
mostProductiveDay: string;
dailyBreakdown: Array<{
date: string;
dayName: string;
checkboxes: number;
completedCheckboxes: number;
tasks: number;
completedTasks: number;
}>;
}
export interface WeeklyActivity {
id: string;
type: 'checkbox' | 'task';
title: string;
completed: boolean;
completedAt?: Date;
createdAt: Date;
date: string;
dayName: string;
}
export interface WeeklySummary {
stats: WeeklyStats;
activities: WeeklyActivity[];
period: {
start: Date;
end: Date;
};
}
export class WeeklySummaryService {
/**
* Récupère le résumé complet de la semaine écoulée
*/
static async getWeeklySummary(): Promise<WeeklySummary> {
const now = new Date();
const startOfWeek = new Date(now);
startOfWeek.setDate(now.getDate() - 7);
startOfWeek.setHours(0, 0, 0, 0);
const endOfWeek = new Date(now);
endOfWeek.setHours(23, 59, 59, 999);
console.log(`📊 Génération du résumé hebdomadaire du ${startOfWeek.toLocaleDateString()} au ${endOfWeek.toLocaleDateString()}`);
const [checkboxes, tasks] = await Promise.all([
this.getWeeklyCheckboxes(startOfWeek, endOfWeek),
this.getWeeklyTasks(startOfWeek, endOfWeek)
]);
const stats = this.calculateStats(checkboxes, tasks, startOfWeek, endOfWeek);
const activities = this.mergeActivities(checkboxes, tasks);
return {
stats,
activities,
period: {
start: startOfWeek,
end: endOfWeek
}
};
}
/**
* Récupère les checkboxes des 7 derniers jours
*/
private static async getWeeklyCheckboxes(startDate: Date, endDate: Date): Promise<DailyItem[]> {
const items = await prisma.dailyCheckbox.findMany({
where: {
date: {
gte: startDate,
lte: endDate
}
},
orderBy: [
{ date: 'desc' },
{ createdAt: 'desc' }
]
});
return items.map(item => ({
id: item.id,
text: item.text,
isChecked: item.isChecked,
createdAt: item.createdAt,
updatedAt: item.updatedAt,
date: item.date
}));
}
/**
* Récupère les tâches des 7 derniers jours (créées ou modifiées)
*/
private static async getWeeklyTasks(startDate: Date, endDate: Date): Promise<Task[]> {
const tasks = await prisma.task.findMany({
where: {
OR: [
{
createdAt: {
gte: startDate,
lte: endDate
}
},
{
updatedAt: {
gte: startDate,
lte: endDate
}
}
]
},
orderBy: {
updatedAt: 'desc'
}
});
return tasks.map(task => ({
id: task.id,
title: task.title,
description: task.description || '',
status: task.status as TaskStatus,
priority: task.priority as TaskPriority,
source: task.source as TaskSource,
sourceId: task.sourceId || undefined,
createdAt: task.createdAt,
updatedAt: task.updatedAt,
dueDate: task.dueDate || undefined,
completedAt: task.completedAt || undefined,
jiraProject: task.jiraProject || undefined,
jiraKey: task.jiraKey || undefined,
jiraType: task.jiraType || undefined,
assignee: task.assignee || undefined,
tags: [] // Les tags sont dans une relation séparée, on les laisse vides pour l'instant
}));
}
/**
* Calcule les statistiques de la semaine
*/
private static calculateStats(
checkboxes: DailyItem[],
tasks: Task[],
startDate: Date,
endDate: Date
): WeeklyStats {
const completedCheckboxes = checkboxes.filter(c => c.isChecked);
const completedTasks = tasks.filter(t => t.status === 'done');
// Créer un breakdown par jour
const dailyBreakdown = [];
const current = new Date(startDate);
while (current <= endDate) {
const dayCheckboxes = checkboxes.filter(c =>
c.date.toISOString().split('T')[0] === current.toISOString().split('T')[0]
);
const dayCompletedCheckboxes = dayCheckboxes.filter(c => c.isChecked);
// Pour les tâches, on compte celles modifiées ce jour-là
const dayTasks = tasks.filter(t =>
t.updatedAt.toISOString().split('T')[0] === current.toISOString().split('T')[0] ||
t.createdAt.toISOString().split('T')[0] === current.toISOString().split('T')[0]
);
const dayCompletedTasks = dayTasks.filter(t => t.status === 'done');
dailyBreakdown.push({
date: current.toISOString().split('T')[0],
dayName: current.toLocaleDateString('fr-FR', { weekday: 'long' }),
checkboxes: dayCheckboxes.length,
completedCheckboxes: dayCompletedCheckboxes.length,
tasks: dayTasks.length,
completedTasks: dayCompletedTasks.length
});
current.setDate(current.getDate() + 1);
}
// Trouver le jour le plus productif
const mostProductiveDay = dailyBreakdown.reduce((max, day) => {
const dayScore = day.completedCheckboxes + day.completedTasks;
const maxScore = max.completedCheckboxes + max.completedTasks;
return dayScore > maxScore ? day : max;
}, dailyBreakdown[0]);
return {
totalCheckboxes: checkboxes.length,
completedCheckboxes: completedCheckboxes.length,
totalTasks: tasks.length,
completedTasks: completedTasks.length,
checkboxCompletionRate: checkboxes.length > 0 ? (completedCheckboxes.length / checkboxes.length) * 100 : 0,
taskCompletionRate: tasks.length > 0 ? (completedTasks.length / tasks.length) * 100 : 0,
mostProductiveDay: mostProductiveDay.dayName,
dailyBreakdown
};
}
/**
* Fusionne les activités (checkboxes + tâches) en une timeline
*/
private static mergeActivities(checkboxes: DailyItem[], tasks: Task[]): WeeklyActivity[] {
const activities: WeeklyActivity[] = [];
// Ajouter les checkboxes
checkboxes.forEach(checkbox => {
activities.push({
id: `checkbox-${checkbox.id}`,
type: 'checkbox',
title: checkbox.text,
completed: checkbox.isChecked,
completedAt: checkbox.isChecked ? checkbox.updatedAt : undefined,
createdAt: checkbox.createdAt,
date: checkbox.date.toISOString().split('T')[0],
dayName: checkbox.date.toLocaleDateString('fr-FR', { weekday: 'long' })
});
});
// Ajouter les tâches
tasks.forEach(task => {
const date = task.updatedAt.toISOString().split('T')[0];
const dateObj = new Date(date + 'T00:00:00');
activities.push({
id: `task-${task.id}`,
type: 'task',
title: task.title,
completed: task.status === 'done',
completedAt: task.status === 'done' ? task.updatedAt : undefined,
createdAt: task.createdAt,
date: date,
dayName: dateObj.toLocaleDateString('fr-FR', { weekday: 'long' })
});
});
// Trier par date (plus récent en premier)
return activities.sort((a, b) => {
const dateA = a.completedAt || a.createdAt;
const dateB = b.completedAt || b.createdAt;
return dateB.getTime() - dateA.getTime();
});
}
}

78
src/actions/metrics.ts Normal file
View File

@@ -0,0 +1,78 @@
'use server';
import { MetricsService, WeeklyMetricsOverview, VelocityTrend } from '@/services/metrics';
import { revalidatePath } from 'next/cache';
/**
* Récupère les métriques hebdomadaires pour une date donnée
*/
export async function getWeeklyMetrics(date?: Date): Promise<{
success: boolean;
data?: WeeklyMetricsOverview;
error?: string;
}> {
try {
const targetDate = date || new Date();
const metrics = await MetricsService.getWeeklyMetrics(targetDate);
return {
success: true,
data: metrics
};
} catch (error) {
console.error('Error fetching weekly metrics:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to fetch weekly metrics'
};
}
}
/**
* Récupère les tendances de vélocité sur plusieurs semaines
*/
export async function getVelocityTrends(weeksBack: number = 4): Promise<{
success: boolean;
data?: VelocityTrend[];
error?: string;
}> {
try {
if (weeksBack < 1 || weeksBack > 12) {
return {
success: false,
error: 'Invalid weeksBack parameter (must be 1-12)'
};
}
const trends = await MetricsService.getVelocityTrends(weeksBack);
return {
success: true,
data: trends
};
} catch (error) {
console.error('Error fetching velocity trends:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to fetch velocity trends'
};
}
}
/**
* Rafraîchir les données de métriques (invalide le cache)
*/
export async function refreshMetrics(): Promise<{
success: boolean;
error?: string;
}> {
try {
revalidatePath('/manager');
return { success: true };
} catch {
return {
success: false,
error: 'Failed to refresh metrics'
};
}
}

View File

@@ -1,4 +1,5 @@
import { userPreferencesService } from '@/services/user-preferences';
import { tagsService } from '@/services/tags';
import { GeneralSettingsPageClient } from '@/components/settings/GeneralSettingsPageClient';
// Force dynamic rendering for real-time data
@@ -6,7 +7,10 @@ export const dynamic = 'force-dynamic';
export default async function GeneralSettingsPage() {
// Fetch data server-side
const preferences = await userPreferencesService.getAllPreferences();
const [preferences, tags] = await Promise.all([
userPreferencesService.getAllPreferences(),
tagsService.getTags()
]);
return <GeneralSettingsPageClient initialPreferences={preferences} />;
return <GeneralSettingsPageClient initialPreferences={preferences} initialTags={tags} />;
}

View File

@@ -1,224 +0,0 @@
'use client';
import { useState, useMemo } from 'react';
import React from 'react';
import { Tag } from '@/lib/types';
import { useTags } from '@/hooks/useTags';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { TagForm } from '@/components/forms/TagForm';
import { Header } from '@/components/ui/Header';
interface TagsPageClientProps {
initialTags: Tag[];
}
export function TagsPageClient({ initialTags }: TagsPageClientProps) {
const {
tags,
loading,
error,
refreshTags,
deleteTag
} = useTags(initialTags as (Tag & { usage: number })[]);
const [searchQuery, setSearchQuery] = useState('');
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [editingTag, setEditingTag] = useState<Tag | null>(null);
const [deletingTagId, setDeletingTagId] = useState<string | null>(null);
// Filtrer et trier les tags
const filteredAndSortedTags = useMemo(() => {
let filtered = tags;
// Filtrer par recherche
if (searchQuery.trim()) {
const query = searchQuery.toLowerCase();
filtered = tags.filter(tag =>
tag.name.toLowerCase().includes(query)
);
}
// Trier par usage puis par nom
return filtered.sort((a, b) => {
const usageA = (a as Tag & { usage?: number }).usage || 0;
const usageB = (b as Tag & { usage?: number }).usage || 0;
if (usageB !== usageA) return usageB - usageA;
return a.name.localeCompare(b.name);
});
}, [tags, searchQuery]);
const handleEditTag = (tag: Tag) => {
setEditingTag(tag);
};
const handleDeleteTag = async (tag: Tag) => {
if (!confirm(`Êtes-vous sûr de vouloir supprimer le tag "${tag.name}" ?`)) {
return;
}
setDeletingTagId(tag.id);
try {
// Utiliser la server action directement depuis useTags
await deleteTag(tag.id);
// Refresh la liste des tags
await refreshTags();
} catch (error) {
console.error('Erreur lors de la suppression:', error);
} finally {
setDeletingTagId(null);
}
};
return (
<div className="min-h-screen bg-[var(--background)]">
{/* Header uniforme */}
<Header
title="TowerControl"
subtitle="Tags - Gestion des étiquettes"
syncing={loading}
/>
{/* Header spécifique aux tags */}
<div className="bg-[var(--card)]/50 border-b border-[var(--border)]/30">
<div className="container mx-auto px-6 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<h2 className="text-lg font-mono font-bold text-[var(--foreground)] tracking-wider">
Tags ({filteredAndSortedTags.length})
</h2>
</div>
<Button
variant="primary"
onClick={() => setIsCreateModalOpen(true)}
className="flex items-center gap-2"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
Nouveau tag
</Button>
</div>
</div>
</div>
{/* Contenu principal */}
<div className="container mx-auto px-6 py-6">
{/* Barre de recherche */}
<div className="max-w-md mx-auto mb-6">
<Input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Rechercher un tag..."
className="w-full"
/>
</div>
{/* Messages d'état */}
{error && (
<div className="max-w-md mx-auto mb-6 bg-[var(--destructive)]/20 border border-[var(--destructive)]/30 rounded-lg p-3 text-center">
<div className="text-[var(--destructive)] text-sm">Erreur : {error}</div>
</div>
)}
{loading && (
<div className="text-center py-8">
<div className="text-[var(--muted-foreground)]">Chargement...</div>
</div>
)}
{/* Tags en grille compacte */}
{!loading && (
<div className="max-w-6xl mx-auto">
{filteredAndSortedTags.length === 0 ? (
<div className="text-center py-12 text-[var(--muted-foreground)]">
<div className="text-6xl mb-4">🏷</div>
<p className="text-lg mb-2">
{searchQuery ? 'Aucun tag trouvé' : 'Aucun tag'}
</p>
<p className="text-sm">
{searchQuery ? 'Essayez un autre terme' : 'Créez votre premier tag'}
</p>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{filteredAndSortedTags.map((tag) => {
const isDeleting = deletingTagId === tag.id;
const usage = (tag as Tag & { usage?: number }).usage || 0;
return (
<div
key={tag.id}
className={`relative bg-[var(--card)] rounded-lg border border-[var(--border)] hover:border-[var(--border)] transition-all duration-200 p-4 group ${
isDeleting ? 'opacity-50 pointer-events-none' : ''
}`}
>
{/* Actions en overlay */}
<div className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity flex gap-1">
<button
onClick={() => handleEditTag(tag)}
className="p-1.5 text-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-colors rounded-lg hover:bg-[var(--card-hover)]"
title="Modifier"
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
<button
onClick={() => handleDeleteTag(tag)}
className="p-1.5 text-[var(--muted-foreground)] hover:text-[var(--destructive)] transition-colors rounded-lg hover:bg-[var(--destructive)]/20"
title="Supprimer"
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
{/* Contenu principal */}
<div className="flex items-start gap-3 pr-12">
<div
className="w-5 h-5 rounded-full flex-shrink-0 mt-0.5"
style={{ backgroundColor: tag.color }}
/>
<div className="flex-1 min-w-0">
<h3 className="text-[var(--foreground)] font-medium truncate text-sm mb-1">
{tag.name}
</h3>
<p className="text-[var(--muted-foreground)] text-xs mb-2">
{usage} tâche{usage > 1 ? 's' : ''}
</p>
{tag.isPinned && (
<span className="inline-flex items-center text-[var(--accent)] text-xs bg-[var(--accent)]/10 px-2 py-1 rounded-full">
🎯 Objectif
</span>
)}
</div>
</div>
</div>
);
})}
</div>
)}
</div>
)}
</div>
{/* Modals */}
<TagForm
isOpen={isCreateModalOpen}
onClose={() => setIsCreateModalOpen(false)}
onSuccess={refreshTags}
/>
<TagForm
isOpen={!!editingTag}
onClose={() => setEditingTag(null)}
onSuccess={refreshTags}
tag={editingTag}
/>
</div>
);
}

View File

@@ -1,14 +0,0 @@
import { tagsService } from '@/services/tags';
import { TagsPageClient } from './TagsPageClient';
// Force dynamic rendering (no static generation)
export const dynamic = 'force-dynamic';
export default async function TagsPage() {
// SSR - Récupération des tags côté serveur
const initialTags = await tagsService.getTags();
return (
<TagsPageClient initialTags={initialTags} />
);
}

View File

@@ -0,0 +1,32 @@
'use client';
import { TasksProvider } from '@/contexts/TasksContext';
import { UserPreferencesProvider } from '@/contexts/UserPreferencesContext';
import ManagerWeeklySummary from '@/components/dashboard/ManagerWeeklySummary';
import { ManagerSummary } from '@/services/manager-summary';
import { Task, Tag, UserPreferences } from '@/lib/types';
interface WeeklyManagerPageClientProps {
initialSummary: ManagerSummary;
initialTasks: Task[];
initialTags: (Tag & { usage: number })[];
initialPreferences: UserPreferences;
}
export function WeeklyManagerPageClient({
initialSummary,
initialTasks,
initialTags,
initialPreferences
}: WeeklyManagerPageClientProps) {
return (
<UserPreferencesProvider initialPreferences={initialPreferences}>
<TasksProvider
initialTasks={initialTasks}
initialTags={initialTags}
>
<ManagerWeeklySummary initialSummary={initialSummary} />
</TasksProvider>
</UserPreferencesProvider>
);
}

View File

@@ -0,0 +1,36 @@
import { Header } from '@/components/ui/Header';
import { ManagerSummaryService } from '@/services/manager-summary';
import { tasksService } from '@/services/tasks';
import { tagsService } from '@/services/tags';
import { userPreferencesService } from '@/services/user-preferences';
import { WeeklyManagerPageClient } from './WeeklyManagerPageClient';
// Force dynamic rendering (no static generation)
export const dynamic = 'force-dynamic';
export default async function WeeklyManagerPage() {
// SSR - Récupération des données côté serveur
const [summary, initialTasks, initialTags, initialPreferences] = await Promise.all([
ManagerSummaryService.getManagerSummary(),
tasksService.getTasks(),
tagsService.getTags(),
userPreferencesService.getAllPreferences()
]);
return (
<div className="min-h-screen bg-[var(--background)]">
<Header />
<div className="container mx-auto px-4 py-8">
<div className="max-w-6xl mx-auto">
<WeeklyManagerPageClient
initialSummary={summary}
initialTasks={initialTasks}
initialTags={initialTags}
initialPreferences={initialPreferences}
/>
</div>
</div>
</div>
);
}

View File

@@ -1,20 +0,0 @@
import { Header } from '@/components/ui/Header';
import WeeklySummaryClient from '@/components/dashboard/WeeklySummaryClient';
import { WeeklySummaryService } from '@/services/weekly-summary';
export default async function WeeklySummaryPage() {
// Récupération côté serveur
const summary = await WeeklySummaryService.getWeeklySummary();
return (
<div className="min-h-screen bg-[var(--background)]">
<Header />
<div className="container mx-auto px-4 py-8">
<div className="max-w-6xl mx-auto">
<WeeklySummaryClient initialSummary={summary} />
</div>
</div>
</div>
);
}