task planner categories optimisation
All checks were successful
Deploy Sokko G / deploy (push) Successful in 7s

This commit is contained in:
Shinuwa 2026-07-28 17:48:13 +02:00
parent 3dc5ed8733
commit 1d3bdd60f1
7 changed files with 217 additions and 37 deletions

View file

@ -178,6 +178,31 @@ function countTaskTree(taskIds, tasks, parentMap) {
return count;
}
function getTaskTreeIds(taskIds, tasks, parentMap) {
const ids = [];
const visited = new Set();
function visit(taskId) {
if (visited.has(taskId)) return;
visited.add(taskId);
ids.push(taskId);
tasks
.filter((task) => (parentMap.get(task.id) || "") === taskId)
.forEach((child) => visit(child.id));
}
taskIds.forEach(visit);
return ids;
}
function countCompletedTaskTree(taskIds, tasks, parentMap) {
const taskIdsSet = new Set(getTaskTreeIds(taskIds, tasks, parentMap));
return tasks.filter((task) => taskIdsSet.has(task.id) && task.checked).length;
}
function isTaskTreeComplete(taskIds, tasks, parentMap) {
const treeIds = getTaskTreeIds(taskIds, tasks, parentMap);
return treeIds.length > 0 && treeIds.every((taskId) => getTaskById(tasks, taskId)?.checked);
}
function getTaskCategoryGroups(tasks, parentMap, parentId, sortMode, categoryOrder) {
const siblings = getTasksForParent(tasks, parentMap, parentId, sortMode);
if (parentId) {
@ -420,6 +445,21 @@ export function TaskPlannerModule({ toolboxId, moduleId, context, editing }) {
save({ ...data, tasks, categoryOrder });
}
function toggleCategoryCollapsed(category) {
const collapsedCategories = data.collapsedCategories.includes(category)
? data.collapsedCategories.filter((item) => item !== category)
: [...data.collapsedCategories, category];
save({ ...data, collapsedCategories });
}
function clearCompletedUniqueTasks() {
const removedTaskIds = new Set(data.tasks.filter((task) => task.type === "unique" && task.checked).map((task) => task.id));
if (!removedTaskIds.size) return;
const tasks = data.tasks.filter((task) => !removedTaskIds.has(task.id));
const relations = data.relations.filter((relation) => !removedTaskIds.has(relation.fromTaskId) && !removedTaskIds.has(relation.toTaskId));
save({ ...data, tasks, relations });
}
function toggleSet(setter, taskId) {
setter((current) => {
const next = new Set(current);
@ -459,6 +499,17 @@ export function TaskPlannerModule({ toolboxId, moduleId, context, editing }) {
<Icon name="sort-time" />
<i aria-hidden="true" />
</button>
<button
className={`tool-split-scroll-toggle task-planner-hide-completed-toggle ${data.hideCompleted ? "active" : ""}`}
type="button"
onClick={() => save({ ...data, hideCompleted: !data.hideCompleted })}
aria-pressed={data.hideCompleted}
aria-label={textContent.hideCompletedTitle || "Masquer les tâches effectuées"}
title={textContent.hideCompletedTitle || "Masquer les tâches effectuées"}
>
<Icon name={data.hideCompleted ? "eye-closed" : "eye-open"} />
<i aria-hidden="true" />
</button>
<button
className={`task-planner-settings-toggle ${settingsOpen ? "active" : ""}`}
type="button"
@ -494,6 +545,19 @@ export function TaskPlannerModule({ toolboxId, moduleId, context, editing }) {
<span>{textContent.resetTimeLabel || "Heure de reset"}</span>
<input type="time" value={data.resetTime} onChange={(event) => save({ ...data, resetTime: event.target.value || "00:00" })} />
</label>
<div className="task-planner-settings-actions">
<button
className="task-planner-clear-completed-button danger"
type="button"
onClick={clearCompletedUniqueTasks}
disabled={!data.tasks.some((task) => task.type === "unique" && task.checked)}
title={textContent.clearCompletedUniqueTitle || "Nettoyer les tâches ponctuelles terminées"}
aria-label={textContent.clearCompletedUniqueTitle || "Nettoyer les tâches ponctuelles terminées"}
>
<Icon name="trash" />
<span>{textContent.clearCompletedUniqueButton || "Nettoyer les tâches ponctuelles terminées"}</span>
</button>
</div>
</section>
)}
@ -523,6 +587,7 @@ export function TaskPlannerModule({ toolboxId, moduleId, context, editing }) {
onRemoveLinkTarget={removeLinkTarget}
onSetPrerequisite={setPrerequisite}
onSetTaskCategory={setTaskCategory}
onToggleCategoryCollapsed={toggleCategoryCollapsed}
/>
) : (
<li className="task-planner-empty">{textContent.emptyTasks || "Aucune tâche planifiée."}</li>
@ -556,6 +621,7 @@ function TaskPlannerBranch({
onRemoveLinkTarget,
onSetPrerequisite,
onSetTaskCategory,
onToggleCategoryCollapsed,
visited = new Set()
}) {
const groupedTasks = getTaskCategoryGroups(tasks, parentMap, parentId, data.sortMode, data.categoryOrder);
@ -563,8 +629,51 @@ function TaskPlannerBranch({
if (!children.length) return null;
const nextVisited = new Set([...visited, ...children.map((task) => task.id)]);
function hasVisibleDescendants(taskId, seen = new Set()) {
if (seen.has(taskId)) return false;
seen.add(taskId);
return getTasksForParent(tasks, parentMap, taskId, data.sortMode).some((child) => !child.checked || hasVisibleDescendants(child.id, seen));
}
function renderTask(task) {
const nestedChildren = getTasksForParent(tasks, parentMap, task.id, data.sortMode).filter((child) => !nextVisited.has(child.id));
const nestedBranch = nestedChildren.length ? (
<TaskPlannerBranch
parentId={task.id}
parentMap={parentMap}
tasks={tasks}
data={data}
draggingId={draggingId}
dropTarget={dropTarget}
draggingCategoryId={draggingCategoryId}
categoryDropTarget={categoryDropTarget}
textContent={textContent}
openDescriptions={openDescriptions}
openTaskSettings={openTaskSettings}
prerequisiteNoticeTaskIds={prerequisiteNoticeTaskIds}
onDragStart={onDragStart}
onCategoryDragStart={onCategoryDragStart}
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-children" key={`${task.id}:visible-children`}>
<ul className="task-planner-list task-planner-child-list">{nestedBranch}</ul>
</li>
);
}
return (
<TaskPlannerItem
key={task.id}
@ -591,34 +700,7 @@ function TaskPlannerBranch({
onSetPrerequisite={onSetPrerequisite}
onSetTaskCategory={onSetTaskCategory}
>
{nestedChildren.length ? (
<TaskPlannerBranch
parentId={task.id}
parentMap={parentMap}
tasks={tasks}
data={data}
draggingId={draggingId}
dropTarget={dropTarget}
draggingCategoryId={draggingCategoryId}
categoryDropTarget={categoryDropTarget}
textContent={textContent}
openDescriptions={openDescriptions}
openTaskSettings={openTaskSettings}
prerequisiteNoticeTaskIds={prerequisiteNoticeTaskIds}
onDragStart={onDragStart}
onCategoryDragStart={onCategoryDragStart}
onToggleDescription={onToggleDescription}
onToggleSettings={onToggleSettings}
onPrerequisiteNotice={onPrerequisiteNotice}
onUpdateTask={onUpdateTask}
onDeleteTask={onDeleteTask}
onAddLinkTarget={onAddLinkTarget}
onRemoveLinkTarget={onRemoveLinkTarget}
onSetPrerequisite={onSetPrerequisite}
onSetTaskCategory={onSetTaskCategory}
visited={nextVisited}
/>
) : null}
{nestedBranch}
</TaskPlannerItem>
);
}
@ -626,10 +708,15 @@ function TaskPlannerBranch({
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 categoryId = getCategorySectionId(parentId, group.category);
const isCollapsed = data.collapsedCategories.includes(group.category);
const sectionClassName = [
"task-planner-category-section",
groupCompletedCount === groupCount ? "is-complete" : "",
isCollapsed ? "is-collapsed" : "",
draggingCategoryId === categoryId ? "is-dragging" : "",
categoryDropTarget.id === categoryId || dropTarget.id === categoryId ? "is-drop-target" : "",
(categoryDropTarget.id === categoryId && categoryDropTarget.placement === "after") || (dropTarget.id === categoryId && dropTarget.placement === "after") ? "drop-after" : ""
@ -637,7 +724,7 @@ function TaskPlannerBranch({
return (
<li className={sectionClassName} key={`${parentId || "root"}:${group.category}`} data-parent-id={parentId} data-category={group.category}>
<div className="checklist-section-header task-planner-category-header">
<div>
<div className="task-planner-category-title">
<button
className="task-planner-category-drag-handle"
type="button"
@ -649,11 +736,25 @@ function TaskPlannerBranch({
</button>
<h3>{group.category}</h3>
</div>
<span>{groupCount}</span>
<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>
<ul className="task-planner-list">
{groupTasks.map(renderTask)}
</ul>
{!isCollapsed && (
<ul className="task-planner-list">
{groupTasks.map(renderTask).filter(Boolean)}
</ul>
)}
</li>
);
}

View file

@ -384,12 +384,17 @@ export function normalizeTaskPlannerData(data) {
return normalized;
});
const taskIds = new Set(tasks.map((task) => task.id));
const categoryOrder = normalizeTaskPlannerCategoryOrder(data?.categoryOrder, tasks);
return {
sortMode: TASK_SORT_MODES.has(data?.sortMode) ? data.sortMode : "manual",
weeklyResetDay: normalizeWeekDay(data?.weeklyResetDay),
resetTime: isClockTimeString(data?.resetTime) ? data.resetTime : DEFAULT_TASK_PLANNER_RESET_TIME,
lastResetAt: normalizeTimestamp(data?.lastResetAt),
categoryOrder: normalizeTaskPlannerCategoryOrder(data?.categoryOrder, tasks),
hideCompleted: data?.hideCompleted === true,
collapsedCategories: (Array.isArray(data?.collapsedCategories) ? data.collapsedCategories : [])
.map((category) => String(category || "").trim())
.filter((category, index, categories) => category && categoryOrder.includes(category) && categories.indexOf(category) === index),
categoryOrder,
tasks,
relations: relations.filter((relation) => taskIds.has(relation.fromTaskId) && taskIds.has(relation.toTaskId))
};
@ -535,6 +540,8 @@ export function compactModuleDataForStorage(type, value) {
if (normalized.weeklyResetDay !== DEFAULT_TASK_PLANNER_WEEKLY_RESET_DAY) compact.weeklyResetDay = normalized.weeklyResetDay;
if (normalized.resetTime !== DEFAULT_TASK_PLANNER_RESET_TIME) compact.resetTime = normalized.resetTime;
if (normalized.lastResetAt) compact.lastResetAt = normalized.lastResetAt;
if (normalized.hideCompleted) compact.hideCompleted = true;
if (normalized.collapsedCategories.length) compact.collapsedCategories = normalized.collapsedCategories;
if (normalized.tasks.length) {
compact.tasks = normalized.tasks.map((task) => {
const compactTask = {

View file

@ -1235,10 +1235,26 @@ textarea:focus {
height: 16px;
}
.task-planner-clear-completed-button {
display: inline-flex;
min-height: 38px;
align-items: center;
justify-content: center;
gap: 8px;
padding: 0 12px;
white-space: nowrap;
}
.task-planner-clear-completed-button .ui-icon {
width: 15px;
height: 15px;
}
.task-planner-settings {
display: grid;
grid-template-columns: minmax(130px, 1fr) minmax(110px, 0.72fr);
gap: 10px;
align-items: end;
padding: 12px;
border: 1px solid rgba(165, 180, 252, 0.12);
border-radius: var(--radius-md);
@ -1247,6 +1263,12 @@ textarea:focus {
rgba(7, 10, 24, 0.34);
}
.task-planner-settings-actions {
display: flex;
grid-column: 1 / -1;
justify-content: flex-end;
}
.task-planner-settings label,
.task-planner-weekly-override {
display: grid;
@ -1318,7 +1340,18 @@ textarea:focus {
position: relative;
}
.task-planner-child-list > .task-planner-item::before {
.task-planner-hidden-parent-children {
position: relative;
display: grid;
min-width: 0;
}
.task-planner-hidden-parent-children > .task-planner-child-list {
margin-top: 0;
}
.task-planner-child-list > .task-planner-item::before,
.task-planner-child-list > .task-planner-hidden-parent-children::before {
content: "";
position: absolute;
top: 25px;
@ -1351,7 +1384,7 @@ textarea:focus {
padding: 0 2px;
}
.task-planner-category-header > div {
.task-planner-category-title {
display: inline-flex;
flex: 1 1 auto;
min-width: 0;
@ -1359,6 +1392,13 @@ textarea:focus {
gap: 8px;
}
.task-planner-category-actions {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
gap: 8px;
}
.task-planner-category-header h3 {
min-width: 0;
margin: 0;
@ -1369,7 +1409,7 @@ textarea:focus {
white-space: nowrap;
}
.task-planner-category-header > span {
.task-planner-category-actions > span {
flex: 0 0 auto;
padding: 3px 9px;
border: 1px solid rgba(246, 196, 83, 0.22);
@ -1380,6 +1420,11 @@ textarea:focus {
font-weight: 800;
}
.task-planner-category-section.is-complete .task-planner-category-header h3 {
color: var(--color-text-muted);
text-decoration: line-through;
}
.task-planner-category-section.is-dragging {
cursor: grabbing;
opacity: 0.58;
@ -2426,6 +2471,7 @@ textarea:focus {
.tool-split-entry > button.danger,
.counter-actions button.danger,
.link-item button.danger,
.task-planner-clear-completed-button.danger,
.task-planner-delete-button.danger {
border-color: rgba(251, 113, 133, 0.2);
background: rgba(21, 26, 48, 0.88);
@ -2436,6 +2482,7 @@ textarea:focus {
.tool-split-entry > button.danger:hover,
.counter-actions button.danger:hover,
.link-item button.danger:hover,
.task-planner-clear-completed-button.danger:hover:not(:disabled),
.task-planner-delete-button.danger:hover {
border-color: rgba(251, 113, 133, 0.56);
background:
@ -2451,6 +2498,7 @@ textarea:focus {
.tool-split-entry > button.danger:hover .ui-icon,
.counter-actions button.danger:hover .ui-icon,
.link-item button.danger:hover .ui-icon,
.task-planner-clear-completed-button.danger:hover .ui-icon,
.task-planner-delete-button.danger:hover .ui-icon {
background-color: #fff;
filter: drop-shadow(0 0 6px rgba(251, 113, 133, 0.26));