// Rôle : fournit l'outil task planner avec tâches récurrentes, parents et pré requis. import { useEffect, useMemo, useRef, useState } from "react"; import { Icon } from "../../../components/Icon.jsx"; import { InlineNotice } from "../../../components/AppOverlays.jsx"; import { usePointerReorder } from "../../../hooks/usePointerReorder.js"; const TASK_TYPES = ["daily", "weekly", "unique"]; const WEEK_DAYS = [ { value: 1, label: "Lundi" }, { value: 2, label: "Mardi" }, { value: 3, label: "Mercredi" }, { value: 4, label: "Jeudi" }, { value: 5, label: "Vendredi" }, { value: 6, label: "Samedi" }, { value: 0, label: "Dimanche" } ]; function getTaskTypeLabel(type, textContent) { if (type === "daily") return textContent.dailyType || "Quotidienne"; if (type === "weekly") return textContent.weeklyType || "Hebdomadaire"; return textContent.uniqueType || "Ponctuelle"; } function parseResetTime(value) { const [hours, minutes] = String(value || "00:00").split(":").map((part) => Number.parseInt(part, 10)); return { hours: Number.isInteger(hours) ? Math.min(23, Math.max(0, hours)) : 0, minutes: Number.isInteger(minutes) ? Math.min(59, Math.max(0, minutes)) : 0 }; } function getLatestDailyReset(now, resetTime) { const { hours, minutes } = parseResetTime(resetTime); const reset = new Date(now); reset.setHours(hours, minutes, 0, 0); if (reset.getTime() > now) reset.setDate(reset.getDate() - 1); return reset.getTime(); } function getLatestWeeklyReset(now, resetTime, weekDay) { const { hours, minutes } = parseResetTime(resetTime); const reset = new Date(now); reset.setHours(hours, minutes, 0, 0); const dayDiff = (reset.getDay() - weekDay + 7) % 7; reset.setDate(reset.getDate() - dayDiff); if (reset.getTime() > now) reset.setDate(reset.getDate() - 7); return reset.getTime(); } function applyDueResets(data, now = Date.now()) { let latestResetAt = data.lastResetAt; let changed = false; const tasks = data.tasks.map((task) => { if (task.type === "unique") return task; const resetAt = task.type === "daily" ? getLatestDailyReset(now, data.resetTime) : getLatestWeeklyReset(now, data.resetTime, task.weeklyResetDay ?? data.weeklyResetDay); latestResetAt = Math.max(latestResetAt, resetAt); if (!task.checked || resetAt <= (task.checkedAt || data.lastResetAt)) return task; changed = true; return { ...task, checked: false, checkedAt: 0 }; }); if (latestResetAt > data.lastResetAt) changed = true; return changed ? { ...data, lastResetAt: latestResetAt, tasks } : data; } function moveTask(tasks, fromTaskId, toTaskId, placement = "before") { const nextTasks = [...tasks]; const fromIndex = nextTasks.findIndex((task) => task.id === fromTaskId); const toIndex = nextTasks.findIndex((task) => task.id === toTaskId); if (fromIndex < 0 || toIndex < 0) return tasks; const [moved] = nextTasks.splice(fromIndex, 1); const targetIndex = nextTasks.findIndex((task) => task.id === toTaskId); nextTasks.splice(placement === "after" ? targetIndex + 1 : targetIndex, 0, moved); return nextTasks; } function moveCategory(categoryOrder, fromCategory, toCategory, placement = "before") { const index = categoryOrder.indexOf(fromCategory); const targetIndex = categoryOrder.indexOf(toCategory); if (index < 0 || targetIndex < 0 || fromCategory === toCategory) return categoryOrder; const nextOrder = [...categoryOrder]; const [moved] = nextOrder.splice(index, 1); const nextTargetIndex = nextOrder.indexOf(toCategory); nextOrder.splice(placement === "after" ? nextTargetIndex + 1 : nextTargetIndex, 0, moved); return nextOrder; } function sortTasks(tasks, sortMode) { if (sortMode !== "type") return tasks; const order = new Map(TASK_TYPES.map((type, index) => [type, index])); return [...tasks].sort((a, b) => (order.get(a.type) ?? 99) - (order.get(b.type) ?? 99)); } function getTaskTitle(tasks, taskId) { return tasks.find((task) => task.id === taskId)?.title || "Tâche supprimée"; } function getTaskParentId(taskId, relations, taskIds) { return relations.find((relation) => relation.fromTaskId === taskId && taskIds.has(relation.toTaskId))?.toTaskId || ""; } function getTaskParentMap(tasks, relations) { const taskIds = new Set(tasks.map((task) => task.id)); const parentMap = new Map(tasks.map((task) => [task.id, getTaskParentId(task.id, relations, taskIds)])); tasks.forEach((task) => { const visited = new Set([task.id]); let parentId = parentMap.get(task.id); while (parentId) { if (visited.has(parentId)) { parentMap.set(task.id, ""); return; } visited.add(parentId); parentId = parentMap.get(parentId); } }); return parentMap; } function getTasksForParent(tasks, parentMap, parentId, sortMode) { return sortTasks(tasks.filter((task) => (parentMap.get(task.id) || "") === parentId), sortMode); } function getTaskCategory(task) { return String(task?.category || "").trim(); } function getTaskById(tasks, taskId) { return tasks.find((task) => task.id === taskId) || null; } function getEffectiveTaskCategory(task, tasks, parentMap) { const parentId = parentMap.get(task?.id) || ""; const parentTask = parentId ? getTaskById(tasks, parentId) : null; return parentTask ? getEffectiveTaskCategory(parentTask, tasks, parentMap) : getTaskCategory(task); } function getTaskCategoryForParent(task, tasks, parentMap, parentId) { return parentId ? getEffectiveTaskCategory(task, tasks, parentMap) : getTaskCategory(task); } function getTaskCategoryDataset(task, tasks, parentMap) { return getEffectiveTaskCategory(task, tasks, parentMap); } function countTaskTree(taskIds, tasks, parentMap) { let count = 0; const visited = new Set(); function visit(taskId) { if (visited.has(taskId)) return; visited.add(taskId); count += 1; tasks .filter((task) => (parentMap.get(task.id) || "") === taskId) .forEach((child) => visit(child.id)); } taskIds.forEach(visit); return count; } function getTaskCategoryGroups(tasks, parentMap, parentId, sortMode, categoryOrder) { const siblings = getTasksForParent(tasks, parentMap, parentId, sortMode); if (parentId) { return { uncategorized: siblings, categories: [] }; } const categories = []; siblings.forEach((task) => { const category = getTaskCategoryForParent(task, tasks, parentMap, parentId); if (category && !categories.includes(category)) categories.push(category); }); const orderedCategories = [ ...categoryOrder.filter((category) => categories.includes(category)), ...categories.filter((category) => !categoryOrder.includes(category)) ]; return { uncategorized: siblings.filter((task) => !getTaskCategoryForParent(task, tasks, parentMap, parentId)), categories: orderedCategories.map((category) => ({ category, tasks: siblings.filter((task) => getTaskCategoryForParent(task, tasks, parentMap, parentId) === category) })).filter((group) => group.tasks.length) }; } function getCategorySectionId(parentId, category) { return `${parentId || ""}\n${category}`; } function parseCategorySectionId(id) { const separatorIndex = id.indexOf("\n"); if (separatorIndex < 0) return { parentId: "", category: id }; return { parentId: id.slice(0, separatorIndex), category: id.slice(separatorIndex + 1) }; } export function TaskPlannerModule({ toolboxId, moduleId, context, editing }) { const data = context.normalizeTaskPlannerData(context.getModuleData(toolboxId, moduleId, { tasks: [] })); const textContent = context.moduleText?.taskPlanner || {}; const [title, setTitle] = useState(""); const [type, setType] = useState("daily"); const [nowMs, setNowMs] = useState(() => Date.now()); const [settingsOpen, setSettingsOpen] = useState(false); const [openDescriptions, setOpenDescriptions] = useState(() => new Set()); const [openTaskSettings, setOpenTaskSettings] = useState(() => new Set()); const [prerequisiteNoticeTaskIds, setPrerequisiteNoticeTaskIds] = useState(() => new Set()); const prerequisiteNoticeTimeouts = useRef(new Map()); const parentMap = useMemo(() => getTaskParentMap(data.tasks, data.relations), [data.tasks, data.relations]); const { draggingId, dropTarget, startDrag } = usePointerReorder({ targetSelector: ".task-planner-item", getTargetId: (target) => target.dataset.taskId, canDropOn: (target, draggingTaskId) => { const draggingTask = data.tasks.find((task) => task.id === draggingTaskId); return data.sortMode === "manual" && target.dataset.parentId === (parentMap.get(draggingTaskId) || "") && target.dataset.category === getTaskCategoryDataset(draggingTask, data.tasks, parentMap); }, onMove: (fromTaskId, toTaskId, placement) => save({ ...data, tasks: moveTask(data.tasks, fromTaskId, toTaskId, placement) }) }); const { draggingId: draggingCategoryId, dropTarget: categoryDropTarget, startDrag: startCategoryDrag } = usePointerReorder({ targetSelector: ".task-planner-category-section", getTargetId: (target) => getCategorySectionId(target.dataset.parentId || "", target.dataset.category || ""), canDropOn: (target, draggingIdValue) => { const draggingCategory = parseCategorySectionId(draggingIdValue); return target.dataset.parentId === draggingCategory.parentId && Boolean(target.dataset.category); }, onMove: (fromId, toId, placement) => { const fromCategory = parseCategorySectionId(fromId).category; const toCategory = parseCategorySectionId(toId).category; save({ ...data, categoryOrder: moveCategory(data.categoryOrder, fromCategory, toCategory, placement) }); } }); function save(nextData) { context.setModuleData(toolboxId, moduleId, nextData, "taskPlanner"); } useEffect(() => { const intervalId = window.setInterval(() => setNowMs(Date.now()), 60000); return () => window.clearInterval(intervalId); }, []); useEffect(() => () => { prerequisiteNoticeTimeouts.current.forEach((timeoutId) => window.clearTimeout(timeoutId)); }, []); useEffect(() => { const resetData = applyDueResets(data, nowMs); if (resetData !== data) save(resetData); }); function addTask(event) { event.preventDefault(); const cleanTitle = title.trim(); if (!cleanTitle) return; save({ ...data, tasks: [ ...data.tasks, { id: context.uid("task"), title: cleanTitle, description: "", type, checked: false, checkedAt: 0 } ] }); setTitle(""); setType("daily"); } function updateTask(taskId, updater) { save({ ...data, tasks: data.tasks.map((task) => task.id === taskId ? updater(task) : task) }); } function deleteTask(taskId) { save({ ...data, tasks: data.tasks.filter((task) => task.id !== taskId), relations: data.relations.filter((relation) => relation.fromTaskId !== taskId && relation.toTaskId !== taskId) }); } function addLinkTarget(fromTaskId, toTaskId) { if (!toTaskId || fromTaskId === toTaskId || data.relations.some((relation) => relation.fromTaskId === fromTaskId && relation.toTaskId === toTaskId)) return; save({ ...data, tasks: data.tasks.map((task) => { if (task.id !== fromTaskId || !task.category) return task; const nextTask = { ...task }; delete nextTask.category; return nextTask; }), relations: [ ...data.relations.filter((relation) => relation.fromTaskId !== fromTaskId), { id: context.uid("relation"), fromTaskId, toTaskId, prerequisite: false } ] }); } function removeLinkTarget(fromTaskId, toTaskId) { save({ ...data, relations: data.relations.filter((relation) => relation.fromTaskId !== fromTaskId || relation.toTaskId !== toTaskId) }); } function setPrerequisite(fromTaskId, toTaskId, prerequisite) { const hasRelation = data.relations.some((relation) => relation.fromTaskId === fromTaskId && relation.toTaskId === toTaskId); save({ ...data, relations: hasRelation ? data.relations.map((relation) => relation.fromTaskId === fromTaskId && relation.toTaskId === toTaskId ? { ...relation, prerequisite } : relation) : [...data.relations, { id: context.uid("relation"), fromTaskId, toTaskId, prerequisite }] }); } function setTaskCategory(taskId, category) { const normalizedCategory = String(category || "").trim(); const tasks = data.tasks.map((task) => { if (task.id !== taskId) return task; const nextTask = { ...task }; if (normalizedCategory) nextTask.category = normalizedCategory; else delete nextTask.category; return nextTask; }); const categoryOrder = [ ...data.categoryOrder.filter((categoryName) => tasks.some((task) => task.category === categoryName)), ...tasks.map((task) => task.category).filter(Boolean).filter((categoryName) => !data.categoryOrder.includes(categoryName)) ]; save({ ...data, tasks, categoryOrder }); } function toggleSet(setter, taskId) { setter((current) => { const next = new Set(current); if (next.has(taskId)) next.delete(taskId); else next.add(taskId); return next; }); } function showPrerequisiteNotice(taskId) { const currentTimeoutId = prerequisiteNoticeTimeouts.current.get(taskId); if (currentTimeoutId) window.clearTimeout(currentTimeoutId); setPrerequisiteNoticeTaskIds((current) => new Set(current).add(taskId)); const timeoutId = window.setTimeout(() => { prerequisiteNoticeTimeouts.current.delete(taskId); setPrerequisiteNoticeTaskIds((current) => { const next = new Set(current); next.delete(taskId); return next; }); }, 2600); prerequisiteNoticeTimeouts.current.set(taskId, timeoutId); } return (