403 lines
15 KiB
TypeScript
403 lines
15 KiB
TypeScript
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<OutlookNotificationData | null>(null);
|
|
const notifiedTaskIdsRef = useRef<Set<string>>(new Set());
|
|
const tasksRef = useRef<Task[]>([]);
|
|
const checkIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
|
|
|
// 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');
|
|
|
|
// Load notified task IDs from localStorage synchronously at the start
|
|
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,
|
|
ids: Array.from(ids),
|
|
});
|
|
}
|
|
} catch (error) {
|
|
console.error('[useTaskNotifications] ❌ Error loading notified task IDs from localStorage', error);
|
|
}
|
|
|
|
// 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 (check both in-memory and localStorage)
|
|
if (notifiedTaskIdsRef.current.has(task.id)) {
|
|
console.log('[useTaskNotifications] ⏭️ Task already notified (skipping)', {
|
|
id: task.id,
|
|
title: task.headline,
|
|
notifiedIds: Array.from(notifiedTaskIdsRef.current),
|
|
});
|
|
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',
|
|
},
|
|
],
|
|
};
|
|
|
|
console.log('[useTaskNotifications] 🎯 Setting task notification', {
|
|
taskId: task.id,
|
|
title: task.headline,
|
|
beforeAdd: Array.from(notifiedTaskIdsRef.current),
|
|
});
|
|
|
|
setTaskNotification(notification);
|
|
notifiedTaskIdsRef.current.add(task.id);
|
|
saveNotifiedTaskIds(); // Persist to localStorage immediately
|
|
|
|
console.log('[useTaskNotifications] ✅ Task notification set and saved', {
|
|
taskId: task.id,
|
|
afterAdd: Array.from(notifiedTaskIdsRef.current),
|
|
});
|
|
|
|
// 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
|
|
console.log('[useTaskNotifications] 🧹 Cleaned up old notification for task', {
|
|
taskId: task.id,
|
|
});
|
|
}, 60 * 60 * 1000); // 1 hour
|
|
}
|
|
};
|
|
|
|
// Clean up localStorage periodically (remove IDs for tasks that no longer exist or are too old)
|
|
const cleanupLocalStorage = () => {
|
|
try {
|
|
const stored = localStorage.getItem('notified-task-ids');
|
|
if (stored) {
|
|
const storedIds = JSON.parse(stored);
|
|
const currentTaskIds = new Set(tasksRef.current.map(t => t.id));
|
|
|
|
// Remove IDs that are no longer in the current tasks list
|
|
const validIds = storedIds.filter((id: string) => currentTaskIds.has(id));
|
|
|
|
if (validIds.length !== storedIds.length) {
|
|
localStorage.setItem('notified-task-ids', JSON.stringify(validIds));
|
|
notifiedTaskIdsRef.current = new Set(validIds);
|
|
console.log('[useTaskNotifications] 🧹 Cleaned up localStorage', {
|
|
removed: storedIds.length - validIds.length,
|
|
remaining: validIds.length,
|
|
});
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('[useTaskNotifications] ❌ Error cleaning up localStorage', error);
|
|
}
|
|
};
|
|
|
|
// Check immediately and then every 10 seconds for more responsive notifications
|
|
checkForDueTasks();
|
|
checkIntervalRef.current = setInterval(checkForDueTasks, 10000); // Every 10 seconds
|
|
|
|
// Clean up localStorage every 5 minutes
|
|
const cleanupInterval = setInterval(cleanupLocalStorage, 5 * 60 * 1000);
|
|
|
|
return () => {
|
|
window.removeEventListener('tasks-updated', handleTasksUpdate as EventListener);
|
|
if (checkIntervalRef.current) {
|
|
clearInterval(checkIntervalRef.current);
|
|
}
|
|
if (cleanupInterval) {
|
|
clearInterval(cleanupInterval);
|
|
}
|
|
};
|
|
}, [saveNotifiedTaskIds]);
|
|
|
|
const handleDismiss = () => {
|
|
setTaskNotification(null);
|
|
};
|
|
|
|
return {
|
|
taskNotification,
|
|
setTaskNotification: handleDismiss,
|
|
};
|
|
}
|