Split remaining toolbox modules by responsibility
All checks were successful
Deploy Sokko G / deploy (push) Successful in 16s

This commit is contained in:
Shinuwa 2026-08-25 09:33:15 +02:00
parent 43a9e791c5
commit a0d7e80a1a
25 changed files with 2199 additions and 1992 deletions

View file

@ -11,6 +11,10 @@ import { getCalendarOccurrences, getCurrentCalendarOccurrence, getMonthEnd, getM
import { exportModuleText, importModuleText } from "../website/src/features/toolboxes/modules/textImport.js"; import { exportModuleText, importModuleText } from "../website/src/features/toolboxes/modules/textImport.js";
import { compactCalendarDataForStorage, normalizeCalendarData } from "../website/src/features/toolboxes/storage/modules/calendar.js"; import { compactCalendarDataForStorage, normalizeCalendarData } from "../website/src/features/toolboxes/storage/modules/calendar.js";
function countLines(source) {
return source.trimEnd().split("\n").length;
}
test("toolbox storage, cards and pages are wired", async () => { test("toolbox storage, cards and pages are wired", async () => {
const source = await readFile("website/src/main.jsx", "utf8"); const source = await readFile("website/src/main.jsx", "utf8");
const toolboxCard = await readFile("website/src/features/toolboxes/ToolboxCard.jsx", "utf8"); const toolboxCard = await readFile("website/src/features/toolboxes/ToolboxCard.jsx", "utf8");
@ -142,6 +146,10 @@ test("toolbox storage, cards and pages are wired", async () => {
test("toolbox module registry and modules expose expected behavior", async () => { test("toolbox module registry and modules expose expected behavior", async () => {
const source = await readFile("website/src/main.jsx", "utf8"); const source = await readFile("website/src/main.jsx", "utf8");
const moduleRegistry = await readFile("website/src/features/toolboxes/modules/index.jsx", "utf8"); const moduleRegistry = await readFile("website/src/features/toolboxes/modules/index.jsx", "utf8");
const moduleComponents = await readFile("website/src/features/toolboxes/modules/toolboxModuleComponents.jsx", "utf8");
const addToolControls = await readFile("website/src/features/toolboxes/modules/AddToolControls.jsx", "utf8");
const toolboxModules = await readFile("website/src/features/toolboxes/modules/ToolboxModules.jsx", "utf8");
const moduleRegistryBundle = `${moduleRegistry}\n${moduleComponents}\n${addToolControls}\n${toolboxModules}`;
const moduleMetadataRegistry = await readFile("website/src/features/toolboxes/toolboxModuleRegistry.js", "utf8"); const moduleMetadataRegistry = await readFile("website/src/features/toolboxes/toolboxModuleRegistry.js", "utf8");
const notepadModule = await readFile("website/src/features/toolboxes/modules/NotepadModule.jsx", "utf8"); const notepadModule = await readFile("website/src/features/toolboxes/modules/NotepadModule.jsx", "utf8");
const notepadToolbar = await readFile("website/src/features/toolboxes/modules/NotepadToolbar.jsx", "utf8"); const notepadToolbar = await readFile("website/src/features/toolboxes/modules/NotepadToolbar.jsx", "utf8");
@ -169,30 +177,44 @@ test("toolbox module registry and modules expose expected behavior", async () =>
const tableFormulaEngine = await readFile("website/src/features/toolboxes/modules/tableFormulaEngine.js", "utf8"); const tableFormulaEngine = await readFile("website/src/features/toolboxes/modules/tableFormulaEngine.js", "utf8");
const timerModule = await readFile("website/src/features/toolboxes/modules/TimerModule.jsx", "utf8"); const timerModule = await readFile("website/src/features/toolboxes/modules/TimerModule.jsx", "utf8");
const timerParts = await readFile("website/src/features/toolboxes/modules/TimerParts.jsx", "utf8"); const timerParts = await readFile("website/src/features/toolboxes/modules/TimerParts.jsx", "utf8");
const timerCountdown = await readFile("website/src/features/toolboxes/modules/TimerCountdown.jsx", "utf8");
const timerSharedControls = await readFile("website/src/features/toolboxes/modules/TimerSharedControls.jsx", "utf8");
const timerStopwatch = await readFile("website/src/features/toolboxes/modules/TimerStopwatch.jsx", "utf8");
const timerBundle = `${timerModule}\n${timerParts}\n${timerCountdown}\n${timerSharedControls}\n${timerStopwatch}`;
const timerOptions = await readFile("website/src/features/toolboxes/modules/timerOptions.js", "utf8"); const timerOptions = await readFile("website/src/features/toolboxes/modules/timerOptions.js", "utf8");
const calendarModule = await readFile("website/src/features/toolboxes/modules/CalendarModule.jsx", "utf8"); const calendarModule = await readFile("website/src/features/toolboxes/modules/CalendarModule.jsx", "utf8");
const calendarController = await readFile("website/src/features/toolboxes/modules/useCalendarController.js", "utf8");
const calendarDraftControls = await readFile("website/src/features/toolboxes/modules/useCalendarDraftControls.js", "utf8");
const calendarEventActions = await readFile("website/src/features/toolboxes/modules/calendarEventActions.js", "utf8");
const calendarLifecycle = await readFile("website/src/features/toolboxes/modules/useCalendarLifecycle.js", "utf8");
const calendarModeChoice = await readFile("website/src/features/toolboxes/modules/CalendarModeChoice.jsx", "utf8");
const calendarContent = await readFile("website/src/features/toolboxes/modules/CalendarContent.jsx", "utf8"); const calendarContent = await readFile("website/src/features/toolboxes/modules/CalendarContent.jsx", "utf8");
const calendarDraft = await readFile("website/src/features/toolboxes/modules/calendarDraft.js", "utf8"); const calendarDraft = await readFile("website/src/features/toolboxes/modules/calendarDraft.js", "utf8");
const calendarEventForm = await readFile("website/src/features/toolboxes/modules/CalendarEventForm.jsx", "utf8"); const calendarEventForm = await readFile("website/src/features/toolboxes/modules/CalendarEventForm.jsx", "utf8");
const calendarEventControls = await readFile("website/src/features/toolboxes/modules/CalendarEventControls.jsx", "utf8"); const calendarEventControls = await readFile("website/src/features/toolboxes/modules/CalendarEventControls.jsx", "utf8");
const calendarMonthDatePicker = await readFile("website/src/features/toolboxes/modules/CalendarMonthDatePicker.jsx", "utf8"); const calendarMonthDatePicker = await readFile("website/src/features/toolboxes/modules/CalendarMonthDatePicker.jsx", "utf8");
const calendarViews = await readFile("website/src/features/toolboxes/modules/CalendarViews.jsx", "utf8"); const calendarViews = await readFile("website/src/features/toolboxes/modules/CalendarViews.jsx", "utf8");
const calendarBundle = `${calendarModule}\n${calendarContent}\n${calendarDraft}\n${calendarEventForm}\n${calendarEventControls}\n${calendarMonthDatePicker}\n${calendarViews}`; const calendarBundle = `${calendarModule}\n${calendarController}\n${calendarDraftControls}\n${calendarEventActions}\n${calendarLifecycle}\n${calendarModeChoice}\n${calendarContent}\n${calendarDraft}\n${calendarEventForm}\n${calendarEventControls}\n${calendarMonthDatePicker}\n${calendarViews}`;
const calendarUtils = await readFile("website/src/features/toolboxes/modules/calendarUtils.js", "utf8"); const calendarUtils = await readFile("website/src/features/toolboxes/modules/calendarUtils.js", "utf8");
const taskPlannerModule = await readFile("website/src/features/toolboxes/modules/TaskPlannerModule.jsx", "utf8"); const taskPlannerModule = await readFile("website/src/features/toolboxes/modules/TaskPlannerModule.jsx", "utf8");
const taskPlannerItems = await readFile("website/src/features/toolboxes/modules/TaskPlannerItems.jsx", "utf8"); const taskPlannerItems = await readFile("website/src/features/toolboxes/modules/TaskPlannerItems.jsx", "utf8");
const taskPlannerBranch = await readFile("website/src/features/toolboxes/modules/TaskPlannerBranch.jsx", "utf8");
const taskPlannerItem = await readFile("website/src/features/toolboxes/modules/TaskPlannerItem.jsx", "utf8");
const taskPlannerItemPanels = await readFile("website/src/features/toolboxes/modules/TaskPlannerItemPanels.jsx", "utf8"); const taskPlannerItemPanels = await readFile("website/src/features/toolboxes/modules/TaskPlannerItemPanels.jsx", "utf8");
const taskPlannerReorder = await readFile("website/src/features/toolboxes/modules/useTaskPlannerReorder.js", "utf8"); const taskPlannerReorder = await readFile("website/src/features/toolboxes/modules/useTaskPlannerReorder.js", "utf8");
const taskPlannerTopbar = await readFile("website/src/features/toolboxes/modules/TaskPlannerTopbar.jsx", "utf8"); const taskPlannerTopbar = await readFile("website/src/features/toolboxes/modules/TaskPlannerTopbar.jsx", "utf8");
const taskPlannerSettings = await readFile("website/src/features/toolboxes/modules/TaskPlannerSettings.jsx", "utf8"); const taskPlannerSettings = await readFile("website/src/features/toolboxes/modules/TaskPlannerSettings.jsx", "utf8");
const taskPlannerBundle = `${taskPlannerItems}\n${taskPlannerItemPanels}\n${taskPlannerReorder}\n${taskPlannerTopbar}\n${taskPlannerSettings}`; const taskPlannerBundle = `${taskPlannerItems}\n${taskPlannerBranch}\n${taskPlannerItem}\n${taskPlannerItemPanels}\n${taskPlannerReorder}\n${taskPlannerTopbar}\n${taskPlannerSettings}`;
const taskPlannerUtils = await readFile("website/src/features/toolboxes/modules/taskPlannerUtils.js", "utf8"); const taskPlannerUtils = await readFile("website/src/features/toolboxes/modules/taskPlannerUtils.js", "utf8");
const goalTreeModule = await readFile("website/src/features/toolboxes/modules/GoalTreeModule.jsx", "utf8"); const goalTreeModule = await readFile("website/src/features/toolboxes/modules/GoalTreeModule.jsx", "utf8");
const goalTreeEditor = await readFile("website/src/features/toolboxes/modules/GoalTreeEditor.jsx", "utf8"); const goalTreeEditor = await readFile("website/src/features/toolboxes/modules/GoalTreeEditor.jsx", "utf8");
const goalTreeParts = await readFile("website/src/features/toolboxes/modules/GoalTreeParts.jsx", "utf8"); const goalTreeParts = await readFile("website/src/features/toolboxes/modules/GoalTreeParts.jsx", "utf8");
const goalTreeBranch = await readFile("website/src/features/toolboxes/modules/GoalTreeBranch.jsx", "utf8");
const goalTreeEditorFields = await readFile("website/src/features/toolboxes/modules/GoalTreeEditorFields.jsx", "utf8");
const goalTreeNodeContent = await readFile("website/src/features/toolboxes/modules/GoalTreeNodeContent.jsx", "utf8");
const goalTreeController = await readFile("website/src/features/toolboxes/modules/useGoalTreeController.js", "utf8"); const goalTreeController = await readFile("website/src/features/toolboxes/modules/useGoalTreeController.js", "utf8");
const goalTreeView = await readFile("website/src/features/toolboxes/modules/GoalTreeView.jsx", "utf8"); const goalTreeView = await readFile("website/src/features/toolboxes/modules/GoalTreeView.jsx", "utf8");
const goalTreeBundle = `${goalTreeModule}\n${goalTreeEditor}\n${goalTreeParts}\n${goalTreeController}\n${goalTreeView}`; const goalTreeBundle = `${goalTreeModule}\n${goalTreeEditor}\n${goalTreeParts}\n${goalTreeBranch}\n${goalTreeEditorFields}\n${goalTreeNodeContent}\n${goalTreeController}\n${goalTreeView}`;
const goalTreeDisplay = await readFile("website/src/features/toolboxes/modules/goalTreeDisplay.js", "utf8"); const goalTreeDisplay = await readFile("website/src/features/toolboxes/modules/goalTreeDisplay.js", "utf8");
const goalTreeUtils = await readFile("website/src/features/toolboxes/modules/goalTreeUtils.js", "utf8"); const goalTreeUtils = await readFile("website/src/features/toolboxes/modules/goalTreeUtils.js", "utf8");
const equipmentPlannerModule = await readFile("website/src/features/toolboxes/modules/EquipmentPlannerModule.jsx", "utf8"); const equipmentPlannerModule = await readFile("website/src/features/toolboxes/modules/EquipmentPlannerModule.jsx", "utf8");
@ -222,51 +244,81 @@ test("toolbox module registry and modules expose expected behavior", async () =>
const textImportTime = await readFile("website/src/features/toolboxes/modules/textImport/time.js", "utf8"); const textImportTime = await readFile("website/src/features/toolboxes/modules/textImport/time.js", "utf8");
const imageViewer = await readFile("website/src/components/ImageViewer.jsx", "utf8"); const imageViewer = await readFile("website/src/components/ImageViewer.jsx", "utf8");
[
["CalendarModule.jsx", calendarModule],
["modules/index.jsx", moduleRegistry],
["GoalTreeParts.jsx", goalTreeParts],
["TimerParts.jsx", timerParts],
["TaskPlannerItems.jsx", taskPlannerItems]
].forEach(([fileName, fileSource]) => {
assert.ok(countLines(fileSource) <= 220, `${fileName} must remain a short orchestrator/barrel`);
});
[
["AddToolControls.jsx", addToolControls],
["ToolboxModules.jsx", toolboxModules],
["useCalendarController.js", calendarController],
["useCalendarDraftControls.js", calendarDraftControls],
["calendarEventActions.js", calendarEventActions],
["useCalendarLifecycle.js", calendarLifecycle],
["CalendarModeChoice.jsx", calendarModeChoice],
["GoalTreeBranch.jsx", goalTreeBranch],
["GoalTreeEditorFields.jsx", goalTreeEditorFields],
["GoalTreeNodeContent.jsx", goalTreeNodeContent],
["TimerCountdown.jsx", timerCountdown],
["TimerSharedControls.jsx", timerSharedControls],
["TimerStopwatch.jsx", timerStopwatch],
["TaskPlannerBranch.jsx", taskPlannerBranch],
["TaskPlannerItem.jsx", taskPlannerItem]
].forEach(([fileName, fileSource]) => {
assert.ok(countLines(fileSource) <= 300, `${fileName} must stay focused; split by responsibility before growing further`);
});
assert.match(moduleMetadataRegistry, /export const TOOLBOX_MODULES/); assert.match(moduleMetadataRegistry, /export const TOOLBOX_MODULES/);
assert.match(moduleMetadataRegistry, /export const TOOLBOX_MODULE_DEFINITIONS/); assert.match(moduleMetadataRegistry, /export const TOOLBOX_MODULE_DEFINITIONS/);
assert.match(moduleMetadataRegistry, /export function getDefaultModuleTitle/); assert.match(moduleMetadataRegistry, /export function getDefaultModuleTitle/);
assert.match(moduleRegistry, /export function AddToolControls/); assert.match(moduleRegistry, /export \{ AddToolControls \}/);
assert.match(moduleRegistry, /export function ToolboxModules/); assert.match(moduleRegistry, /export \{ ToolboxModules \}/);
assert.match(moduleRegistry, /MODULE_COMPONENTS/); assert.match(moduleComponents, /MODULE_COMPONENTS/);
assert.match(moduleRegistry, /lazy\(\(\) => import\("\.\/CombosModule\.jsx"\)/); assert.match(moduleComponents, /lazy\(\(\) => import\("\.\/CombosModule\.jsx"\)/);
assert.match(moduleRegistry, /lazy\(\(\) => import\("\.\/CalendarModule\.jsx"\)/); assert.match(moduleComponents, /lazy\(\(\) => import\("\.\/CalendarModule\.jsx"\)/);
assert.match(moduleRegistry, /lazy\(\(\) => import\("\.\/GoalTreeModule\.jsx"\)/); assert.match(moduleComponents, /lazy\(\(\) => import\("\.\/GoalTreeModule\.jsx"\)/);
assert.match(moduleRegistry, /lazy\(\(\) => import\("\.\/EquipmentPlannerModule\.jsx"\)/); assert.match(moduleComponents, /lazy\(\(\) => import\("\.\/EquipmentPlannerModule\.jsx"\)/);
assert.match(moduleRegistry, /Suspense fallback=\{<ModuleLoadingFallback label=\{label\} \/>\}/); assert.match(toolboxModules, /Suspense fallback=\{<ModuleLoadingFallback label=\{label\} \/>\}/);
assert.match(moduleRegistry, /LAZY_MODULE_TYPES/); assert.match(moduleComponents, /LAZY_MODULE_TYPES/);
assert.match(moduleRegistry, /notepad:/); assert.match(moduleComponents, /notepad:/);
assert.match(moduleRegistry, /checklist:/); assert.match(moduleComponents, /checklist:/);
assert.match(moduleRegistry, /images:/); assert.match(moduleComponents, /images:/);
assert.match(moduleRegistry, /links:/); assert.match(moduleComponents, /links:/);
assert.match(moduleRegistry, /counters:/); assert.match(moduleComponents, /counters:/);
assert.match(moduleRegistry, /combos:/); assert.match(moduleComponents, /combos:/);
assert.match(moduleRegistry, /CombosModule/); assert.match(moduleComponents, /CombosModule/);
assert.match(moduleRegistry, /calculator:/); assert.match(moduleComponents, /calculator:/);
assert.match(moduleRegistry, /table:/); assert.match(moduleComponents, /table:/);
assert.match(moduleRegistry, /table: \{ \.\.\.TOOLBOX_MODULE_DEFINITIONS\.table, Component: TableModule \}/); assert.match(moduleComponents, /table: \{ \.\.\.TOOLBOX_MODULE_DEFINITIONS\.table, Component: TableModule \}/);
assert.match(moduleRegistry, /timer:/); assert.match(moduleComponents, /timer:/);
assert.match(moduleRegistry, /calendar:/); assert.match(moduleComponents, /calendar:/);
assert.match(moduleRegistry, /taskPlanner:/); assert.match(moduleComponents, /taskPlanner:/);
assert.match(moduleRegistry, /goalTree:/); assert.match(moduleComponents, /goalTree:/);
assert.match(moduleRegistry, /equipmentPlanner:/); assert.match(moduleComponents, /equipmentPlanner:/);
assert.match(moduleMetadataRegistry, /Planificateur d'équipements/); assert.match(moduleMetadataRegistry, /Planificateur d'équipements/);
assert.match(moduleRegistry, /imageAnnotation:/); assert.match(moduleComponents, /imageAnnotation:/);
assert.match(moduleMetadataRegistry, /Annotation d'images/); assert.match(moduleMetadataRegistry, /Annotation d'images/);
assert.match(moduleRegistry, /notepad: \{ \.\.\.TOOLBOX_MODULE_DEFINITIONS\.notepad, Component: NotepadModule \}/); assert.match(moduleComponents, /notepad: \{ \.\.\.TOOLBOX_MODULE_DEFINITIONS\.notepad, Component: NotepadModule \}/);
assert.match(moduleRegistry, /module-edit-button/); assert.match(toolboxModules, /module-edit-button/);
assert.match(moduleRegistry, /itemReorder\.startDrag/); assert.match(toolboxModules, /itemReorder\.startDrag/);
assert.match(moduleRegistry, /onPointerDown/); assert.match(moduleRegistryBundle, /onPointerDown/);
assert.match(moduleRegistry, /useGroupedReorder/); assert.match(toolboxModules, /useGroupedReorder/);
assert.match(moduleRegistry, /useInlineEdit/); assert.match(toolboxModules, /useInlineEdit/);
assert.match(moduleRegistry, /getContentEditableProps/); assert.match(toolboxModules, /getContentEditableProps/);
assert.match(moduleRegistry, /tool-add-card/); assert.match(addToolControls, /tool-add-card/);
assert.match(moduleRegistry, /tool-quick-add-button/); assert.match(addToolControls, /tool-quick-add-button/);
assert.match(moduleRegistry, /tool-add-quick-toggle/); assert.match(addToolControls, /tool-add-quick-toggle/);
assert.match(moduleMetadataRegistry, /scrollable: true/); assert.match(moduleMetadataRegistry, /scrollable: true/);
assert.match(moduleRegistry, /module-content/); assert.match(toolboxModules, /module-content/);
assert.match(moduleRegistry, /module-scroll-button/); assert.match(toolboxModules, /module-scroll-button/);
assert.match(moduleRegistry, /Icon name="dropdown"/); assert.match(addToolControls, /Icon name="dropdown"/);
assert.match(moduleRegistry, /CompactDropdown/); assert.match(addToolControls, /CompactDropdown/);
assert.doesNotMatch(source, /<select value="" onChange=\{\(event\) => addModule/); assert.doesNotMatch(source, /<select value="" onChange=\{\(event\) => addModule/);
assert.match(notepadModule, /export function NotepadModule/); assert.match(notepadModule, /export function NotepadModule/);
assert.match(notepadModule, /TextExchangeActions/); assert.match(notepadModule, /TextExchangeActions/);
@ -364,7 +416,7 @@ test("toolbox module registry and modules expose expected behavior", async () =>
assert.match(timerModule, /Tabs/); assert.match(timerModule, /Tabs/);
assert.match(timerModule, /stopwatch/); assert.match(timerModule, /stopwatch/);
assert.match(timerModule, /countdown/); assert.match(timerModule, /countdown/);
assert.match(timerParts, /useInlineEdit/); assert.match(timerBundle, /useInlineEdit/);
assert.match(timerModule, /useDraftForm/); assert.match(timerModule, /useDraftForm/);
assert.match(timerModule, /normalizeTimerData/); assert.match(timerModule, /normalizeTimerData/);
assert.match(timerModule, /TextExchangeActions/); assert.match(timerModule, /TextExchangeActions/);
@ -378,16 +430,16 @@ test("toolbox module registry and modules expose expected behavior", async () =>
assert.match(calendarBundle, /MonthDateDropdown/); assert.match(calendarBundle, /MonthDateDropdown/);
assert.match(calendarBundle, /MonthEventRepeatSwitch/); assert.match(calendarBundle, /MonthEventRepeatSwitch/);
assert.match(calendarBundle, /monthlyRepeatBy/); assert.match(calendarBundle, /monthlyRepeatBy/);
assert.match(calendarModule, /displayedMonth/); assert.match(calendarController, /displayedMonth/);
assert.match(calendarModule, /deleteOccurrence/); assert.match(calendarController, /deleteOccurrence/);
assert.match(calendarBundle, /onPointerDown/); assert.match(calendarBundle, /onPointerDown/);
assert.match(calendarModule, /copyEvent/); assert.match(calendarController, /copyEvent/);
assert.match(calendarBundle, /calendar-repeat-switch/); assert.match(calendarBundle, /calendar-repeat-switch/);
assert.match(calendarBundle, /layout-switch calendar-month-repeat-switch/); assert.match(calendarBundle, /layout-switch calendar-month-repeat-switch/);
assert.match(calendarBundle, /WEEK_TIME_INPUT_STEP_MINUTES \* 60/); assert.match(calendarBundle, /WEEK_TIME_INPUT_STEP_MINUTES \* 60/);
assert.match(calendarBundle, /durationMinutes < 120/); assert.match(calendarBundle, /durationMinutes < 120/);
assert.match(calendarBundle, /is-compact/); assert.match(calendarBundle, /is-compact/);
assert.match(calendarModule, /handleKeyDown/); assert.match(calendarController, /handleKeyDown/);
assert.match(calendarContent, /TextExchangeActions/); assert.match(calendarContent, /TextExchangeActions/);
assert.match(calendarContent, /type="calendar"/); assert.match(calendarContent, /type="calendar"/);
assert.match(calendarBundle, /repeatLabel/); assert.match(calendarBundle, /repeatLabel/);
@ -408,8 +460,8 @@ test("toolbox module registry and modules expose expected behavior", async () =>
assert.match(taskPlannerModule, /useDraftForm/); assert.match(taskPlannerModule, /useDraftForm/);
assert.match(taskPlannerBundle, /TextExchangeActions/); assert.match(taskPlannerBundle, /TextExchangeActions/);
assert.match(taskPlannerBundle, /reorderFeatures/); assert.match(taskPlannerBundle, /reorderFeatures/);
assert.match(taskPlannerItems, /export function TaskPlannerBranch/); assert.match(taskPlannerBranch, /export function TaskPlannerBranch/);
assert.match(taskPlannerItems, /function TaskPlannerItem/); assert.match(taskPlannerItem, /export function TaskPlannerItem/);
assert.match(taskPlannerBundle, /task-planner-warning/); assert.match(taskPlannerBundle, /task-planner-warning/);
assert.match(taskPlannerBundle, /useInlineEdit/); assert.match(taskPlannerBundle, /useInlineEdit/);
assert.match(taskPlannerBundle, /commitOnEnter: false/); assert.match(taskPlannerBundle, /commitOnEnter: false/);
@ -428,14 +480,14 @@ test("toolbox module registry and modules expose expected behavior", async () =>
assert.match(goalTreeEditor, /Tabs/); assert.match(goalTreeEditor, /Tabs/);
assert.match(goalTreeEditor, /GoalTreeFormulaInput/); assert.match(goalTreeEditor, /GoalTreeFormulaInput/);
assert.match(goalTreeEditor, /GoalTreePrerequisitePicker/); assert.match(goalTreeEditor, /GoalTreePrerequisitePicker/);
assert.match(goalTreeParts, /Tooltip/); assert.match(goalTreeBranch, /Tooltip/);
assert.match(goalTreeParts, /export function GoalTreeIconPicker/); assert.match(goalTreeEditorFields, /export function GoalTreeIconPicker/);
assert.match(goalTreeParts, /export function GoalTreeContentEditor/); assert.match(goalTreeEditorFields, /export function GoalTreeContentEditor/);
assert.match(goalTreeParts, /function GoalTreeNodeContentPreview/); assert.match(goalTreeNodeContent, /export function GoalTreeNodeContentPreview/);
assert.doesNotMatch(goalTreeBundle, /\.slice\(0, 3\)/); assert.doesNotMatch(goalTreeBundle, /\.slice\(0, 3\)/);
assert.match(goalTreeParts, /GoalTreeChecklistInlineControl/); assert.match(goalTreeNodeContent, /GoalTreeChecklistInlineControl/);
assert.match(goalTreeParts, /GoalTreeStepLeafControl/); assert.match(goalTreeNodeContent, /GoalTreeStepLeafControl/);
assert.match(goalTreeParts, /export function GoalTreePrerequisitePicker/); assert.match(goalTreeEditorFields, /export function GoalTreePrerequisitePicker/);
assert.match(goalTreeBundle, /function toggleBranch/); assert.match(goalTreeBundle, /function toggleBranch/);
assert.match(goalTreeBundle, /function editNode/); assert.match(goalTreeBundle, /function editNode/);
assert.match(goalTreeBundle, /function addNodeContentItem/); assert.match(goalTreeBundle, /function addNodeContentItem/);
@ -453,7 +505,7 @@ test("toolbox module registry and modules expose expected behavior", async () =>
assert.match(goalTreeDisplay, /progress\.current/); assert.match(goalTreeDisplay, /progress\.current/);
assert.match(goalTreeDisplay, /label: textContent\.itemLabelPlaceholder \|\| "Élément"/); assert.match(goalTreeDisplay, /label: textContent\.itemLabelPlaceholder \|\| "Élément"/);
assert.doesNotMatch(goalTreeUtils, /"calculator"/); assert.doesNotMatch(goalTreeUtils, /"calculator"/);
assert.match(goalTreeParts, /getItemProps\(\{ itemId: node\.id, parentId \}\)/); assert.match(goalTreeBranch, /getItemProps\(\{ itemId: node\.id, parentId \}\)/);
assert.match(goalTreeUtils, /export function calculateGoalTreeFormula/); assert.match(goalTreeUtils, /export function calculateGoalTreeFormula/);
assert.match(goalTreeUtils, /export function evaluateGoalTreeNodes/); assert.match(goalTreeUtils, /export function evaluateGoalTreeNodes/);
assert.match(equipmentPlannerModule, /export function EquipmentPlannerModule/); assert.match(equipmentPlannerModule, /export function EquipmentPlannerModule/);
@ -540,10 +592,10 @@ test("toolbox module registry and modules expose expected behavior", async () =>
assert.match(timerModule, /useGroupedReorder/); assert.match(timerModule, /useGroupedReorder/);
assert.match(timerModule, /reorderEnabled={!data\.sortResults}/); assert.match(timerModule, /reorderEnabled={!data\.sortResults}/);
assert.match(timerModule, /parentId=\{moduleId\}/); assert.match(timerModule, /parentId=\{moduleId\}/);
assert.match(timerParts, /timer-drag-handle/); assert.match(timerCountdown, /timer-drag-handle/);
assert.match(timerParts, /export function StopwatchControls/); assert.match(timerStopwatch, /export function StopwatchControls/);
assert.match(timerParts, /export function CountdownControls/); assert.match(timerCountdown, /export function CountdownControls/);
assert.match(timerParts, /export function CountdownList/); assert.match(timerCountdown, /export function CountdownList/);
assert.match(timerOptions, /export const ALERT_MIN_DELAY_MS/); assert.match(timerOptions, /export const ALERT_MIN_DELAY_MS/);
assert.match(textImport, /export \{ parseColonImportLines \}/); assert.match(textImport, /export \{ parseColonImportLines \}/);
assert.match(textImport, /export function exportModuleText/); assert.match(textImport, /export function exportModuleText/);

View file

@ -0,0 +1,105 @@
// Rôle : rend les contrôles modal et quick-add d'ajout d'outils toolbox.
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { CompactDropdown } from "../../../components/CompactDropdown.jsx";
import { Icon } from "../../../components/Icon.jsx";
import { lockBodyScroll } from "../../../utils/bodyScrollLock.js";
import { TOOLBOX_MODULES } from "../toolboxModuleRegistry.js";
export function AddToolControls({ onAdd }) {
const [open, setOpen] = useState(false);
const modules = Object.entries(TOOLBOX_MODULES);
useEffect(() => {
if (!open) return undefined;
return lockBodyScroll();
}, [open]);
function addTool(type) {
onAdd(type);
setOpen(false);
}
return (
<>
<div
className="tool-add-controls"
aria-label="Ajouter un outil"
>
<button
className="primary tool-add-modal-button"
type="button"
onClick={() => setOpen(true)}
data-toolbox-tutorial-target="addTool"
>
Ajouter un outil
</button>
<CompactDropdown
className="tool-add-quick-dropdown"
menuClassName="tool-quick-add"
label="Ajout rapide"
openOnHover
closeOnMouseLeave
renderTrigger={({ open: quickOpen, toggle }) => (
<button
className={`primary tool-add-quick-toggle ${quickOpen ? "active" : ""}`}
type="button"
onClick={toggle}
aria-label="Afficher l'ajout rapide"
aria-expanded={quickOpen}
title="Ajout rapide"
>
<Icon name="dropdown" />
</button>
)}
>
{({ close }) => (
<>
{modules.map(([type, module]) => (
<button
key={type}
className="tool-quick-add-button"
type="button"
onClick={() => {
onAdd(type);
close();
}}
aria-label={`Ajouter ${module.label}`}
title={module.label}
>
<span className="module-icon" aria-hidden="true">
<span className={`module-icon-svg module-icon-${module.icon}`} />
</span>
</button>
))}
</>
)}
</CompactDropdown>
</div>
{open && createPortal(
<div className="tool-add-modal-root" role="dialog" aria-modal="true" aria-labelledby="tool-add-modal-title">
<div className="confirm-backdrop" onClick={() => setOpen(false)} />
<section className="confirm-modal tool-add-modal">
<header>
<h2 id="tool-add-modal-title">Ajouter un outil</h2>
<button className="drawer-close-button" type="button" onClick={() => setOpen(false)} aria-label="Fermer" title="Fermer">
<Icon name="close" />
</button>
</header>
<div className="tool-add-grid">
{modules.map(([type, module]) => (
<button key={type} className="tool-add-card" type="button" onClick={() => addTool(type)}>
<span className="module-icon" aria-hidden="true">
<span className={`module-icon-svg module-icon-${module.icon}`} />
</span>
<span>{module.label}</span>
</button>
))}
</div>
</section>
</div>,
document.body
)}
</>
);
}

View file

@ -0,0 +1,19 @@
// Rôle : rend l'écran de choix initial du mode calendrier.
export function CalendarModeChoice({ textContent, onInitializeWeek, onInitializeMonth }) {
return (
<div className="calendar-module">
<section className="calendar-mode-choice" aria-label={textContent.init?.title || "Créer un calendrier"}>
<div className="calendar-mode-grid">
<button type="button" className="calendar-mode-card" onClick={onInitializeWeek}>
<strong>{textContent.init?.weekly || "Hebdomadaire"}</strong>
<span>{textContent.init?.weeklyDescription || "Grille jours par créneaux de 30 minutes."}</span>
</button>
<button type="button" className="calendar-mode-card" onClick={onInitializeMonth}>
<strong>{textContent.init?.monthly || "Mensuel"}</strong>
<span>{textContent.init?.monthlyDescription || "Grille du mois courant."}</span>
</button>
</div>
</section>
</div>
);
}

View file

@ -1,433 +1,22 @@
// Rôle : fournit l'outil calendrier hebdomadaire/mensuel avec événements, récurrences et alertes. // Rôle : orchestre l'outil calendrier et délègue l'état métier à son contrôleur.
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { CalendarContent } from "./CalendarContent.jsx"; import { CalendarContent } from "./CalendarContent.jsx";
import { import { CalendarModeChoice } from "./CalendarModeChoice.jsx";
CALENDAR_ALERT_MODES, import { useCalendarController } from "./useCalendarController.js";
CALENDAR_COLORS,
WEEK_SLOT_MINUTES,
addDays,
formatLocalDateKey,
getCalendarOccurrences,
getLocalDayIndex,
getMonthEnd,
getMonthGridDays,
getMonthStart,
getWeekDays,
getWeekStart,
minutesToTimeValue,
parseLocalDateKey,
timeValueToMinutes
} from "./calendarUtils.js";
import { WEEK_SLOT_COUNT, getWeekGridLayout } from "./calendarLayout.js";
import {
createDefaultDraft,
getCalendarRepeatMode,
getDateSpanDays,
getDraftSelection,
normalizeDraftAlertOffsets,
normalizeDraftDateKey,
normalizeDraftMonthlyRepeatBy
} from "./calendarDraft.js";
export function CalendarModule({ toolboxId, moduleId, context, editing = false, setEditing, textExchangeOpen }) { export function CalendarModule({ toolboxId, moduleId, context, editing = false, setEditing, textExchangeOpen }) {
const data = context.normalizeCalendarData(context.getModuleData(toolboxId, moduleId, { initialized: false, viewMode: "week", events: [] })); const data = context.normalizeCalendarData(context.getModuleData(toolboxId, moduleId, { initialized: false, viewMode: "week", events: [] }));
const textContent = context.moduleText?.calendar || {}; const textContent = context.moduleText?.calendar || {};
const [now, setNow] = useState(() => new Date()); const controller = useCalendarController({ data, context, toolboxId, moduleId, editing, setEditing, textContent });
const [displayedMonth, setDisplayedMonth] = useState(() => getMonthStart(new Date()));
const weekStart = useMemo(() => getWeekStart(new Date(), data.weekStartsOn), [data.weekStartsOn]);
const monthStart = useMemo(() => getMonthStart(displayedMonth), [displayedMonth]);
const monthEnd = useMemo(() => getMonthEnd(monthStart), [monthStart]);
const monthGridDays = useMemo(() => getMonthGridDays(monthStart), [monthStart]);
const [selection, setSelection] = useState(null);
const [monthSelection, setMonthSelection] = useState(null);
const [draft, setDraft] = useState(() => createDefaultDraft());
const [activeEventId, setActiveEventId] = useState("");
const [activeOccurrenceDate, setActiveOccurrenceDate] = useState("");
const [copiedEvent, setCopiedEvent] = useState(null);
const [hideEmptyHours, setHideEmptyHours] = useState(false);
const [error, setError] = useState("");
const previousEditingRef = useRef(editing);
const days = useMemo(() => getWeekDays(weekStart), [weekStart]);
const weekEnd = useMemo(() => addDays(weekStart, 7), [weekStart]);
const rangeStart = data.viewMode === "month" ? monthStart : weekStart;
const rangeEnd = data.viewMode === "month" ? monthEnd : weekEnd;
const occurrences = useMemo(() => getCalendarOccurrences(data.events, rangeStart, rangeEnd), [data.events, rangeEnd, rangeStart]);
const draftSelection = data.viewMode === "week" ? getDraftSelection(draft) : null;
const nowMinutes = now.getHours() * 60 + now.getMinutes();
const nowDayIndex = getLocalDayIndex(now);
const formOpen = editing || Boolean(activeEventId);
const weekGridLayout = useMemo(() => {
return getWeekGridLayout(occurrences, data.viewMode === "week" && hideEmptyHours && !formOpen);
}, [data.viewMode, formOpen, hideEmptyHours, occurrences]);
const save = useCallback((nextData) => {
context.setModuleData(toolboxId, moduleId, nextData, "calendar");
}, [context, moduleId, toolboxId]);
function initializeWeek() {
save({ ...data, initialized: true, viewMode: "week", weekStartsOn: 1, events: [] });
}
function initializeMonth() {
save({ ...data, initialized: true, viewMode: "month", weekStartsOn: 1, events: [] });
}
function updateDraft(values) {
setDraft((current) => ({ ...current, ...values }));
setError("");
}
function updateAlertMode(alertMode) {
setDraft((current) => {
const currentOffsets = normalizeDraftAlertOffsets(current.alertOffsetMinutes);
return {
...current,
alertMode,
alertOffsetMinutes: alertMode === "off" ? [] : currentOffsets.length ? currentOffsets : [10]
};
});
setError("");
}
function toggleAlertOffset(offset) {
setDraft((current) => {
const currentOffsets = normalizeDraftAlertOffsets(current.alertOffsetMinutes);
const nextOffsets = currentOffsets.includes(offset)
? currentOffsets.filter((item) => item !== offset)
: [...currentOffsets, offset].sort((a, b) => b - a);
return {
...current,
alertMode: nextOffsets.length ? current.alertMode === "off" ? "visible" : current.alertMode : "off",
alertOffsetMinutes: nextOffsets
};
});
setError("");
}
const resetDraft = useCallback(() => {
setDraft(createDefaultDraft());
setSelection(null);
setMonthSelection(null);
setActiveEventId("");
setActiveOccurrenceDate("");
setError("");
}, []);
function closeForm() {
resetDraft();
setEditing?.(false);
}
function applySelection(dayIndex, startSlot, endSlot = startSlot) {
const orderedStart = Math.min(startSlot, endSlot);
const orderedEnd = Math.max(startSlot, endSlot);
updateDraft({
id: "",
dayIndex,
startTime: minutesToTimeValue(orderedStart * WEEK_SLOT_MINUTES),
endTime: minutesToTimeValue(Math.min(23 * 60 + 59, (Math.min(WEEK_SLOT_COUNT, orderedEnd + 1)) * WEEK_SLOT_MINUTES))
});
}
function applyMonthSelection(startDate, endDate = startDate) {
const orderedStart = startDate <= endDate ? startDate : endDate;
const orderedEnd = startDate <= endDate ? endDate : startDate;
const start = parseLocalDateKey(orderedStart);
setActiveEventId("");
setActiveOccurrenceDate("");
updateDraft({
id: "",
startDate: orderedStart,
endDate: orderedEnd,
dayIndex: start ? getLocalDayIndex(start) : 0
});
}
function pasteEventAtTarget(target) {
if (!copiedEvent) return;
const startMinutes = data.viewMode === "month" ? 0 : target.slotIndex >= 0 ? target.slotIndex * WEEK_SLOT_MINUTES : timeValueToMinutes(draft.startTime);
const durationMinutes = copiedEvent.endMinutes && copiedEvent.endMinutes > copiedEvent.startMinutes ? copiedEvent.endMinutes - copiedEvent.startMinutes : 0;
const nextEvent = {
...copiedEvent,
id: context.uid("calendar"),
startMinutes,
repeatable: copiedEvent.repeatable !== false,
repeat: copiedEvent.repeatable === false ? "none" : getCalendarRepeatMode(data.viewMode)
};
if (data.viewMode === "month") {
const copiedDaySpan = getDateSpanDays(copiedEvent.startDate, copiedEvent.endDate || copiedEvent.startDate);
nextEvent.startDate = target.startDate;
nextEvent.endDate = formatLocalDateKey(addDays(parseLocalDateKey(target.startDate), copiedDaySpan));
nextEvent.dayIndex = getLocalDayIndex(parseLocalDateKey(target.startDate));
nextEvent.monthlyRepeatBy = target.monthlyRepeatBy || copiedEvent.monthlyRepeatBy || "monthDay";
nextEvent.repeat = copiedEvent.repeat || "monthly";
nextEvent.excludedOccurrences = [];
delete nextEvent.monthDay;
} else {
nextEvent.dayIndex = target.dayIndex;
delete nextEvent.monthDay;
delete nextEvent.monthlyRepeatBy;
}
delete nextEvent.endMinutes;
if (data.viewMode === "week" && durationMinutes > 0) nextEvent.endMinutes = Math.min(23 * 60 + 59, startMinutes + durationMinutes);
save({ ...data, initialized: true, events: [...data.events, nextEvent] });
context.notify?.(textContent.pastedTitle || "Événement collé.");
}
function pasteCopiedEventAtDraftSelection() {
if (!copiedEvent) return;
if (data.viewMode === "month") {
if (!parseLocalDateKey(draft.startDate)) return;
pasteEventAtTarget({
startDate: draft.startDate,
monthlyRepeatBy: draft.monthlyRepeatBy || "monthDay",
slotIndex: -1
});
return;
}
const targetSelection = getDraftSelection(draft);
if (targetSelection) pasteEventAtTarget({ dayIndex: targetSelection.dayIndex, slotIndex: targetSelection.startSlot });
}
function handleSlotPointerDown(dayIndex, slotIndex) {
setActiveEventId("");
setActiveOccurrenceDate("");
setSelection({ dayIndex, startSlot: slotIndex, endSlot: slotIndex });
applySelection(dayIndex, slotIndex);
}
function handleSlotPointerEnter(event, dayIndex, slotIndex) {
if (event.buttons !== 1) return;
setSelection((current) => {
if (!current || current.dayIndex !== dayIndex) return current;
applySelection(dayIndex, current.startSlot, slotIndex);
return { ...current, endSlot: slotIndex };
});
}
function handleMonthPointerDown(dateKey) {
setActiveEventId("");
setActiveOccurrenceDate("");
setMonthSelection({ startDate: dateKey, endDate: dateKey });
applyMonthSelection(dateKey);
}
function handleMonthPointerEnter(event, dateKey) {
if (event.buttons !== 1) return;
setMonthSelection((current) => {
if (!current) return current;
applyMonthSelection(current.startDate, dateKey);
return { ...current, endDate: dateKey };
});
}
function editEvent(event, occurrence = null) {
const occurrenceDate = occurrence ? formatLocalDateKey(new Date(occurrence.startMs)) : "";
if (occurrenceDate) setDisplayedMonth(getMonthStart(parseLocalDateKey(occurrenceDate)));
setDraft({
id: event.id,
title: event.title,
dayIndex: event.dayIndex,
startDate: occurrenceDate || event.startDate || formatLocalDateKey(now),
endDate: occurrenceDate && event.startDate ? formatLocalDateKey(addDays(parseLocalDateKey(occurrenceDate), getDateSpanDays(event.startDate, event.endDate || event.startDate))) : event.endDate || event.startDate || formatLocalDateKey(now),
monthlyRepeatBy: event.monthlyRepeatBy || "monthDay",
repeat: event.repeat || "none",
startTime: minutesToTimeValue(event.startMinutes),
endTime: event.endMinutes ? minutesToTimeValue(event.endMinutes) : "",
repeatable: event.repeatable !== false,
alertMode: event.alertMode || "off",
alertOffsetMinutes: event.alertMode === "off" ? [] : normalizeDraftAlertOffsets(event.alertOffsetMinutes),
color: event.color || "violet"
});
setActiveEventId(event.id);
setActiveOccurrenceDate(occurrenceDate);
setError("");
}
function deleteEvent(eventId) {
save({ ...data, initialized: true, events: data.events.filter((event) => event.id !== eventId) });
if (draft.id === eventId || activeEventId === eventId) closeForm();
}
function deleteOccurrence(eventId) {
const event = data.events.find((item) => item.id === eventId);
if (!event || !activeOccurrenceDate || event.repeat === "none") return;
const events = data.events.map((item) => item.id === eventId
? { ...item, excludedOccurrences: [...new Set([...(item.excludedOccurrences || []), activeOccurrenceDate])].sort() }
: item);
save({ ...data, initialized: true, events });
closeForm();
}
function copyEvent(event) {
setCopiedEvent(event);
context.notify?.(textContent.copiedTitle || "Événement copié.");
}
function submitEvent(event) {
event.preventDefault();
const title = draft.title.trim();
const dayIndex = Number(draft.dayIndex);
const startDate = normalizeDraftDateKey(draft.startDate);
const endDate = normalizeDraftDateKey(draft.endDate || draft.startDate);
const startMinutes = data.viewMode === "month" ? 0 : timeValueToMinutes(draft.startTime);
const endMinutes = data.viewMode === "month" ? -1 : draft.endTime ? timeValueToMinutes(draft.endTime) : -1;
const monthlyRepeatBy = normalizeDraftMonthlyRepeatBy(draft.monthlyRepeatBy);
const monthRepeat = monthlyRepeatBy === "monthDay" && draft.repeat === "weekly" ? "none" : ["none", "weekly", "monthly"].includes(draft.repeat) ? draft.repeat : "monthly";
const validWeekTarget = data.viewMode === "week" && dayIndex >= 0 && dayIndex <= 6;
const validMonthTarget = data.viewMode === "month" && startDate && endDate && endDate >= startDate;
if (!title || (!validWeekTarget && !validMonthTarget) || startMinutes < 0) {
setError(textContent.requiredError || "Titre, date et heure de début requis.");
return;
}
if (draft.endTime && data.viewMode === "week" && endMinutes <= startMinutes) {
setError(textContent.invalidRangeError || "La fin doit être après le début.");
return;
}
const alertOffsets = normalizeDraftAlertOffsets(draft.alertOffsetMinutes);
const alertMode = alertOffsets.length && CALENDAR_ALERT_MODES.includes(draft.alertMode) ? draft.alertMode : "off";
const nextEvent = {
id: draft.id || context.uid("calendar"),
title,
startMinutes,
...(data.viewMode === "week" && draft.endTime ? { endMinutes } : {}),
repeatable: draft.repeatable !== false,
repeat: data.viewMode === "month" ? monthRepeat : draft.repeatable === false ? "none" : getCalendarRepeatMode(data.viewMode),
alertMode,
alertOffsetMinutes: alertOffsets.length ? alertOffsets : [10],
color: CALENDAR_COLORS.includes(draft.color) ? draft.color : "violet"
};
if (data.viewMode === "month") {
nextEvent.monthlyRepeatBy = monthlyRepeatBy;
nextEvent.startDate = startDate;
nextEvent.endDate = endDate;
nextEvent.dayIndex = getLocalDayIndex(parseLocalDateKey(startDate));
nextEvent.excludedOccurrences = data.events.find((item) => item.id === draft.id)?.excludedOccurrences || [];
} else {
nextEvent.dayIndex = dayIndex;
}
const events = draft.id
? data.events.map((item) => item.id === draft.id ? nextEvent : item)
: [...data.events, nextEvent];
save({ ...data, initialized: true, events });
closeForm();
}
function handleKeyDown(event) {
if (!event.ctrlKey && !event.metaKey) return;
const target = event.target;
if (target?.closest?.("input, textarea, select, [contenteditable='true']")) return;
const key = event.key.toLowerCase();
if (key === "c") {
const activeEvent = data.events.find((item) => item.id === activeEventId);
if (!activeEvent) return;
event.preventDefault();
copyEvent(activeEvent);
}
if (key === "v") {
if (!copiedEvent) return;
event.preventDefault();
pasteCopiedEventAtDraftSelection();
}
}
useEffect(() => {
const intervalId = window.setInterval(() => setNow(new Date()), 60000);
return () => window.clearInterval(intervalId);
}, []);
useEffect(() => {
const previousEditing = previousEditingRef.current;
previousEditingRef.current = editing;
if (!previousEditing && editing) {
setDraft(createDefaultDraft());
setActiveEventId("");
setSelection(null);
setError("");
}
if (previousEditing && !editing) {
resetDraft();
}
}, [editing, resetDraft]);
useEffect(() => {
if (!data.events.length) return undefined;
function pruneExpiredOneShotEvents() {
const nowMs = Date.now();
const nextEvents = data.events.filter((event) => {
if (event.repeatable !== false) return true;
const occurrence = getCalendarOccurrences([event], rangeStart, rangeEnd)[0];
return !occurrence || occurrence.endMs > nowMs;
});
if (nextEvents.length === data.events.length) return;
save({ ...data, initialized: true, events: nextEvents });
if (activeEventId && !nextEvents.some((event) => event.id === activeEventId)) resetDraft();
}
pruneExpiredOneShotEvents();
const intervalId = window.setInterval(pruneExpiredOneShotEvents, 30000);
return () => window.clearInterval(intervalId);
}, [activeEventId, data, rangeEnd, rangeStart, resetDraft, save]);
if (!data.initialized) { if (!data.initialized) {
return ( return (
<div className="calendar-module"> <CalendarModeChoice
<section className="calendar-mode-choice" aria-label={textContent.init?.title || "Créer un calendrier"}>
<div className="calendar-mode-grid">
<button type="button" className="calendar-mode-card" onClick={initializeWeek}>
<strong>{textContent.init?.weekly || "Hebdomadaire"}</strong>
<span>{textContent.init?.weeklyDescription || "Grille jours par créneaux de 30 minutes."}</span>
</button>
<button type="button" className="calendar-mode-card" onClick={initializeMonth}>
<strong>{textContent.init?.monthly || "Mensuel"}</strong>
<span>{textContent.init?.monthlyDescription || "Grille du mois courant."}</span>
</button>
</div>
</section>
</div>
);
}
return (
<CalendarContent
data={data}
context={context}
draft={{ ...draft, alertOffsetMinutes: draft.alertMode === "off" ? [] : normalizeDraftAlertOffsets(draft.alertOffsetMinutes) }}
error={error}
activeOccurrenceDate={activeOccurrenceDate}
monthStart={monthStart}
monthGridDays={monthGridDays}
occurrences={occurrences}
textContent={textContent} textContent={textContent}
activeEventId={activeEventId} onInitializeWeek={controller.initializeWeek}
formOpen={formOpen} onInitializeMonth={controller.initializeMonth}
draftSelection={draftSelection}
selection={selection}
monthSelection={monthSelection}
days={days}
weekGridLayout={weekGridLayout}
now={now}
nowDayIndex={nowDayIndex}
nowMinutes={nowMinutes}
hideEmptyHours={hideEmptyHours}
textExchangeOpen={textExchangeOpen}
onKeyDown={handleKeyDown}
onSubmit={submitEvent}
onDraftChange={updateDraft}
onAlertModeChange={updateAlertMode}
onAlertOffsetToggle={toggleAlertOffset}
onDeleteEvent={deleteEvent}
onDeleteOccurrence={deleteOccurrence}
onClose={closeForm}
onDisplayedMonthChange={setDisplayedMonth}
onImport={(nextData) => save(nextData)}
onHideEmptyHoursToggle={() => setHideEmptyHours((current) => !current)}
onMonthPointerDown={handleMonthPointerDown}
onMonthPointerEnter={handleMonthPointerEnter}
onMonthPointerUp={() => setMonthSelection(null)}
onSlotPointerDown={handleSlotPointerDown}
onSlotPointerEnter={handleSlotPointerEnter}
onSlotPointerUp={() => setSelection(null)}
onEditEvent={editEvent}
/> />
); );
} }
return <CalendarContent {...controller.contentProps} textExchangeOpen={textExchangeOpen} />;
}

View file

@ -0,0 +1,160 @@
// Rôle : rend récursivement les branches, cartes et actions de l'arbre d'objectifs.
import { Icon } from "../../../components/Icon.jsx";
import { Tooltip } from "../../../components/Tooltip.jsx";
import {
getGoalTreeChildren,
getGoalTreeParentIds
} from "./goalTreeUtils.js";
import {
getEffectiveGoalTreeProgressMode,
getGoalTreeNodeIcon,
getGoalTreeNodeIconColor,
getGoalTreeNodeTooltip,
isSingleValueGoalTreeNode
} from "./goalTreeDisplay.js";
import { GoalTreeNodeContentPreview, GoalTreeStepLeafControl } from "./GoalTreeNodeContent.jsx";
export function GoalTreeBranch({ nodes, parentId, selectedNodeId, editing, reorder, textContent, onSelect, onAdd, onAddContentItem, onSetStepLeafQuantity, onSetStepChildrenCompletion, onUpdateNodeItem, onEditNode, onToggleBranch, onDeleteNode }) {
const children = getGoalTreeChildren(nodes, parentId);
if (!children.length) return null;
return (
<ul className={`goal-tree-list ${parentId ? "is-child-list" : "is-root-list"}`} data-goal-tree-parent={parentId || "root"}>
{children.map((node) => {
const nestedChildren = getGoalTreeChildren(nodes, node.id);
const linkedChildren = nodes.filter((candidate) => candidate.parentId !== node.id && getGoalTreeParentIds(candidate).includes(node.id));
const branchCollapsed = node.collapsed;
const isSelected = selectedNodeId === node.id;
const isComplete = node.progress.complete;
const hasSingleValueChildren = nestedChildren.length > 0 && nestedChildren.every(isSingleValueGoalTreeNode);
const manualProgress = getEffectiveGoalTreeProgressMode(node, nestedChildren) === "manual";
const hasBranchToggle = nestedChildren.length > 0 || linkedChildren.length > 0;
const hasNodeActions = editing || hasBranchToggle;
const iconColor = getGoalTreeNodeIconColor(node);
const selectFromCard = (event) => {
if (!editing) return;
if (event.target.closest("button:not(.goal-tree-node-main)")) return;
onSelect(node.id);
};
const className = [
"goal-tree-node",
`is-${node.kind}`,
isSelected ? "is-selected" : "",
isComplete ? "is-complete" : "",
reorder.isItemDragging(node.id) ? "is-dragging" : "",
reorder.isItemDropTarget(node.id) ? "is-drop-target" : "",
reorder.getDropPlacement("item", node.id) === "after" ? "drop-after" : ""
].filter(Boolean).join(" ");
return (
<li key={node.id} className={className} {...reorder.getItemProps({ itemId: node.id, parentId })}>
<article className={`goal-tree-node-card ${editing ? "is-editing" : "is-viewing"} ${hasNodeActions ? "has-actions" : ""}`} data-diagram-node={node.id} onClick={selectFromCard}>
{!editing && (
<div className="goal-tree-node-hover-actions" aria-label={textContent.cardActionsLabel || "Actions de l'objectif"}>
<button type="button" onClick={() => onEditNode(node.id)} aria-label={`${textContent.editTitle || "Modifier"} ${node.title}`} title={textContent.editTitle || "Modifier"}>
<Icon name="edit" />
</button>
<button className="danger" type="button" onClick={() => onDeleteNode(node.id)} aria-label={`${textContent.deleteTitle || "Supprimer"} ${node.title}`} title={textContent.deleteTitle || "Supprimer"}>
<Icon name="trash" />
</button>
</div>
)}
{editing && (
<button
className="goal-tree-drag-handle"
type="button"
onPointerDown={(event) => reorder.itemReorder.startDrag(event, node.id)}
aria-label={`${textContent.reorderTitle || "Déplacer"} ${node.title}`}
title={textContent.reorderTitle || "Déplacer"}
>
<Icon name="drag" />
</button>
)}
<Tooltip as="div" className="goal-tree-node-main" content={node.kind === "step" ? getGoalTreeNodeTooltip(node) : ""} tooltipClassName="goal-tree-node-tooltip" aria-label={node.title}>
{node.kind === "step" ? (
<>
<span className="goal-tree-node-icon" aria-hidden="true" style={{ "--goal-tree-node-color": iconColor }}>
<Icon name={getGoalTreeNodeIcon(node)} />
</span>
{(manualProgress || !nestedChildren.length) && (
<GoalTreeStepLeafControl node={node} textContent={textContent} onChange={(quantity) => onSetStepLeafQuantity(node.id, quantity)} />
)}
{!manualProgress && hasSingleValueChildren && (
<GoalTreeStepLeafControl
node={{ ...node, computedQtyTarget: 1, qtyTarget: 1, qtyCurrent: node.progress.complete ? 1 : 0 }}
textContent={textContent}
onChange={(quantity) => onSetStepChildrenCompletion(node.id, quantity > 0)}
/>
)}
</>
) : (
<>
<span className="goal-tree-node-heading">
<span className="goal-tree-node-icon" aria-hidden="true" style={{ "--goal-tree-node-color": iconColor }}>
<Icon name={getGoalTreeNodeIcon(node)} />
</span>
<span className="goal-tree-node-title">{node.title}</span>
</span>
<GoalTreeNodeContentPreview
node={node}
editing={editing}
textContent={textContent}
onAddContentItem={() => onAddContentItem(node.id)}
onUpdateItem={(itemId, updater) => onUpdateNodeItem(node.id, itemId, updater)}
/>
</>
)}
<span className="goal-tree-progress-track" aria-hidden="true">
<span style={{ width: `${Math.round(node.progress.ratio * 100)}%` }} />
</span>
</Tooltip>
{hasNodeActions && (
<div className="goal-tree-node-actions">
{editing && (
<button type="button" onClick={() => onAdd(node.id)} aria-label={`${textContent.addChildTitle || "Ajouter une sous-étape"} ${node.title}`} title={textContent.addChildTitle || "Ajouter une sous-étape"}>
<Icon name="add" />
</button>
)}
{hasBranchToggle && (
<button
className="goal-tree-branch-toggle"
type="button"
onClick={() => onToggleBranch(node.id)}
aria-expanded={!branchCollapsed}
aria-label={branchCollapsed ? textContent.showChildrenTitle || "Afficher la branche" : textContent.hideChildrenTitle || "Réduire la branche"}
title={branchCollapsed ? textContent.showChildrenTitle || "Afficher la branche" : textContent.hideChildrenTitle || "Réduire la branche"}
>
<Icon name={branchCollapsed ? "chevron-down" : "chevron-up"} />
</button>
)}
{editing && (
<button className="danger" type="button" onClick={() => onDeleteNode(node.id)} aria-label={`${textContent.deleteTitle || "Supprimer"} ${node.title}`} title={textContent.deleteTitle || "Supprimer"}>
<Icon name="trash" />
</button>
)}
</div>
)}
</article>
{!node.collapsed && nestedChildren.length > 0 && (
<GoalTreeBranch
nodes={nodes}
parentId={node.id}
selectedNodeId={selectedNodeId}
editing={editing}
reorder={reorder}
textContent={textContent}
onSelect={onSelect}
onAdd={onAdd}
onAddContentItem={onAddContentItem}
onSetStepLeafQuantity={onSetStepLeafQuantity}
onSetStepChildrenCompletion={onSetStepChildrenCompletion}
onUpdateNodeItem={onUpdateNodeItem}
onEditNode={onEditNode}
onToggleBranch={onToggleBranch}
onDeleteNode={onDeleteNode}
/>
)}
</li>
);
})}
</ul>
);
}

View file

@ -0,0 +1,157 @@
// Rôle : regroupe les champs et pickers utilisés par l'éditeur de noeuds goal tree.
import { useState } from "react";
import { CompactDropdown } from "../../../components/CompactDropdown.jsx";
import { Icon } from "../../../components/Icon.jsx";
import { MixTagsInput } from "../../../components/MixTagsInput.jsx";
import {
GOAL_TREE_ICON_NAMES,
getGoalTreeDescendantIds,
getGoalTreeParentIds
} from "./goalTreeUtils.js";
import { BASE_TOKEN } from "./goalTreeDisplay.js";
export function GoalTreePrerequisitePicker({ nodes, node, textContent, onToggle }) {
const parentIds = new Set(getGoalTreeParentIds(node));
const blockedIds = new Set([node.id, ...getGoalTreeDescendantIds(nodes, node.id)]);
const primaryParent = nodes.find((candidate) => candidate.id === node.parentId);
const options = nodes.filter((candidate) => !blockedIds.has(candidate.id) && candidate.id !== node.parentId);
if (!primaryParent && !options.length) return null;
return (
<div className="goal-tree-prerequisite-field">
<span className="goal-tree-form-section-title">{textContent.prerequisitesSectionTitle || "Prérequis supplémentaires"}</span>
<div className="goal-tree-prerequisite-list themed-scrollbar">
{primaryParent && (
<label className="goal-tree-prerequisite-item is-primary">
<input type="checkbox" checked disabled readOnly />
<span>{primaryParent.title}</span>
</label>
)}
{options.map((candidate) => (
<label className="goal-tree-prerequisite-item" key={candidate.id}>
<input type="checkbox" checked={parentIds.has(candidate.id)} onChange={() => onToggle(candidate.id)} />
<span>{candidate.title}</span>
</label>
))}
</div>
</div>
);
}
export function GoalTreeTitleInput({ value, onChange }) {
const [draft, setDraft] = useState(value || "");
return (
<input
value={draft}
onChange={(event) => {
const title = event.target.value;
setDraft(title);
onChange(title);
}}
/>
);
}
export function GoalTreeFormulaInput({ value, onChange, hasParent, textContent, placeholder, title, className = "", "aria-label": ariaLabel }) {
const normalizedValue = String(value ?? "");
if (!hasParent) {
return <input aria-label={ariaLabel} className={className} inputMode="decimal" onChange={(event) => onChange(event.target.value.replaceAll(",", "."))} placeholder={placeholder} title={title} value={normalizedValue} />;
}
return (
<MixTagsInput
aria-label={ariaLabel}
className={className}
formulaSpacing
inputMode="decimal"
insertToken={{
value: BASE_TOKEN,
label: textContent.baseTokenLabel || "Valeur du parent",
icon: "parent",
title: textContent.insertBaseTitle || "Ajouter la valeur du parent"
}}
onChange={(nextValue) => onChange(nextValue.replaceAll(",", "."))}
placeholder={placeholder}
title={title}
tokens={[{
value: BASE_TOKEN,
label: textContent.baseTokenLabel || "Valeur du parent"
}]}
value={normalizedValue}
/>
);
}
export function GoalTreeContentEditor({ node, textContent, onAdd, onUpdate, onDelete }) {
return (
<div className="goal-tree-content-editor">
<div className="goal-tree-form-section-title">{textContent.contentSectionTitle || "Contenu"}</div>
{(node.items || []).map((item) => (
<div className={`goal-tree-content-editor-row is-${node.kind}`} key={item.id}>
<input className="goal-tree-content-label-input" value={item.label} placeholder={textContent.itemLabelPlaceholder || "Élément"} onChange={(event) => onUpdate(item.id, (current) => ({ ...current, label: event.target.value }))} aria-label={textContent.itemLabelLabel || "Libellé"} />
{node.kind === "checklist" && (
<GoalTreeFormulaInput
aria-label={textContent.itemTotalLabel || "Total"}
className="goal-tree-content-formula-input"
hasParent={Boolean(node.parentId)}
onChange={(value) => onUpdate(item.id, (current) => ({ ...current, formula: value.replaceAll(",", ".") }))}
value={item.formula || "1"}
placeholder={textContent.itemTotalPlaceholder || "base"}
textContent={textContent}
title={textContent.itemTotalHelp || "Saisissez un nombre ou une formule avec base pour référencer la cible du parent."}
/>
)}
<button className="danger" type="button" onClick={() => onDelete(item.id)} aria-label={textContent.deleteTitle || "Supprimer"} title={textContent.deleteTitle || "Supprimer"}>
<Icon name="trash" />
</button>
</div>
))}
<button className="goal-tree-content-add-button" type="button" onClick={onAdd}>
<Icon name="add" />
<span>{textContent.addContentItemButton || "Ajouter une ligne"}</span>
</button>
</div>
);
}
export function GoalTreeIconPicker({ label, value, textContent, onChange }) {
return (
<div className="goal-tree-icon-field">
<span>{label}</span>
<CompactDropdown
className="goal-tree-icon-picker"
menuClassName="goal-tree-icon-picker-menu"
label={label}
renderTrigger={({ open, toggle }) => (
<button className="goal-tree-icon-picker-button" type="button" onClick={toggle} aria-label={label} aria-expanded={open} title={label}>
<span className="goal-tree-icon-preview" aria-hidden="true">
<Icon name={value} />
</span>
<span>{textContent[`icon${value}`] || value}</span>
<Icon name="dropdown" />
</button>
)}
>
{({ close }) => (
<>
{GOAL_TREE_ICON_NAMES.map((icon) => (
<button
key={icon}
className={icon === value ? "active" : ""}
type="button"
onClick={() => {
onChange(icon);
close();
}}
aria-label={textContent[`icon${icon}`] || icon}
aria-pressed={icon === value}
title={textContent[`icon${icon}`] || icon}
>
<Icon name={icon} />
</button>
))}
</>
)}
</CompactDropdown>
</div>
);
}

View file

@ -0,0 +1,71 @@
// Rôle : rend les contrôles inline et aperçus de contenu des noeuds goal tree.
import { Icon } from "../../../components/Icon.jsx";
import { Tooltip } from "../../../components/Tooltip.jsx";
import { formatGoalTreeNumber } from "./goalTreeUtils.js";
function GoalTreeChecklistInlineControl({ item, textContent, onUpdate }) {
const target = Math.max(1, item.qtyTarget || 1);
const current = Math.min(Math.max(0, item.qtyCurrent || 0), target);
if (target <= 1) {
return <input className="goal-tree-node-check" type="checkbox" checked={current >= 1} onChange={(event) => onUpdate((entry) => ({ ...entry, qtyCurrent: event.target.checked ? 1 : 0 }))} aria-label={textContent.itemCheckedLabel || "État"} />;
}
return (
<span className="goal-tree-content-qty-stepper">
<button type="button" onClick={() => onUpdate((entry) => ({ ...entry, qtyCurrent: Math.max(0, (entry.qtyCurrent || 0) - 1) }))} aria-label={textContent.decrementItemTitle || "Retirer"} title={textContent.decrementItemTitle || "Retirer"}>
<span aria-hidden="true">-</span>
</button>
<span>{formatGoalTreeNumber(current)} / {formatGoalTreeNumber(target)}</span>
<button type="button" onClick={() => onUpdate((entry) => ({ ...entry, qtyCurrent: Math.min(target, (entry.qtyCurrent || 0) + 1) }))} aria-label={textContent.incrementItemTitle || "Ajouter"} title={textContent.incrementItemTitle || "Ajouter"}>
<Icon name="add" />
</button>
</span>
);
}
export function GoalTreeStepLeafControl({ node, textContent, onChange }) {
const target = Math.max(1, node.computedQtyTarget || node.qtyTarget || 1);
const current = Math.min(Math.max(0, node.qtyCurrent || 0), target);
if (target <= 1) {
return <input className="goal-tree-step-check" type="checkbox" checked={current >= 1} onChange={(event) => onChange(event.target.checked ? 1 : 0)} aria-label={textContent.itemCheckedLabel || "État"} />;
}
return (
<span className="goal-tree-step-qty-stepper goal-tree-content-qty-stepper">
<button type="button" onClick={() => onChange(Math.max(0, current - 1))} aria-label={textContent.decrementItemTitle || "Retirer"} title={textContent.decrementItemTitle || "Retirer"}>
<span aria-hidden="true">-</span>
</button>
<span>{formatGoalTreeNumber(current)} / {formatGoalTreeNumber(target)}</span>
<button type="button" onClick={() => onChange(Math.min(target, current + 1))} aria-label={textContent.incrementItemTitle || "Ajouter"} title={textContent.incrementItemTitle || "Ajouter"}>
<Icon name="add" />
</button>
</span>
);
}
export function GoalTreeNodeContentPreview({ node, editing, textContent, onAddContentItem, onUpdateItem }) {
const items = node.items || [];
return (
<span className="goal-tree-node-content-preview">
{items.map((item) => (
<span className="goal-tree-node-content-row" key={item.id}>
{node.kind === "checklist" && <GoalTreeChecklistInlineControl item={item} textContent={textContent} onUpdate={(updater) => onUpdateItem(item.id, updater)} />}
<Tooltip as="span" className="goal-tree-node-content-label" content={item.label} tooltipClassName="goal-tree-node-tooltip">
{item.label}
</Tooltip>
</span>
))}
{editing && (
<Tooltip
as="button"
className="goal-tree-node-content-add-button"
type="button"
onClick={onAddContentItem}
content={textContent.addContentItemTooltip || "Ajouter une entrée interne à cette carte."}
tooltipClassName="goal-tree-node-tooltip"
aria-label={`${textContent.addContentItemTitle || "Ajouter une entrée"} ${node.title}`}
>
<Icon name="add" />
</Tooltip>
)}
</span>
);
}

View file

@ -1,379 +1,13 @@
// Rôle : rend les sous-composants d'édition et de prévisualisation de l'arbre d'objectifs. // Rôle : conserve les exports publics historiques des sous-composants goal tree.
import { useState } from "react"; export { GoalTreeBranch } from "./GoalTreeBranch.jsx";
import { CompactDropdown } from "../../../components/CompactDropdown.jsx"; export {
import { Icon } from "../../../components/Icon.jsx"; GoalTreeContentEditor,
import { MixTagsInput } from "../../../components/MixTagsInput.jsx"; GoalTreeFormulaInput,
import { Tooltip } from "../../../components/Tooltip.jsx"; GoalTreeIconPicker,
import { GoalTreePrerequisitePicker,
GOAL_TREE_ICON_NAMES, GoalTreeTitleInput
formatGoalTreeNumber, } from "./GoalTreeEditorFields.jsx";
getGoalTreeChildren, export {
getGoalTreeDescendantIds, GoalTreeNodeContentPreview,
getGoalTreeParentIds GoalTreeStepLeafControl
} from "./goalTreeUtils.js"; } from "./GoalTreeNodeContent.jsx";
import {
BASE_TOKEN,
getEffectiveGoalTreeProgressMode,
getGoalTreeNodeIcon,
getGoalTreeNodeIconColor,
getGoalTreeNodeTooltip,
isSingleValueGoalTreeNode
} from "./goalTreeDisplay.js";
export function GoalTreePrerequisitePicker({ nodes, node, textContent, onToggle }) {
const parentIds = new Set(getGoalTreeParentIds(node));
const blockedIds = new Set([node.id, ...getGoalTreeDescendantIds(nodes, node.id)]);
const primaryParent = nodes.find((candidate) => candidate.id === node.parentId);
const options = nodes.filter((candidate) => !blockedIds.has(candidate.id) && candidate.id !== node.parentId);
if (!primaryParent && !options.length) return null;
return (
<div className="goal-tree-prerequisite-field">
<span className="goal-tree-form-section-title">{textContent.prerequisitesSectionTitle || "Prérequis supplémentaires"}</span>
<div className="goal-tree-prerequisite-list themed-scrollbar">
{primaryParent && (
<label className="goal-tree-prerequisite-item is-primary">
<input type="checkbox" checked disabled readOnly />
<span>{primaryParent.title}</span>
</label>
)}
{options.map((candidate) => (
<label className="goal-tree-prerequisite-item" key={candidate.id}>
<input type="checkbox" checked={parentIds.has(candidate.id)} onChange={() => onToggle(candidate.id)} />
<span>{candidate.title}</span>
</label>
))}
</div>
</div>
);
}
export function GoalTreeBranch({ nodes, parentId, selectedNodeId, editing, reorder, textContent, onSelect, onAdd, onAddContentItem, onSetStepLeafQuantity, onSetStepChildrenCompletion, onUpdateNodeItem, onEditNode, onToggleBranch, onDeleteNode }) {
const children = getGoalTreeChildren(nodes, parentId);
if (!children.length) return null;
return (
<ul className={`goal-tree-list ${parentId ? "is-child-list" : "is-root-list"}`} data-goal-tree-parent={parentId || "root"}>
{children.map((node) => {
const nestedChildren = getGoalTreeChildren(nodes, node.id);
const linkedChildren = nodes.filter((candidate) => candidate.parentId !== node.id && getGoalTreeParentIds(candidate).includes(node.id));
const branchCollapsed = node.collapsed;
const isSelected = selectedNodeId === node.id;
const isComplete = node.progress.complete;
const hasSingleValueChildren = nestedChildren.length > 0 && nestedChildren.every(isSingleValueGoalTreeNode);
const manualProgress = getEffectiveGoalTreeProgressMode(node, nestedChildren) === "manual";
const hasBranchToggle = nestedChildren.length > 0 || linkedChildren.length > 0;
const hasNodeActions = editing || hasBranchToggle;
const iconColor = getGoalTreeNodeIconColor(node);
const selectFromCard = (event) => {
if (!editing) return;
if (event.target.closest("button:not(.goal-tree-node-main)")) return;
onSelect(node.id);
};
const className = [
"goal-tree-node",
`is-${node.kind}`,
isSelected ? "is-selected" : "",
isComplete ? "is-complete" : "",
reorder.isItemDragging(node.id) ? "is-dragging" : "",
reorder.isItemDropTarget(node.id) ? "is-drop-target" : "",
reorder.getDropPlacement("item", node.id) === "after" ? "drop-after" : ""
].filter(Boolean).join(" ");
return (
<li key={node.id} className={className} {...reorder.getItemProps({ itemId: node.id, parentId })}>
<article className={`goal-tree-node-card ${editing ? "is-editing" : "is-viewing"} ${hasNodeActions ? "has-actions" : ""}`} data-diagram-node={node.id} onClick={selectFromCard}>
{!editing && (
<div className="goal-tree-node-hover-actions" aria-label={textContent.cardActionsLabel || "Actions de l'objectif"}>
<button type="button" onClick={() => onEditNode(node.id)} aria-label={`${textContent.editTitle || "Modifier"} ${node.title}`} title={textContent.editTitle || "Modifier"}>
<Icon name="edit" />
</button>
<button className="danger" type="button" onClick={() => onDeleteNode(node.id)} aria-label={`${textContent.deleteTitle || "Supprimer"} ${node.title}`} title={textContent.deleteTitle || "Supprimer"}>
<Icon name="trash" />
</button>
</div>
)}
{editing && (
<button
className="goal-tree-drag-handle"
type="button"
onPointerDown={(event) => reorder.itemReorder.startDrag(event, node.id)}
aria-label={`${textContent.reorderTitle || "Déplacer"} ${node.title}`}
title={textContent.reorderTitle || "Déplacer"}
>
<Icon name="drag" />
</button>
)}
<Tooltip as="div" className="goal-tree-node-main" content={node.kind === "step" ? getGoalTreeNodeTooltip(node) : ""} tooltipClassName="goal-tree-node-tooltip" aria-label={node.title}>
{node.kind === "step" ? (
<>
<span className="goal-tree-node-icon" aria-hidden="true" style={{ "--goal-tree-node-color": iconColor }}>
<Icon name={getGoalTreeNodeIcon(node)} />
</span>
{(manualProgress || !nestedChildren.length) && (
<GoalTreeStepLeafControl node={node} textContent={textContent} onChange={(quantity) => onSetStepLeafQuantity(node.id, quantity)} />
)}
{!manualProgress && hasSingleValueChildren && (
<GoalTreeStepLeafControl
node={{ ...node, computedQtyTarget: 1, qtyTarget: 1, qtyCurrent: node.progress.complete ? 1 : 0 }}
textContent={textContent}
onChange={(quantity) => onSetStepChildrenCompletion(node.id, quantity > 0)}
/>
)}
</>
) : (
<>
<span className="goal-tree-node-heading">
<span className="goal-tree-node-icon" aria-hidden="true" style={{ "--goal-tree-node-color": iconColor }}>
<Icon name={getGoalTreeNodeIcon(node)} />
</span>
<span className="goal-tree-node-title">{node.title}</span>
</span>
<GoalTreeNodeContentPreview
node={node}
editing={editing}
textContent={textContent}
onAddContentItem={() => onAddContentItem(node.id)}
onUpdateItem={(itemId, updater) => onUpdateNodeItem(node.id, itemId, updater)}
/>
</>
)}
<span className="goal-tree-progress-track" aria-hidden="true">
<span style={{ width: `${Math.round(node.progress.ratio * 100)}%` }} />
</span>
</Tooltip>
{hasNodeActions && (
<div className="goal-tree-node-actions">
{editing && (
<button type="button" onClick={() => onAdd(node.id)} aria-label={`${textContent.addChildTitle || "Ajouter une sous-étape"} ${node.title}`} title={textContent.addChildTitle || "Ajouter une sous-étape"}>
<Icon name="add" />
</button>
)}
{hasBranchToggle && (
<button
className="goal-tree-branch-toggle"
type="button"
onClick={() => onToggleBranch(node.id)}
aria-expanded={!branchCollapsed}
aria-label={branchCollapsed ? textContent.showChildrenTitle || "Afficher la branche" : textContent.hideChildrenTitle || "Réduire la branche"}
title={branchCollapsed ? textContent.showChildrenTitle || "Afficher la branche" : textContent.hideChildrenTitle || "Réduire la branche"}
>
<Icon name={branchCollapsed ? "chevron-down" : "chevron-up"} />
</button>
)}
{editing && (
<button className="danger" type="button" onClick={() => onDeleteNode(node.id)} aria-label={`${textContent.deleteTitle || "Supprimer"} ${node.title}`} title={textContent.deleteTitle || "Supprimer"}>
<Icon name="trash" />
</button>
)}
</div>
)}
</article>
{!node.collapsed && nestedChildren.length > 0 && (
<GoalTreeBranch
nodes={nodes}
parentId={node.id}
selectedNodeId={selectedNodeId}
editing={editing}
reorder={reorder}
textContent={textContent}
onSelect={onSelect}
onAdd={onAdd}
onAddContentItem={onAddContentItem}
onSetStepLeafQuantity={onSetStepLeafQuantity}
onSetStepChildrenCompletion={onSetStepChildrenCompletion}
onUpdateNodeItem={onUpdateNodeItem}
onEditNode={onEditNode}
onToggleBranch={onToggleBranch}
onDeleteNode={onDeleteNode}
/>
)}
</li>
);
})}
</ul>
);
}
export function GoalTreeTitleInput({ value, onChange }) {
const [draft, setDraft] = useState(value || "");
return (
<input
value={draft}
onChange={(event) => {
const title = event.target.value;
setDraft(title);
onChange(title);
}}
/>
);
}
export function GoalTreeFormulaInput({ value, onChange, hasParent, textContent, placeholder, title, className = "", "aria-label": ariaLabel }) {
const normalizedValue = String(value ?? "");
if (!hasParent) {
return <input aria-label={ariaLabel} className={className} inputMode="decimal" onChange={(event) => onChange(event.target.value.replaceAll(",", "."))} placeholder={placeholder} title={title} value={normalizedValue} />;
}
return (
<MixTagsInput
aria-label={ariaLabel}
className={className}
formulaSpacing
inputMode="decimal"
insertToken={{
value: BASE_TOKEN,
label: textContent.baseTokenLabel || "Valeur du parent",
icon: "parent",
title: textContent.insertBaseTitle || "Ajouter la valeur du parent"
}}
onChange={(nextValue) => onChange(nextValue.replaceAll(",", "."))}
placeholder={placeholder}
title={title}
tokens={[{
value: BASE_TOKEN,
label: textContent.baseTokenLabel || "Valeur du parent"
}]}
value={normalizedValue}
/>
);
}
export function GoalTreeContentEditor({ node, textContent, onAdd, onUpdate, onDelete }) {
return (
<div className="goal-tree-content-editor">
<div className="goal-tree-form-section-title">{textContent.contentSectionTitle || "Contenu"}</div>
{(node.items || []).map((item) => (
<div className={`goal-tree-content-editor-row is-${node.kind}`} key={item.id}>
<input className="goal-tree-content-label-input" value={item.label} placeholder={textContent.itemLabelPlaceholder || "Élément"} onChange={(event) => onUpdate(item.id, (current) => ({ ...current, label: event.target.value }))} aria-label={textContent.itemLabelLabel || "Libellé"} />
{node.kind === "checklist" && (
<GoalTreeFormulaInput
aria-label={textContent.itemTotalLabel || "Total"}
className="goal-tree-content-formula-input"
hasParent={Boolean(node.parentId)}
onChange={(value) => onUpdate(item.id, (current) => ({ ...current, formula: value.replaceAll(",", ".") }))}
value={item.formula || "1"}
placeholder={textContent.itemTotalPlaceholder || "base"}
textContent={textContent}
title={textContent.itemTotalHelp || "Saisissez un nombre ou une formule avec base pour référencer la cible du parent."}
/>
)}
<button className="danger" type="button" onClick={() => onDelete(item.id)} aria-label={textContent.deleteTitle || "Supprimer"} title={textContent.deleteTitle || "Supprimer"}>
<Icon name="trash" />
</button>
</div>
))}
<button className="goal-tree-content-add-button" type="button" onClick={onAdd}>
<Icon name="add" />
<span>{textContent.addContentItemButton || "Ajouter une ligne"}</span>
</button>
</div>
);
}
function GoalTreeChecklistInlineControl({ item, textContent, onUpdate }) {
const target = Math.max(1, item.qtyTarget || 1);
const current = Math.min(Math.max(0, item.qtyCurrent || 0), target);
if (target <= 1) {
return <input className="goal-tree-node-check" type="checkbox" checked={current >= 1} onChange={(event) => onUpdate((entry) => ({ ...entry, qtyCurrent: event.target.checked ? 1 : 0 }))} aria-label={textContent.itemCheckedLabel || "État"} />;
}
return (
<span className="goal-tree-content-qty-stepper">
<button type="button" onClick={() => onUpdate((entry) => ({ ...entry, qtyCurrent: Math.max(0, (entry.qtyCurrent || 0) - 1) }))} aria-label={textContent.decrementItemTitle || "Retirer"} title={textContent.decrementItemTitle || "Retirer"}>
<span aria-hidden="true">-</span>
</button>
<span>{formatGoalTreeNumber(current)} / {formatGoalTreeNumber(target)}</span>
<button type="button" onClick={() => onUpdate((entry) => ({ ...entry, qtyCurrent: Math.min(target, (entry.qtyCurrent || 0) + 1) }))} aria-label={textContent.incrementItemTitle || "Ajouter"} title={textContent.incrementItemTitle || "Ajouter"}>
<Icon name="add" />
</button>
</span>
);
}
function GoalTreeStepLeafControl({ node, textContent, onChange }) {
const target = Math.max(1, node.computedQtyTarget || node.qtyTarget || 1);
const current = Math.min(Math.max(0, node.qtyCurrent || 0), target);
if (target <= 1) {
return <input className="goal-tree-step-check" type="checkbox" checked={current >= 1} onChange={(event) => onChange(event.target.checked ? 1 : 0)} aria-label={textContent.itemCheckedLabel || "État"} />;
}
return (
<span className="goal-tree-step-qty-stepper goal-tree-content-qty-stepper">
<button type="button" onClick={() => onChange(Math.max(0, current - 1))} aria-label={textContent.decrementItemTitle || "Retirer"} title={textContent.decrementItemTitle || "Retirer"}>
<span aria-hidden="true">-</span>
</button>
<span>{formatGoalTreeNumber(current)} / {formatGoalTreeNumber(target)}</span>
<button type="button" onClick={() => onChange(Math.min(target, current + 1))} aria-label={textContent.incrementItemTitle || "Ajouter"} title={textContent.incrementItemTitle || "Ajouter"}>
<Icon name="add" />
</button>
</span>
);
}
function GoalTreeNodeContentPreview({ node, editing, textContent, onAddContentItem, onUpdateItem }) {
const items = node.items || [];
return (
<span className="goal-tree-node-content-preview">
{items.map((item) => (
<span className="goal-tree-node-content-row" key={item.id}>
{node.kind === "checklist" && <GoalTreeChecklistInlineControl item={item} textContent={textContent} onUpdate={(updater) => onUpdateItem(item.id, updater)} />}
<Tooltip as="span" className="goal-tree-node-content-label" content={item.label} tooltipClassName="goal-tree-node-tooltip">
{item.label}
</Tooltip>
</span>
))}
{editing && (
<Tooltip
as="button"
className="goal-tree-node-content-add-button"
type="button"
onClick={onAddContentItem}
content={textContent.addContentItemTooltip || "Ajouter une entrée interne à cette carte."}
tooltipClassName="goal-tree-node-tooltip"
aria-label={`${textContent.addContentItemTitle || "Ajouter une entrée"} ${node.title}`}
>
<Icon name="add" />
</Tooltip>
)}
</span>
);
}
export function GoalTreeIconPicker({ label, value, textContent, onChange }) {
return (
<div className="goal-tree-icon-field">
<span>{label}</span>
<CompactDropdown
className="goal-tree-icon-picker"
menuClassName="goal-tree-icon-picker-menu"
label={label}
renderTrigger={({ open, toggle }) => (
<button className="goal-tree-icon-picker-button" type="button" onClick={toggle} aria-label={label} aria-expanded={open} title={label}>
<span className="goal-tree-icon-preview" aria-hidden="true">
<Icon name={value} />
</span>
<span>{textContent[`icon${value}`] || value}</span>
<Icon name="dropdown" />
</button>
)}
>
{({ close }) => (
<>
{GOAL_TREE_ICON_NAMES.map((icon) => (
<button
key={icon}
className={icon === value ? "active" : ""}
type="button"
onClick={() => {
onChange(icon);
close();
}}
aria-label={textContent[`icon${icon}`] || icon}
aria-pressed={icon === value}
title={textContent[`icon${icon}`] || icon}
>
<Icon name={icon} />
</button>
))}
</>
)}
</CompactDropdown>
</div>
);
}

View file

@ -2,7 +2,7 @@
import { useLayoutEffect, useMemo, useRef, useState } from "react"; import { useLayoutEffect, useMemo, useRef, useState } from "react";
import { DiagramConnectors } from "../../../components/DiagramConnectors.jsx"; import { DiagramConnectors } from "../../../components/DiagramConnectors.jsx";
import { Icon } from "../../../components/Icon.jsx"; import { Icon } from "../../../components/Icon.jsx";
import { GoalTreeBranch } from "./GoalTreeParts.jsx"; import { GoalTreeBranch } from "./GoalTreeBranch.jsx";
import { getVisibleGoalTreeEdges } from "./goalTreeDisplay.js"; import { getVisibleGoalTreeEdges } from "./goalTreeDisplay.js";
export function GoalTreeView({ export function GoalTreeView({

View file

@ -0,0 +1,199 @@
// Rôle : rend les branches récursives, catégories et zones de drop du task planner.
import { Icon } from "../../../components/Icon.jsx";
import { TaskPlannerItem } from "./TaskPlannerItem.jsx";
import {
countCompletedTaskTree,
countTaskTree,
getTaskTypeLabel,
getTasksForParent,
getTaskCategoryGroups,
isTaskTreeComplete
} from "./taskPlannerUtils.js";
export function TaskPlannerBranch({
parentId,
parentMap,
tasks,
data,
nowMs,
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 ? (
<TaskPlannerBranch
parentId={task.id}
parentMap={parentMap}
tasks={tasks}
data={data}
nowMs={nowMs}
reorder={reorder}
textContent={textContent}
openDescriptions={openDescriptions}
openTaskSettings={openTaskSettings}
prerequisiteNoticeTaskIds={prerequisiteNoticeTaskIds}
onToggleDescription={onToggleDescription}
onToggleSettings={onToggleSettings}
onPrerequisiteNotice={onPrerequisiteNotice}
onUpdateTask={onUpdateTask}
onDeleteTask={onDeleteTask}
onAddLinkTarget={onAddLinkTarget}
onRemoveLinkTarget={onRemoveLinkTarget}
onSetPrerequisite={onSetPrerequisite}
onSetTaskCategory={onSetTaskCategory}
onToggleCategoryCollapsed={onToggleCategoryCollapsed}
visited={nextVisited}
/>
) : null;
if (data.hideCompleted && task.checked) {
if (!hasVisibleDescendants(task.id)) return null;
return (
<li className="task-planner-hidden-parent" key={`${task.id}:visible-children`}>
<div className="task-planner-hidden-parent-line">
<input
type="checkbox"
checked={task.checked}
onChange={(event) => 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}`}
/>
<span title={task.title}>{task.title}</span>
<small>{getTaskTypeLabel(task.type, textContent)}</small>
</div>
<ul className="task-planner-list task-planner-child-list">{nestedBranch}</ul>
</li>
);
}
return (
<TaskPlannerItem
key={task.id}
task={task}
tasks={tasks}
data={data}
nowMs={nowMs}
parentMap={parentMap}
parentId={parentId}
reorder={reorder}
textContent={textContent}
descriptionOpen={openDescriptions.has(task.id)}
settingsOpen={openTaskSettings.has(task.id)}
prerequisiteNoticeVisible={prerequisiteNoticeTaskIds.has(task.id)}
onToggleDescription={() => onToggleDescription(task.id)}
onToggleSettings={() => onToggleSettings(task.id)}
onPrerequisiteNotice={onPrerequisiteNotice}
onUpdateTask={onUpdateTask}
onDeleteTask={onDeleteTask}
onAddLinkTarget={onAddLinkTarget}
onRemoveLinkTarget={onRemoveLinkTarget}
onSetPrerequisite={onSetPrerequisite}
onSetTaskCategory={onSetTaskCategory}
>
{nestedBranch}
</TaskPlannerItem>
);
}
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 ? (
<li
className={`task-planner-category-boundary-drop-zone ${reorder.isBoundaryDropTarget({ groupId: group.category, parentId, placement: "before" }) ? "is-drop-target" : ""}`}
key={`${parentId || "root"}:${group.category}:before-drop`}
{...reorder.getGroupBoundaryProps({ groupId: group.category, parentId, placement: "before" })}
/>
) : null;
const afterDropZone = showCategoryBoundaryDropZones ? (
<li
className={`task-planner-category-boundary-drop-zone ${reorder.isBoundaryDropTarget({ groupId: group.category, parentId, placement: "after" }) ? "is-drop-target" : ""}`}
key={`${parentId || "root"}:${group.category}:after-drop`}
{...reorder.getGroupBoundaryProps({ groupId: group.category, parentId, placement: "after" })}
/>
) : null;
return [
beforeDropZone,
<li className={sectionClassName} key={`${parentId || "root"}:${group.category}`} {...reorder.getGroupProps({ groupId: group.category, parentId })}>
<div className="checklist-section-header task-planner-category-header">
<div className="task-planner-category-title">
<button
className="task-planner-category-drag-handle"
type="button"
onPointerDown={(event) => reorder.groupReorder.startDrag(event, { groupId: group.category, parentId })}
aria-label={`${textContent.categoryReorderTitle || "Déplacer la catégorie"} ${group.category}`}
title={textContent.categoryReorderTitle || "Déplacer la catégorie"}
>
<Icon name="drag" />
</button>
<h3>{group.category}</h3>
</div>
<div className="task-planner-category-actions">
<span>{groupCompletedCount} / {groupCount}</span>
<button
className="checklist-section-collapse-button"
type="button"
onClick={() => onToggleCategoryCollapsed(group.category)}
aria-expanded={!isCollapsed}
title={isCollapsed ? textContent.showCategoryTitle || "Afficher cette catégorie" : textContent.hideCategoryTitle || "Réduire cette catégorie"}
aria-label={isCollapsed ? textContent.showCategoryTitle || "Afficher cette catégorie" : textContent.hideCategoryTitle || "Réduire cette catégorie"}
>
<Icon name={isCollapsed ? "chevron-down" : "chevron-up"} />
</button>
</div>
</div>
{!isCollapsed && (
<ul className="task-planner-list">
{groupTasks.map(renderTask).filter(Boolean)}
</ul>
)}
</li>,
afterDropZone
];
}
return groupedTasks.entries.map((entry) => {
if (entry.type === "task") return visited.has(entry.task.id) ? null : renderTask(entry.task);
return renderCategory(entry.group);
}).filter(Boolean);
}

View file

@ -0,0 +1,152 @@
// Rôle : rend une ligne de tâche task planner et ses panneaux associés.
import { Icon } from "../../../components/Icon.jsx";
import { useInlineEdit } from "../../../hooks/useInlineEdit.js";
import { MissingPrerequisitesTooltip, TaskPlannerItemDetails } from "./TaskPlannerItemPanels.jsx";
import {
TASK_TYPES,
getTaskCategoryDataset,
getTaskTypeLabel,
isTaskResetSoon
} from "./taskPlannerUtils.js";
export function TaskPlannerItem({
task,
tasks,
data,
nowMs,
parentMap,
parentId,
reorder,
textContent,
descriptionOpen,
settingsOpen,
prerequisiteNoticeVisible,
onToggleDescription,
onToggleSettings,
onPrerequisiteNotice,
onUpdateTask,
onDeleteTask,
onAddLinkTarget,
onRemoveLinkTarget,
onSetPrerequisite,
onSetTaskCategory,
children
}) {
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" : "",
isTaskResetSoon(task, data, nowMs) ? "is-reset-soon" : "",
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 }))
});
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 (
<li className={className} {...reorder.getItemProps({ itemId: task.id, groupId: getTaskCategoryDataset(task, tasks, parentMap), parentId })}>
<div className="task-planner-line">
<button
className="task-planner-drag-handle"
type="button"
onPointerDown={(event) => reorder.itemReorder.startDrag(event, task.id)}
aria-label={`${textContent.reorderTitle || "Déplacer"} ${task.title}`}
title={textContent.reorderTitle || "Déplacer"}
>
<Icon name="drag" />
</button>
<input
type="checkbox"
checked={task.checked}
onChange={updateChecked}
aria-label={`${task.checked ? textContent.uncheckTitle || "Marquer non effectué" : textContent.checkTitle || "Marquer effectué"} ${task.title}`}
/>
<input
{...titleEdit.getInputProps({
className: "tool-split-entry-label task-planner-title-input",
"aria-label": textContent.titleLabel || "Titre"
})}
/>
<select
className="task-planner-type-select"
value={task.type}
onChange={(event) => onUpdateTask(task.id, (current) => {
const nextType = event.target.value;
const nextTask = { ...current, type: nextType };
if (nextType !== "weekly") delete nextTask.weeklyResetDay;
if (nextType !== "daily") delete nextTask.dailyResetTime;
return nextTask;
})}
aria-label={textContent.typeLabel || "Type"}
>
{TASK_TYPES.map((taskType) => <option key={taskType} value={taskType}>{getTaskTypeLabel(taskType, textContent)}</option>)}
</select>
{missingPrerequisites.length > 0 && (
<MissingPrerequisitesTooltip missingPrerequisites={missingPrerequisites} tasks={tasks} textContent={textContent} />
)}
<div className="task-planner-actions">
<button
className={`task-planner-dropdown-button ${descriptionOpen ? "active" : ""}`}
type="button"
onClick={onToggleDescription}
aria-expanded={descriptionOpen}
aria-label={`${descriptionOpen ? textContent.hideDescriptionTitle || "Masquer la description" : textContent.showDescriptionTitle || "Afficher la description"} ${task.title}`}
title={descriptionOpen ? textContent.hideDescriptionTitle || "Masquer la description" : textContent.showDescriptionTitle || "Afficher la description"}
>
<Icon name={descriptionOpen ? "chevron-up" : "chevron-down"} />
</button>
<button
className={`task-planner-settings-button ${settingsOpen ? "active" : ""}`}
type="button"
onClick={onToggleSettings}
aria-expanded={settingsOpen}
aria-label={`${textContent.taskSettingsTitle || "Réglages de la tâche"} ${task.title}`}
title={textContent.taskSettingsTitle || "Réglages de la tâche"}
>
<Icon name="settings" />
</button>
<button className="task-planner-delete-button danger" type="button" onClick={() => onDeleteTask(task.id)} aria-label={`${textContent.deleteTitle || "Supprimer"} ${task.title}`} title={textContent.deleteTitle || "Supprimer"}>
<Icon name="trash" />
</button>
</div>
</div>
<TaskPlannerItemDetails
task={task}
tasks={tasks}
data={data}
parentMap={parentMap}
parentId={parentId}
textContent={textContent}
descriptionOpen={descriptionOpen}
settingsOpen={settingsOpen}
noticeMessage={noticeMessage}
relations={relations}
relationTargets={relationTargets}
onUpdateTask={onUpdateTask}
onAddLinkTarget={onAddLinkTarget}
onRemoveLinkTarget={onRemoveLinkTarget}
onSetPrerequisite={onSetPrerequisite}
onSetTaskCategory={onSetTaskCategory}
/>
{children && <ul className="task-planner-list task-planner-child-list">{children}</ul>}
</li>
);
}

View file

@ -1,346 +1,3 @@
// Rôle : rend les branches et lignes de tâches du task planner. // Rôle : conserve les exports publics historiques des sous-composants task planner.
import { Icon } from "../../../components/Icon.jsx"; export { TaskPlannerBranch } from "./TaskPlannerBranch.jsx";
import { useInlineEdit } from "../../../hooks/useInlineEdit.js"; export { TaskPlannerItem } from "./TaskPlannerItem.jsx";
import { MissingPrerequisitesTooltip, TaskPlannerItemDetails } from "./TaskPlannerItemPanels.jsx";
import {
TASK_TYPES,
countCompletedTaskTree,
countTaskTree,
getTaskCategoryDataset,
getTaskCategoryGroups,
getTaskTypeLabel,
getTasksForParent,
isTaskResetSoon,
isTaskTreeComplete
} from "./taskPlannerUtils.js";
export function TaskPlannerBranch({
parentId,
parentMap,
tasks,
data,
nowMs,
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 ? (
<TaskPlannerBranch
parentId={task.id}
parentMap={parentMap}
tasks={tasks}
data={data}
nowMs={nowMs}
reorder={reorder}
textContent={textContent}
openDescriptions={openDescriptions}
openTaskSettings={openTaskSettings}
prerequisiteNoticeTaskIds={prerequisiteNoticeTaskIds}
onToggleDescription={onToggleDescription}
onToggleSettings={onToggleSettings}
onPrerequisiteNotice={onPrerequisiteNotice}
onUpdateTask={onUpdateTask}
onDeleteTask={onDeleteTask}
onAddLinkTarget={onAddLinkTarget}
onRemoveLinkTarget={onRemoveLinkTarget}
onSetPrerequisite={onSetPrerequisite}
onSetTaskCategory={onSetTaskCategory}
onToggleCategoryCollapsed={onToggleCategoryCollapsed}
visited={nextVisited}
/>
) : null;
if (data.hideCompleted && task.checked) {
if (!hasVisibleDescendants(task.id)) return null;
return (
<li className="task-planner-hidden-parent" key={`${task.id}:visible-children`}>
<div className="task-planner-hidden-parent-line">
<input
type="checkbox"
checked={task.checked}
onChange={(event) => 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}`}
/>
<span title={task.title}>{task.title}</span>
<small>{getTaskTypeLabel(task.type, textContent)}</small>
</div>
<ul className="task-planner-list task-planner-child-list">{nestedBranch}</ul>
</li>
);
}
return (
<TaskPlannerItem
key={task.id}
task={task}
tasks={tasks}
data={data}
nowMs={nowMs}
parentMap={parentMap}
parentId={parentId}
reorder={reorder}
textContent={textContent}
descriptionOpen={openDescriptions.has(task.id)}
settingsOpen={openTaskSettings.has(task.id)}
prerequisiteNoticeVisible={prerequisiteNoticeTaskIds.has(task.id)}
onToggleDescription={() => onToggleDescription(task.id)}
onToggleSettings={() => onToggleSettings(task.id)}
onPrerequisiteNotice={onPrerequisiteNotice}
onUpdateTask={onUpdateTask}
onDeleteTask={onDeleteTask}
onAddLinkTarget={onAddLinkTarget}
onRemoveLinkTarget={onRemoveLinkTarget}
onSetPrerequisite={onSetPrerequisite}
onSetTaskCategory={onSetTaskCategory}
>
{nestedBranch}
</TaskPlannerItem>
);
}
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 ? (
<li
className={`task-planner-category-boundary-drop-zone ${reorder.isBoundaryDropTarget({ groupId: group.category, parentId, placement: "before" }) ? "is-drop-target" : ""}`}
key={`${parentId || "root"}:${group.category}:before-drop`}
{...reorder.getGroupBoundaryProps({ groupId: group.category, parentId, placement: "before" })}
/>
) : null;
const afterDropZone = showCategoryBoundaryDropZones ? (
<li
className={`task-planner-category-boundary-drop-zone ${reorder.isBoundaryDropTarget({ groupId: group.category, parentId, placement: "after" }) ? "is-drop-target" : ""}`}
key={`${parentId || "root"}:${group.category}:after-drop`}
{...reorder.getGroupBoundaryProps({ groupId: group.category, parentId, placement: "after" })}
/>
) : null;
return [
beforeDropZone,
<li className={sectionClassName} key={`${parentId || "root"}:${group.category}`} {...reorder.getGroupProps({ groupId: group.category, parentId })}>
<div className="checklist-section-header task-planner-category-header">
<div className="task-planner-category-title">
<button
className="task-planner-category-drag-handle"
type="button"
onPointerDown={(event) => reorder.groupReorder.startDrag(event, { groupId: group.category, parentId })}
aria-label={`${textContent.categoryReorderTitle || "Déplacer la catégorie"} ${group.category}`}
title={textContent.categoryReorderTitle || "Déplacer la catégorie"}
>
<Icon name="drag" />
</button>
<h3>{group.category}</h3>
</div>
<div className="task-planner-category-actions">
<span>{groupCompletedCount} / {groupCount}</span>
<button
className="checklist-section-collapse-button"
type="button"
onClick={() => onToggleCategoryCollapsed(group.category)}
aria-expanded={!isCollapsed}
title={isCollapsed ? textContent.showCategoryTitle || "Afficher cette catégorie" : textContent.hideCategoryTitle || "Réduire cette catégorie"}
aria-label={isCollapsed ? textContent.showCategoryTitle || "Afficher cette catégorie" : textContent.hideCategoryTitle || "Réduire cette catégorie"}
>
<Icon name={isCollapsed ? "chevron-down" : "chevron-up"} />
</button>
</div>
</div>
{!isCollapsed && (
<ul className="task-planner-list">
{groupTasks.map(renderTask).filter(Boolean)}
</ul>
)}
</li>,
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,
nowMs,
parentMap,
parentId,
reorder,
textContent,
descriptionOpen,
settingsOpen,
prerequisiteNoticeVisible,
onToggleDescription,
onToggleSettings,
onPrerequisiteNotice,
onUpdateTask,
onDeleteTask,
onAddLinkTarget,
onRemoveLinkTarget,
onSetPrerequisite,
onSetTaskCategory,
children
}) {
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" : "",
isTaskResetSoon(task, data, nowMs) ? "is-reset-soon" : "",
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 }))
});
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 (
<li className={className} {...reorder.getItemProps({ itemId: task.id, groupId: getTaskCategoryDataset(task, tasks, parentMap), parentId })}>
<div className="task-planner-line">
<button
className="task-planner-drag-handle"
type="button"
onPointerDown={(event) => reorder.itemReorder.startDrag(event, task.id)}
aria-label={`${textContent.reorderTitle || "Déplacer"} ${task.title}`}
title={textContent.reorderTitle || "Déplacer"}
>
<Icon name="drag" />
</button>
<input
type="checkbox"
checked={task.checked}
onChange={updateChecked}
aria-label={`${task.checked ? textContent.uncheckTitle || "Marquer non effectué" : textContent.checkTitle || "Marquer effectué"} ${task.title}`}
/>
<input
{...titleEdit.getInputProps({
className: "tool-split-entry-label task-planner-title-input",
"aria-label": textContent.titleLabel || "Titre"
})}
/>
<select
className="task-planner-type-select"
value={task.type}
onChange={(event) => onUpdateTask(task.id, (current) => {
const nextType = event.target.value;
const nextTask = { ...current, type: nextType };
if (nextType !== "weekly") delete nextTask.weeklyResetDay;
if (nextType !== "daily") delete nextTask.dailyResetTime;
return nextTask;
})}
aria-label={textContent.typeLabel || "Type"}
>
{TASK_TYPES.map((taskType) => <option key={taskType} value={taskType}>{getTaskTypeLabel(taskType, textContent)}</option>)}
</select>
{missingPrerequisites.length > 0 && (
<MissingPrerequisitesTooltip missingPrerequisites={missingPrerequisites} tasks={tasks} textContent={textContent} />
)}
<div className="task-planner-actions">
<button
className={`task-planner-dropdown-button ${descriptionOpen ? "active" : ""}`}
type="button"
onClick={onToggleDescription}
aria-expanded={descriptionOpen}
aria-label={`${descriptionOpen ? textContent.hideDescriptionTitle || "Masquer la description" : textContent.showDescriptionTitle || "Afficher la description"} ${task.title}`}
title={descriptionOpen ? textContent.hideDescriptionTitle || "Masquer la description" : textContent.showDescriptionTitle || "Afficher la description"}
>
<Icon name={descriptionOpen ? "chevron-up" : "chevron-down"} />
</button>
<button
className={`task-planner-settings-button ${settingsOpen ? "active" : ""}`}
type="button"
onClick={onToggleSettings}
aria-expanded={settingsOpen}
aria-label={`${textContent.taskSettingsTitle || "Réglages de la tâche"} ${task.title}`}
title={textContent.taskSettingsTitle || "Réglages de la tâche"}
>
<Icon name="settings" />
</button>
<button className="task-planner-delete-button danger" type="button" onClick={() => onDeleteTask(task.id)} aria-label={`${textContent.deleteTitle || "Supprimer"} ${task.title}`} title={textContent.deleteTitle || "Supprimer"}>
<Icon name="trash" />
</button>
</div>
</div>
<TaskPlannerItemDetails
task={task}
tasks={tasks}
data={data}
parentMap={parentMap}
parentId={parentId}
textContent={textContent}
descriptionOpen={descriptionOpen}
settingsOpen={settingsOpen}
noticeMessage={noticeMessage}
relations={relations}
relationTargets={relationTargets}
onUpdateTask={onUpdateTask}
onAddLinkTarget={onAddLinkTarget}
onRemoveLinkTarget={onRemoveLinkTarget}
onSetPrerequisite={onSetPrerequisite}
onSetTaskCategory={onSetTaskCategory}
/>
{children && <ul className="task-planner-list task-planner-child-list">{children}</ul>}
</li>
);
}

View file

@ -1,7 +1,7 @@
// Rôle : fournit l'outil task planner avec tâches récurrentes, parents, pré requis et échange texte. // 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 { useEffect, useMemo, useRef, useState } from "react";
import { useDraftForm } from "../../../hooks/useDraftForm.js"; import { useDraftForm } from "../../../hooks/useDraftForm.js";
import { TaskPlannerBranch } from "./TaskPlannerItems.jsx"; import { TaskPlannerBranch } from "./TaskPlannerBranch.jsx";
import { TaskPlannerSettings } from "./TaskPlannerSettings.jsx"; import { TaskPlannerSettings } from "./TaskPlannerSettings.jsx";
import { TaskPlannerTopbar } from "./TaskPlannerTopbar.jsx"; import { TaskPlannerTopbar } from "./TaskPlannerTopbar.jsx";
import { import {

View file

@ -0,0 +1,196 @@
// Rôle : rend le formulaire et la liste des comptes à rebours timer.
import { useState } from "react";
import { Icon } from "../../../components/Icon.jsx";
import { formatTargetTime } from "./timerUtils.js";
import {
AUTO_REFRESH_COUNTDOWN_TYPES,
TOGGLE_REFRESH_COUNTDOWN_TYPES,
canToggleAutoRefresh,
getAlertGuardMessage,
getCountdownMetaParts,
handleEditableKeyDown,
isAlertGuarded
} from "./timerOptions.js";
import { EditableTimerLabel, TimerAlertModeSwitch, TimerValue, TimePartsInput } from "./TimerSharedControls.jsx";
export function CountdownControls({ textContent, form, error, onChange, onSubmit }) {
return (
<form className="timer-control-panel timer-countdown-form" role="tabpanel" onSubmit={onSubmit} aria-describedby={error ? "timer-countdown-error" : undefined}>
<label>
<span>{textContent.countdownLabel || "Libellé"}</span>
<input value={form.label} onChange={(event) => onChange({ label: event.target.value })} placeholder={textContent.countdownLabelPlaceholder || "Boss, event, craft..."} />
</label>
<label>
<span>{textContent.countdownTypeLabel || "Type"}</span>
<select value={form.type} onChange={(event) => onChange({ type: event.target.value })}>
<option value="duration">{textContent.durationType || "Durée"}</option>
<option value="daily_time">{textContent.dailyTimeType || "Heure précise"}</option>
<option value="time_pattern">{textContent.timePatternType || "Pattern horaire"}</option>
<option value="interval">{textContent.intervalType || "Intervalle"}</option>
</select>
</label>
{form.type === "duration" && (
<label>
<span className="timer-field-spacer" aria-hidden="true" />
<span className="sr-only">{textContent.durationLabel || "Durée"}</span>
<TimePartsInput value={form.duration} onChange={(duration) => onChange({ duration })} />
</label>
)}
{form.type === "daily_time" && (
<label>
<span className="timer-field-spacer" aria-hidden="true" />
<span className="sr-only">{textContent.dailyTimeLabel || "Heure"}</span>
<TimePartsInput value={form.time} onChange={(time) => onChange({ time })} />
</label>
)}
{form.type === "time_pattern" && (
<label>
<span className="timer-field-spacer" aria-hidden="true" />
<span className="sr-only">{textContent.timePatternLabel || "Heure / minute / seconde"}</span>
<TimePartsInput value={form.pattern} onChange={(pattern) => onChange({ pattern })} allowWildcard />
</label>
)}
{form.type === "interval" && (
<div className="timer-interval-fields">
<label>
<span>{textContent.intervalLabel || "Toutes les"}</span>
<TimePartsInput value={form.interval} onChange={(interval) => onChange({ interval })} />
</label>
<label>
<span>{textContent.intervalStartTimeLabel || "Départ"}</span>
<TimePartsInput value={form.intervalStartTime} onChange={(intervalStartTime) => onChange({ intervalStartTime })} />
</label>
</div>
)}
{error && (
<p id="timer-countdown-error" className="timer-form-error" role="alert">
<Icon name="close" />
<span>{error}</span>
</p>
)}
<button className="primary" type="submit">{textContent.addCountdownButton || "Ajouter"}</button>
</form>
);
}
export function CountdownList({ countdowns, nowMs, textContent, reorder, reorderEnabled, parentId, onRename, onAlertModeChange, onReset, onAutoRefreshToggle, onDelete }) {
const [editingId, setEditingId] = useState("");
if (!countdowns.length) return <p className="muted">{textContent.emptyCountdowns || "Aucun compte à rebours configuré."}</p>;
return (
<ul className="tool-split-entry-list timer-list">
{countdowns.map((countdown) => {
const remainingMs = countdown.targetMs - nowMs;
const expired = remainingMs <= 0;
const canReset = countdown.type === "duration";
const canToggleRefresh = TOGGLE_REFRESH_COUNTDOWN_TYPES.has(countdown.type);
const refreshBlocked = canToggleRefresh && !canToggleAutoRefresh(countdown);
const refreshTitle = refreshBlocked
? getAlertGuardMessage(textContent)
: textContent.autoRefreshTitle || "Répéter automatiquement";
const className = [
"timer-list-item",
expired ? "is-expired" : "",
editingId === countdown.id ? "is-editing" : "",
reorderEnabled && reorder.isItemDragging(countdown.id) ? "is-dragging" : "",
reorderEnabled && reorder.isItemDropTarget(countdown.id) ? "is-drop-target" : "",
reorderEnabled && reorder.getDropPlacement("item", countdown.id) === "after" ? "drop-after" : ""
].filter(Boolean).join(" ");
const reorderProps = reorderEnabled ? reorder.getItemProps({ itemId: countdown.id, parentId }) : {};
return (
<li key={countdown.id} className={className} {...reorderProps}>
<div className={`tool-split-entry timer-entry has-single-action ${reorderEnabled ? "has-drag-handle" : ""} ${editingId !== countdown.id ? "has-inline-controls" : ""} ${(canReset || canToggleRefresh) && editingId !== countdown.id ? "has-inline-reset" : ""}`}>
{reorderEnabled && (
<button
className="timer-drag-handle"
type="button"
onPointerDown={(event) => reorder.itemReorder.startDrag(event, countdown.id)}
aria-label={`${textContent.reorderTitle || "Déplacer"} ${countdown.label}`}
title={textContent.reorderTitle || "Déplacer"}
>
<Icon name="drag" />
</button>
)}
{editingId === countdown.id ? (
<EditableTimerLabel
value={countdown.label}
placeholder={textContent.defaultCountdownLabel || "Timer"}
ariaLabel={textContent.countdownLabel || "Libellé"}
onRename={(label) => onRename(countdown.id, label)}
onDone={() => setEditingId("")}
>
<TimerValue ms={remainingMs} showCentiseconds={false} />
</EditableTimerLabel>
) : (
<span
className="tool-split-entry-summary timer-entry-summary"
role="button"
tabIndex={0}
onClick={() => setEditingId(countdown.id)}
onKeyDown={(event) => handleEditableKeyDown(event, () => setEditingId(countdown.id))}
>
<span className="timer-entry-line">
<TimerValue ms={remainingMs} showCentiseconds={false} />
<span>{countdown.label}</span>
<span className="timer-inline-controls">
{canReset && (
<button
className="timer-inline-reset"
type="button"
onClick={(event) => {
event.stopPropagation();
onReset(countdown);
}}
aria-label={textContent.resetTitle || "Réinitialiser"}
title={textContent.resetTitle || "Réinitialiser"}
>
<Icon name="refresh" />
</button>
)}
{canToggleRefresh && (
<button
className={`timer-inline-refresh-toggle ${countdown.autoRefresh ? "active" : ""} ${refreshBlocked ? "is-blocked" : ""}`}
type="button"
onClick={(event) => {
event.stopPropagation();
onAutoRefreshToggle(countdown.id);
}}
disabled={refreshBlocked}
aria-pressed={Boolean(countdown.autoRefresh)}
aria-label={refreshTitle}
title={refreshTitle}
>
<Icon name="refresh" />
<i aria-hidden="true" />
</button>
)}
<TimerAlertModeSwitch
mode={countdown.alertMode}
blocked={AUTO_REFRESH_COUNTDOWN_TYPES.has(countdown.type) && isAlertGuarded(countdown)}
textContent={textContent}
onChange={(alertMode) => onAlertModeChange(countdown.id, alertMode)}
/>
</span>
</span>
</span>
)}
<button className="danger" type="button" onClick={() => onDelete(countdown.id)} aria-label={textContent.deleteTitle || "Supprimer"} title={textContent.deleteTitle || "Supprimer"}>
<Icon name="trash" />
</button>
</div>
<p className="timer-entry-meta">
<span>
{getCountdownMetaParts(countdown, textContent).map((part, index) => (
<b key={`${part}-${index}`}>{part}</b>
))}
</span>
<em>
<b>{expired ? textContent.finishedLabel || "Terminé" : `${textContent.targetLabel || "Prochaine occurrence"} ${formatTargetTime(countdown.targetMs)}`}</b>
</em>
</p>
</li>
);
})}
</ul>
);
}

View file

@ -14,7 +14,8 @@ import {
timePartsToString timePartsToString
} from "./timerUtils.js"; } from "./timerUtils.js";
import { TextExchangeActions } from "./TextExchangeActions.jsx"; import { TextExchangeActions } from "./TextExchangeActions.jsx";
import { CountdownControls, CountdownList, StopwatchControls, StopwatchLapList } from "./TimerParts.jsx"; import { CountdownControls, CountdownList } from "./TimerCountdown.jsx";
import { StopwatchControls, StopwatchLapList } from "./TimerStopwatch.jsx";
import { import {
ALERT_MIN_DELAY_MS, ALERT_MIN_DELAY_MS,
AUTO_REFRESH_COUNTDOWN_TYPES, AUTO_REFRESH_COUNTDOWN_TYPES,

View file

@ -1,350 +1,4 @@
// Rôle : rend les contrôles, valeurs et listes de résultats du module Timer. // Rôle : conserve les exports publics historiques des sous-composants timer.
import { useState } from "react"; export { CountdownControls, CountdownList } from "./TimerCountdown.jsx";
import { Icon } from "../../../components/Icon.jsx"; export { EditableTimerLabel, TimerAlertModeSwitch, TimerValue, TimePartsInput } from "./TimerSharedControls.jsx";
import { useInlineEdit } from "../../../hooks/useInlineEdit.js"; export { StopwatchControls, StopwatchLapList } from "./TimerStopwatch.jsx";
import { formatDuration, formatDurationWithCentiseconds, formatTargetTime, getCentiseconds } from "./timerUtils.js";
import {
ALERT_MODES,
AUTO_REFRESH_COUNTDOWN_TYPES,
TOGGLE_REFRESH_COUNTDOWN_TYPES,
canToggleAutoRefresh,
getAlertGuardMessage,
getCountdownMetaParts,
handleEditableKeyDown,
isAlertGuarded
} from "./timerOptions.js";
export function StopwatchControls({ textContent, elapsedMs, running, onStart, onPause, onReset, onLap }) {
return (
<div className="timer-control-panel" role="tabpanel">
<span>{textContent.stopwatchTitle || "Chronomètre à étapes"}</span>
<strong><TimerValue ms={elapsedMs} compact /></strong>
<div className="timer-actions">
<button className="primary timer-action-button" type="button" onClick={running ? onPause : onStart} aria-label={running ? textContent.pauseButton || "Pause" : textContent.startButton || "Lancer"} title={running ? textContent.pauseButton || "Pause" : textContent.startButton || "Lancer"}>
<Icon name={running ? "pause-circle" : "play-circle"} />
</button>
<button className="timer-action-button" type="button" onClick={onLap} disabled={!elapsedMs} aria-label={textContent.lapButton || "Étape"} title={textContent.lapButton || "Étape"}>
<Icon name="record-circle" />
</button>
<button className="danger timer-action-button" type="button" onClick={onReset} aria-label={textContent.resetTitle || "Réinitialiser"} title={textContent.resetTitle || "Réinitialiser"}>
<Icon name="stop-circle" />
</button>
</div>
</div>
);
}
export function CountdownControls({ textContent, form, error, onChange, onSubmit }) {
return (
<form className="timer-control-panel timer-countdown-form" role="tabpanel" onSubmit={onSubmit} aria-describedby={error ? "timer-countdown-error" : undefined}>
<label>
<span>{textContent.countdownLabel || "Libellé"}</span>
<input value={form.label} onChange={(event) => onChange({ label: event.target.value })} placeholder={textContent.countdownLabelPlaceholder || "Boss, event, craft..."} />
</label>
<label>
<span>{textContent.countdownTypeLabel || "Type"}</span>
<select value={form.type} onChange={(event) => onChange({ type: event.target.value })}>
<option value="duration">{textContent.durationType || "Durée"}</option>
<option value="daily_time">{textContent.dailyTimeType || "Heure précise"}</option>
<option value="time_pattern">{textContent.timePatternType || "Pattern horaire"}</option>
<option value="interval">{textContent.intervalType || "Intervalle"}</option>
</select>
</label>
{form.type === "duration" && (
<label>
<span className="timer-field-spacer" aria-hidden="true" />
<span className="sr-only">{textContent.durationLabel || "Durée"}</span>
<TimePartsInput value={form.duration} onChange={(duration) => onChange({ duration })} />
</label>
)}
{form.type === "daily_time" && (
<label>
<span className="timer-field-spacer" aria-hidden="true" />
<span className="sr-only">{textContent.dailyTimeLabel || "Heure"}</span>
<TimePartsInput value={form.time} onChange={(time) => onChange({ time })} />
</label>
)}
{form.type === "time_pattern" && (
<label>
<span className="timer-field-spacer" aria-hidden="true" />
<span className="sr-only">{textContent.timePatternLabel || "Heure / minute / seconde"}</span>
<TimePartsInput value={form.pattern} onChange={(pattern) => onChange({ pattern })} allowWildcard />
</label>
)}
{form.type === "interval" && (
<div className="timer-interval-fields">
<label>
<span>{textContent.intervalLabel || "Toutes les"}</span>
<TimePartsInput value={form.interval} onChange={(interval) => onChange({ interval })} />
</label>
<label>
<span>{textContent.intervalStartTimeLabel || "Départ"}</span>
<TimePartsInput value={form.intervalStartTime} onChange={(intervalStartTime) => onChange({ intervalStartTime })} />
</label>
</div>
)}
{error && (
<p id="timer-countdown-error" className="timer-form-error" role="alert">
<Icon name="close" />
<span>{error}</span>
</p>
)}
<button className="primary" type="submit">{textContent.addCountdownButton || "Ajouter"}</button>
</form>
);
}
export function StopwatchLapList({ laps, textContent, onRename, onDelete }) {
const [editingId, setEditingId] = useState("");
if (!laps.length) return <p className="muted">{textContent.emptySteps || "Aucune étape enregistrée."}</p>;
return (
<ul className="tool-split-entry-list timer-list">
{laps.map((lap, index) => (
<li key={lap.id} className={`timer-list-item ${editingId === lap.id ? "is-editing" : ""}`}>
<div className="tool-split-entry timer-entry has-single-action">
{editingId === lap.id ? (
<EditableTimerLabel
value={lap.label}
placeholder={`${textContent.stepLabel || "Étape"} ${laps.length - index}`}
ariaLabel={textContent.lapLabelAriaLabel || "Libellé de l'étape"}
onRename={(label) => onRename(lap.id, label)}
onDone={() => setEditingId("")}
>
<TimerValue ms={lap.elapsedMs} />
</EditableTimerLabel>
) : (
<span
className="tool-split-entry-summary timer-entry-summary"
role="button"
tabIndex={0}
onClick={() => setEditingId(lap.id)}
onKeyDown={(event) => handleEditableKeyDown(event, () => setEditingId(lap.id))}
>
<span className="timer-entry-line">
<TimerValue ms={lap.elapsedMs} />
<span>{lap.label || `${textContent.stepLabel || "Étape"} ${laps.length - index}`}</span>
</span>
</span>
)}
<button className="danger" type="button" onClick={() => onDelete(lap.id)} aria-label={textContent.deleteTitle || "Supprimer"} title={textContent.deleteTitle || "Supprimer"}>
<Icon name="trash" />
</button>
</div>
</li>
))}
</ul>
);
}
export function CountdownList({ countdowns, nowMs, textContent, reorder, reorderEnabled, parentId, onRename, onAlertModeChange, onReset, onAutoRefreshToggle, onDelete }) {
const [editingId, setEditingId] = useState("");
if (!countdowns.length) return <p className="muted">{textContent.emptyCountdowns || "Aucun compte à rebours configuré."}</p>;
return (
<ul className="tool-split-entry-list timer-list">
{countdowns.map((countdown) => {
const remainingMs = countdown.targetMs - nowMs;
const expired = remainingMs <= 0;
const canReset = countdown.type === "duration";
const canToggleRefresh = TOGGLE_REFRESH_COUNTDOWN_TYPES.has(countdown.type);
const refreshBlocked = canToggleRefresh && !canToggleAutoRefresh(countdown);
const refreshTitle = refreshBlocked
? getAlertGuardMessage(textContent)
: textContent.autoRefreshTitle || "Répéter automatiquement";
const className = [
"timer-list-item",
expired ? "is-expired" : "",
editingId === countdown.id ? "is-editing" : "",
reorderEnabled && reorder.isItemDragging(countdown.id) ? "is-dragging" : "",
reorderEnabled && reorder.isItemDropTarget(countdown.id) ? "is-drop-target" : "",
reorderEnabled && reorder.getDropPlacement("item", countdown.id) === "after" ? "drop-after" : ""
].filter(Boolean).join(" ");
const reorderProps = reorderEnabled ? reorder.getItemProps({ itemId: countdown.id, parentId }) : {};
return (
<li key={countdown.id} className={className} {...reorderProps}>
<div className={`tool-split-entry timer-entry has-single-action ${reorderEnabled ? "has-drag-handle" : ""} ${editingId !== countdown.id ? "has-inline-controls" : ""} ${(canReset || canToggleRefresh) && editingId !== countdown.id ? "has-inline-reset" : ""}`}>
{reorderEnabled && (
<button
className="timer-drag-handle"
type="button"
onPointerDown={(event) => reorder.itemReorder.startDrag(event, countdown.id)}
aria-label={`${textContent.reorderTitle || "Déplacer"} ${countdown.label}`}
title={textContent.reorderTitle || "Déplacer"}
>
<Icon name="drag" />
</button>
)}
{editingId === countdown.id ? (
<EditableTimerLabel
value={countdown.label}
placeholder={textContent.defaultCountdownLabel || "Timer"}
ariaLabel={textContent.countdownLabel || "Libellé"}
onRename={(label) => onRename(countdown.id, label)}
onDone={() => setEditingId("")}
>
<TimerValue ms={remainingMs} showCentiseconds={false} />
</EditableTimerLabel>
) : (
<span
className="tool-split-entry-summary timer-entry-summary"
role="button"
tabIndex={0}
onClick={() => setEditingId(countdown.id)}
onKeyDown={(event) => handleEditableKeyDown(event, () => setEditingId(countdown.id))}
>
<span className="timer-entry-line">
<TimerValue ms={remainingMs} showCentiseconds={false} />
<span>{countdown.label}</span>
<span className="timer-inline-controls">
{canReset && (
<button
className="timer-inline-reset"
type="button"
onClick={(event) => {
event.stopPropagation();
onReset(countdown);
}}
aria-label={textContent.resetTitle || "Réinitialiser"}
title={textContent.resetTitle || "Réinitialiser"}
>
<Icon name="refresh" />
</button>
)}
{canToggleRefresh && (
<button
className={`timer-inline-refresh-toggle ${countdown.autoRefresh ? "active" : ""} ${refreshBlocked ? "is-blocked" : ""}`}
type="button"
onClick={(event) => {
event.stopPropagation();
onAutoRefreshToggle(countdown.id);
}}
disabled={refreshBlocked}
aria-pressed={Boolean(countdown.autoRefresh)}
aria-label={refreshTitle}
title={refreshTitle}
>
<Icon name="refresh" />
<i aria-hidden="true" />
</button>
)}
<TimerAlertModeSwitch
mode={countdown.alertMode}
blocked={AUTO_REFRESH_COUNTDOWN_TYPES.has(countdown.type) && isAlertGuarded(countdown)}
textContent={textContent}
onChange={(alertMode) => onAlertModeChange(countdown.id, alertMode)}
/>
</span>
</span>
</span>
)}
<button className="danger" type="button" onClick={() => onDelete(countdown.id)} aria-label={textContent.deleteTitle || "Supprimer"} title={textContent.deleteTitle || "Supprimer"}>
<Icon name="trash" />
</button>
</div>
<p className="timer-entry-meta">
<span>
{getCountdownMetaParts(countdown, textContent).map((part, index) => (
<b key={`${part}-${index}`}>{part}</b>
))}
</span>
<em>
<b>{expired ? textContent.finishedLabel || "Terminé" : `${textContent.targetLabel || "Prochaine occurrence"} ${formatTargetTime(countdown.targetMs)}`}</b>
</em>
</p>
</li>
);
})}
</ul>
);
}
function TimerValue({ ms, compact = false, showCentiseconds = true }) {
if (!showCentiseconds) return <span className="timer-value">{formatDuration(ms)}</span>;
if (!compact) return <span className="timer-value">{formatDurationWithCentiseconds(ms)}</span>;
return (
<span className="timer-value is-compact">
<span>{formatDuration(ms)}</span>
<small>{getCentiseconds(ms)}</small>
</span>
);
}
function TimerAlertModeSwitch({ mode, blocked, textContent, onChange }) {
const labels = {
off: textContent.alertOffTitle || "Pas d'alerte",
visible: textContent.alertVisibleTitle || "Alerte quand la toolbox est affichée",
site: textContent.alertSiteTitle || "Alerte sur tout le site"
};
const blockedTitle = getAlertGuardMessage(textContent);
return (
<div
className={`timer-alert-mode-switch ${blocked ? "is-blocked" : ""}`}
role="group"
aria-label={textContent.alertModeTitle || "Mode d'alerte"}
title={blocked ? blockedTitle : undefined}
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => event.stopPropagation()}
>
{ALERT_MODES.map((option) => {
const label = labels[option.mode];
const disabled = Boolean(blocked);
return (
<button
key={option.mode}
className={`timer-alert-mode-button ${!blocked && mode === option.mode ? "active" : ""}`}
type="button"
onClick={() => onChange(option.mode)}
disabled={disabled}
aria-pressed={mode === option.mode}
aria-label={disabled ? blockedTitle : label}
title={disabled ? blockedTitle : label}
>
<Icon name={option.icon} />
</button>
);
})}
</div>
);
}
function TimePartsInput({ value, onChange, allowWildcard = false }) {
function updatePart(part, nextValue) {
const cleanValue = allowWildcard && /^x+$/i.test(nextValue.trim()) ? "X" : nextValue.replace(/[^\d]/g, "").slice(0, 2);
onChange({ ...value, [part]: cleanValue });
}
return (
<div className="time-parts-input" role="group" aria-label="Format heure minute seconde">
<input value={value.hours} onChange={(event) => updatePart("hours", event.target.value)} placeholder="hh" inputMode={allowWildcard ? "text" : "numeric"} />
<span>:</span>
<input value={value.minutes} onChange={(event) => updatePart("minutes", event.target.value)} placeholder="mm" inputMode={allowWildcard ? "text" : "numeric"} />
<span>:</span>
<input value={value.seconds} onChange={(event) => updatePart("seconds", event.target.value)} placeholder="ss" inputMode={allowWildcard ? "text" : "numeric"} />
</div>
);
}
function EditableTimerLabel({ value, placeholder, ariaLabel, children, onRename, onDone }) {
const inlineEdit = useInlineEdit({
value,
onCommit: onRename,
onDone,
autoFocus: true,
commitUnchanged: true
});
return (
<>
<span className="tool-split-entry-value timer-entry-value is-readonly">{children}</span>
<input
{...inlineEdit.getInputProps({
className: "tool-split-entry-label timer-lap-label",
placeholder,
"aria-label": ariaLabel
})}
/>
</>
);
}

View file

@ -0,0 +1,96 @@
// Rôle : regroupe les contrôles partagés entre chronomètre et comptes à rebours.
import { Icon } from "../../../components/Icon.jsx";
import { useInlineEdit } from "../../../hooks/useInlineEdit.js";
import { formatDuration, formatDurationWithCentiseconds, getCentiseconds } from "./timerUtils.js";
import { ALERT_MODES, getAlertGuardMessage } from "./timerOptions.js";
export function TimerValue({ ms, compact = false, showCentiseconds = true }) {
if (!showCentiseconds) return <span className="timer-value">{formatDuration(ms)}</span>;
if (!compact) return <span className="timer-value">{formatDurationWithCentiseconds(ms)}</span>;
return (
<span className="timer-value is-compact">
<span>{formatDuration(ms)}</span>
<small>{getCentiseconds(ms)}</small>
</span>
);
}
export function TimerAlertModeSwitch({ mode, blocked, textContent, onChange }) {
const labels = {
off: textContent.alertOffTitle || "Pas d'alerte",
visible: textContent.alertVisibleTitle || "Alerte quand la toolbox est affichée",
site: textContent.alertSiteTitle || "Alerte sur tout le site"
};
const blockedTitle = getAlertGuardMessage(textContent);
return (
<div
className={`timer-alert-mode-switch ${blocked ? "is-blocked" : ""}`}
role="group"
aria-label={textContent.alertModeTitle || "Mode d'alerte"}
title={blocked ? blockedTitle : undefined}
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => event.stopPropagation()}
>
{ALERT_MODES.map((option) => {
const label = labels[option.mode];
const disabled = Boolean(blocked);
return (
<button
key={option.mode}
className={`timer-alert-mode-button ${!blocked && mode === option.mode ? "active" : ""}`}
type="button"
onClick={() => onChange(option.mode)}
disabled={disabled}
aria-pressed={mode === option.mode}
aria-label={disabled ? blockedTitle : label}
title={disabled ? blockedTitle : label}
>
<Icon name={option.icon} />
</button>
);
})}
</div>
);
}
export function TimePartsInput({ value, onChange, allowWildcard = false }) {
function updatePart(part, nextValue) {
const cleanValue = allowWildcard && /^x+$/i.test(nextValue.trim()) ? "X" : nextValue.replace(/[^\d]/g, "").slice(0, 2);
onChange({ ...value, [part]: cleanValue });
}
return (
<div className="time-parts-input" role="group" aria-label="Format heure minute seconde">
<input value={value.hours} onChange={(event) => updatePart("hours", event.target.value)} placeholder="hh" inputMode={allowWildcard ? "text" : "numeric"} />
<span>:</span>
<input value={value.minutes} onChange={(event) => updatePart("minutes", event.target.value)} placeholder="mm" inputMode={allowWildcard ? "text" : "numeric"} />
<span>:</span>
<input value={value.seconds} onChange={(event) => updatePart("seconds", event.target.value)} placeholder="ss" inputMode={allowWildcard ? "text" : "numeric"} />
</div>
);
}
export function EditableTimerLabel({ value, placeholder, ariaLabel, children, onRename, onDone }) {
const inlineEdit = useInlineEdit({
value,
onCommit: onRename,
onDone,
autoFocus: true,
commitUnchanged: true
});
return (
<>
<span className="tool-split-entry-value timer-entry-value is-readonly">{children}</span>
<input
{...inlineEdit.getInputProps({
className: "tool-split-entry-label timer-lap-label",
placeholder,
"aria-label": ariaLabel
})}
/>
</>
);
}

View file

@ -0,0 +1,67 @@
// Rôle : rend les contrôles et la liste d'étapes du chronomètre timer.
import { useState } from "react";
import { Icon } from "../../../components/Icon.jsx";
import { handleEditableKeyDown } from "./timerOptions.js";
import { EditableTimerLabel, TimerValue } from "./TimerSharedControls.jsx";
export function StopwatchControls({ textContent, elapsedMs, running, onStart, onPause, onReset, onLap }) {
return (
<div className="timer-control-panel" role="tabpanel">
<span>{textContent.stopwatchTitle || "Chronomètre à étapes"}</span>
<strong><TimerValue ms={elapsedMs} compact /></strong>
<div className="timer-actions">
<button className="primary timer-action-button" type="button" onClick={running ? onPause : onStart} aria-label={running ? textContent.pauseButton || "Pause" : textContent.startButton || "Lancer"} title={running ? textContent.pauseButton || "Pause" : textContent.startButton || "Lancer"}>
<Icon name={running ? "pause-circle" : "play-circle"} />
</button>
<button className="timer-action-button" type="button" onClick={onLap} disabled={!elapsedMs} aria-label={textContent.lapButton || "Étape"} title={textContent.lapButton || "Étape"}>
<Icon name="record-circle" />
</button>
<button className="danger timer-action-button" type="button" onClick={onReset} aria-label={textContent.resetTitle || "Réinitialiser"} title={textContent.resetTitle || "Réinitialiser"}>
<Icon name="stop-circle" />
</button>
</div>
</div>
);
}
export function StopwatchLapList({ laps, textContent, onRename, onDelete }) {
const [editingId, setEditingId] = useState("");
if (!laps.length) return <p className="muted">{textContent.emptySteps || "Aucune étape enregistrée."}</p>;
return (
<ul className="tool-split-entry-list timer-list">
{laps.map((lap, index) => (
<li key={lap.id} className={`timer-list-item ${editingId === lap.id ? "is-editing" : ""}`}>
<div className="tool-split-entry timer-entry has-single-action">
{editingId === lap.id ? (
<EditableTimerLabel
value={lap.label}
placeholder={`${textContent.stepLabel || "Étape"} ${laps.length - index}`}
ariaLabel={textContent.lapLabelAriaLabel || "Libellé de l'étape"}
onRename={(label) => onRename(lap.id, label)}
onDone={() => setEditingId("")}
>
<TimerValue ms={lap.elapsedMs} />
</EditableTimerLabel>
) : (
<span
className="tool-split-entry-summary timer-entry-summary"
role="button"
tabIndex={0}
onClick={() => setEditingId(lap.id)}
onKeyDown={(event) => handleEditableKeyDown(event, () => setEditingId(lap.id))}
>
<span className="timer-entry-line">
<TimerValue ms={lap.elapsedMs} />
<span>{lap.label || `${textContent.stepLabel || "Étape"} ${laps.length - index}`}</span>
</span>
</span>
)}
<button className="danger" type="button" onClick={() => onDelete(lap.id)} aria-label={textContent.deleteTitle || "Supprimer"} title={textContent.deleteTitle || "Supprimer"}>
<Icon name="trash" />
</button>
</div>
</li>
))}
</ul>
);
}

View file

@ -0,0 +1,295 @@
// Rôle : rend les modules d'une toolbox, leur shell commun et leur réorganisation.
import { Suspense, useMemo, useState } from "react";
import { Icon } from "../../../components/Icon.jsx";
import { useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
import { useInlineEdit } from "../../../hooks/useInlineEdit.js";
import { supportsTextExchange } from "./textImport.js";
import { LAZY_MODULE_TYPES, MODULE_COMPONENTS } from "./toolboxModuleComponents.jsx";
const MODULE_COLUMN_IDS = ["left", "right"];
function getModuleMap(modules) {
return new Map(modules.map((module) => [module.id, module]));
}
function getModulesByIds(modules, ids) {
const moduleMap = getModuleMap(modules);
return (Array.isArray(ids) ? ids : []).map((id) => moduleMap.get(id)).filter(Boolean);
}
function getModuleColumnId(moduleId, twoColumnOrder) {
return twoColumnOrder?.[1]?.includes(moduleId) ? MODULE_COLUMN_IDS[1] : MODULE_COLUMN_IDS[0];
}
function moveId(ids, sourceId, targetId, placement = "before") {
const nextIds = ids.filter((id) => id !== sourceId);
const targetIndex = nextIds.indexOf(targetId);
if (targetIndex < 0) return ids;
nextIds.splice(placement === "after" ? targetIndex + 1 : targetIndex, 0, sourceId);
return nextIds;
}
function moveModuleInOneColumnOrder(order, operation) {
if (!operation.sourceId || !operation.targetId || operation.sourceId === operation.targetId) return order;
return {
...order,
one: moveId(order.one, operation.sourceId, operation.targetId, operation.placement)
};
}
function moveModuleInTwoColumnOrder(order, operation) {
const columns = [
[...(order.two?.[0] || [])],
[...(order.two?.[1] || [])]
];
const sourceColumnIndex = columns.findIndex((column) => column.includes(operation.sourceId));
if (sourceColumnIndex < 0) return order;
const targetColumnIndex = operation.targetType === "item"
? columns.findIndex((column) => column.includes(operation.targetId))
: MODULE_COLUMN_IDS.indexOf(operation.targetGroup);
if (targetColumnIndex < 0) return order;
const [movedId] = columns[sourceColumnIndex].splice(columns[sourceColumnIndex].indexOf(operation.sourceId), 1);
if (operation.targetType === "item") {
const targetIndex = columns[targetColumnIndex].indexOf(operation.targetId);
if (targetIndex < 0) return order;
columns[targetColumnIndex].splice(operation.placement === "after" ? targetIndex + 1 : targetIndex, 0, movedId);
} else {
columns[targetColumnIndex].splice(operation.placement === "after" ? columns[targetColumnIndex].length : 0, 0, movedId);
}
return { ...order, two: columns };
}
export function ToolboxModules({ toolbox, moduleColumns, context, onRename, onUpdateModule, onDelete, onModuleOrderChange }) {
const twoColumnOrder = useMemo(() => toolbox.moduleOrder?.two || [[], []], [toolbox.moduleOrder?.two]);
const orderedModules = useMemo(() => getModulesByIds(toolbox.modules, toolbox.moduleOrder?.one || []), [toolbox.modules, toolbox.moduleOrder?.one]);
const columns = useMemo(() => [
getModulesByIds(toolbox.modules, twoColumnOrder[0]),
getModulesByIds(toolbox.modules, twoColumnOrder[1])
], [toolbox.modules, twoColumnOrder]);
const {
itemReorder,
getItemProps,
getGroupBoundaryProps,
isItemDragging,
isItemDropTarget,
isBoundaryDropTarget,
getDropPlacement
} = useGroupedReorder({
namespace: "toolbox-modules",
items: toolbox.modules,
getItemId: (module) => module.id,
getItemGroup: (module) => moduleColumns === 2 ? getModuleColumnId(module.id, twoColumnOrder) : "",
getEffectiveGroup: (module) => moduleColumns === 2 ? getModuleColumnId(module.id, twoColumnOrder) : "",
getParentId: () => toolbox.id,
reorderFeatures: {
item: {
groupChange: moduleColumns === 2,
boundaryDrop: moduleColumns === 2
}
},
onItemMove: (operation) => {
const nextOrder = moduleColumns === 2
? moveModuleInTwoColumnOrder(toolbox.moduleOrder, operation)
: moveModuleInOneColumnOrder(toolbox.moduleOrder, operation);
onModuleOrderChange(nextOrder);
},
hierarchy: { enabled: true, stickyParents: true }
});
if (moduleColumns === 1) {
return (
<section className="modules" data-toolbox-id={toolbox.id} data-layout-cols={moduleColumns}>
{orderedModules.map((module) => (
<ModuleShell
key={module.id}
toolbox={toolbox}
module={module}
context={context}
reorder={{ itemReorder, getItemProps, isItemDragging, isItemDropTarget, getDropPlacement }}
itemGroup=""
onRename={onRename}
onUpdateModule={onUpdateModule}
onDelete={onDelete}
/>
))}
</section>
);
}
const hasEmptyColumn = toolbox.modules.length > 1 && columns.some((modules) => modules.length === 0);
return (
<section className="modules" data-toolbox-id={toolbox.id} data-layout-cols={moduleColumns}>
{columns.map((modules, index) => (
<div className={`module-column ${hasEmptyColumn && !modules.length ? "is-empty-warning" : ""}`} key={MODULE_COLUMN_IDS[index]}>
<div
className={`module-column-boundary-drop-zone ${isBoundaryDropTarget({ groupId: MODULE_COLUMN_IDS[index], parentId: toolbox.id, placement: "before" }) ? "is-drop-target" : ""}`}
{...getGroupBoundaryProps({ groupId: MODULE_COLUMN_IDS[index], parentId: toolbox.id, placement: "before" })}
/>
{modules.map((module) => (
<ModuleShell
key={module.id}
toolbox={toolbox}
module={module}
context={context}
reorder={{ itemReorder, getItemProps, isItemDragging, isItemDropTarget, getDropPlacement }}
itemGroup={MODULE_COLUMN_IDS[index]}
onRename={onRename}
onUpdateModule={onUpdateModule}
onDelete={onDelete}
/>
))}
<div
className={`module-column-boundary-drop-zone ${isBoundaryDropTarget({ groupId: MODULE_COLUMN_IDS[index], parentId: toolbox.id, placement: "after" }) ? "is-drop-target" : ""}`}
{...getGroupBoundaryProps({ groupId: MODULE_COLUMN_IDS[index], parentId: toolbox.id, placement: "after" })}
/>
</div>
))}
</section>
);
}
function ModuleShell({ toolbox, module, context, reorder, itemGroup = "", onRename, onUpdateModule, onDelete }) {
const definition = MODULE_COMPONENTS[module.type] || MODULE_COMPONENTS.notepad;
const Component = definition.Component;
const label = definition.label || module.type;
const [editing, setEditing] = useState(false);
const [textExchangeOpen, setTextExchangeOpen] = useState(false);
const hasAddPanel = definition.editable && definition.addable !== false;
const hasTextExchange = supportsTextExchange(module.type);
const scrollable = definition.scrollable && module.scrollable === true;
const className = [
"module",
reorder.isItemDragging(module.id) ? "is-dragging" : "",
reorder.isItemDropTarget(module.id) ? "is-drop-target" : "",
reorder.getDropPlacement("item", module.id) === "after" ? "drop-after" : "",
scrollable ? "is-scrollable" : ""
].filter(Boolean).join(" ");
const componentProps = {
toolboxId: toolbox.id,
moduleId: module.id,
context,
editing,
setEditing,
openEditor: () => {
setTextExchangeOpen(false);
setEditing(true);
},
textExchangeOpen
};
return (
<article
className={className}
data-module-type={module.type}
{...reorder.getItemProps({ itemId: module.id, groupId: itemGroup, parentId: toolbox.id })}
>
<header>
<div>
<button
className="module-drag-handle"
type="button"
onPointerDown={(event) => reorder.itemReorder.startDrag(event, module.id)}
aria-label={`Déplacer ${module.title || label}`}
title="Déplacer"
>
<Icon name="drag" />
</button>
<span className="module-icon" aria-hidden="true">
<span className={`module-icon-svg module-icon-${definition.icon || "notepad"}`} />
</span>
<EditableModuleTitle value={module.title || label} fallback={label} tutorialTarget={module.type === "notepad" ? "moduleTitle" : ""} onSave={(title) => onRename(module.id, title)} />
</div>
<div>
{definition.scrollable && (
<button
className={`module-scroll-button ${scrollable ? "active" : ""}`}
onClick={() => onUpdateModule(module.id, (currentModule) => {
const nextModule = { ...currentModule };
if (scrollable) {
delete nextModule.scrollable;
} else {
nextModule.scrollable = true;
}
return nextModule;
})}
aria-label={`${scrollable ? "Désactiver" : "Activer"} le scroll de ${module.title || label}`}
aria-pressed={scrollable}
title={scrollable ? "Désactiver le scroll" : "Activer le scroll"}
>
<Icon name="scrollable" />
<i aria-hidden="true" />
</button>
)}
{hasAddPanel && (
<button
className={`module-edit-button ${editing ? "active" : ""}`}
onClick={() => setEditing((value) => {
const nextEditing = !value;
if (nextEditing) setTextExchangeOpen(false);
return nextEditing;
})}
aria-label={`${editing ? "Masquer" : "Afficher"} l'ajout de ${module.title || label}`}
aria-pressed={editing}
title={editing ? "Masquer l'ajout" : "Ajouter"}
>
<Icon name="add" />
</button>
)}
{hasTextExchange && (
<button
className={`module-exchange-button ${textExchangeOpen ? "active" : ""}`}
onClick={() => setTextExchangeOpen((value) => {
const nextOpen = !value;
if (nextOpen) setEditing(false);
return nextOpen;
})}
aria-label={`${textExchangeOpen ? "Masquer" : "Afficher"} l'import/export de ${module.title || label}`}
aria-pressed={textExchangeOpen}
title={textExchangeOpen ? "Masquer import/export" : "Import / export"}
>
<Icon name="import" />
</button>
)}
<button className="module-delete-button danger" onClick={() => onDelete(module.id)} aria-label={`Retirer ${module.title || label}`} data-toolbox-tutorial-target={module.type === "links" ? "deleteTool" : undefined} title="Retirer">
<span className="ui-icon ui-icon-trash" aria-hidden="true" />
</button>
</div>
</header>
<div className={`module-content ${scrollable ? "is-scrollable legacy-scrollbar" : ""}`}>
{LAZY_MODULE_TYPES.has(module.type) ? (
<Suspense fallback={<ModuleLoadingFallback label={label} />}>
<Component {...componentProps} />
</Suspense>
) : (
<Component {...componentProps} />
)}
</div>
</article>
);
}
function ModuleLoadingFallback({ label }) {
return <div className="module-loading-fallback">Chargement de {label}...</div>;
}
function EditableModuleTitle({ value, fallback, onSave, tutorialTarget = "" }) {
const inlineEdit = useInlineEdit({
value,
onCommit: (title) => onSave(title || fallback),
blurOnEscape: true
});
return (
<h2
{...inlineEdit.getContentEditableProps({
className: "module-title",
"data-toolbox-tutorial-target": tutorialTarget || undefined,
title: "Cliquer pour renommer"
})}
>
{value}
</h2>
);
}

View file

@ -0,0 +1,110 @@
// Rôle : construit les événements calendrier depuis les drafts, occurrences et actions de collage.
import {
CALENDAR_ALERT_MODES,
CALENDAR_COLORS,
WEEK_SLOT_MINUTES,
addDays,
formatLocalDateKey,
getLocalDayIndex,
minutesToTimeValue,
parseLocalDateKey,
timeValueToMinutes
} from "./calendarUtils.js";
import {
getCalendarRepeatMode,
getDateSpanDays,
normalizeDraftAlertOffsets,
normalizeDraftDateKey,
normalizeDraftMonthlyRepeatBy
} from "./calendarDraft.js";
export function buildPastedCalendarEvent({ copiedEvent, target, data, draft, context }) {
if (!copiedEvent) return null;
const startMinutes = data.viewMode === "month" ? 0 : target.slotIndex >= 0 ? target.slotIndex * WEEK_SLOT_MINUTES : timeValueToMinutes(draft.startTime);
const durationMinutes = copiedEvent.endMinutes && copiedEvent.endMinutes > copiedEvent.startMinutes ? copiedEvent.endMinutes - copiedEvent.startMinutes : 0;
const nextEvent = {
...copiedEvent,
id: context.uid("calendar"),
startMinutes,
repeatable: copiedEvent.repeatable !== false,
repeat: copiedEvent.repeatable === false ? "none" : getCalendarRepeatMode(data.viewMode)
};
if (data.viewMode === "month") {
const copiedDaySpan = getDateSpanDays(copiedEvent.startDate, copiedEvent.endDate || copiedEvent.startDate);
nextEvent.startDate = target.startDate;
nextEvent.endDate = formatLocalDateKey(addDays(parseLocalDateKey(target.startDate), copiedDaySpan));
nextEvent.dayIndex = getLocalDayIndex(parseLocalDateKey(target.startDate));
nextEvent.monthlyRepeatBy = target.monthlyRepeatBy || copiedEvent.monthlyRepeatBy || "monthDay";
nextEvent.repeat = copiedEvent.repeat || "monthly";
nextEvent.excludedOccurrences = [];
delete nextEvent.monthDay;
} else {
nextEvent.dayIndex = target.dayIndex;
delete nextEvent.monthDay;
delete nextEvent.monthlyRepeatBy;
}
delete nextEvent.endMinutes;
if (data.viewMode === "week" && durationMinutes > 0) nextEvent.endMinutes = Math.min(23 * 60 + 59, startMinutes + durationMinutes);
return nextEvent;
}
export function getCalendarDraftFromEvent({ event, occurrence, now }) {
const occurrenceDate = occurrence ? formatLocalDateKey(new Date(occurrence.startMs)) : "";
return {
occurrenceDate,
draft: {
id: event.id,
title: event.title,
dayIndex: event.dayIndex,
startDate: occurrenceDate || event.startDate || formatLocalDateKey(now),
endDate: occurrenceDate && event.startDate ? formatLocalDateKey(addDays(parseLocalDateKey(occurrenceDate), getDateSpanDays(event.startDate, event.endDate || event.startDate))) : event.endDate || event.startDate || formatLocalDateKey(now),
monthlyRepeatBy: event.monthlyRepeatBy || "monthDay",
repeat: event.repeat || "none",
startTime: minutesToTimeValue(event.startMinutes),
endTime: event.endMinutes ? minutesToTimeValue(event.endMinutes) : "",
repeatable: event.repeatable !== false,
alertMode: event.alertMode || "off",
alertOffsetMinutes: event.alertMode === "off" ? [] : normalizeDraftAlertOffsets(event.alertOffsetMinutes),
color: event.color || "violet"
}
};
}
export function buildCalendarEventFromDraft({ draft, data, context }) {
const title = draft.title.trim();
const dayIndex = Number(draft.dayIndex);
const startDate = normalizeDraftDateKey(draft.startDate);
const endDate = normalizeDraftDateKey(draft.endDate || draft.startDate);
const startMinutes = data.viewMode === "month" ? 0 : timeValueToMinutes(draft.startTime);
const endMinutes = data.viewMode === "month" ? -1 : draft.endTime ? timeValueToMinutes(draft.endTime) : -1;
const monthlyRepeatBy = normalizeDraftMonthlyRepeatBy(draft.monthlyRepeatBy);
const monthRepeat = monthlyRepeatBy === "monthDay" && draft.repeat === "weekly" ? "none" : ["none", "weekly", "monthly"].includes(draft.repeat) ? draft.repeat : "monthly";
const validWeekTarget = data.viewMode === "week" && dayIndex >= 0 && dayIndex <= 6;
const validMonthTarget = data.viewMode === "month" && startDate && endDate && endDate >= startDate;
if (!title || (!validWeekTarget && !validMonthTarget) || startMinutes < 0) return { error: "required" };
if (draft.endTime && data.viewMode === "week" && endMinutes <= startMinutes) return { error: "range" };
const alertOffsets = normalizeDraftAlertOffsets(draft.alertOffsetMinutes);
const alertMode = alertOffsets.length && CALENDAR_ALERT_MODES.includes(draft.alertMode) ? draft.alertMode : "off";
const nextEvent = {
id: draft.id || context.uid("calendar"),
title,
startMinutes,
...(data.viewMode === "week" && draft.endTime ? { endMinutes } : {}),
repeatable: draft.repeatable !== false,
repeat: data.viewMode === "month" ? monthRepeat : draft.repeatable === false ? "none" : getCalendarRepeatMode(data.viewMode),
alertMode,
alertOffsetMinutes: alertOffsets.length ? alertOffsets : [10],
color: CALENDAR_COLORS.includes(draft.color) ? draft.color : "violet"
};
if (data.viewMode === "month") {
nextEvent.monthlyRepeatBy = monthlyRepeatBy;
nextEvent.startDate = startDate;
nextEvent.endDate = endDate;
nextEvent.dayIndex = getLocalDayIndex(parseLocalDateKey(startDate));
nextEvent.excludedOccurrences = data.events.find((item) => item.id === draft.id)?.excludedOccurrences || [];
} else {
nextEvent.dayIndex = dayIndex;
}
return { event: nextEvent };
}

View file

@ -1,432 +1,5 @@
// Rôle : registre des outils toolbox, rendu commun et contrôles d'ajout/réorganisation. // Rôle : expose l'API publique du registre et du rendu des modules toolbox.
import { lazy, Suspense, useEffect, useMemo, useState } from "react"; export { TOOLBOX_MODULES } from "../toolboxModuleRegistry.js";
import { createPortal } from "react-dom"; export { AddToolControls } from "./AddToolControls.jsx";
import { CompactDropdown } from "../../../components/CompactDropdown.jsx"; export { ToolboxModules } from "./ToolboxModules.jsx";
import { Icon } from "../../../components/Icon.jsx"; export { LAZY_MODULE_TYPES, MODULE_COMPONENTS } from "./toolboxModuleComponents.jsx";
import { useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
import { useInlineEdit } from "../../../hooks/useInlineEdit.js";
import { lockBodyScroll } from "../../../utils/bodyScrollLock.js";
import { TOOLBOX_MODULE_DEFINITIONS, TOOLBOX_MODULES } from "../toolboxModuleRegistry.js";
import { supportsTextExchange } from "./textImport.js";
import { CalculatorModule } from "./CalculatorModule.jsx";
import { ChecklistModule } from "./ChecklistModule.jsx";
import { CountersModule } from "./CountersModule.jsx";
import { ImageAnnotationModule } from "./ImageAnnotationModule.jsx";
import { LinksModule } from "./LinksModule.jsx";
import { NotepadModule } from "./NotepadModule.jsx";
import { ImagesModule } from "./ImagesModule.jsx";
import { TableModule } from "./TableModule.jsx";
import { TaskPlannerModule } from "./TaskPlannerModule.jsx";
import { TimerModule } from "./TimerModule.jsx";
const CombosModule = lazy(() => import("./CombosModule.jsx").then((module) => ({ default: module.CombosModule })));
const CalendarModule = lazy(() => import("./CalendarModule.jsx").then((module) => ({ default: module.CalendarModule })));
const GoalTreeModule = lazy(() => import("./GoalTreeModule.jsx").then((module) => ({ default: module.GoalTreeModule })));
const EquipmentPlannerModule = lazy(() => import("./EquipmentPlannerModule.jsx").then((module) => ({ default: module.EquipmentPlannerModule })));
const MODULE_COMPONENTS = {
notepad: { ...TOOLBOX_MODULE_DEFINITIONS.notepad, Component: NotepadModule },
checklist: { ...TOOLBOX_MODULE_DEFINITIONS.checklist, Component: ChecklistModule },
images: { ...TOOLBOX_MODULE_DEFINITIONS.images, Component: ImagesModule },
links: { ...TOOLBOX_MODULE_DEFINITIONS.links, Component: LinksModule },
counters: { ...TOOLBOX_MODULE_DEFINITIONS.counters, Component: CountersModule },
combos: { ...TOOLBOX_MODULE_DEFINITIONS.combos, Component: CombosModule },
calculator: { ...TOOLBOX_MODULE_DEFINITIONS.calculator, Component: CalculatorModule },
calendar: { ...TOOLBOX_MODULE_DEFINITIONS.calendar, Component: CalendarModule },
table: { ...TOOLBOX_MODULE_DEFINITIONS.table, Component: TableModule },
goalTree: { ...TOOLBOX_MODULE_DEFINITIONS.goalTree, Component: GoalTreeModule },
timer: { ...TOOLBOX_MODULE_DEFINITIONS.timer, Component: TimerModule },
taskPlanner: { ...TOOLBOX_MODULE_DEFINITIONS.taskPlanner, Component: TaskPlannerModule },
equipmentPlanner: { ...TOOLBOX_MODULE_DEFINITIONS.equipmentPlanner, Component: EquipmentPlannerModule },
imageAnnotation: { ...TOOLBOX_MODULE_DEFINITIONS.imageAnnotation, Component: ImageAnnotationModule }
};
const LAZY_MODULE_TYPES = new Set(["combos", "calendar", "goalTree", "equipmentPlanner"]);
const MODULE_COLUMN_IDS = ["left", "right"];
export { TOOLBOX_MODULES };
function getModuleMap(modules) {
return new Map(modules.map((module) => [module.id, module]));
}
function getModulesByIds(modules, ids) {
const moduleMap = getModuleMap(modules);
return (Array.isArray(ids) ? ids : []).map((id) => moduleMap.get(id)).filter(Boolean);
}
function getModuleColumnId(moduleId, twoColumnOrder) {
return twoColumnOrder?.[1]?.includes(moduleId) ? MODULE_COLUMN_IDS[1] : MODULE_COLUMN_IDS[0];
}
function moveId(ids, sourceId, targetId, placement = "before") {
const nextIds = ids.filter((id) => id !== sourceId);
const targetIndex = nextIds.indexOf(targetId);
if (targetIndex < 0) return ids;
nextIds.splice(placement === "after" ? targetIndex + 1 : targetIndex, 0, sourceId);
return nextIds;
}
function moveModuleInOneColumnOrder(order, operation) {
if (!operation.sourceId || !operation.targetId || operation.sourceId === operation.targetId) return order;
return {
...order,
one: moveId(order.one, operation.sourceId, operation.targetId, operation.placement)
};
}
function moveModuleInTwoColumnOrder(order, operation) {
const columns = [
[...(order.two?.[0] || [])],
[...(order.two?.[1] || [])]
];
const sourceColumnIndex = columns.findIndex((column) => column.includes(operation.sourceId));
if (sourceColumnIndex < 0) return order;
const targetColumnIndex = operation.targetType === "item"
? columns.findIndex((column) => column.includes(operation.targetId))
: MODULE_COLUMN_IDS.indexOf(operation.targetGroup);
if (targetColumnIndex < 0) return order;
const [movedId] = columns[sourceColumnIndex].splice(columns[sourceColumnIndex].indexOf(operation.sourceId), 1);
if (operation.targetType === "item") {
const targetIndex = columns[targetColumnIndex].indexOf(operation.targetId);
if (targetIndex < 0) return order;
columns[targetColumnIndex].splice(operation.placement === "after" ? targetIndex + 1 : targetIndex, 0, movedId);
} else {
columns[targetColumnIndex].splice(operation.placement === "after" ? columns[targetColumnIndex].length : 0, 0, movedId);
}
return { ...order, two: columns };
}
export function AddToolControls({ onAdd }) {
const [open, setOpen] = useState(false);
const modules = Object.entries(TOOLBOX_MODULES);
useEffect(() => {
if (!open) return undefined;
return lockBodyScroll();
}, [open]);
function addTool(type) {
onAdd(type);
setOpen(false);
}
return (
<>
<div
className="tool-add-controls"
aria-label="Ajouter un outil"
>
<button
className="primary tool-add-modal-button"
type="button"
onClick={() => setOpen(true)}
data-toolbox-tutorial-target="addTool"
>
Ajouter un outil
</button>
<CompactDropdown
className="tool-add-quick-dropdown"
menuClassName="tool-quick-add"
label="Ajout rapide"
openOnHover
closeOnMouseLeave
renderTrigger={({ open: quickOpen, toggle }) => (
<button
className={`primary tool-add-quick-toggle ${quickOpen ? "active" : ""}`}
type="button"
onClick={toggle}
aria-label="Afficher l'ajout rapide"
aria-expanded={quickOpen}
title="Ajout rapide"
>
<Icon name="dropdown" />
</button>
)}
>
{({ close }) => (
<>
{modules.map(([type, module]) => (
<button
key={type}
className="tool-quick-add-button"
type="button"
onClick={() => {
onAdd(type);
close();
}}
aria-label={`Ajouter ${module.label}`}
title={module.label}
>
<span className="module-icon" aria-hidden="true">
<span className={`module-icon-svg module-icon-${module.icon}`} />
</span>
</button>
))}
</>
)}
</CompactDropdown>
</div>
{open && createPortal(
<div className="tool-add-modal-root" role="dialog" aria-modal="true" aria-labelledby="tool-add-modal-title">
<div className="confirm-backdrop" onClick={() => setOpen(false)} />
<section className="confirm-modal tool-add-modal">
<header>
<h2 id="tool-add-modal-title">Ajouter un outil</h2>
<button className="drawer-close-button" type="button" onClick={() => setOpen(false)} aria-label="Fermer" title="Fermer">
<Icon name="close" />
</button>
</header>
<div className="tool-add-grid">
{modules.map(([type, module]) => (
<button key={type} className="tool-add-card" type="button" onClick={() => addTool(type)}>
<span className="module-icon" aria-hidden="true">
<span className={`module-icon-svg module-icon-${module.icon}`} />
</span>
<span>{module.label}</span>
</button>
))}
</div>
</section>
</div>,
document.body
)}
</>
);
}
export function ToolboxModules({ toolbox, moduleColumns, context, onRename, onUpdateModule, onDelete, onModuleOrderChange }) {
const twoColumnOrder = useMemo(() => toolbox.moduleOrder?.two || [[], []], [toolbox.moduleOrder?.two]);
const orderedModules = useMemo(() => getModulesByIds(toolbox.modules, toolbox.moduleOrder?.one || []), [toolbox.modules, toolbox.moduleOrder?.one]);
const columns = useMemo(() => [
getModulesByIds(toolbox.modules, twoColumnOrder[0]),
getModulesByIds(toolbox.modules, twoColumnOrder[1])
], [toolbox.modules, twoColumnOrder]);
const {
itemReorder,
getItemProps,
getGroupBoundaryProps,
isItemDragging,
isItemDropTarget,
isBoundaryDropTarget,
getDropPlacement
} = useGroupedReorder({
namespace: "toolbox-modules",
items: toolbox.modules,
getItemId: (module) => module.id,
getItemGroup: (module) => moduleColumns === 2 ? getModuleColumnId(module.id, twoColumnOrder) : "",
getEffectiveGroup: (module) => moduleColumns === 2 ? getModuleColumnId(module.id, twoColumnOrder) : "",
getParentId: () => toolbox.id,
reorderFeatures: {
item: {
groupChange: moduleColumns === 2,
boundaryDrop: moduleColumns === 2
}
},
onItemMove: (operation) => {
const nextOrder = moduleColumns === 2
? moveModuleInTwoColumnOrder(toolbox.moduleOrder, operation)
: moveModuleInOneColumnOrder(toolbox.moduleOrder, operation);
onModuleOrderChange(nextOrder);
},
hierarchy: { enabled: true, stickyParents: true }
});
if (moduleColumns === 1) {
return (
<section className="modules" data-toolbox-id={toolbox.id} data-layout-cols={moduleColumns}>
{orderedModules.map((module) => (
<ModuleShell
key={module.id}
toolbox={toolbox}
module={module}
context={context}
reorder={{ itemReorder, getItemProps, isItemDragging, isItemDropTarget, getDropPlacement }}
itemGroup=""
onRename={onRename}
onUpdateModule={onUpdateModule}
onDelete={onDelete}
/>
))}
</section>
);
}
const hasEmptyColumn = toolbox.modules.length > 1 && columns.some((modules) => modules.length === 0);
return (
<section className="modules" data-toolbox-id={toolbox.id} data-layout-cols={moduleColumns}>
{columns.map((modules, index) => (
<div className={`module-column ${hasEmptyColumn && !modules.length ? "is-empty-warning" : ""}`} key={MODULE_COLUMN_IDS[index]}>
<div
className={`module-column-boundary-drop-zone ${isBoundaryDropTarget({ groupId: MODULE_COLUMN_IDS[index], parentId: toolbox.id, placement: "before" }) ? "is-drop-target" : ""}`}
{...getGroupBoundaryProps({ groupId: MODULE_COLUMN_IDS[index], parentId: toolbox.id, placement: "before" })}
/>
{modules.map((module) => (
<ModuleShell
key={module.id}
toolbox={toolbox}
module={module}
context={context}
reorder={{ itemReorder, getItemProps, isItemDragging, isItemDropTarget, getDropPlacement }}
itemGroup={MODULE_COLUMN_IDS[index]}
onRename={onRename}
onUpdateModule={onUpdateModule}
onDelete={onDelete}
/>
))}
<div
className={`module-column-boundary-drop-zone ${isBoundaryDropTarget({ groupId: MODULE_COLUMN_IDS[index], parentId: toolbox.id, placement: "after" }) ? "is-drop-target" : ""}`}
{...getGroupBoundaryProps({ groupId: MODULE_COLUMN_IDS[index], parentId: toolbox.id, placement: "after" })}
/>
</div>
))}
</section>
);
}
function ModuleShell({ toolbox, module, context, reorder, itemGroup = "", onRename, onUpdateModule, onDelete }) {
const definition = MODULE_COMPONENTS[module.type] || MODULE_COMPONENTS.notepad;
const Component = definition.Component;
const label = definition.label || module.type;
const [editing, setEditing] = useState(false);
const [textExchangeOpen, setTextExchangeOpen] = useState(false);
const hasAddPanel = definition.editable && definition.addable !== false;
const hasTextExchange = supportsTextExchange(module.type);
const scrollable = definition.scrollable && module.scrollable === true;
const className = [
"module",
reorder.isItemDragging(module.id) ? "is-dragging" : "",
reorder.isItemDropTarget(module.id) ? "is-drop-target" : "",
reorder.getDropPlacement("item", module.id) === "after" ? "drop-after" : "",
scrollable ? "is-scrollable" : ""
].filter(Boolean).join(" ");
const componentProps = {
toolboxId: toolbox.id,
moduleId: module.id,
context,
editing,
setEditing,
openEditor: () => {
setTextExchangeOpen(false);
setEditing(true);
},
textExchangeOpen
};
return (
<article
className={className}
data-module-type={module.type}
{...reorder.getItemProps({ itemId: module.id, groupId: itemGroup, parentId: toolbox.id })}
>
<header>
<div>
<button
className="module-drag-handle"
type="button"
onPointerDown={(event) => reorder.itemReorder.startDrag(event, module.id)}
aria-label={`Déplacer ${module.title || label}`}
title="Déplacer"
>
<Icon name="drag" />
</button>
<span className="module-icon" aria-hidden="true">
<span className={`module-icon-svg module-icon-${definition.icon || "notepad"}`} />
</span>
<EditableModuleTitle value={module.title || label} fallback={label} tutorialTarget={module.type === "notepad" ? "moduleTitle" : ""} onSave={(title) => onRename(module.id, title)} />
</div>
<div>
{definition.scrollable && (
<button
className={`module-scroll-button ${scrollable ? "active" : ""}`}
onClick={() => onUpdateModule(module.id, (currentModule) => {
const nextModule = { ...currentModule };
if (scrollable) {
delete nextModule.scrollable;
} else {
nextModule.scrollable = true;
}
return nextModule;
})}
aria-label={`${scrollable ? "Désactiver" : "Activer"} le scroll de ${module.title || label}`}
aria-pressed={scrollable}
title={scrollable ? "Désactiver le scroll" : "Activer le scroll"}
>
<Icon name="scrollable" />
<i aria-hidden="true" />
</button>
)}
{hasAddPanel && (
<button
className={`module-edit-button ${editing ? "active" : ""}`}
onClick={() => setEditing((value) => {
const nextEditing = !value;
if (nextEditing) setTextExchangeOpen(false);
return nextEditing;
})}
aria-label={`${editing ? "Masquer" : "Afficher"} l'ajout de ${module.title || label}`}
aria-pressed={editing}
title={editing ? "Masquer l'ajout" : "Ajouter"}
>
<Icon name="add" />
</button>
)}
{hasTextExchange && (
<button
className={`module-exchange-button ${textExchangeOpen ? "active" : ""}`}
onClick={() => setTextExchangeOpen((value) => {
const nextOpen = !value;
if (nextOpen) setEditing(false);
return nextOpen;
})}
aria-label={`${textExchangeOpen ? "Masquer" : "Afficher"} l'import/export de ${module.title || label}`}
aria-pressed={textExchangeOpen}
title={textExchangeOpen ? "Masquer import/export" : "Import / export"}
>
<Icon name="import" />
</button>
)}
<button className="module-delete-button danger" onClick={() => onDelete(module.id)} aria-label={`Retirer ${module.title || label}`} data-toolbox-tutorial-target={module.type === "links" ? "deleteTool" : undefined} title="Retirer">
<span className="ui-icon ui-icon-trash" aria-hidden="true" />
</button>
</div>
</header>
<div className={`module-content ${scrollable ? "is-scrollable legacy-scrollbar" : ""}`}>
{LAZY_MODULE_TYPES.has(module.type) ? (
<Suspense fallback={<ModuleLoadingFallback label={label} />}>
<Component {...componentProps} />
</Suspense>
) : (
<Component {...componentProps} />
)}
</div>
</article>
);
}
function ModuleLoadingFallback({ label }) {
return <div className="module-loading-fallback">Chargement de {label}...</div>;
}
function EditableModuleTitle({ value, fallback, onSave, tutorialTarget = "" }) {
const inlineEdit = useInlineEdit({
value,
onCommit: (title) => onSave(title || fallback),
blurOnEscape: true
});
return (
<h2
{...inlineEdit.getContentEditableProps({
className: "module-title",
"data-toolbox-tutorial-target": tutorialTarget || undefined,
title: "Cliquer pour renommer"
})}
>
{value}
</h2>
);
}

View file

@ -0,0 +1,38 @@
// Rôle : associe les types d'outils toolbox à leurs composants React, synchrones ou lazy.
/* eslint-disable react-refresh/only-export-components -- registre de composants toolbox consommé par le shell. */
import { lazy } from "react";
import { TOOLBOX_MODULE_DEFINITIONS } from "../toolboxModuleRegistry.js";
import { CalculatorModule } from "./CalculatorModule.jsx";
import { ChecklistModule } from "./ChecklistModule.jsx";
import { CountersModule } from "./CountersModule.jsx";
import { ImageAnnotationModule } from "./ImageAnnotationModule.jsx";
import { ImagesModule } from "./ImagesModule.jsx";
import { LinksModule } from "./LinksModule.jsx";
import { NotepadModule } from "./NotepadModule.jsx";
import { TableModule } from "./TableModule.jsx";
import { TaskPlannerModule } from "./TaskPlannerModule.jsx";
import { TimerModule } from "./TimerModule.jsx";
const CombosModule = lazy(() => import("./CombosModule.jsx").then((module) => ({ default: module.CombosModule })));
const CalendarModule = lazy(() => import("./CalendarModule.jsx").then((module) => ({ default: module.CalendarModule })));
const GoalTreeModule = lazy(() => import("./GoalTreeModule.jsx").then((module) => ({ default: module.GoalTreeModule })));
const EquipmentPlannerModule = lazy(() => import("./EquipmentPlannerModule.jsx").then((module) => ({ default: module.EquipmentPlannerModule })));
export const MODULE_COMPONENTS = {
notepad: { ...TOOLBOX_MODULE_DEFINITIONS.notepad, Component: NotepadModule },
checklist: { ...TOOLBOX_MODULE_DEFINITIONS.checklist, Component: ChecklistModule },
images: { ...TOOLBOX_MODULE_DEFINITIONS.images, Component: ImagesModule },
links: { ...TOOLBOX_MODULE_DEFINITIONS.links, Component: LinksModule },
counters: { ...TOOLBOX_MODULE_DEFINITIONS.counters, Component: CountersModule },
combos: { ...TOOLBOX_MODULE_DEFINITIONS.combos, Component: CombosModule },
calculator: { ...TOOLBOX_MODULE_DEFINITIONS.calculator, Component: CalculatorModule },
calendar: { ...TOOLBOX_MODULE_DEFINITIONS.calendar, Component: CalendarModule },
table: { ...TOOLBOX_MODULE_DEFINITIONS.table, Component: TableModule },
goalTree: { ...TOOLBOX_MODULE_DEFINITIONS.goalTree, Component: GoalTreeModule },
timer: { ...TOOLBOX_MODULE_DEFINITIONS.timer, Component: TimerModule },
taskPlanner: { ...TOOLBOX_MODULE_DEFINITIONS.taskPlanner, Component: TaskPlannerModule },
equipmentPlanner: { ...TOOLBOX_MODULE_DEFINITIONS.equipmentPlanner, Component: EquipmentPlannerModule },
imageAnnotation: { ...TOOLBOX_MODULE_DEFINITIONS.imageAnnotation, Component: ImageAnnotationModule }
};
export const LAZY_MODULE_TYPES = new Set(["combos", "calendar", "goalTree", "equipmentPlanner"]);

View file

@ -0,0 +1,282 @@
// Rôle : orchestre l'état, les handlers et les effets du module calendrier.
import { useCallback, useMemo, useRef, useState } from "react";
import {
WEEK_SLOT_MINUTES,
addDays,
getCalendarOccurrences,
getLocalDayIndex,
getMonthEnd,
getMonthGridDays,
getMonthStart,
getWeekDays,
getWeekStart,
minutesToTimeValue,
parseLocalDateKey
} from "./calendarUtils.js";
import { WEEK_SLOT_COUNT, getWeekGridLayout } from "./calendarLayout.js";
import {
createDefaultDraft,
getDraftSelection,
normalizeDraftAlertOffsets
} from "./calendarDraft.js";
import {
buildCalendarEventFromDraft,
buildPastedCalendarEvent,
getCalendarDraftFromEvent
} from "./calendarEventActions.js";
import {
useCalendarClock,
useCalendarEditingReset,
usePruneExpiredCalendarEvents
} from "./useCalendarLifecycle.js";
import { useCalendarDraftControls } from "./useCalendarDraftControls.js";
export function useCalendarController({ data, context, toolboxId, moduleId, editing = false, setEditing, textContent }) {
const [now, setNow] = useState(() => new Date());
const [displayedMonth, setDisplayedMonth] = useState(() => getMonthStart(new Date()));
const weekStart = useMemo(() => getWeekStart(new Date(), data.weekStartsOn), [data.weekStartsOn]);
const monthStart = useMemo(() => getMonthStart(displayedMonth), [displayedMonth]);
const monthEnd = useMemo(() => getMonthEnd(monthStart), [monthStart]);
const monthGridDays = useMemo(() => getMonthGridDays(monthStart), [monthStart]);
const [selection, setSelection] = useState(null);
const [monthSelection, setMonthSelection] = useState(null);
const [draft, setDraft] = useState(() => createDefaultDraft());
const [activeEventId, setActiveEventId] = useState("");
const [activeOccurrenceDate, setActiveOccurrenceDate] = useState("");
const [copiedEvent, setCopiedEvent] = useState(null);
const [hideEmptyHours, setHideEmptyHours] = useState(false);
const [error, setError] = useState("");
const previousEditingRef = useRef(editing);
const days = useMemo(() => getWeekDays(weekStart), [weekStart]);
const weekEnd = useMemo(() => addDays(weekStart, 7), [weekStart]);
const rangeStart = data.viewMode === "month" ? monthStart : weekStart;
const rangeEnd = data.viewMode === "month" ? monthEnd : weekEnd;
const occurrences = useMemo(() => getCalendarOccurrences(data.events, rangeStart, rangeEnd), [data.events, rangeEnd, rangeStart]);
const draftSelection = data.viewMode === "week" ? getDraftSelection(draft) : null;
const nowMinutes = now.getHours() * 60 + now.getMinutes();
const nowDayIndex = getLocalDayIndex(now);
const formOpen = editing || Boolean(activeEventId);
const weekGridLayout = useMemo(() => {
return getWeekGridLayout(occurrences, data.viewMode === "week" && hideEmptyHours && !formOpen);
}, [data.viewMode, formOpen, hideEmptyHours, occurrences]);
const { updateAlertMode, updateDraft, toggleAlertOffset } = useCalendarDraftControls({ setDraft, setError });
const save = useCallback((nextData) => {
context.setModuleData(toolboxId, moduleId, nextData, "calendar");
}, [context, moduleId, toolboxId]);
function initializeWeek() {
save({ ...data, initialized: true, viewMode: "week", weekStartsOn: 1, events: [] });
}
function initializeMonth() {
save({ ...data, initialized: true, viewMode: "month", weekStartsOn: 1, events: [] });
}
const resetDraft = useCallback(() => {
setDraft(createDefaultDraft());
setSelection(null);
setMonthSelection(null);
setActiveEventId("");
setActiveOccurrenceDate("");
setError("");
}, []);
function closeForm() {
resetDraft();
setEditing?.(false);
}
function applySelection(dayIndex, startSlot, endSlot = startSlot) {
const orderedStart = Math.min(startSlot, endSlot);
const orderedEnd = Math.max(startSlot, endSlot);
updateDraft({
id: "",
dayIndex,
startTime: minutesToTimeValue(orderedStart * WEEK_SLOT_MINUTES),
endTime: minutesToTimeValue(Math.min(23 * 60 + 59, (Math.min(WEEK_SLOT_COUNT, orderedEnd + 1)) * WEEK_SLOT_MINUTES))
});
}
function applyMonthSelection(startDate, endDate = startDate) {
const orderedStart = startDate <= endDate ? startDate : endDate;
const orderedEnd = startDate <= endDate ? endDate : startDate;
const start = parseLocalDateKey(orderedStart);
setActiveEventId("");
setActiveOccurrenceDate("");
updateDraft({
id: "",
startDate: orderedStart,
endDate: orderedEnd,
dayIndex: start ? getLocalDayIndex(start) : 0
});
}
function pasteEventAtTarget(target) {
const nextEvent = buildPastedCalendarEvent({ copiedEvent, target, data, draft, context });
if (!nextEvent) return;
save({ ...data, initialized: true, events: [...data.events, nextEvent] });
context.notify?.(textContent.pastedTitle || "Événement collé.");
}
function pasteCopiedEventAtDraftSelection() {
if (!copiedEvent) return;
if (data.viewMode === "month") {
if (!parseLocalDateKey(draft.startDate)) return;
pasteEventAtTarget({
startDate: draft.startDate,
monthlyRepeatBy: draft.monthlyRepeatBy || "monthDay",
slotIndex: -1
});
return;
}
const targetSelection = getDraftSelection(draft);
if (targetSelection) pasteEventAtTarget({ dayIndex: targetSelection.dayIndex, slotIndex: targetSelection.startSlot });
}
function handleSlotPointerDown(dayIndex, slotIndex) {
setActiveEventId("");
setActiveOccurrenceDate("");
setSelection({ dayIndex, startSlot: slotIndex, endSlot: slotIndex });
applySelection(dayIndex, slotIndex);
}
function handleSlotPointerEnter(event, dayIndex, slotIndex) {
if (event.buttons !== 1) return;
setSelection((current) => {
if (!current || current.dayIndex !== dayIndex) return current;
applySelection(dayIndex, current.startSlot, slotIndex);
return { ...current, endSlot: slotIndex };
});
}
function handleMonthPointerDown(dateKey) {
setActiveEventId("");
setActiveOccurrenceDate("");
setMonthSelection({ startDate: dateKey, endDate: dateKey });
applyMonthSelection(dateKey);
}
function handleMonthPointerEnter(event, dateKey) {
if (event.buttons !== 1) return;
setMonthSelection((current) => {
if (!current) return current;
applyMonthSelection(current.startDate, dateKey);
return { ...current, endDate: dateKey };
});
}
function editEvent(event, occurrence = null) {
const { draft: nextDraft, occurrenceDate } = getCalendarDraftFromEvent({ event, occurrence, now });
if (occurrenceDate) setDisplayedMonth(getMonthStart(parseLocalDateKey(occurrenceDate)));
setDraft(nextDraft);
setActiveEventId(event.id);
setActiveOccurrenceDate(occurrenceDate);
setError("");
}
function deleteEvent(eventId) {
save({ ...data, initialized: true, events: data.events.filter((event) => event.id !== eventId) });
if (draft.id === eventId || activeEventId === eventId) closeForm();
}
function deleteOccurrence(eventId) {
const event = data.events.find((item) => item.id === eventId);
if (!event || !activeOccurrenceDate || event.repeat === "none") return;
const events = data.events.map((item) => item.id === eventId
? { ...item, excludedOccurrences: [...new Set([...(item.excludedOccurrences || []), activeOccurrenceDate])].sort() }
: item);
save({ ...data, initialized: true, events });
closeForm();
}
function copyEvent(event) {
setCopiedEvent(event);
context.notify?.(textContent.copiedTitle || "Événement copié.");
}
function submitEvent(event) {
event.preventDefault();
const result = buildCalendarEventFromDraft({ draft, data, context });
if (result.error === "required") {
setError(textContent.requiredError || "Titre, date et heure de début requis.");
return;
}
if (result.error === "range") {
setError(textContent.invalidRangeError || "La fin doit être après le début.");
return;
}
const events = draft.id
? data.events.map((item) => item.id === draft.id ? result.event : item)
: [...data.events, result.event];
save({ ...data, initialized: true, events });
closeForm();
}
function handleKeyDown(event) {
if (!event.ctrlKey && !event.metaKey) return;
const target = event.target;
if (target?.closest?.("input, textarea, select, [contenteditable='true']")) return;
const key = event.key.toLowerCase();
if (key === "c") {
const activeEvent = data.events.find((item) => item.id === activeEventId);
if (!activeEvent) return;
event.preventDefault();
copyEvent(activeEvent);
}
if (key === "v") {
if (!copiedEvent) return;
event.preventDefault();
pasteCopiedEventAtDraftSelection();
}
}
useCalendarClock(setNow);
useCalendarEditingReset({ editing, previousEditingRef, resetDraft, setActiveEventId, setDraft, setError, setSelection });
usePruneExpiredCalendarEvents({ activeEventId, data, rangeEnd, rangeStart, resetDraft, save });
return {
initializeWeek,
initializeMonth,
contentProps: {
data,
context,
draft: { ...draft, alertOffsetMinutes: draft.alertMode === "off" ? [] : normalizeDraftAlertOffsets(draft.alertOffsetMinutes) },
error,
activeOccurrenceDate,
monthStart,
monthGridDays,
occurrences,
textContent,
activeEventId,
formOpen,
draftSelection,
selection,
monthSelection,
days,
weekGridLayout,
now,
nowDayIndex,
nowMinutes,
hideEmptyHours,
onKeyDown: handleKeyDown,
onSubmit: submitEvent,
onDraftChange: updateDraft,
onAlertModeChange: updateAlertMode,
onAlertOffsetToggle: toggleAlertOffset,
onDeleteEvent: deleteEvent,
onDeleteOccurrence: deleteOccurrence,
onClose: closeForm,
onDisplayedMonthChange: setDisplayedMonth,
onImport: (nextData) => save(nextData),
onHideEmptyHoursToggle: () => setHideEmptyHours((current) => !current),
onMonthPointerDown: handleMonthPointerDown,
onMonthPointerEnter: handleMonthPointerEnter,
onMonthPointerUp: () => setMonthSelection(null),
onSlotPointerDown: handleSlotPointerDown,
onSlotPointerEnter: handleSlotPointerEnter,
onSlotPointerUp: () => setSelection(null),
onEditEvent: editEvent
}
};
}

View file

@ -0,0 +1,38 @@
// Rôle : centralise les mises à jour de draft et d'alertes du calendrier.
import { normalizeDraftAlertOffsets } from "./calendarDraft.js";
export function useCalendarDraftControls({ setDraft, setError }) {
function updateDraft(values) {
setDraft((current) => ({ ...current, ...values }));
setError("");
}
function updateAlertMode(alertMode) {
setDraft((current) => {
const currentOffsets = normalizeDraftAlertOffsets(current.alertOffsetMinutes);
return {
...current,
alertMode,
alertOffsetMinutes: alertMode === "off" ? [] : currentOffsets.length ? currentOffsets : [10]
};
});
setError("");
}
function toggleAlertOffset(offset) {
setDraft((current) => {
const currentOffsets = normalizeDraftAlertOffsets(current.alertOffsetMinutes);
const nextOffsets = currentOffsets.includes(offset)
? currentOffsets.filter((item) => item !== offset)
: [...currentOffsets, offset].sort((a, b) => b - a);
return {
...current,
alertMode: nextOffsets.length ? current.alertMode === "off" ? "visible" : current.alertMode : "off",
alertOffsetMinutes: nextOffsets
};
});
setError("");
}
return { updateAlertMode, updateDraft, toggleAlertOffset };
}

View file

@ -0,0 +1,62 @@
// Rôle : regroupe les effets de cycle de vie du contrôleur calendrier.
import { useEffect } from "react";
import { getCalendarOccurrences } from "./calendarUtils.js";
import { createDefaultDraft } from "./calendarDraft.js";
export function useCalendarClock(setNow) {
useEffect(() => {
const intervalId = window.setInterval(() => setNow(new Date()), 60000);
return () => window.clearInterval(intervalId);
}, [setNow]);
}
export function useCalendarEditingReset({
editing,
previousEditingRef,
resetDraft,
setActiveEventId,
setDraft,
setError,
setSelection
}) {
useEffect(() => {
const previousEditing = previousEditingRef.current;
previousEditingRef.current = editing;
if (!previousEditing && editing) {
setDraft(createDefaultDraft());
setActiveEventId("");
setSelection(null);
setError("");
}
if (previousEditing && !editing) {
resetDraft();
}
}, [editing, previousEditingRef, resetDraft, setActiveEventId, setDraft, setError, setSelection]);
}
export function usePruneExpiredCalendarEvents({
activeEventId,
data,
rangeEnd,
rangeStart,
resetDraft,
save
}) {
useEffect(() => {
if (!data.events.length) return undefined;
function pruneExpiredOneShotEvents() {
const nowMs = Date.now();
const nextEvents = data.events.filter((event) => {
if (event.repeatable !== false) return true;
const occurrence = getCalendarOccurrences([event], rangeStart, rangeEnd)[0];
return !occurrence || occurrence.endMs > nowMs;
});
if (nextEvents.length === data.events.length) return;
save({ ...data, initialized: true, events: nextEvents });
if (activeEventId && !nextEvents.some((event) => event.id === activeEventId)) resetDraft();
}
pruneExpiredOneShotEvents();
const intervalId = window.setInterval(pruneExpiredOneShotEvents, 30000);
return () => window.clearInterval(intervalId);
}, [activeEventId, data, rangeEnd, rangeStart, resetDraft, save]);
}