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

View File

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