// 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 { useDraftForm } from "../../../hooks/useDraftForm.js"; import { applyGroupedReorderOperation, useGroupedReorder } from "../../../hooks/useGroupedReorder.js"; import { useInlineEdit } from "../../../hooks/useInlineEdit.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, task.dailyResetTime || 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 getBoundaryTaskId(tasks, taskIds, placement) { const ids = new Set(taskIds); const orderedTasks = tasks.filter((task) => ids.has(task.id)); return placement === "after" ? orderedTasks.at(-1)?.id || "" : orderedTasks[0]?.id || ""; } 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) { return tasks.filter((task) => (parentMap.get(task.id) || "") === parentId); } 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 getTaskTreeIds(taskIds, tasks, parentMap) { const ids = []; const visited = new Set(); function visit(taskId) { if (visited.has(taskId)) return; visited.add(taskId); ids.push(taskId); tasks .filter((task) => (parentMap.get(task.id) || "") === taskId) .forEach((child) => visit(child.id)); } taskIds.forEach(visit); return ids; } function countCompletedTaskTree(taskIds, tasks, parentMap) { const taskIdsSet = new Set(getTaskTreeIds(taskIds, tasks, parentMap)); return tasks.filter((task) => taskIdsSet.has(task.id) && task.checked).length; } function isTaskTreeComplete(taskIds, tasks, parentMap) { const treeIds = getTaskTreeIds(taskIds, tasks, parentMap); return treeIds.length > 0 && treeIds.every((taskId) => getTaskById(tasks, taskId)?.checked); } function getTaskCategoryGroups(tasks, parentMap, parentId, categoryOrder) { const siblings = getTasksForParent(tasks, parentMap, parentId); if (parentId) { return { entries: siblings.map((task) => ({ type: "task", task })), 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)) ]; const uncategorized = siblings.filter((task) => !getTaskCategoryForParent(task, tasks, parentMap, parentId)); const categoryGroups = orderedCategories.map((category) => ({ category, tasks: siblings.filter((task) => getTaskCategoryForParent(task, tasks, parentMap, parentId) === category) })).filter((group) => group.tasks.length); const renderedCategories = new Set(); const entries = siblings.map((task) => { const category = getTaskCategoryForParent(task, tasks, parentMap, parentId); if (!category) return { type: "task", task }; if (renderedCategories.has(category)) return null; renderedCategories.add(category); return { type: "category", group: categoryGroups.find((group) => group.category === category) }; }).filter(Boolean); return { entries, uncategorized, categories: categoryGroups }; } function getRootTaskIdsForCategory(tasks, parentMap, category) { return tasks .filter((task) => !parentMap.get(task.id) && getTaskCategory(task) === category) .map((task) => task.id); } export function TaskPlannerModule({ toolboxId, moduleId, context, editing }) { const data = context.normalizeTaskPlannerData(context.getModuleData(toolboxId, moduleId, { tasks: [] })); const textContent = context.moduleText?.taskPlanner || {}; const taskDraft = useDraftForm({ title: "", type: "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 { itemReorder, groupReorder, getItemProps, getGroupProps, getGroupBoundaryProps, isItemDragging, isItemDropTarget, isGroupDragging, isGroupDropTarget, isBoundaryDropTarget, getDropPlacement, shouldShowGroupBoundaries } = useGroupedReorder({ namespace: "task-planner", items: data.tasks, getItemId: (task) => task.id, getParentId: (task) => parentMap.get(task.id) || "", getItemGroup: (task) => getTaskCategoryDataset(task, data.tasks, parentMap), getEffectiveGroup: (task) => getTaskCategoryDataset(task, data.tasks, parentMap), reorderFeatures: { item: { groupChange: true, boundaryDrop: true, rootOnly: true }, group: { reorder: true, boundaryDrop: true, itemDrop: true, rootOnly: true } }, onItemMove: (operation) => { const fromTaskId = operation.sourceId; const fromTask = data.tasks.find((task) => task.id === fromTaskId); if (!fromTask) return; function setRootTaskCategory(tasks, category) { return tasks.map((task) => { if (task.id !== fromTaskId) return task; const nextTask = { ...task }; if (category) nextTask.category = category; else delete nextTask.category; return nextTask; }); } function moveToCategory(category, placement = operation.placement) { const nextTasks = setRootTaskCategory(data.tasks, category); const targetIds = category ? getRootTaskIdsForCategory(data.tasks, parentMap, category).filter((taskId) => taskId !== fromTaskId) : data.tasks.filter((task) => !parentMap.get(task.id) && !getTaskCategory(task) && task.id !== fromTaskId).map((task) => task.id); const boundaryTaskId = getBoundaryTaskId(data.tasks, targetIds, placement); save({ ...data, tasks: boundaryTaskId ? moveTask(nextTasks, fromTaskId, boundaryTaskId, placement) : nextTasks }); } if (operation.targetType === "boundary") { const targetIds = getRootTaskIdsForCategory(data.tasks, parentMap, operation.targetGroup); const boundaryTaskId = getBoundaryTaskId(data.tasks, targetIds, operation.placement); if (!boundaryTaskId) return; const sourceCategory = getTaskCategoryDataset(fromTask, data.tasks, parentMap); const tasks = sourceCategory ? setRootTaskCategory(data.tasks, "") : data.tasks; save({ ...data, tasks: moveTask(tasks, fromTaskId, boundaryTaskId, operation.placement) }); return; } if (operation.targetType === "group") { const targetCategory = operation.targetGroup; const sourceCategory = getTaskCategoryDataset(fromTask, data.tasks, parentMap); if (!sourceCategory) { const targetIds = getRootTaskIdsForCategory(data.tasks, parentMap, targetCategory); const boundaryTaskId = getBoundaryTaskId(data.tasks, targetIds, operation.placement); if (boundaryTaskId) save({ ...data, tasks: moveTask(data.tasks, fromTaskId, boundaryTaskId, operation.placement) }); return; } moveToCategory(targetCategory); return; } const targetTask = data.tasks.find((task) => task.id === operation.targetId); const targetCategory = targetTask ? getTaskCategoryDataset(targetTask, data.tasks, parentMap) : ""; const sourceCategory = getTaskCategoryDataset(fromTask, data.tasks, parentMap); const shouldChangeRootCategory = !parentMap.get(fromTaskId) && targetTask && !parentMap.get(operation.targetId) && targetCategory !== sourceCategory; const tasks = shouldChangeRootCategory ? setRootTaskCategory(data.tasks, targetCategory) : data.tasks; save({ ...data, tasks: moveTask(tasks, fromTaskId, operation.targetId, operation.placement) }); }, onGroupMove: (operation) => { save(applyGroupedReorderOperation(data, { operation, itemsKey: "tasks", groupOrderKey: "categoryOrder", getItemGroup: (task) => parentMap.get(task.id) ? "" : getTaskCategory(task), setItemGroup: (task) => task })); }, hierarchy: { enabled: true, stickyParents: true } }); const reorder = { itemReorder, groupReorder, getItemProps, getGroupProps, getGroupBoundaryProps, isItemDragging, isItemDropTarget, isGroupDragging, isGroupDropTarget, isBoundaryDropTarget, getDropPlacement, shouldShowGroupBoundaries }; 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) { taskDraft.handleSubmit(event, (draft, { reset }) => { const cleanTitle = draft.title.trim(); if (!cleanTitle) return; save({ ...data, tasks: [ ...data.tasks, { id: context.uid("task"), title: cleanTitle, description: "", type: draft.type, checked: false, checkedAt: 0 } ] }); reset(); }); } 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 toggleCategoryCollapsed(category) { const collapsedCategories = data.collapsedCategories.includes(category) ? data.collapsedCategories.filter((item) => item !== category) : [...data.collapsedCategories, category]; save({ ...data, collapsedCategories }); } function clearCompletedUniqueTasks() { const removedTaskIds = new Set(data.tasks.filter((task) => task.type === "unique" && task.checked).map((task) => task.id)); if (!removedTaskIds.size) return; const tasks = data.tasks.filter((task) => !removedTaskIds.has(task.id)); const relations = data.relations.filter((relation) => !removedTaskIds.has(relation.fromTaskId) && !removedTaskIds.has(relation.toTaskId)); save({ ...data, tasks, relations }); } 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 (