import { useState, useEffect, useRef, useCallback } from 'react'; import { CheckSquare } from 'lucide-react'; import { OutlookNotificationData } from '@/components/outlook-notification'; import { format } from 'date-fns'; import { fr } from 'date-fns/locale'; interface Task { id: string; headline: string; dateToFinish: string | null; source: 'leantime' | 'twenty-crm'; projectName: string; url?: string; } /** * Hook to manage task notifications and show Outlook-style notifications * - Leantime tasks: notification at due date time * - Twenty CRM tasks: notification at start date time (or due date if no start date) */ export function useTaskNotifications() { const [taskNotification, setTaskNotification] = useState(null); const notifiedTaskIdsRef = useRef>(new Set()); const tasksRef = useRef([]); const checkIntervalRef = useRef(null); // Load notified task IDs from localStorage on mount useEffect(() => { try { const stored = localStorage.getItem('notified-task-ids'); if (stored) { const ids = JSON.parse(stored); notifiedTaskIdsRef.current = new Set(ids); console.log('[useTaskNotifications] 📦 Loaded notified task IDs from localStorage', { count: ids.length, }); } } catch (error) { console.error('[useTaskNotifications] ❌ Error loading notified task IDs from localStorage', error); } }, []); // Save notified task IDs to localStorage whenever it changes const saveNotifiedTaskIds = useCallback(() => { try { const ids = Array.from(notifiedTaskIdsRef.current); localStorage.setItem('notified-task-ids', JSON.stringify(ids)); console.log('[useTaskNotifications] 💾 Saved notified task IDs to localStorage', { count: ids.length, }); } catch (error) { console.error('[useTaskNotifications] ❌ Error saving notified task IDs to localStorage', error); } }, []); useEffect(() => { console.log('[useTaskNotifications] 🎧 Hook initialized, listening for tasks-updated'); // Listen for tasks updates const handleTasksUpdate = (event: CustomEvent) => { const tasks = event.detail?.tasks || []; console.log('[useTaskNotifications] 📋 Received tasks update', { tasksCount: tasks.length, tasks: tasks.map((t: any) => ({ id: t.id, title: t.headline, dateToFinish: t.dateToFinish, source: t.source, })), }); if (!tasks || tasks.length === 0) { tasksRef.current = []; return; } // Convert tasks to Task format const formattedTasks: Task[] = tasks.map((task: any) => ({ id: task.id?.toString() || '', headline: task.headline || task.title || '', dateToFinish: task.dateToFinish || null, source: task.source || 'leantime', projectName: task.projectName || '', url: task.url || null, })); tasksRef.current = formattedTasks; console.log('[useTaskNotifications] Tasks stored', { count: formattedTasks.length, tasks: formattedTasks.map(t => ({ id: t.id, title: t.headline, dateToFinish: t.dateToFinish, source: t.source, })), }); }; window.addEventListener('tasks-updated', handleTasksUpdate as EventListener); // Check for tasks that are due/starting now (every 10 seconds) const checkForDueTasks = () => { const now = new Date(); const currentTime = now.getTime(); console.log('[useTaskNotifications] 🔍 Checking for due tasks', { now: now.toISOString(), tasksCount: tasksRef.current.length, notifiedCount: notifiedTaskIdsRef.current.size, }); // Helper function to parse date correctly (handles Leantime and Twenty CRM dates) const parseTaskDate = (dateStr: string | null, taskId?: string, taskTitle?: string, source?: string): Date | null => { if (!dateStr) return null; // Twenty CRM dates are in ISO format with timezone (usually UTC) // Unlike Leantime, Twenty CRM stores dates in true UTC // When a user enters 10:45 (local time) in Twenty CRM, it's stored as UTC (e.g., 09:45 UTC if UTC+1) // We need to parse it and let JavaScript convert it to local time automatically if (source === 'twenty-crm') { // Parse the ISO date string - JavaScript will automatically convert UTC to local time // If Twenty CRM stores 09:45 UTC (which displays as 10:45 local), new Date() will convert it to 10:45 local const parsedDate = new Date(dateStr); console.log('[useTaskNotifications] 📅 Parsing Twenty CRM date (UTC -> local)', { taskId, taskTitle, rawDate: dateStr, parsedLocal: parsedDate.toLocaleString('fr-FR'), parsedISO: parsedDate.toISOString(), localHour: parsedDate.getHours(), localMinute: parsedDate.getMinutes(), utcHour: parsedDate.getUTCHours(), utcMinute: parsedDate.getUTCMinutes(), timezoneOffset: new Date().getTimezoneOffset(), }); return parsedDate; } // Leantime dates with 'Z' are actually local time stored as UTC // We need to extract the time components and create a local date if (dateStr.endsWith('Z')) { // Parse as UTC first to get the components, then create local date // Example: "2026-01-16T01:13:00.000Z" -> treat 01:13 as local time, not UTC const utcDate = new Date(dateStr); // Extract components from UTC date and create local date // This assumes the UTC date actually represents local time const parsedDate = new Date( utcDate.getUTCFullYear(), utcDate.getUTCMonth(), utcDate.getUTCDate(), utcDate.getUTCHours(), utcDate.getUTCMinutes(), utcDate.getUTCSeconds(), utcDate.getUTCMilliseconds() ); console.log('[useTaskNotifications] 📅 Parsing Leantime date (Z -> local)', { taskId, taskTitle, rawDate: dateStr, utcComponents: { year: utcDate.getUTCFullYear(), month: utcDate.getUTCMonth() + 1, day: utcDate.getUTCDate(), hour: utcDate.getUTCHours(), minute: utcDate.getUTCMinutes(), }, parsedLocal: parsedDate.toLocaleString('fr-FR'), parsedISO: parsedDate.toISOString(), }); return parsedDate; } else if (dateStr.includes('T')) { // ISO format without Z - treat as local time return new Date(dateStr); } else if (dateStr.includes(' ')) { // MySQL format: "YYYY-MM-DD HH:MM:SS" - parse as local time const isoLike = dateStr.replace(' ', 'T'); return new Date(isoLike); } else { // Try direct parsing return new Date(dateStr); } }; interface TaskWithParsedDate extends Task { parsedNotificationDate: Date; } const dueTasksWithDates: TaskWithParsedDate[] = []; for (const task of tasksRef.current) { // Skip if already notified if (notifiedTaskIdsRef.current.has(task.id)) { console.log('[useTaskNotifications] ⏭️ Task already notified', { id: task.id, title: task.headline, }); continue; } // Parse the notification date (pass source to handle Twenty CRM dates correctly) const notificationDate = parseTaskDate(task.dateToFinish, task.id, task.headline, task.source); if (!notificationDate || isNaN(notificationDate.getTime())) { console.log('[useTaskNotifications] ⏭️ Task has no valid date', { id: task.id, title: task.headline, dateToFinish: task.dateToFinish, }); continue; } const notificationTime = notificationDate.getTime(); const timeUntilNotification = notificationTime - currentTime; const timeUntilNotificationMinutes = Math.round(timeUntilNotification / 1000 / 60); const timeUntilNotificationSeconds = Math.round(timeUntilNotification / 1000); // Check if task is due today const today = new Date(); const isDueToday = notificationDate.getFullYear() === today.getFullYear() && notificationDate.getMonth() === today.getMonth() && notificationDate.getDate() === today.getDate(); // Notification window logic: // - For tasks due today but already past due: notify if within 24 hours after due time // - For tasks due today but not yet due: notify if within ±1 minute of due time // - For future tasks: notify if within ±1 minute of due time let inNotificationWindow = false; if (isDueToday && timeUntilNotification < 0) { // Task is due today but already past due - notify if within 24 hours after const hoursPastDue = Math.abs(timeUntilNotificationMinutes) / 60; inNotificationWindow = hoursPastDue <= 24; } else { // Task is due today (not yet due) or future - notify if within ±1 minute const notificationWindow = 60 * 1000; // 1 minute inNotificationWindow = timeUntilNotification >= -notificationWindow && timeUntilNotification <= notificationWindow; } console.log('[useTaskNotifications] ⏰ Checking task', { id: task.id, title: task.headline, source: task.source, rawDate: task.dateToFinish, parsedDateLocal: notificationDate.toLocaleString('fr-FR', { timeZone: 'Europe/Paris' }), parsedDateISO: notificationDate.toISOString(), nowLocal: now.toLocaleString('fr-FR', { timeZone: 'Europe/Paris' }), nowISO: now.toISOString(), timeUntilNotificationMinutes, timeUntilNotificationSeconds, isDueToday, inWindow: inNotificationWindow, }); if (inNotificationWindow) { dueTasksWithDates.push({ ...task, parsedNotificationDate: notificationDate, }); } } const dueTasks = dueTasksWithDates; if (dueTasks.length > 0) { // Sort by notification time (earliest first) using parsed dates dueTasks.sort((a, b) => { return a.parsedNotificationDate.getTime() - b.parsedNotificationDate.getTime(); }); // Show notification for the first task const task = dueTasks[0]; const notificationDate = task.parsedNotificationDate; const timeStr = format(notificationDate, 'HH:mm', { locale: fr }); const sourceLabel = task.source === 'twenty-crm' ? 'Médiation' : 'Agilité'; console.log('[useTaskNotifications] ✅ Task due detected!', { id: task.id, title: task.headline, source: task.source, rawDate: task.dateToFinish, parsedNotificationDateLocal: notificationDate.toLocaleString('fr-FR'), parsedNotificationDateISO: notificationDate.toISOString(), nowLocal: now.toLocaleString('fr-FR'), nowISO: now.toISOString(), }); const notification: OutlookNotificationData = { id: `task-${task.id}-${Date.now()}`, source: 'leantime', title: 'Devoirs', subtitle: task.source === 'twenty-crm' ? 'Tâche Médiation' : 'Tâche Agilité', message: `${task.headline} - Échéance à ${timeStr} (${sourceLabel})`, icon: CheckSquare, iconColor: 'text-blue-600', iconBgColor: 'bg-blue-100', borderColor: 'border-blue-500', link: task.url || '/agilite', timestamp: notificationDate, autoDismiss: 30000, // 30 seconds actions: [ { label: 'Ouvrir', onClick: () => { if (task.url) { window.open(task.url, '_blank'); } else { window.location.href = '/agilite'; } }, variant: 'default', className: 'bg-blue-600 hover:bg-blue-700 text-white', }, ], }; setTaskNotification(notification); notifiedTaskIdsRef.current.add(task.id); saveNotifiedTaskIds(); // Persist to localStorage // Clean up old notified tasks (older than 1 hour) to allow re-notification if needed setTimeout(() => { notifiedTaskIdsRef.current.delete(task.id); saveNotifiedTaskIds(); // Update localStorage after cleanup }, 60 * 60 * 1000); // 1 hour } }; // Check immediately and then every 10 seconds for more responsive notifications checkForDueTasks(); checkIntervalRef.current = setInterval(checkForDueTasks, 10000); // Every 10 seconds return () => { window.removeEventListener('tasks-updated', handleTasksUpdate as EventListener); if (checkIntervalRef.current) { clearInterval(checkIntervalRef.current); } }; }, []); const handleDismiss = () => { setTaskNotification(null); }; return { taskNotification, setTaskNotification: handleDismiss, }; }