diff --git a/website/src/features/toolboxes/modules/EquipmentPlannerModule.jsx b/website/src/features/toolboxes/modules/EquipmentPlannerModule.jsx
index 512204e..580f728 100644
--- a/website/src/features/toolboxes/modules/EquipmentPlannerModule.jsx
+++ b/website/src/features/toolboxes/modules/EquipmentPlannerModule.jsx
@@ -6,70 +6,19 @@ import { Tooltip } from "../../../components/Tooltip.jsx";
import { applyGroupedReorderOperation, moveItem, useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
import { useInlineEdit } from "../../../hooks/useInlineEdit.js";
import { TextExchangeActions } from "./TextExchangeActions.jsx";
-
-const SOCKET_SHAPES = ["ball", "jewel"];
-const SOCKET_COLORS = ["red", "orange", "amber", "yellow", "lime", "green", "teal", "cyan", "blue", "indigo", "violet", "purple", "pink", "rose", "white", "gray", "black"];
-const SOCKET_COLOR_VALUES = {
- red: "#ef4444",
- orange: "#f97316",
- amber: "#f59e0b",
- yellow: "var(--color-accent-gold)",
- lime: "#84cc16",
- green: "#22c55e",
- teal: "#14b8a6",
- cyan: "var(--color-accent-cyan)",
- blue: "#3b82f6",
- indigo: "var(--color-accent-indigo)",
- violet: "#7c3aed",
- purple: "var(--color-primary)",
- pink: "var(--color-accent-pink)",
- rose: "#f43f5e",
- white: "#f8fafc",
- gray: "#64748b",
- black: "#020617"
-};
-const EQUIPMENT_ICONS = [
- { icon: "helmet", label: "Casque" },
- { icon: "chest-armor", label: "Chest" },
- { icon: "glove", label: "Gants" },
- { icon: "boot", label: "Bottes" },
- { icon: "necklace", label: "Collier" },
- { icon: "earring", label: "Earring" },
- { icon: "ring", label: "Anneau" },
- { icon: "sword", label: "Épée" },
- { icon: "gun", label: "Gun" },
- { icon: "shield", label: "Bouclier" }
-];
-const TRAIT_ICONS = ["sword", "shield"];
-const DETAIL_TABS = ["stats", "sockets", "craft"];
-
-function getCharacteristicCategories(equipment) {
- const categories = equipment.characteristics.map((trait) => trait.category).filter(Boolean);
- return [
- ...equipment.categoryOrder.filter((category) => categories.includes(category)),
- ...categories.filter((category, index) => categories.indexOf(category) === index && !equipment.categoryOrder.includes(category))
- ];
-}
-
-function setItemCategory(item, category) {
- const nextItem = { ...item };
- if (category) nextItem.category = category;
- else delete nextItem.category;
- return nextItem;
-}
-
-function formatValue(value) {
- if (typeof value === "number") return Number.isInteger(value) ? String(value) : String(Math.round(value * 1000) / 1000);
- return String(value || "");
-}
-
-function createLocalId(prefix) {
- return globalThis.crypto?.randomUUID?.() || `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
-}
-
-function getSocketItemName(equipment, socketItemId) {
- return equipment.socketItems.find((socketItem) => socketItem.id === socketItemId)?.name || "";
-}
+import {
+ DETAIL_TABS,
+ EQUIPMENT_ICONS,
+ SOCKET_COLORS,
+ SOCKET_COLOR_VALUES,
+ SOCKET_SHAPES,
+ TRAIT_ICONS,
+ createLocalId,
+ formatValue,
+ getCharacteristicCategories,
+ getSocketItemName,
+ setItemCategory
+} from "./equipmentPlannerOptions.js";
function SocketShapeIcon({ shape, color = "gray", className = "" }) {
return (
diff --git a/website/src/features/toolboxes/modules/TaskPlannerItems.jsx b/website/src/features/toolboxes/modules/TaskPlannerItems.jsx
new file mode 100644
index 0000000..fff3b41
--- /dev/null
+++ b/website/src/features/toolboxes/modules/TaskPlannerItems.jsx
@@ -0,0 +1,522 @@
+// Rôle : rend les branches, items et panneaux secondaires du task planner.
+import { Icon } from "../../../components/Icon.jsx";
+import { InlineNotice } from "../../../components/AppOverlays.jsx";
+import { Tooltip } from "../../../components/Tooltip.jsx";
+import { useInlineEdit } from "../../../hooks/useInlineEdit.js";
+import {
+ TASK_TYPES,
+ WEEK_DAYS,
+ countCompletedTaskTree,
+ countTaskTree,
+ getEffectiveTaskCategory,
+ getTaskById,
+ getTaskCategoryDataset,
+ getTaskCategoryGroups,
+ getTaskTitle,
+ getTaskTypeLabel,
+ getTasksForParent,
+ isTaskTreeComplete
+} from "./taskPlannerUtils.js";
+
+export function TaskPlannerBranch({
+ parentId,
+ parentMap,
+ tasks,
+ data,
+ reorder,
+ textContent,
+ openDescriptions,
+ openTaskSettings,
+ prerequisiteNoticeTaskIds,
+ onToggleDescription,
+ onToggleSettings,
+ onPrerequisiteNotice,
+ onUpdateTask,
+ onDeleteTask,
+ onAddLinkTarget,
+ onRemoveLinkTarget,
+ onSetPrerequisite,
+ onSetTaskCategory,
+ onToggleCategoryCollapsed,
+ visited = new Set()
+}) {
+ const groupedTasks = getTaskCategoryGroups(tasks, parentMap, parentId, data.categoryOrder);
+ const children = [...groupedTasks.uncategorized, ...groupedTasks.categories.flatMap((group) => group.tasks)].filter((task) => !visited.has(task.id));
+ if (!children.length) return null;
+ const nextVisited = new Set([...visited, ...children.map((task) => task.id)]);
+ const showCategoryBoundaryDropZones = !parentId && reorder.shouldShowGroupBoundaries({ parentId });
+
+ function hasVisibleDescendants(taskId, seen = new Set()) {
+ if (seen.has(taskId)) return false;
+ seen.add(taskId);
+ return getTasksForParent(tasks, parentMap, taskId).some((child) => !child.checked || hasVisibleDescendants(child.id, seen));
+ }
+
+ function renderTask(task) {
+ const nestedChildren = getTasksForParent(tasks, parentMap, task.id).filter((child) => !nextVisited.has(child.id));
+ const nestedBranch = nestedChildren.length ? (
+
+ ) : null;
+ if (data.hideCompleted && task.checked) {
+ if (!hasVisibleDescendants(task.id)) return null;
+ return (
+
+
+ onUpdateTask(task.id, (current) => ({
+ ...current,
+ checked: event.target.checked,
+ checkedAt: event.target.checked ? Date.now() : 0
+ }))}
+ aria-label={`${textContent.uncheckTitle || "Marquer non effectué"} ${task.title}`}
+ />
+ {task.title}
+ {getTaskTypeLabel(task.type, textContent)}
+
+
+
+ );
+ }
+ return (
+ onToggleDescription(task.id)}
+ onToggleSettings={() => onToggleSettings(task.id)}
+ onPrerequisiteNotice={onPrerequisiteNotice}
+ onUpdateTask={onUpdateTask}
+ onDeleteTask={onDeleteTask}
+ onAddLinkTarget={onAddLinkTarget}
+ onRemoveLinkTarget={onRemoveLinkTarget}
+ onSetPrerequisite={onSetPrerequisite}
+ onSetTaskCategory={onSetTaskCategory}
+ >
+ {nestedBranch}
+
+ );
+ }
+
+ function renderCategory(group) {
+ const groupTasks = group.tasks.filter((task) => !visited.has(task.id));
+ if (!groupTasks.length) return null;
+ if (data.hideCompleted && isTaskTreeComplete(groupTasks.map((task) => task.id), tasks, parentMap)) return null;
+ const groupCount = countTaskTree(groupTasks.map((task) => task.id), tasks, parentMap);
+ const groupCompletedCount = countCompletedTaskTree(groupTasks.map((task) => task.id), tasks, parentMap);
+ const isCollapsed = data.collapsedCategories.includes(group.category);
+ const sectionClassName = [
+ "task-planner-category-section",
+ groupCompletedCount === groupCount ? "is-complete" : "",
+ isCollapsed ? "is-collapsed" : "",
+ reorder.isGroupDragging(group.category, parentId) ? "is-dragging" : "",
+ reorder.isGroupDropTarget(group.category, parentId) ? "is-drop-target" : "",
+ reorder.getDropPlacement("group", group.category, parentId) === "after" ? "drop-after" : ""
+ ].filter(Boolean).join(" ");
+ const beforeDropZone = showCategoryBoundaryDropZones ? (
+
+ ) : null;
+ const afterDropZone = showCategoryBoundaryDropZones ? (
+
+ ) : null;
+ return [
+ beforeDropZone,
+
+
+
+
+
{group.category}
+
+
+ {groupCompletedCount} / {groupCount}
+
+
+
+ {!isCollapsed && (
+
+ {groupTasks.map(renderTask).filter(Boolean)}
+
+ )}
+ ,
+ afterDropZone
+ ];
+ }
+
+ const entries = groupedTasks.entries.map((entry) => {
+ if (entry.type === "task") return visited.has(entry.task.id) ? null : renderTask(entry.task);
+ return renderCategory(entry.group);
+ }).filter(Boolean);
+ return entries;
+}
+
+function TaskPlannerItem({
+ task,
+ tasks,
+ data,
+ parentMap,
+ parentId,
+ reorder,
+ textContent,
+ descriptionOpen,
+ settingsOpen,
+ prerequisiteNoticeVisible,
+ onToggleDescription,
+ onToggleSettings,
+ onPrerequisiteNotice,
+ onUpdateTask,
+ onDeleteTask,
+ onAddLinkTarget,
+ onRemoveLinkTarget,
+ onSetPrerequisite,
+ onSetTaskCategory,
+ children
+}) {
+ const parentTask = parentId ? getTaskById(tasks, parentId) : null;
+ const inheritedCategory = parentTask ? getEffectiveTaskCategory(parentTask, tasks, parentMap) : "";
+ const categoryValue = parentTask ? inheritedCategory : task.category || "";
+ const categoryEditable = !parentTask;
+ const relations = data.relations.filter((relation) => relation.fromTaskId === task.id);
+ const missingPrerequisites = relations.filter((relation) => relation.prerequisite && !tasks.find((item) => item.id === relation.toTaskId)?.checked);
+ const relationTargets = tasks.filter((item) => item.id !== task.id && !relations.some((relation) => relation.toTaskId === item.id));
+ const noticeMessage = prerequisiteNoticeVisible && missingPrerequisites.length > 0 ? textContent.missingPrerequisiteNotice || "Pré requis parent non effectué" : "";
+ const className = [
+ "task-planner-item",
+ task.checked ? "is-complete" : "",
+ missingPrerequisites.length ? "has-missing-prerequisite" : "",
+ reorder.isItemDragging(task.id) ? "is-dragging" : "",
+ reorder.isItemDropTarget(task.id) ? "is-drop-target" : "",
+ reorder.getDropPlacement("item", task.id) === "after" ? "drop-after" : ""
+ ].filter(Boolean).join(" ");
+ const titleEdit = useInlineEdit({
+ value: task.title,
+ transform: (draft) => String(draft || "").trim() || task.title,
+ onCommit: (title) => onUpdateTask(task.id, (current) => ({ ...current, title }))
+ });
+ const categoryEdit = useInlineEdit({
+ value: categoryValue,
+ onCommit: (category) => {
+ if (categoryEditable) onSetTaskCategory(task.id, category);
+ }
+ });
+ const descriptionEdit = useInlineEdit({
+ value: task.description,
+ onCommit: (description) => onUpdateTask(task.id, (current) => ({ ...current, description })),
+ commitOnEnter: false
+ });
+
+ function updateChecked(event) {
+ const checked = event.target.checked;
+ if (checked && missingPrerequisites.length > 0) onPrerequisiteNotice(task.id);
+ onUpdateTask(task.id, (current) => ({
+ ...current,
+ checked,
+ checkedAt: checked ? Date.now() : 0
+ }));
+ }
+
+ return (
+
+
+
+
+
+
+ {missingPrerequisites.length > 0 && (
+
}
+ position="top-start"
+ >
+ {textContent.missingPrerequisiteBadge || "Pré requis"}
+
+ )}
+
+
+
+
+
+
+
+ {descriptionOpen && (
+
+
+
+
+ )}
+ {settingsOpen && (
+
+
+
+
+ )}
+ {children && }
+
+ );
+}
+
+function MissingPrerequisitesTooltip({ missingPrerequisites, tasks, textContent }) {
+ return (
+
+ {textContent.missingPrerequisiteTitle || "Pré requis non effectué"}
+
+ {missingPrerequisites.map((relation) => (
+ {getTaskTitle(tasks, relation.toTaskId)}
+ ))}
+
+
+ );
+}
+
+function TaskResetSettings({ task, data, textContent, onUpdateTask }) {
+ if (task.type === "daily") {
+ const hasOverride = Boolean(task.dailyResetTime);
+ return (
+
+
{textContent.taskDailyResetTimeLabel || "Heure quotidienne"}
+
+ onUpdateTask(task.id, (current) => {
+ const nextTask = { ...current };
+ if (event.target.value && event.target.value !== data.resetTime) nextTask.dailyResetTime = event.target.value;
+ else delete nextTask.dailyResetTime;
+ return nextTask;
+ })}
+ aria-label={`${textContent.taskDailyResetTimeLabel || "Heure quotidienne"} ${task.title}`}
+ />
+
+
+
+ );
+ }
+
+ if (task.type === "weekly") {
+ return (
+
+ );
+ }
+
+ return (
+
+
{textContent.taskResetLabel || "Réinitialisation"}
+
{textContent.noTaskResetLabel || "Aucun reset"}
+
+ );
+}
+
+function LinksEditor({ label, relations, task, tasks, parentMap, relationTargets, emptyLabel, addLabel, removeLabel, prerequisiteLabel, onAddLinkTarget, onRemoveLinkTarget, onSetPrerequisite }) {
+ const descendantIds = new Set();
+ function collectDescendants(taskId) {
+ tasks.filter((item) => parentMap.get(item.id) === taskId).forEach((child) => {
+ descendantIds.add(child.id);
+ collectDescendants(child.id);
+ });
+ }
+ collectDescendants(task.id);
+ const availableTargets = relationTargets.filter((target) => !descendantIds.has(target.id));
+
+ return (
+
+
{label}
+
+
+ {relations.length ? relations.map((relation) => (
+
+ {getTaskTitle(tasks, relation.toTaskId)}
+
+
+
+ )) :
{emptyLabel}}
+
+
+ );
+}
+
diff --git a/website/src/features/toolboxes/modules/TaskPlannerModule.jsx b/website/src/features/toolboxes/modules/TaskPlannerModule.jsx
index 75c226d..c9e9510 100644
--- a/website/src/features/toolboxes/modules/TaskPlannerModule.jsx
+++ b/website/src/features/toolboxes/modules/TaskPlannerModule.jsx
@@ -1,226 +1,22 @@
// Rôle : fournit l'outil task planner avec tâches récurrentes, parents, pré requis et échange texte.
import { useEffect, useMemo, useRef, useState } from "react";
import { Icon } from "../../../components/Icon.jsx";
-import { InlineNotice } from "../../../components/AppOverlays.jsx";
-import { Tooltip } from "../../../components/Tooltip.jsx";
import { useDraftForm } from "../../../hooks/useDraftForm.js";
import { applyGroupedReorderOperation, useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
-import { useInlineEdit } from "../../../hooks/useInlineEdit.js";
import { TextExchangeActions } from "./TextExchangeActions.jsx";
-
-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);
-}
+import { TaskPlannerBranch } from "./TaskPlannerItems.jsx";
+import {
+ TASK_TYPES,
+ WEEK_DAYS,
+ applyDueResets,
+ getBoundaryTaskId,
+ getRootTaskIdsForCategory,
+ getTaskCategory,
+ getTaskCategoryDataset,
+ getTaskParentMap,
+ getTaskTypeLabel,
+ moveTask
+} from "./taskPlannerUtils.js";
export function TaskPlannerModule({ toolboxId, moduleId, context, editing }) {
const data = context.normalizeTaskPlannerData(context.getModuleData(toolboxId, moduleId, { tasks: [] }));
@@ -581,505 +377,3 @@ export function TaskPlannerModule({ toolboxId, moduleId, context, editing }) {
);
}
-
-function TaskPlannerBranch({
- parentId,
- parentMap,
- tasks,
- data,
- reorder,
- textContent,
- openDescriptions,
- openTaskSettings,
- prerequisiteNoticeTaskIds,
- onToggleDescription,
- onToggleSettings,
- onPrerequisiteNotice,
- onUpdateTask,
- onDeleteTask,
- onAddLinkTarget,
- onRemoveLinkTarget,
- onSetPrerequisite,
- onSetTaskCategory,
- onToggleCategoryCollapsed,
- visited = new Set()
-}) {
- const groupedTasks = getTaskCategoryGroups(tasks, parentMap, parentId, data.categoryOrder);
- const children = [...groupedTasks.uncategorized, ...groupedTasks.categories.flatMap((group) => group.tasks)].filter((task) => !visited.has(task.id));
- if (!children.length) return null;
- const nextVisited = new Set([...visited, ...children.map((task) => task.id)]);
- const showCategoryBoundaryDropZones = !parentId && reorder.shouldShowGroupBoundaries({ parentId });
-
- function hasVisibleDescendants(taskId, seen = new Set()) {
- if (seen.has(taskId)) return false;
- seen.add(taskId);
- return getTasksForParent(tasks, parentMap, taskId).some((child) => !child.checked || hasVisibleDescendants(child.id, seen));
- }
-
- function renderTask(task) {
- const nestedChildren = getTasksForParent(tasks, parentMap, task.id).filter((child) => !nextVisited.has(child.id));
- const nestedBranch = nestedChildren.length ? (
-
- ) : null;
- if (data.hideCompleted && task.checked) {
- if (!hasVisibleDescendants(task.id)) return null;
- return (
-
-
- onUpdateTask(task.id, (current) => ({
- ...current,
- checked: event.target.checked,
- checkedAt: event.target.checked ? Date.now() : 0
- }))}
- aria-label={`${textContent.uncheckTitle || "Marquer non effectué"} ${task.title}`}
- />
- {task.title}
- {getTaskTypeLabel(task.type, textContent)}
-
-
-
- );
- }
- return (
- onToggleDescription(task.id)}
- onToggleSettings={() => onToggleSettings(task.id)}
- onPrerequisiteNotice={onPrerequisiteNotice}
- onUpdateTask={onUpdateTask}
- onDeleteTask={onDeleteTask}
- onAddLinkTarget={onAddLinkTarget}
- onRemoveLinkTarget={onRemoveLinkTarget}
- onSetPrerequisite={onSetPrerequisite}
- onSetTaskCategory={onSetTaskCategory}
- >
- {nestedBranch}
-
- );
- }
-
- function renderCategory(group) {
- const groupTasks = group.tasks.filter((task) => !visited.has(task.id));
- if (!groupTasks.length) return null;
- if (data.hideCompleted && isTaskTreeComplete(groupTasks.map((task) => task.id), tasks, parentMap)) return null;
- const groupCount = countTaskTree(groupTasks.map((task) => task.id), tasks, parentMap);
- const groupCompletedCount = countCompletedTaskTree(groupTasks.map((task) => task.id), tasks, parentMap);
- const isCollapsed = data.collapsedCategories.includes(group.category);
- const sectionClassName = [
- "task-planner-category-section",
- groupCompletedCount === groupCount ? "is-complete" : "",
- isCollapsed ? "is-collapsed" : "",
- reorder.isGroupDragging(group.category, parentId) ? "is-dragging" : "",
- reorder.isGroupDropTarget(group.category, parentId) ? "is-drop-target" : "",
- reorder.getDropPlacement("group", group.category, parentId) === "after" ? "drop-after" : ""
- ].filter(Boolean).join(" ");
- const beforeDropZone = showCategoryBoundaryDropZones ? (
-
- ) : null;
- const afterDropZone = showCategoryBoundaryDropZones ? (
-
- ) : null;
- return [
- beforeDropZone,
-
-
-
-
-
{group.category}
-
-
- {groupCompletedCount} / {groupCount}
-
-
-
- {!isCollapsed && (
-
- {groupTasks.map(renderTask).filter(Boolean)}
-
- )}
- ,
- afterDropZone
- ];
- }
-
- const entries = groupedTasks.entries.map((entry) => {
- if (entry.type === "task") return visited.has(entry.task.id) ? null : renderTask(entry.task);
- return renderCategory(entry.group);
- }).filter(Boolean);
- return entries;
-}
-
-function TaskPlannerItem({
- task,
- tasks,
- data,
- parentMap,
- parentId,
- reorder,
- textContent,
- descriptionOpen,
- settingsOpen,
- prerequisiteNoticeVisible,
- onToggleDescription,
- onToggleSettings,
- onPrerequisiteNotice,
- onUpdateTask,
- onDeleteTask,
- onAddLinkTarget,
- onRemoveLinkTarget,
- onSetPrerequisite,
- onSetTaskCategory,
- children
-}) {
- const parentTask = parentId ? getTaskById(tasks, parentId) : null;
- const inheritedCategory = parentTask ? getEffectiveTaskCategory(parentTask, tasks, parentMap) : "";
- const categoryValue = parentTask ? inheritedCategory : task.category || "";
- const categoryEditable = !parentTask;
- const relations = data.relations.filter((relation) => relation.fromTaskId === task.id);
- const missingPrerequisites = relations.filter((relation) => relation.prerequisite && !tasks.find((item) => item.id === relation.toTaskId)?.checked);
- const relationTargets = tasks.filter((item) => item.id !== task.id && !relations.some((relation) => relation.toTaskId === item.id));
- const noticeMessage = prerequisiteNoticeVisible && missingPrerequisites.length > 0 ? textContent.missingPrerequisiteNotice || "Pré requis parent non effectué" : "";
- const className = [
- "task-planner-item",
- task.checked ? "is-complete" : "",
- missingPrerequisites.length ? "has-missing-prerequisite" : "",
- reorder.isItemDragging(task.id) ? "is-dragging" : "",
- reorder.isItemDropTarget(task.id) ? "is-drop-target" : "",
- reorder.getDropPlacement("item", task.id) === "after" ? "drop-after" : ""
- ].filter(Boolean).join(" ");
- const titleEdit = useInlineEdit({
- value: task.title,
- transform: (draft) => String(draft || "").trim() || task.title,
- onCommit: (title) => onUpdateTask(task.id, (current) => ({ ...current, title }))
- });
- const categoryEdit = useInlineEdit({
- value: categoryValue,
- onCommit: (category) => {
- if (categoryEditable) onSetTaskCategory(task.id, category);
- }
- });
- const descriptionEdit = useInlineEdit({
- value: task.description,
- onCommit: (description) => onUpdateTask(task.id, (current) => ({ ...current, description })),
- commitOnEnter: false
- });
-
- function updateChecked(event) {
- const checked = event.target.checked;
- if (checked && missingPrerequisites.length > 0) onPrerequisiteNotice(task.id);
- onUpdateTask(task.id, (current) => ({
- ...current,
- checked,
- checkedAt: checked ? Date.now() : 0
- }));
- }
-
- return (
-
-
-
-
-
-
- {missingPrerequisites.length > 0 && (
-
}
- position="top-start"
- >
- {textContent.missingPrerequisiteBadge || "Pré requis"}
-
- )}
-
-
-
-
-
-
-
- {descriptionOpen && (
-
-
-
-
- )}
- {settingsOpen && (
-
-
-
-
- )}
- {children && }
-
- );
-}
-
-function MissingPrerequisitesTooltip({ missingPrerequisites, tasks, textContent }) {
- return (
-
- {textContent.missingPrerequisiteTitle || "Pré requis non effectué"}
-
- {missingPrerequisites.map((relation) => (
- {getTaskTitle(tasks, relation.toTaskId)}
- ))}
-
-
- );
-}
-
-function TaskResetSettings({ task, data, textContent, onUpdateTask }) {
- if (task.type === "daily") {
- const hasOverride = Boolean(task.dailyResetTime);
- return (
-
-
{textContent.taskDailyResetTimeLabel || "Heure quotidienne"}
-
- onUpdateTask(task.id, (current) => {
- const nextTask = { ...current };
- if (event.target.value && event.target.value !== data.resetTime) nextTask.dailyResetTime = event.target.value;
- else delete nextTask.dailyResetTime;
- return nextTask;
- })}
- aria-label={`${textContent.taskDailyResetTimeLabel || "Heure quotidienne"} ${task.title}`}
- />
-
-
-
- );
- }
-
- if (task.type === "weekly") {
- return (
-
- );
- }
-
- return (
-
-
{textContent.taskResetLabel || "Réinitialisation"}
-
{textContent.noTaskResetLabel || "Aucun reset"}
-
- );
-}
-
-function LinksEditor({ label, relations, task, tasks, parentMap, relationTargets, emptyLabel, addLabel, removeLabel, prerequisiteLabel, onAddLinkTarget, onRemoveLinkTarget, onSetPrerequisite }) {
- const descendantIds = new Set();
- function collectDescendants(taskId) {
- tasks.filter((item) => parentMap.get(item.id) === taskId).forEach((child) => {
- descendantIds.add(child.id);
- collectDescendants(child.id);
- });
- }
- collectDescendants(task.id);
- const availableTargets = relationTargets.filter((target) => !descendantIds.has(target.id));
-
- return (
-
-
{label}
-
-
- {relations.length ? relations.map((relation) => (
-
- {getTaskTitle(tasks, relation.toTaskId)}
-
-
-
- )) :
{emptyLabel}}
-
-
- );
-}
diff --git a/website/src/features/toolboxes/modules/equipmentPlannerOptions.js b/website/src/features/toolboxes/modules/equipmentPlannerOptions.js
new file mode 100644
index 0000000..0bed408
--- /dev/null
+++ b/website/src/features/toolboxes/modules/equipmentPlannerOptions.js
@@ -0,0 +1,64 @@
+// Rôle : regroupe les options et helpers purs de l'outil equipment planner.
+export const SOCKET_SHAPES = ["ball", "jewel"];
+export const SOCKET_COLORS = ["red", "orange", "amber", "yellow", "lime", "green", "teal", "cyan", "blue", "indigo", "violet", "purple", "pink", "rose", "white", "gray", "black"];
+export const SOCKET_COLOR_VALUES = {
+ red: "#ef4444",
+ orange: "#f97316",
+ amber: "#f59e0b",
+ yellow: "var(--color-accent-gold)",
+ lime: "#84cc16",
+ green: "#22c55e",
+ teal: "#14b8a6",
+ cyan: "var(--color-accent-cyan)",
+ blue: "#3b82f6",
+ indigo: "var(--color-accent-indigo)",
+ violet: "#7c3aed",
+ purple: "var(--color-primary)",
+ pink: "var(--color-accent-pink)",
+ rose: "#f43f5e",
+ white: "#f8fafc",
+ gray: "#64748b",
+ black: "#020617"
+};
+export const EQUIPMENT_ICONS = [
+ { icon: "helmet", label: "Casque" },
+ { icon: "chest-armor", label: "Chest" },
+ { icon: "glove", label: "Gants" },
+ { icon: "boot", label: "Bottes" },
+ { icon: "necklace", label: "Collier" },
+ { icon: "earring", label: "Earring" },
+ { icon: "ring", label: "Anneau" },
+ { icon: "sword", label: "Épée" },
+ { icon: "gun", label: "Gun" },
+ { icon: "shield", label: "Bouclier" }
+];
+export const TRAIT_ICONS = ["sword", "shield"];
+export const DETAIL_TABS = ["stats", "sockets", "craft"];
+
+export function getCharacteristicCategories(equipment) {
+ const categories = equipment.characteristics.map((trait) => trait.category).filter(Boolean);
+ return [
+ ...equipment.categoryOrder.filter((category) => categories.includes(category)),
+ ...categories.filter((category, index) => categories.indexOf(category) === index && !equipment.categoryOrder.includes(category))
+ ];
+}
+
+export function setItemCategory(item, category) {
+ const nextItem = { ...item };
+ if (category) nextItem.category = category;
+ else delete nextItem.category;
+ return nextItem;
+}
+
+export function formatValue(value) {
+ if (typeof value === "number") return Number.isInteger(value) ? String(value) : String(Math.round(value * 1000) / 1000);
+ return String(value || "");
+}
+
+export function createLocalId(prefix) {
+ return globalThis.crypto?.randomUUID?.() || `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
+}
+
+export function getSocketItemName(equipment, socketItemId) {
+ return equipment.socketItems.find((socketItem) => socketItem.id === socketItemId)?.name || "";
+}
diff --git a/website/src/features/toolboxes/modules/taskPlannerUtils.js b/website/src/features/toolboxes/modules/taskPlannerUtils.js
new file mode 100644
index 0000000..4998287
--- /dev/null
+++ b/website/src/features/toolboxes/modules/taskPlannerUtils.js
@@ -0,0 +1,214 @@
+// Rôle : regroupe les constantes et helpers purs de l'outil task planner.
+export const TASK_TYPES = ["daily", "weekly", "unique"];
+export 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" }
+];
+
+export function getTaskTypeLabel(type, textContent) {
+ if (type === "daily") return textContent.dailyType || "Quotidienne";
+ if (type === "weekly") return textContent.weeklyType || "Hebdomadaire";
+ return textContent.uniqueType || "Ponctuelle";
+}
+
+export 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
+ };
+}
+
+export 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();
+}
+
+export 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();
+}
+
+export 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;
+}
+
+export 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;
+}
+
+export 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 || "";
+}
+
+export function getTaskTitle(tasks, taskId) {
+ return tasks.find((task) => task.id === taskId)?.title || "Tâche supprimée";
+}
+
+export function getTaskParentId(taskId, relations, taskIds) {
+ return relations.find((relation) => relation.fromTaskId === taskId && taskIds.has(relation.toTaskId))?.toTaskId || "";
+}
+
+export 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;
+}
+
+export function getTasksForParent(tasks, parentMap, parentId) {
+ return tasks.filter((task) => (parentMap.get(task.id) || "") === parentId);
+}
+
+export function getTaskCategory(task) {
+ return String(task?.category || "").trim();
+}
+
+export function getTaskById(tasks, taskId) {
+ return tasks.find((task) => task.id === taskId) || null;
+}
+
+export 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);
+}
+
+export function getTaskCategoryForParent(task, tasks, parentMap, parentId) {
+ return parentId ? getEffectiveTaskCategory(task, tasks, parentMap) : getTaskCategory(task);
+}
+
+export function getTaskCategoryDataset(task, tasks, parentMap) {
+ return getEffectiveTaskCategory(task, tasks, parentMap);
+}
+
+export 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;
+}
+
+export 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;
+}
+
+export function countCompletedTaskTree(taskIds, tasks, parentMap) {
+ const taskIdsSet = new Set(getTaskTreeIds(taskIds, tasks, parentMap));
+ return tasks.filter((task) => taskIdsSet.has(task.id) && task.checked).length;
+}
+
+export function isTaskTreeComplete(taskIds, tasks, parentMap) {
+ const treeIds = getTaskTreeIds(taskIds, tasks, parentMap);
+ return treeIds.length > 0 && treeIds.every((taskId) => getTaskById(tasks, taskId)?.checked);
+}
+
+export 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
+ };
+}
+
+export function getRootTaskIdsForCategory(tasks, parentMap, category) {
+ return tasks
+ .filter((task) => !parentMap.get(task.id) && getTaskCategory(task) === category)
+ .map((task) => task.id);
+}