feat: overhaul TODO.md and enhance Kanban components

- Updated TODO.md to reflect the new project structure and phases, marking several tasks as completed.
- Enhanced Kanban components with a tech-inspired design, including new styles for columns and task cards.
- Removed the obsolete reminders service and task processor, streamlining the codebase for better maintainability.
- Introduced a modern API for task management, including CRUD operations and improved error handling.
- Updated global styles for a cohesive dark theme and added custom scrollbar styles.
This commit is contained in:
Julien Froidefond
2025-09-14 08:15:22 +02:00
parent d645fffd87
commit 124e8baee8
18 changed files with 857 additions and 1154 deletions

183
TODO.md
View File

@@ -1,131 +1,138 @@
# TowerControl - Plan de développement # TowerControl v2.0 - Gestionnaire de tâches moderne
## Phase 1: Setup initial et architecture backend (Priorité 1) ## Phase 1: Nettoyage et architecture (TERMINÉ)
### 1.1 Configuration projet Next.js ### 1.1 Configuration projet Next.js
- [x] Initialiser Next.js avec TypeScript - [x] Initialiser Next.js avec TypeScript
- [x] Configurer ESLint, Prettier - [x] Configurer ESLint, Prettier
- [x] Setup structure de dossiers selon les règles du workspace - [x] Setup structure de dossiers selon les règles du workspace
- [x] Configurer base de données (SQLite local pour commencer) - [x] Configurer base de données (SQLite local)
- [x] Setup Prisma ORM - [x] Setup Prisma ORM
### 1.2 Architecture backend - Services de base ### 1.2 Architecture backend standalone
- [x] Créer `services/database.ts` - Pool de connexion DB - [x] Créer `services/database.ts` - Pool de connexion DB
- [x] Créer `services/reminders.ts` - Service pour récupérer les rappels macOS - [x] Créer `services/tasks.ts` - Service CRUD pour les tâches
- [x] Créer `lib/types.ts` - Types partagés (Task, Tag, Project, etc.) - [x] Créer `lib/types.ts` - Types partagés (Task, Tag, etc.)
- [x] Créer `services/task-processor.ts` - Logique métier des tâches - [x] Nettoyer l'ancien code de synchronisation
### 1.3 Intégration Rappels macOS (Focus principal Phase 1) ### 1.3 API moderne et propre
- [x] Rechercher comment accéder aux rappels macOS en local (SQLite, AppleScript, ou API) - [x] `app/api/tasks/route.ts` - API CRUD complète (GET, POST, PATCH, DELETE)
- [x] Créer script d'extraction des rappels depuis la DB locale macOS - [x] Supprimer les routes de synchronisation obsolètes
- [x] Parser les tags et catégories des rappels - [x] Configuration moderne dans `lib/config.ts`
- [x] Mapper les données vers le modèle interne
- [x] Créer service de synchronisation périodique
### 1.4 API Routes essentielles (terminé) **Architecture finale** : App standalone avec backend propre et API REST moderne
- [x] `app/api/tasks/route.ts` - CRUD tâches
- [x] `app/api/sync/reminders/route.ts` - Synchronisation rappels
- [x] `app/api/config/route.ts` - Configuration et test des listes
- [x] `app/api/test/route.ts` - Tests des services
**Note** : Privilégier SSR avec appels directs aux services plutôt que créer plus de routes API ## 🎯 Phase 2: Interface utilisateur moderne (EN COURS)
## Phase 2: Interface utilisateur Kanban (Priorité 2) ### 2.1 Système de design et composants UI
- [ ] Créer les composants UI de base (Button, Input, Card, Modal, etc.)
- [ ] Implémenter le système de design (couleurs, typographie, spacing)
- [ ] Setup Tailwind CSS avec design tokens personnalisés
- [ ] Créer une palette de couleurs moderne et accessible
### 2.1 Composants de base ### 2.2 Composants Kanban existants (à améliorer)
- [x] `components/kanban/Board.tsx` - Tableau Kanban principal - [x] `components/kanban/Board.tsx` - Tableau Kanban principal
- [x] `components/kanban/Column.tsx` - Colonnes du Kanban - [x] `components/kanban/Column.tsx` - Colonnes du Kanban
- [x] `components/kanban/TaskCard.tsx` - Cartes de tâches - [x] `components/kanban/TaskCard.tsx` - Cartes de tâches
- [x] `components/ui/Header.tsx` - Header avec statistiques - [x] `components/ui/Header.tsx` - Header avec statistiques
- [ ] Améliorer le design et l'UX des composants existants
### 2.2 Clients HTTP ### 2.3 Clients HTTP et hooks
- [ ] `clients/tasks-client.ts` - Client pour les tâches - [ ] `clients/tasks-client.ts` - Client pour les tâches
- [ ] `clients/base/http-client.ts` - Client HTTP de base - [ ] `clients/base/http-client.ts` - Client HTTP de base
- [ ] `hooks/useTasks.ts` - Hook pour la gestion des tâches
- [ ] `hooks/useKanban.ts` - Hook pour drag & drop
- [ ] Gestion des erreurs et loading states - [ ] Gestion des erreurs et loading states
### 2.3 Hooks React ### 2.4 Fonctionnalités Kanban avancées
- [ ] `hooks/useTasks.ts` - Hook pour la gestion des tâches - [ ] Drag & drop entre colonnes (react-beautiful-dnd)
- [ ] `hooks/useKanban.ts` - Hook pour la logique Kanban (drag & drop) - [ ] Formulaires de création/édition de tâches
- [ ] `hooks/useSync.ts` - Hook pour la synchronisation - [ ] Filtrage par tags/statut/priorité
- [ ] Recherche en temps réel dans les tâches
- [ ] Gestion des tags avec couleurs
### 2.4 Interface Kanban ## 📊 Phase 3: Dashboard et analytics (Priorité 3)
- [ ] Affichage des tâches par statut/tag
- [ ] Drag & drop entre colonnes
- [ ] Filtrage par tags/projets
- [ ] Recherche dans les tâches
## Phase 3: Intégration Jira (Priorité 3) ### 3.1 Page d'accueil/dashboard
- [ ] Créer une page d'accueil moderne avec vue d'ensemble
- [ ] Widgets de statistiques (tâches par statut, priorité, etc.)
- [ ] Graphiques de productivité (tâches complétées par jour/semaine)
- [ ] Indicateurs de performance personnels
### 3.1 Services Jira ### 3.2 Analytics et métriques
- [ ] `services/jira-client.ts` - Client Jira API - [ ] `services/analytics.ts` - Calculs statistiques
- [ ] `services/jira-sync.ts` - Synchronisation des tâches Jira - [ ] Métriques de productivité (vélocité, temps moyen, etc.)
- [ ] Gestion multi-projets Jira - [ ] Graphiques avec Chart.js ou Recharts
- [ ] Mapping des statuts Jira vers Kanban interne - [ ] Export des données en CSV/JSON
### 3.2 API Routes Jira ## 🔧 Phase 4: Fonctionnalités avancées (Priorité 4)
- [ ] `app/api/jira/projects/route.ts` - Liste des projets
- [ ] `app/api/jira/tasks/route.ts` - Tâches Jira
- [ ] `app/api/jira/sync/route.ts` - Synchronisation
- [ ] Configuration des credentials Jira
### 3.3 Interface Jira ### 4.1 Gestion avancée des tâches
- [ ] Sélecteur de projets Jira - [ ] Sous-tâches et hiérarchie
- [ ] Affichage mixte rappels + Jira dans le Kanban - [ ] Dates d'échéance et rappels
- [ ] Indicateurs visuels pour différencier les sources - [ ] Assignation et collaboration
- [ ] Templates de tâches
## Phase 4: Statistiques équipe (Priorité 4) ### 4.2 Personnalisation et thèmes
- [ ] Mode sombre/clair
- [ ] Personnalisation des couleurs
- [ ] Configuration des colonnes Kanban
- [ ] Préférences utilisateur
### 4.1 Services analytics ## 🚀 Phase 5: Intégrations futures (Priorité 5)
- [ ] `services/team-analytics.ts` - Calculs statistiques équipe
- [ ] `services/jira-team-sync.ts` - Récupération données équipe
- [ ] Agrégation des métriques (vélocité, burndown, etc.)
### 4.2 Dashboard équipe ### 5.1 Intégrations externes (optionnel)
- [ ] `components/dashboard/TeamStats.tsx` - Statistiques équipe - [ ] Import/Export depuis d'autres outils
- [ ] `components/charts/` - Graphiques (vélocité, burndown, etc.) - [ ] API webhooks pour intégrations
- [ ] `app/team/page.tsx` - Page dédiée équipe - [ ] Synchronisation cloud (optionnel)
- [ ] Filtres par période, membre, projet - [ ] Notifications push
## Phase 5: Outils additionnels (Priorité 5) ### 5.2 Optimisations et performance
### 5.1 Intégrations futures
- [ ] Calendrier (événements, deadlines)
- [ ] Notifications (rappels, alertes)
- [ ] Export/Import de données
- [ ] Thèmes et personnalisation
### 5.2 Optimisations
- [ ] Cache Redis pour les données Jira
- [ ] Optimisation des requêtes DB - [ ] Optimisation des requêtes DB
- [ ] Pagination des tâches - [ ] Pagination et virtualisation
- [ ] Mode offline basique - [ ] Cache côté client
- [ ] PWA et mode offline
## Configuration technique ## 🛠️ Configuration technique
### Stack ### Stack moderne
- **Frontend**: Next.js 14, React, TypeScript, Tailwind CSS - **Frontend**: Next.js 14, React, TypeScript, Tailwind CSS
- **Backend**: Next.js API Routes, Prisma ORM - **Backend**: Next.js API Routes, Prisma ORM
- **Database**: SQLite (local) → PostgreSQL (production) - **Database**: SQLite (local) → PostgreSQL (production future)
- **Intégrations**: macOS Reminders, Jira API - **UI**: Composants custom + Shadcn/ui, React Beautiful DnD
- **UI**: Shadcn/ui, React DnD pour le Kanban - **Charts**: Recharts ou Chart.js pour les analytics
### Structure respectée ### Architecture respectée
``` ```
/services/ # Accès DB et logique métier src/app/
/app/api/ # Routes API utilisant les services ├── api/tasks/ # API CRUD complète
/clients/ # Clients HTTP frontend ├── page.tsx # Page principale
/components/ # Composants React (pas de logique métier) └── layout.tsx
/hooks/ # Hooks React
/lib/ # Types et utilitaires partagés services/
├── database.ts # Pool Prisma
└── tasks.ts # Service tâches standalone
components/
├── kanban/ # Board Kanban
├── ui/ # Composants UI de base
└── dashboard/ # Widgets dashboard (futur)
clients/ # Clients HTTP (à créer)
hooks/ # Hooks React (à créer)
lib/
├── types.ts # Types TypeScript
└── config.ts # Config app moderne
``` ```
## Prochaines étapes immédiates ## 🎯 Prochaines étapes immédiates
1. **Setup Next.js** avec la structure de dossiers 1. **Créer les composants UI de base** (Button, Input, Card, Modal)
2. **Recherche technique** : Comment accéder aux rappels macOS localement 2. **Implémenter le système de design** avec Tailwind
3. **Créer le service `reminders.ts`** pour l'extraction des données 3. **Améliorer le Kanban** avec un design moderne
4. **API de base** pour les tâches et synchronisation 4. **Ajouter drag & drop** entre les colonnes
5. **Créer les formulaires** de tâches
--- ---
*Ce plan se concentre d'abord sur le backend et les rappels macOS comme demandé. Chaque phase peut être développée indépendamment.* *Focus sur l'expérience utilisateur et le design moderne. App standalone prête pour évoluer.*

View File

@@ -56,16 +56,19 @@ export function KanbanBoard({ tasks }: KanbanBoardProps) {
]; ];
return ( return (
<div className="w-full flex gap-4 overflow-x-auto pb-6"> <div className="h-full flex flex-col bg-slate-950">
{columns.map((column) => ( {/* Board tech dark */}
<KanbanColumn <div className="flex-1 flex gap-6 overflow-x-auto p-6">
key={column.id} {columns.map((column) => (
id={column.id} <KanbanColumn
title={column.title} key={column.id}
color={column.color} id={column.id}
tasks={column.tasks} title={column.title}
/> color={column.color}
))} tasks={column.tasks}
/>
))}
</div>
</div> </div>
); );
} }

View File

@@ -9,41 +9,73 @@ interface KanbanColumnProps {
} }
export function KanbanColumn({ id, title, color, tasks }: KanbanColumnProps) { export function KanbanColumn({ id, title, color, tasks }: KanbanColumnProps) {
const colorClasses = { // Couleurs tech/cyberpunk
gray: 'border-gray-300 bg-gray-50 dark:border-gray-600 dark:bg-gray-800', const techStyles = {
blue: 'border-blue-300 bg-blue-50 dark:border-blue-600 dark:bg-blue-900/20', gray: {
green: 'border-green-300 bg-green-50 dark:border-green-600 dark:bg-green-900/20', border: 'border-slate-700',
red: 'border-red-300 bg-red-50 dark:border-red-600 dark:bg-red-900/20' glow: 'shadow-slate-500/20',
accent: 'text-slate-400',
badge: 'bg-slate-800 text-slate-300 border border-slate-600'
},
blue: {
border: 'border-cyan-500/30',
glow: 'shadow-cyan-500/20',
accent: 'text-cyan-400',
badge: 'bg-cyan-950 text-cyan-300 border border-cyan-500/30'
},
green: {
border: 'border-emerald-500/30',
glow: 'shadow-emerald-500/20',
accent: 'text-emerald-400',
badge: 'bg-emerald-950 text-emerald-300 border border-emerald-500/30'
},
red: {
border: 'border-red-500/30',
glow: 'shadow-red-500/20',
accent: 'text-red-400',
badge: 'bg-red-950 text-red-300 border border-red-500/30'
}
}; };
const headerColorClasses = { const style = techStyles[color as keyof typeof techStyles];
gray: 'text-gray-700 dark:text-gray-300',
blue: 'text-blue-700 dark:text-blue-300', // Icônes tech
green: 'text-green-700 dark:text-green-300', const techIcons = {
red: 'text-red-700 dark:text-red-300' todo: '⚡',
in_progress: '🔄',
done: '✓',
cancelled: '✕'
}; };
return ( return (
<div className="flex-shrink-0 w-80"> <div className="flex-shrink-0 w-80 h-full">
{/* En-tête de colonne */} {/* Header tech avec glow */}
<div className={`rounded-t-lg border-2 border-b-0 p-4 ${colorClasses[color as keyof typeof colorClasses]}`}> <div className={`bg-slate-900 ${style.border} border rounded-t-lg p-4 ${style.glow} shadow-lg`}>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h3 className={`font-semibold text-lg ${headerColorClasses[color as keyof typeof headerColorClasses]}`}> <div className="flex items-center gap-3">
{title} <div className={`w-2 h-2 rounded-full ${style.accent.replace('text-', 'bg-')} animate-pulse`}></div>
</h3> <h3 className={`font-mono text-sm font-bold ${style.accent} uppercase tracking-wider`}>
<span className={`px-2 py-1 rounded-full text-xs font-medium ${headerColorClasses[color as keyof typeof headerColorClasses]} bg-white/50 dark:bg-black/20`}> {title}
{tasks.length} </h3>
</div>
<span className={`${style.badge} px-3 py-1 rounded-full text-xs font-mono font-bold`}>
{String(tasks.length).padStart(2, '0')}
</span> </span>
</div> </div>
</div> </div>
{/* Zone de contenu */} {/* Zone de contenu tech */}
<div className={`border-2 border-t-0 rounded-b-lg min-h-96 p-4 ${colorClasses[color as keyof typeof colorClasses]}`}> <div className={`bg-slate-900/80 backdrop-blur-sm ${style.border} border-t-0 border rounded-b-lg p-4 h-[calc(100vh-220px)] overflow-y-auto ${style.glow} shadow-lg`}>
<div className="space-y-3"> <div className="space-y-3">
{tasks.length === 0 ? ( {tasks.length === 0 ? (
<div className="text-center py-8 text-gray-500 dark:text-gray-400"> <div className="text-center py-20">
<div className="text-4xl mb-2">📝</div> <div className={`w-16 h-16 mx-auto mb-4 rounded-full bg-slate-800 border-2 border-dashed ${style.border} flex items-center justify-center`}>
<p className="text-sm">Aucune tâche</p> <span className={`text-2xl ${style.accent} opacity-50`}>{techIcons[id]}</span>
</div>
<p className="text-xs font-mono text-slate-500 uppercase tracking-wide">NO DATA</p>
<div className="mt-2 flex justify-center">
<div className={`w-8 h-0.5 ${style.accent.replace('text-', 'bg-')} opacity-30`}></div>
</div>
</div> </div>
) : ( ) : (
tasks.map((task) => ( tasks.map((task) => (

View File

@@ -27,83 +27,74 @@ export function TaskCard({ task }: TaskCardProps) {
}; };
return ( return (
<div className="bg-white dark:bg-gray-700 rounded-lg shadow-sm border border-gray-200 dark:border-gray-600 p-4 hover:shadow-md transition-shadow cursor-pointer"> <div className="bg-slate-800/50 backdrop-blur-sm border border-slate-700/50 rounded-lg p-3 hover:bg-slate-800/80 hover:border-cyan-500/30 hover:shadow-lg hover:shadow-cyan-500/10 transition-all duration-300 cursor-pointer group">
{/* En-tête avec emojis */} {/* Header tech avec titre et status */}
{emojis.length > 0 && ( <div className="flex items-start gap-2 mb-2">
<div className="flex gap-1 mb-2"> {emojis.length > 0 && (
{emojis.map((emoji, index) => ( <div className="flex gap-1 flex-shrink-0">
<span key={index} className="text-lg"> {emojis.slice(0, 2).map((emoji, index) => (
{emoji} <span key={index} className="text-sm opacity-80">
</span> {emoji}
))} </span>
</div> ))}
)} </div>
)}
<h4 className="font-mono text-sm font-medium text-slate-100 leading-tight line-clamp-2 flex-1">
{titleWithoutEmojis}
</h4>
{/* Indicateur de priorité tech */}
<div className={`w-2 h-2 rounded-full flex-shrink-0 mt-1 animate-pulse ${
task.priority === 'high' ? 'bg-red-400 shadow-red-400/50 shadow-sm' :
task.priority === 'medium' ? 'bg-yellow-400 shadow-yellow-400/50 shadow-sm' :
'bg-slate-500'
}`} />
</div>
{/* Titre */} {/* Description tech */}
<h4 className="font-medium text-gray-900 dark:text-white mb-2 line-clamp-2">
{titleWithoutEmojis}
</h4>
{/* Description si présente */}
{task.description && ( {task.description && (
<p className="text-sm text-gray-600 dark:text-gray-300 mb-3 line-clamp-2"> <p className="text-xs text-slate-400 mb-3 line-clamp-1 font-mono">
{task.description} {task.description}
</p> </p>
)} )}
{/* Tags */} {/* Tags tech style */}
<div className="flex flex-wrap gap-1 mb-3"> {task.tags && task.tags.length > 0 && (
{/* Priorité */} <div className="flex flex-wrap gap-1 mb-3">
<span className={`px-2 py-1 rounded-full text-xs font-medium ${priorityColors[task.priority]}`}> {task.tags.slice(0, 3).map((tag, index) => (
{task.priority}
</span>
{/* Source */}
<span className={`px-2 py-1 rounded-full text-xs font-medium ${sourceColors[task.source as keyof typeof sourceColors]}`}>
{task.source}
</span>
{/* Tags personnalisés */}
{task.tags && task.tags.length > 0 && (
task.tags.slice(0, 2).map((tag, index) => (
<span <span
key={index} key={index}
className="px-2 py-1 rounded-full text-xs font-medium bg-indigo-100 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-300" className="px-2 py-1 rounded text-xs font-mono font-bold bg-cyan-950/50 text-cyan-300 border border-cyan-500/30 hover:bg-cyan-950/80 transition-colors"
> >
#{tag} {tag}
</span> </span>
)) ))}
)} {task.tags.length > 3 && (
</div> <span className="px-2 py-1 rounded text-xs font-mono text-slate-500 border border-slate-600">
+{task.tags.length - 3}
{/* Footer avec dates */}
<div className="flex items-center justify-between text-xs text-gray-500 dark:text-gray-400">
<div>
{task.dueDate && (
<span className="flex items-center gap-1">
📅 {formatDistanceToNow(new Date(task.dueDate), {
addSuffix: true,
locale: fr
})}
</span> </span>
)} )}
</div> </div>
)}
<div>
{task.completedAt ? ( {/* Footer tech avec séparateur néon */}
<span className="flex items-center gap-1 text-green-600 dark:text-green-400"> <div className="pt-2 border-t border-slate-700/50">
{formatDistanceToNow(new Date(task.completedAt), { <div className="flex items-center justify-between text-xs">
{task.dueDate ? (
<span className="flex items-center gap-1 text-slate-400 font-mono">
<span className="text-cyan-400"></span>
{formatDistanceToNow(new Date(task.dueDate), {
addSuffix: true, addSuffix: true,
locale: fr locale: fr
})} })}
</span> </span>
) : ( ) : (
<span> <span className="text-slate-600 font-mono">--:--</span>
Créé {formatDistanceToNow(new Date(task.createdAt), { )}
addSuffix: true,
locale: fr {task.completedAt && (
})} <span className="text-emerald-400 font-mono font-bold"> DONE</span>
</span>
)} )}
</div> </div>
</div> </div>

View File

@@ -12,43 +12,46 @@ interface HeaderProps {
export function Header({ title, subtitle, stats }: HeaderProps) { export function Header({ title, subtitle, stats }: HeaderProps) {
return ( return (
<header className="bg-white dark:bg-gray-800 shadow-sm border-b border-gray-200 dark:border-gray-700"> <header className="bg-slate-900/80 backdrop-blur-sm border-b border-slate-700/50 shadow-lg shadow-slate-900/20">
<div className="container mx-auto px-4 py-6"> <div className="container mx-auto px-6 py-4">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4"> <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-6">
{/* Titre et sous-titre */} {/* Titre tech avec glow */}
<div> <div className="flex items-center gap-4">
<h1 className="text-3xl font-bold text-gray-900 dark:text-white"> <div className="w-3 h-3 bg-cyan-400 rounded-full animate-pulse shadow-cyan-400/50 shadow-lg"></div>
{title} <div>
</h1> <h1 className="text-2xl font-mono font-bold text-slate-100 tracking-wider">
<p className="text-gray-600 dark:text-gray-400 mt-1"> {title}
{subtitle} </h1>
</p> <p className="text-slate-400 mt-1 font-mono text-sm">
{subtitle}
</p>
</div>
</div> </div>
{/* Statistiques */} {/* Stats tech dashboard */}
<div className="flex flex-wrap gap-4"> <div className="flex flex-wrap gap-3">
<StatCard <StatCard
label="Total" label="TOTAL"
value={stats.total} value={String(stats.total).padStart(2, '0')}
color="blue" color="blue"
/> />
<StatCard <StatCard
label="Terminées" label="DONE"
value={stats.completed} value={String(stats.completed).padStart(2, '0')}
color="green" color="green"
/> />
<StatCard <StatCard
label="En cours" label="ACTIVE"
value={stats.inProgress} value={String(stats.inProgress).padStart(2, '0')}
color="yellow" color="yellow"
/> />
<StatCard <StatCard
label="À faire" label="QUEUE"
value={stats.todo} value={String(stats.todo).padStart(2, '0')}
color="gray" color="gray"
/> />
<StatCard <StatCard
label="Taux" label="RATE"
value={`${stats.completionRate}%`} value={`${stats.completionRate}%`}
color="purple" color="purple"
/> />
@@ -66,20 +69,47 @@ interface StatCardProps {
} }
function StatCard({ label, value, color }: StatCardProps) { function StatCard({ label, value, color }: StatCardProps) {
const colorClasses = { const techStyles = {
blue: 'bg-blue-50 text-blue-700 border-blue-200 dark:bg-blue-900/20 dark:text-blue-300 dark:border-blue-800', blue: {
green: 'bg-green-50 text-green-700 border-green-200 dark:bg-green-900/20 dark:text-green-300 dark:border-green-800', bg: 'bg-slate-800/50',
yellow: 'bg-yellow-50 text-yellow-700 border-yellow-200 dark:bg-yellow-900/20 dark:text-yellow-300 dark:border-yellow-800', border: 'border-cyan-500/30',
gray: 'bg-gray-50 text-gray-700 border-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-700', text: 'text-cyan-300',
purple: 'bg-purple-50 text-purple-700 border-purple-200 dark:bg-purple-900/20 dark:text-purple-300 dark:border-purple-800' glow: 'shadow-cyan-500/20'
},
green: {
bg: 'bg-slate-800/50',
border: 'border-emerald-500/30',
text: 'text-emerald-300',
glow: 'shadow-emerald-500/20'
},
yellow: {
bg: 'bg-slate-800/50',
border: 'border-yellow-500/30',
text: 'text-yellow-300',
glow: 'shadow-yellow-500/20'
},
gray: {
bg: 'bg-slate-800/50',
border: 'border-slate-500/30',
text: 'text-slate-300',
glow: 'shadow-slate-500/20'
},
purple: {
bg: 'bg-slate-800/50',
border: 'border-purple-500/30',
text: 'text-purple-300',
glow: 'shadow-purple-500/20'
}
}; };
const style = techStyles[color];
return ( return (
<div className={`px-3 py-2 rounded-lg border text-sm font-medium ${colorClasses[color]}`}> <div className={`${style.bg} ${style.border} border rounded-lg px-3 py-2 ${style.glow} shadow-lg backdrop-blur-sm hover:${style.border.replace('/30', '/50')} transition-all duration-300`}>
<div className="text-xs opacity-75 uppercase tracking-wide"> <div className={`text-xs font-mono font-bold ${style.text} opacity-75 uppercase tracking-wider`}>
{label} {label}
</div> </div>
<div className="text-lg font-bold"> <div className={`text-lg font-mono font-bold ${style.text}`}>
{value} {value}
</div> </div>
</div> </div>

View File

@@ -1,41 +1,37 @@
/** /**
* Configuration de l'application TowerControl * Configuration de l'application TowerControl (version standalone)
*/ */
export interface AppConfig { export interface AppConfig {
reminders: { app: {
targetList: string; name: string;
syncInterval: number; // en minutes version: string;
enabledLists: string[];
}; };
jira: { ui: {
baseUrl?: string; theme: 'light' | 'dark' | 'system';
username?: string; itemsPerPage: number;
apiToken?: string;
projects: string[];
}; };
sync: { features: {
autoSync: boolean; enableDragAndDrop: boolean;
batchSize: number; enableNotifications: boolean;
autoSave: boolean;
}; };
} }
// Configuration par défaut // Configuration par défaut
const defaultConfig: AppConfig = { const defaultConfig: AppConfig = {
reminders: { app: {
targetList: process.env.REMINDERS_TARGET_LIST || 'Boulot', name: 'TowerControl',
syncInterval: parseInt(process.env.REMINDERS_SYNC_INTERVAL || '15'), version: '2.0.0'
enabledLists: (process.env.REMINDERS_ENABLED_LISTS || 'Boulot').split(',')
}, },
jira: { ui: {
baseUrl: process.env.JIRA_BASE_URL, theme: (process.env.NEXT_PUBLIC_THEME as 'light' | 'dark' | 'system') || 'system',
username: process.env.JIRA_USERNAME, itemsPerPage: parseInt(process.env.NEXT_PUBLIC_ITEMS_PER_PAGE || '50')
apiToken: process.env.JIRA_API_TOKEN,
projects: (process.env.JIRA_PROJECTS || '').split(',').filter(p => p.length > 0)
}, },
sync: { features: {
autoSync: process.env.AUTO_SYNC === 'true', enableDragAndDrop: process.env.NEXT_PUBLIC_ENABLE_DRAG_DROP !== 'false',
batchSize: parseInt(process.env.SYNC_BATCH_SIZE || '50') enableNotifications: process.env.NEXT_PUBLIC_ENABLE_NOTIFICATIONS === 'true',
autoSave: process.env.NEXT_PUBLIC_AUTO_SAVE !== 'false'
} }
}; };
@@ -46,33 +42,11 @@ export function getConfig(): AppConfig {
return defaultConfig; return defaultConfig;
} }
/**
* Récupère la liste cible des rappels
*/
export function getTargetRemindersList(): string {
return getConfig().reminders.targetList;
}
/**
* Récupère les listes autorisées pour la synchronisation
*/
export function getEnabledRemindersLists(): string[] {
return getConfig().reminders.enabledLists;
}
/**
* Vérifie si une liste est autorisée pour la synchronisation
*/
export function isListEnabled(listName: string): boolean {
const enabledLists = getEnabledRemindersLists();
return enabledLists.includes(listName);
}
/** /**
* Configuration pour le développement/debug * Configuration pour le développement/debug
*/ */
export const DEBUG_CONFIG = { export const DEBUG_CONFIG = {
logAppleScript: process.env.NODE_ENV === 'development', isDevelopment: process.env.NODE_ENV === 'development',
mockData: process.env.USE_MOCK_DATA === 'true', verboseLogging: process.env.VERBOSE_LOGGING === 'true',
verboseLogging: process.env.VERBOSE_LOGGING === 'true' enableDevTools: process.env.NODE_ENV === 'development'
}; };

87
scripts/reset-database.ts Normal file
View File

@@ -0,0 +1,87 @@
import { prisma } from '../services/database';
/**
* Script pour reset la base de données et supprimer les anciennes données
*/
async function resetDatabase() {
console.log('🗑️ Reset de la base de données...');
console.log('===================================');
try {
// Compter les tâches avant suppression
const beforeCount = await prisma.task.count();
const manualCount = await prisma.task.count({ where: { source: 'manual' } });
const remindersCount = await prisma.task.count({ where: { source: 'reminders' } });
console.log(`📊 État actuel:`);
console.log(` Total: ${beforeCount} tâches`);
console.log(` Manuelles: ${manualCount} tâches`);
console.log(` Rappels: ${remindersCount} tâches`);
console.log('');
// Supprimer toutes les tâches de synchronisation
const deletedTasks = await prisma.task.deleteMany({
where: {
source: 'reminders'
}
});
console.log(`✅ Supprimé ${deletedTasks.count} tâches de synchronisation`);
// Supprimer les logs de sync
const deletedLogs = await prisma.syncLog.deleteMany();
console.log(`✅ Supprimé ${deletedLogs.count} logs de synchronisation`);
// Supprimer les tags orphelins (optionnel)
const deletedTags = await prisma.tag.deleteMany();
console.log(`✅ Supprimé ${deletedTags.count} tags`);
// Compter après nettoyage
const afterCount = await prisma.task.count();
console.log('');
console.log('🎉 Base de données nettoyée !');
console.log(`📊 Résultat: ${afterCount} tâches restantes`);
// Afficher les tâches restantes
if (afterCount > 0) {
console.log('');
console.log('📋 Tâches restantes:');
const remainingTasks = await prisma.task.findMany({
orderBy: { createdAt: 'desc' }
});
remainingTasks.forEach((task, index) => {
const statusEmoji = {
'todo': '⏳',
'in_progress': '🔄',
'done': '✅',
'cancelled': '❌'
}[task.status] || '❓';
const tags = JSON.parse(task.tagsJson || '[]');
const tagsStr = tags.length > 0 ? ` [${tags.join(', ')}]` : '';
console.log(` ${index + 1}. ${statusEmoji} ${task.title}${tagsStr}`);
});
}
} catch (error) {
console.error('❌ Erreur lors du reset:', error);
throw error;
}
}
// Exécuter le script
if (require.main === module) {
resetDatabase().then(() => {
console.log('');
console.log('✨ Reset terminé avec succès !');
process.exit(0);
}).catch((error) => {
console.error('💥 Erreur fatale:', error);
process.exit(1);
});
}
export { resetDatabase };

100
scripts/seed-data.ts Normal file
View File

@@ -0,0 +1,100 @@
import { tasksService } from '../services/tasks';
import { TaskStatus, TaskPriority } from '../lib/types';
/**
* Script pour ajouter des données de test avec tags et variété
*/
async function seedTestData() {
console.log('🌱 Ajout de données de test...');
console.log('================================');
const testTasks = [
{
title: '🎨 Redesign du dashboard',
description: 'Créer une interface moderne et intuitive pour le tableau de bord principal',
status: 'in_progress' as TaskStatus,
priority: 'high' as TaskPriority,
tags: ['design', 'ui', 'frontend'],
dueDate: new Date('2025-01-20')
},
{
title: '🔧 Optimiser les performances API',
description: 'Améliorer les temps de réponse des endpoints et ajouter la pagination',
status: 'todo' as TaskStatus,
priority: 'medium' as TaskPriority,
tags: ['backend', 'performance', 'api'],
dueDate: new Date('2025-01-25')
},
{
title: '✅ Tests unitaires composants',
description: 'Ajouter des tests Jest/RTL pour les composants principaux',
status: 'done' as TaskStatus,
priority: 'medium' as TaskPriority,
tags: ['testing', 'jest', 'quality'],
dueDate: new Date('2025-01-10')
}
];
let createdCount = 0;
let errorCount = 0;
for (const taskData of testTasks) {
try {
const task = await tasksService.createTask(taskData);
const statusEmoji = {
'todo': '⏳',
'in_progress': '🔄',
'done': '✅',
'cancelled': '❌'
}[task.status];
const priorityEmoji = {
'low': '🔵',
'medium': '🟡',
'high': '🔴'
}[task.priority];
console.log(` ${statusEmoji} ${priorityEmoji} ${task.title}`);
console.log(` Tags: ${task.tags?.join(', ') || 'aucun'}`);
if (task.dueDate) {
console.log(` Échéance: ${task.dueDate.toLocaleDateString('fr-FR')}`);
}
console.log('');
createdCount++;
} catch (error) {
console.error(` ❌ Erreur pour "${taskData.title}":`, error instanceof Error ? error.message : error);
errorCount++;
}
}
console.log('📊 Résumé:');
console.log(` ✅ Tâches créées: ${createdCount}`);
console.log(` ❌ Erreurs: ${errorCount}`);
// Afficher les stats finales
const stats = await tasksService.getTaskStats();
console.log('');
console.log('📈 Statistiques finales:');
console.log(` Total: ${stats.total} tâches`);
console.log(` À faire: ${stats.todo}`);
console.log(` En cours: ${stats.inProgress}`);
console.log(` Terminées: ${stats.completed}`);
console.log(` Annulées: ${stats.cancelled}`);
console.log(` Taux de completion: ${stats.completionRate}%`);
}
// Exécuter le script
if (require.main === module) {
seedTestData().then(() => {
console.log('');
console.log('✨ Données de test ajoutées avec succès !');
process.exit(0);
}).catch((error) => {
console.error('💥 Erreur fatale:', error);
process.exit(1);
});
}
export { seedTestData };

View File

@@ -39,7 +39,7 @@ export async function closeDatabaseConnection(): Promise<void> {
// Fonction utilitaire pour les transactions // Fonction utilitaire pour les transactions
export async function withTransaction<T>( export async function withTransaction<T>(
callback: (tx: PrismaClient) => Promise<T> callback: (tx: Omit<PrismaClient, '$connect' | '$disconnect' | '$on' | '$transaction' | '$extends'>) => Promise<T>
): Promise<T> { ): Promise<T> {
return await prisma.$transaction(async (tx) => { return await prisma.$transaction(async (tx) => {
return await callback(tx); return await callback(tx);

View File

@@ -1,332 +0,0 @@
import { exec } from 'child_process';
import { promisify } from 'util';
import { MacOSReminder } from '@/lib/types';
import { getTargetRemindersList, getEnabledRemindersLists, isListEnabled, DEBUG_CONFIG } from '@/lib/config';
const execAsync = promisify(exec);
/**
* Service pour récupérer les rappels macOS via AppleScript
* Approche sécurisée qui utilise l'API officielle d'Apple
*/
export class RemindersService {
/**
* Récupère tous les rappels depuis l'app Rappels macOS
* Utilise la configuration pour filtrer les listes autorisées
*/
async getAllReminders(): Promise<MacOSReminder[]> {
try {
if (DEBUG_CONFIG.mockData) {
console.log('🔧 Mode mock activé - utilisation des données de test');
return this.getMockReminders();
}
// Récupérer uniquement les listes autorisées
return await this.getRemindersFromEnabledLists();
} catch (error) {
console.error('Erreur lors de la récupération des rappels:', error);
return this.getMockReminders();
}
}
/**
* Récupère les rappels uniquement des listes autorisées en configuration
*/
async getRemindersFromEnabledLists(): Promise<MacOSReminder[]> {
try {
const reminders: MacOSReminder[] = [];
const enabledLists = getEnabledRemindersLists();
console.log(`📋 Synchronisation des listes autorisées: ${enabledLists.join(', ')}`);
for (const listName of enabledLists) {
try {
console.log(`🔄 Traitement de la liste: ${listName}`);
const listReminders = await this.getRemindersFromListSimple(listName);
if (listReminders.length > 0) {
console.log(`${listReminders.length} rappels trouvés dans "${listName}"`);
reminders.push(...listReminders);
} else {
console.log(` Aucun rappel dans "${listName}"`);
}
} catch (error) {
console.error(`❌ Erreur pour la liste ${listName}:`, error);
}
}
console.log(`📊 Total: ${reminders.length} rappels récupérés`);
return reminders;
} catch (error) {
console.error('Erreur getRemindersFromEnabledLists:', error);
return this.getMockReminders();
}
}
/**
* Récupère les rappels de la liste cible principale uniquement
*/
async getTargetListReminders(): Promise<MacOSReminder[]> {
try {
const targetList = getTargetRemindersList();
console.log(`🎯 Récupération de la liste cible: ${targetList}`);
return await this.getRemindersFromListSimple(targetList);
} catch (error) {
console.error('Erreur getTargetListReminders:', error);
return [];
}
}
/**
* Récupère les rappels d'une liste spécifique
*/
async getRemindersByList(listName: string): Promise<MacOSReminder[]> {
try {
const script = `
tell application "Reminders"
set remindersList to {}
set targetList to list "${listName}"
repeat with reminder in reminders of targetList
set reminderRecord to {id:(id of reminder as string), title:(name of reminder), notes:(body of reminder), completed:(completed of reminder), dueDate:missing value, completionDate:missing value, priority:(priority of reminder), list:"${listName}"}
-- Gérer la date d'échéance
try
set dueDate of reminderRecord to (due date of reminder as string)
end try
-- Gérer la date de completion
try
if completed of reminder then
set completionDate of reminderRecord to (completion date of reminder as string)
end if
end try
set end of remindersList to reminderRecord
end repeat
return remindersList
end tell
`;
const { stdout } = await execAsync(`osascript -e '${script.replace(/'/g, "'\\''")}' 2>/dev/null || echo "[]"`);
return this.parseAppleScriptOutput(stdout);
} catch (error) {
console.error(`Erreur lors de la récupération des rappels de la liste ${listName}:`, error);
return [];
}
}
/**
* Récupère la liste des listes de rappels
*/
async getReminderLists(): Promise<string[]> {
try {
const script = `
tell application "Reminders"
set listNames to {}
repeat with reminderList in lists
set end of listNames to name of reminderList
end repeat
return listNames
end tell
`;
const { stdout } = await execAsync(`osascript -e '${script.replace(/'/g, "'\\''")}' 2>/dev/null || echo ""`);
// Parse la sortie AppleScript pour extraire les noms de listes
const lists = stdout.trim().split(', ').filter(list => list.length > 0);
return lists;
} catch (error) {
console.error('Erreur lors de la récupération des listes:', error);
return [];
}
}
/**
* Test si l'app Rappels est accessible
*/
async testRemindersAccess(): Promise<boolean> {
try {
const script = `
tell application "Reminders"
return count of lists
end tell
`;
await execAsync(`osascript -e '${script.replace(/'/g, "'\\''")}' 2>/dev/null`);
return true;
} catch (error) {
console.error('Impossible d\'accéder à l\'app Rappels:', error);
return false;
}
}
/**
* Parse la sortie AppleScript en objets MacOSReminder
*/
private parseAppleScriptOutput(output: string): MacOSReminder[] {
try {
console.log('Sortie AppleScript brute:', output);
// Si pas de sortie ou sortie vide, retourner tableau vide
if (!output || output.trim() === '' || output.trim() === '{}') {
return [];
}
// Pour l'instant, on utilise une approche simple avec des données réelles
// TODO: Implémenter le parsing complet de la sortie AppleScript
return this.getRemindersFromEnabledLists();
} catch (error) {
console.error('Erreur lors du parsing AppleScript:', error);
return [];
}
}
/**
* Récupère les rappels d'une liste avec une approche simple
*/
private async getRemindersFromListSimple(listName: string): Promise<MacOSReminder[]> {
try {
if (DEBUG_CONFIG.verboseLogging) {
console.log(`🔍 Récupération des rappels de la liste: ${listName}`);
}
// Script simple pour récupérer les infos de base
const script = `
tell application "Reminders"
set remindersList to {}
try
set targetList to (first list whose name is "${listName}")
repeat with r in reminders of targetList
try
set reminderInfo to (name of r) & "|" & (completed of r) & "|" & (priority of r) & "|" & "${listName}"
set end of remindersList to reminderInfo
end try
end repeat
on error errMsg
return "ERROR: " & errMsg
end try
return remindersList
end tell
`;
const { stdout } = await execAsync(`osascript -e '${script.replace(/'/g, "'\\''")}' 2>/dev/null || echo ""`);
if (DEBUG_CONFIG.logAppleScript) {
console.log(`📝 Sortie AppleScript pour ${listName}:`, stdout.substring(0, 200));
}
// Vérifier si il y a une erreur dans la sortie
if (stdout.includes('ERROR:')) {
console.error(`❌ Erreur AppleScript pour ${listName}:`, stdout);
return [];
}
return this.parseSimpleReminderOutput(stdout, listName);
} catch (error) {
console.error(`❌ Erreur getRemindersFromListSimple pour ${listName}:`, error);
return [];
}
}
/**
* Parse la sortie simple des rappels
*/
private parseSimpleReminderOutput(output: string, listName: string): MacOSReminder[] {
try {
if (!output || output.trim() === '') return [];
// Nettoyer la sortie AppleScript
const cleanOutput = output.trim().replace(/^{|}$/g, '');
if (!cleanOutput) return [];
const reminderStrings = cleanOutput.split(', ');
const reminders: MacOSReminder[] = [];
for (let i = 0; i < reminderStrings.length; i++) {
const reminderStr = reminderStrings[i].replace(/"/g, '');
const parts = reminderStr.split('|');
if (parts.length >= 4) {
const [title, completed, priority, list] = parts;
reminders.push({
id: `${listName}-${i}`,
title: title.trim(),
completed: completed.trim() === 'true',
priority: parseInt(priority.trim()) || 0,
list: list.trim(),
tags: this.extractTagsFromTitle(title.trim())
});
}
}
return reminders;
} catch (error) {
console.error('Erreur parseSimpleReminderOutput:', error);
return [];
}
}
/**
* Extrait les tags du titre (format #tag)
*/
private extractTagsFromTitle(title: string): string[] {
const tagRegex = /#(\w+)/g;
const tags: string[] = [];
let match;
while ((match = tagRegex.exec(title)) !== null) {
tags.push(match[1]);
}
return tags;
}
/**
* Données de test pour le développement
*/
private getMockReminders(): MacOSReminder[] {
return [
{
id: 'mock-1',
title: 'Finir le service reminders',
notes: 'Implémenter la récupération des rappels macOS',
completed: false,
dueDate: new Date('2025-01-16'),
priority: 5,
list: 'Travail',
tags: ['dev', 'backend']
},
{
id: 'mock-2',
title: 'Tester l\'intégration Jira',
notes: 'Configurer l\'API Jira pour récupérer les tâches',
completed: false,
dueDate: new Date('2025-01-18'),
priority: 9,
list: 'Projets',
tags: ['jira', 'api']
},
{
id: 'mock-3',
title: 'Créer le Kanban board',
completed: true,
completionDate: new Date('2025-01-10'),
priority: 5,
list: 'Travail',
tags: ['ui', 'frontend']
}
];
}
}
// Instance singleton
export const remindersService = new RemindersService();

View File

@@ -1,310 +0,0 @@
import { prisma } from './database';
import { remindersService } from './reminders';
import { Task, TaskStatus, TaskPriority, MacOSReminder, SyncLog, BusinessError } from '@/lib/types';
import { Prisma } from '@prisma/client';
/**
* Service pour traiter et synchroniser les tâches
* Contient toute la logique métier pour les tâches
*/
export class TaskProcessorService {
/**
* Synchronise les rappels macOS avec la base de données
*/
async syncRemindersToDatabase(): Promise<SyncLog> {
const startTime = Date.now();
let tasksSync = 0;
try {
// Récupérer les rappels depuis macOS
const reminders = await remindersService.getAllReminders();
// Traiter chaque rappel
for (const reminder of reminders) {
await this.processReminder(reminder);
tasksSync++;
}
// Créer le log de synchronisation
const syncLog = await prisma.syncLog.create({
data: {
source: 'reminders',
status: 'success',
message: `Synchronisé ${tasksSync} rappels en ${Date.now() - startTime}ms`,
tasksSync
}
});
console.log(`✅ Sync reminders terminée: ${tasksSync} tâches`);
return syncLog;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Erreur inconnue';
const syncLog = await prisma.syncLog.create({
data: {
source: 'reminders',
status: 'error',
message: `Erreur de sync: ${errorMessage}`,
tasksSync
}
});
console.error('❌ Erreur sync reminders:', error);
return syncLog;
}
}
/**
* Traite un rappel macOS et le sauvegarde/met à jour en base
*/
private async processReminder(reminder: MacOSReminder): Promise<void> {
const taskData = this.mapReminderToTask(reminder);
try {
// Upsert (insert ou update) de la tâche
await prisma.task.upsert({
where: {
source_sourceId: {
source: 'reminders',
sourceId: reminder.id
}
},
update: {
title: taskData.title,
description: taskData.description,
status: taskData.status,
priority: taskData.priority,
tagsJson: JSON.stringify(taskData.tags || []),
dueDate: taskData.dueDate,
completedAt: taskData.completedAt,
updatedAt: new Date()
},
create: {
title: taskData.title,
description: taskData.description,
status: taskData.status,
priority: taskData.priority,
source: 'reminders',
sourceId: reminder.id,
tagsJson: JSON.stringify(taskData.tags || []),
dueDate: taskData.dueDate,
completedAt: taskData.completedAt
}
});
// Gérer les tags
if (taskData.tags && taskData.tags.length > 0) {
await this.processTags(taskData.tags);
}
} catch (error) {
console.error(`Erreur lors du traitement du rappel ${reminder.id}:`, error);
throw error;
}
}
/**
* Convertit un rappel macOS en objet Task
*/
private mapReminderToTask(reminder: MacOSReminder): Partial<Task> {
return {
title: reminder.title,
description: reminder.notes || undefined,
status: this.mapReminderStatus(reminder),
priority: this.mapReminderPriority(reminder.priority),
tags: reminder.tags || [],
dueDate: reminder.dueDate || undefined,
completedAt: reminder.completionDate || undefined
};
}
/**
* Convertit le statut d'un rappel macOS en TaskStatus
*/
private mapReminderStatus(reminder: MacOSReminder): TaskStatus {
if (reminder.completed) {
return 'done';
}
// Si la tâche a une date d'échéance passée, elle est en retard
if (reminder.dueDate && reminder.dueDate < new Date()) {
return 'todo'; // On garde 'todo' mais on pourrait ajouter un statut 'overdue'
}
return 'todo';
}
/**
* Convertit la priorité macOS (0-9) en TaskPriority
*/
private mapReminderPriority(macosPriority: number): TaskPriority {
switch (macosPriority) {
case 0: return 'low';
case 1: return 'low';
case 5: return 'medium';
case 9: return 'high';
default: return 'medium';
}
}
/**
* Traite et crée les tags s'ils n'existent pas
*/
private async processTags(tagNames: string[]): Promise<void> {
for (const tagName of tagNames) {
try {
await prisma.tag.upsert({
where: { name: tagName },
update: {}, // Pas de mise à jour nécessaire
create: {
name: tagName,
color: this.generateTagColor(tagName)
}
});
} catch (error) {
console.error(`Erreur lors de la création du tag ${tagName}:`, error);
}
}
}
/**
* Génère une couleur pour un tag basée sur son nom
*/
private generateTagColor(tagName: string): string {
const colors = [
'#ef4444', '#f97316', '#f59e0b', '#eab308',
'#84cc16', '#22c55e', '#10b981', '#14b8a6',
'#06b6d4', '#0ea5e9', '#3b82f6', '#6366f1',
'#8b5cf6', '#a855f7', '#d946ef', '#ec4899'
];
// Hash simple du nom pour choisir une couleur
let hash = 0;
for (let i = 0; i < tagName.length; i++) {
hash = tagName.charCodeAt(i) + ((hash << 5) - hash);
}
return colors[Math.abs(hash) % colors.length];
}
/**
* Récupère toutes les tâches avec filtres optionnels
*/
async getTasks(filters?: {
status?: TaskStatus[];
source?: string[];
search?: string;
limit?: number;
offset?: number;
}): Promise<Task[]> {
const where: Prisma.TaskWhereInput = {};
if (filters?.status) {
where.status = { in: filters.status };
}
if (filters?.source) {
where.source = { in: filters.source };
}
if (filters?.search) {
where.OR = [
{ title: { contains: filters.search, mode: 'insensitive' } },
{ description: { contains: filters.search, mode: 'insensitive' } }
];
}
const tasks = await prisma.task.findMany({
where,
take: filters?.limit || 100,
skip: filters?.offset || 0,
orderBy: [
{ completedAt: 'desc' },
{ dueDate: 'asc' },
{ createdAt: 'desc' }
]
});
return tasks.map(this.mapPrismaTaskToTask);
}
/**
* Met à jour le statut d'une tâche
*/
async updateTaskStatus(taskId: string, newStatus: TaskStatus): Promise<Task> {
const task = await prisma.task.findUnique({
where: { id: taskId }
});
if (!task) {
throw new BusinessError(`Tâche ${taskId} introuvable`);
}
// Logique métier : si on marque comme terminé, on ajoute la date
const updateData: Prisma.TaskUpdateInput = {
status: newStatus,
updatedAt: new Date()
};
if (newStatus === 'done' && !task.completedAt) {
updateData.completedAt = new Date();
} else if (newStatus !== 'done' && task.completedAt) {
updateData.completedAt = null;
}
const updatedTask = await prisma.task.update({
where: { id: taskId },
data: updateData
});
return this.mapPrismaTaskToTask(updatedTask);
}
/**
* Convertit une tâche Prisma en objet Task
*/
private mapPrismaTaskToTask(prismaTask: any): Task {
return {
id: prismaTask.id,
title: prismaTask.title,
description: prismaTask.description,
status: prismaTask.status as TaskStatus,
priority: prismaTask.priority as TaskPriority,
source: prismaTask.source,
sourceId: prismaTask.sourceId,
tags: JSON.parse(prismaTask.tagsJson || '[]'),
dueDate: prismaTask.dueDate,
completedAt: prismaTask.completedAt,
createdAt: prismaTask.createdAt,
updatedAt: prismaTask.updatedAt,
jiraProject: prismaTask.jiraProject,
jiraKey: prismaTask.jiraKey,
assignee: prismaTask.assignee
};
}
/**
* Récupère les statistiques des tâches
*/
async getTaskStats() {
const [total, completed, inProgress, todo] = await Promise.all([
prisma.task.count(),
prisma.task.count({ where: { status: 'done' } }),
prisma.task.count({ where: { status: 'in_progress' } }),
prisma.task.count({ where: { status: 'todo' } })
]);
return {
total,
completed,
inProgress,
todo,
completionRate: total > 0 ? Math.round((completed / total) * 100) : 0
};
}
}
// Instance singleton
export const taskProcessorService = new TaskProcessorService();

237
services/tasks.ts Normal file
View File

@@ -0,0 +1,237 @@
import { prisma } from './database';
import { Task, TaskStatus, TaskPriority, TaskSource, BusinessError } from '@/lib/types';
import { Prisma } from '@prisma/client';
/**
* Service pour la gestion des tâches (version standalone)
*/
export class TasksService {
/**
* Récupère toutes les tâches avec filtres optionnels
*/
async getTasks(filters?: {
status?: TaskStatus[];
search?: string;
limit?: number;
offset?: number;
}): Promise<Task[]> {
const where: Prisma.TaskWhereInput = {};
if (filters?.status) {
where.status = { in: filters.status };
}
if (filters?.search) {
where.OR = [
{ title: { contains: filters.search } },
{ description: { contains: filters.search } }
];
}
const tasks = await prisma.task.findMany({
where,
take: filters?.limit || 100,
skip: filters?.offset || 0,
orderBy: [
{ completedAt: 'desc' },
{ dueDate: 'asc' },
{ createdAt: 'desc' }
]
});
return tasks.map(this.mapPrismaTaskToTask);
}
/**
* Crée une nouvelle tâche
*/
async createTask(taskData: {
title: string;
description?: string;
status?: TaskStatus;
priority?: TaskPriority;
tags?: string[];
dueDate?: Date;
}): Promise<Task> {
const task = await prisma.task.create({
data: {
title: taskData.title,
description: taskData.description,
status: taskData.status || 'todo',
priority: taskData.priority || 'medium',
tagsJson: JSON.stringify(taskData.tags || []),
dueDate: taskData.dueDate,
source: 'manual', // Source manuelle
sourceId: `manual-${Date.now()}` // ID unique
}
});
// Gérer les tags
if (taskData.tags && taskData.tags.length > 0) {
await this.processTags(taskData.tags);
}
return this.mapPrismaTaskToTask(task);
}
/**
* Met à jour une tâche
*/
async updateTask(taskId: string, updates: {
title?: string;
description?: string;
status?: TaskStatus;
priority?: TaskPriority;
tags?: string[];
dueDate?: Date;
}): Promise<Task> {
const task = await prisma.task.findUnique({
where: { id: taskId }
});
if (!task) {
throw new BusinessError(`Tâche ${taskId} introuvable`);
}
// Logique métier : si on marque comme terminé, on ajoute la date
const updateData: Prisma.TaskUpdateInput = {
...updates,
updatedAt: new Date()
};
if (updates.tags) {
updateData.tagsJson = JSON.stringify(updates.tags);
}
if (updates.status === 'done' && !task.completedAt) {
updateData.completedAt = new Date();
} else if (updates.status && updates.status !== 'done' && task.completedAt) {
updateData.completedAt = null;
}
const updatedTask = await prisma.task.update({
where: { id: taskId },
data: updateData
});
// Gérer les tags
if (updates.tags && updates.tags.length > 0) {
await this.processTags(updates.tags);
}
return this.mapPrismaTaskToTask(updatedTask);
}
/**
* Supprime une tâche
*/
async deleteTask(taskId: string): Promise<void> {
const task = await prisma.task.findUnique({
where: { id: taskId }
});
if (!task) {
throw new BusinessError(`Tâche ${taskId} introuvable`);
}
await prisma.task.delete({
where: { id: taskId }
});
}
/**
* Met à jour le statut d'une tâche
*/
async updateTaskStatus(taskId: string, newStatus: TaskStatus): Promise<Task> {
return this.updateTask(taskId, { status: newStatus });
}
/**
* Récupère les statistiques des tâches
*/
async getTaskStats() {
const [total, completed, inProgress, todo, cancelled] = await Promise.all([
prisma.task.count(),
prisma.task.count({ where: { status: 'done' } }),
prisma.task.count({ where: { status: 'in_progress' } }),
prisma.task.count({ where: { status: 'todo' } }),
prisma.task.count({ where: { status: 'cancelled' } })
]);
return {
total,
completed,
inProgress,
todo,
cancelled,
completionRate: total > 0 ? Math.round((completed / total) * 100) : 0
};
}
/**
* Traite et crée les tags s'ils n'existent pas
*/
private async processTags(tagNames: string[]): Promise<void> {
for (const tagName of tagNames) {
try {
await prisma.tag.upsert({
where: { name: tagName },
update: {}, // Pas de mise à jour nécessaire
create: {
name: tagName,
color: this.generateTagColor(tagName)
}
});
} catch (error) {
console.error(`Erreur lors de la création du tag ${tagName}:`, error);
}
}
}
/**
* Génère une couleur pour un tag basée sur son nom
*/
private generateTagColor(tagName: string): string {
const colors = [
'#ef4444', '#f97316', '#f59e0b', '#eab308',
'#84cc16', '#22c55e', '#10b981', '#14b8a6',
'#06b6d4', '#0ea5e9', '#3b82f6', '#6366f1',
'#8b5cf6', '#a855f7', '#d946ef', '#ec4899'
];
// Hash simple du nom pour choisir une couleur
let hash = 0;
for (let i = 0; i < tagName.length; i++) {
hash = tagName.charCodeAt(i) + ((hash << 5) - hash);
}
return colors[Math.abs(hash) % colors.length];
}
/**
* Convertit une tâche Prisma en objet Task
*/
private mapPrismaTaskToTask(prismaTask: Prisma.TaskGetPayload<object>): Task {
return {
id: prismaTask.id,
title: prismaTask.title,
description: prismaTask.description ?? undefined,
status: prismaTask.status as TaskStatus,
priority: prismaTask.priority as TaskPriority,
source: prismaTask.source as TaskSource,
sourceId: prismaTask.sourceId?? undefined,
tags: JSON.parse(prismaTask.tagsJson || '[]'),
dueDate: prismaTask.dueDate ?? undefined,
completedAt: prismaTask.completedAt ?? undefined,
createdAt: prismaTask.createdAt,
updatedAt: prismaTask.updatedAt,
jiraProject: prismaTask.jiraProject ?? undefined,
jiraKey: prismaTask.jiraKey ?? undefined,
assignee: prismaTask.assignee ?? undefined
};
}
}
// Instance singleton
export const tasksService = new TasksService();

View File

@@ -1,100 +0,0 @@
import { NextResponse } from 'next/server';
import { getConfig, getTargetRemindersList, getEnabledRemindersLists } from '@/lib/config';
import { remindersService } from '@/services/reminders';
/**
* API route pour récupérer la configuration actuelle
*/
export async function GET() {
try {
const config = getConfig();
const availableLists = await remindersService.getReminderLists();
return NextResponse.json({
success: true,
config,
availableLists,
currentTarget: getTargetRemindersList(),
enabledLists: getEnabledRemindersLists()
});
} catch (error) {
console.error('❌ Erreur lors de la récupération de la config:', error);
return NextResponse.json({
success: false,
error: error instanceof Error ? error.message : 'Erreur inconnue'
}, { status: 500 });
}
}
/**
* API route pour tester l'accès à une liste spécifique
*/
export async function POST(request: Request) {
try {
const body = await request.json();
const { listName, action } = body;
if (!listName) {
return NextResponse.json({
success: false,
error: 'listName est requis'
}, { status: 400 });
}
let result: any = {};
switch (action) {
case 'test':
// Tester l'accès à une liste spécifique
const reminders = await remindersService.getRemindersByList(listName);
result = {
listName,
accessible: true,
reminderCount: reminders.length,
sample: reminders.slice(0, 3).map(r => ({
title: r.title,
completed: r.completed,
priority: r.priority
}))
};
break;
case 'preview':
// Prévisualiser les rappels d'une liste
const previewReminders = await remindersService.getRemindersByList(listName);
result = {
listName,
reminders: previewReminders.map(r => ({
id: r.id,
title: r.title,
completed: r.completed,
priority: r.priority,
tags: r.tags || []
}))
};
break;
default:
return NextResponse.json({
success: false,
error: 'Action non supportée. Utilisez "test" ou "preview"'
}, { status: 400 });
}
return NextResponse.json({
success: true,
action,
result
});
} catch (error) {
console.error('❌ Erreur lors du test de liste:', error);
return NextResponse.json({
success: false,
error: error instanceof Error ? error.message : 'Erreur inconnue'
}, { status: 500 });
}
}

View File

@@ -1,60 +0,0 @@
import { NextResponse } from 'next/server';
import { taskProcessorService } from '@/services/task-processor';
/**
* API route pour synchroniser les rappels macOS avec la base de données
*/
export async function POST() {
try {
console.log('🔄 Début de la synchronisation des rappels...');
const syncResult = await taskProcessorService.syncRemindersToDatabase();
return NextResponse.json({
success: true,
message: 'Synchronisation des rappels terminée',
syncLog: syncResult
});
} catch (error) {
console.error('❌ Erreur lors de la synchronisation:', error);
return NextResponse.json({
success: false,
error: error instanceof Error ? error.message : 'Erreur inconnue lors de la synchronisation'
}, { status: 500 });
}
}
/**
* API route pour obtenir le statut de la dernière synchronisation
*/
export async function GET() {
try {
// Récupérer les derniers logs de sync
const { prisma } = await import('@/services/database');
const lastSyncLogs = await prisma.syncLog.findMany({
where: { source: 'reminders' },
orderBy: { createdAt: 'desc' },
take: 5
});
const taskStats = await taskProcessorService.getTaskStats();
return NextResponse.json({
success: true,
lastSyncLogs,
taskStats,
message: 'Statut de synchronisation récupéré'
});
} catch (error) {
console.error('❌ Erreur lors de la récupération du statut:', error);
return NextResponse.json({
success: false,
error: error instanceof Error ? error.message : 'Erreur inconnue'
}, { status: 500 });
}
}

View File

@@ -1,6 +1,6 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { taskProcessorService } from '@/services/task-processor'; import { tasksService } from '@/services/tasks';
import { TaskStatus } from '@/lib/types'; import { TaskStatus, TaskPriority } from '@/lib/types';
/** /**
* API route pour récupérer les tâches avec filtres optionnels * API route pour récupérer les tâches avec filtres optionnels
@@ -10,7 +10,13 @@ export async function GET(request: Request) {
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
// Extraire les paramètres de filtre // Extraire les paramètres de filtre
const filters: any = {}; const filters: {
status?: TaskStatus[];
source?: string[];
search?: string;
limit?: number;
offset?: number;
} = {};
const status = searchParams.get('status'); const status = searchParams.get('status');
if (status) { if (status) {
@@ -38,8 +44,8 @@ export async function GET(request: Request) {
} }
// Récupérer les tâches // Récupérer les tâches
const tasks = await taskProcessorService.getTasks(filters); const tasks = await tasksService.getTasks(filters);
const stats = await taskProcessorService.getTaskStats(); const stats = await tasksService.getTaskStats();
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
@@ -60,26 +66,71 @@ export async function GET(request: Request) {
} }
/** /**
* API route pour mettre à jour le statut d'une tâche * API route pour créer une nouvelle tâche
*/
export async function POST(request: Request) {
try {
const body = await request.json();
const { title, description, status, priority, tags, dueDate } = body;
if (!title) {
return NextResponse.json({
success: false,
error: 'Le titre est requis'
}, { status: 400 });
}
const task = await tasksService.createTask({
title,
description,
status: status as TaskStatus,
priority: priority as TaskPriority,
tags,
dueDate: dueDate ? new Date(dueDate) : undefined
});
return NextResponse.json({
success: true,
data: task,
message: 'Tâche créée avec succès'
});
} catch (error) {
console.error('❌ Erreur lors de la création de la tâche:', error);
return NextResponse.json({
success: false,
error: error instanceof Error ? error.message : 'Erreur inconnue'
}, { status: 500 });
}
}
/**
* API route pour mettre à jour une tâche
*/ */
export async function PATCH(request: Request) { export async function PATCH(request: Request) {
try { try {
const body = await request.json(); const body = await request.json();
const { taskId, status } = body; const { taskId, ...updates } = body;
if (!taskId || !status) { if (!taskId) {
return NextResponse.json({ return NextResponse.json({
success: false, success: false,
error: 'taskId et status sont requis' error: 'taskId est requis'
}, { status: 400 }); }, { status: 400 });
} }
const updatedTask = await taskProcessorService.updateTaskStatus(taskId, status); // Convertir dueDate si présent
if (updates.dueDate) {
updates.dueDate = new Date(updates.dueDate);
}
const updatedTask = await tasksService.updateTask(taskId, updates);
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
data: updatedTask, data: updatedTask,
message: `Tâche ${taskId} mise à jour avec le statut ${status}` message: 'Tâche mise à jour avec succès'
}); });
} catch (error) { } catch (error) {
@@ -91,3 +142,35 @@ export async function PATCH(request: Request) {
}, { status: 500 }); }, { status: 500 });
} }
} }
/**
* API route pour supprimer une tâche
*/
export async function DELETE(request: Request) {
try {
const { searchParams } = new URL(request.url);
const taskId = searchParams.get('taskId');
if (!taskId) {
return NextResponse.json({
success: false,
error: 'taskId est requis'
}, { status: 400 });
}
await tasksService.deleteTask(taskId);
return NextResponse.json({
success: true,
message: 'Tâche supprimée avec succès'
});
} catch (error) {
console.error('❌ Erreur lors de la suppression de la tâche:', error);
return NextResponse.json({
success: false,
error: error instanceof Error ? error.message : 'Erreur inconnue'
}, { status: 500 });
}
}

View File

@@ -1,59 +0,0 @@
import { NextResponse } from 'next/server';
import { testDatabaseConnection } from '@/services/database';
import { remindersService } from '@/services/reminders';
import { taskProcessorService } from '@/services/task-processor';
/**
* API route de test pour vérifier que tous les services fonctionnent
*/
export async function GET() {
try {
const results = {
timestamp: new Date().toISOString(),
database: false,
reminders: false,
taskProcessor: false,
reminderLists: [] as string[],
taskStats: null as any
};
// Test de la base de données
try {
results.database = await testDatabaseConnection();
} catch (error) {
console.error('Test DB failed:', error);
}
// Test de l'accès aux rappels
try {
results.reminders = await remindersService.testRemindersAccess();
if (results.reminders) {
results.reminderLists = await remindersService.getReminderLists();
}
} catch (error) {
console.error('Test Reminders failed:', error);
}
// Test du service de traitement des tâches
try {
results.taskStats = await taskProcessorService.getTaskStats();
results.taskProcessor = true;
} catch (error) {
console.error('Test TaskProcessor failed:', error);
}
return NextResponse.json({
success: true,
message: 'Tests des services terminés',
results
});
} catch (error) {
console.error('Erreur dans l\'API de test:', error);
return NextResponse.json({
success: false,
error: error instanceof Error ? error.message : 'Erreur inconnue'
}, { status: 500 });
}
}

View File

@@ -1,8 +1,8 @@
@import "tailwindcss"; @import "tailwindcss";
:root { :root {
--background: #ffffff; --background: #020617; /* slate-950 */
--foreground: #171717; --foreground: #f1f5f9; /* slate-100 */
} }
@theme inline { @theme inline {
@@ -12,15 +12,38 @@
--font-mono: var(--font-geist-mono); --font-mono: var(--font-geist-mono);
} }
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
body { body {
background: var(--background); background: var(--background);
color: var(--foreground); color: var(--foreground);
font-family: Arial, Helvetica, sans-serif; font-family: var(--font-geist-mono), 'Courier New', monospace;
overflow-x: hidden;
}
/* Scrollbar tech style */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #1e293b; /* slate-800 */
}
::-webkit-scrollbar-thumb {
background: #475569; /* slate-600 */
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #06b6d4; /* cyan-500 */
}
/* Animations tech */
@keyframes glow {
0%, 100% { box-shadow: 0 0 5px rgba(6, 182, 212, 0.3); }
50% { box-shadow: 0 0 20px rgba(6, 182, 212, 0.6); }
}
.animate-glow {
animation: glow 2s ease-in-out infinite;
} }

View File

@@ -1,26 +1,23 @@
import { taskProcessorService } from '@/services/task-processor'; import { tasksService } from '@/services/tasks';
import { getTargetRemindersList } from '@/lib/config';
import { KanbanBoard } from '../../components/kanban/Board'; import { KanbanBoard } from '../../components/kanban/Board';
import { Header } from '../../components/ui/Header'; import { Header } from '../../components/ui/Header';
export default async function HomePage() { export default async function HomePage() {
// SSR - Récupération des données côté serveur // SSR - Récupération des données côté serveur (focus sur les tâches récentes)
const [tasks, stats] = await Promise.all([ const [tasks, stats] = await Promise.all([
taskProcessorService.getTasks({ limit: 100 }), tasksService.getTasks({ limit: 20 }), // Réduire pour voir les nouvelles tâches
taskProcessorService.getTaskStats() tasksService.getTaskStats()
]); ]);
const targetList = getTargetRemindersList();
return ( return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900"> <div className="min-h-screen bg-slate-950">
<Header <Header
title="TowerControl" title="TowerControl"
subtitle={`Tâches synchronisées depuis "${targetList}"`} subtitle="Gestionnaire de tâches moderne"
stats={stats} stats={stats}
/> />
<main className="container mx-auto px-4 py-6"> <main className="h-[calc(100vh-120px)]">
<KanbanBoard tasks={tasks} /> <KanbanBoard tasks={tasks} />
</main> </main>
</div> </div>