From e306bfc5d69768c2dd4fbc8072b841ebee2aaf17 Mon Sep 17 00:00:00 2001 From: Shinuwa Date: Mon, 27 Jul 2026 22:44:08 +0200 Subject: [PATCH] add task planner & alert improvments --- docs/STORAGE_SCHEMA.md | 66 ++ tests/helpers/data-validation.mjs | 33 + tests/static-toolboxes.test.mjs | 10 + tests/toolbox-modules.test.mjs | 80 +++ website/public/data/site.json | 35 + website/public/static/icons/settings.svg | 5 + website/public/static/icons/tasklist.svg | 2 + website/src/components/AppOverlays.jsx | 12 + .../src/features/toolboxes/ToolboxPages.jsx | 2 + .../toolboxes/modules/TaskPlannerModule.jsx | 623 ++++++++++++++++++ .../src/features/toolboxes/modules/index.jsx | 2 + .../toolboxes/storage/toolboxStorage.js | 134 +++- website/src/styles/_icons.scss | 10 + website/src/styles/_responsive.scss | 24 + website/src/styles/_toolboxes.scss | 513 +++++++++++++- 15 files changed, 1547 insertions(+), 4 deletions(-) create mode 100644 website/public/static/icons/settings.svg create mode 100644 website/public/static/icons/tasklist.svg create mode 100644 website/src/features/toolboxes/modules/TaskPlannerModule.jsx diff --git a/docs/STORAGE_SCHEMA.md b/docs/STORAGE_SCHEMA.md index 16afe7d..11b6480 100644 --- a/docs/STORAGE_SCHEMA.md +++ b/docs/STORAGE_SCHEMA.md @@ -259,6 +259,72 @@ Stockage compact : - `countdowns[].alertMode` vaut `off`, `visible` ou `site`, et est omis si `off`. - L'activation d'une alerte est bloquée dans l'interface si la fréquence configurée est inférieure à 5 minutes pour les comptes à rebours récurrents (`time_pattern` et `interval`). Le blocage ne dépend pas du temps restant avant la prochaine échéance. +## Outil Task Planner + +Type : `taskPlanner` + +```json +{ + "sortMode": "manual", + "weeklyResetDay": 1, + "resetTime": "00:00", + "lastResetAt": 1760000000000, + "tasks": [ + { + "id": "task1", + "title": "Quête 1", + "description": "Préparer la route.", + "type": "daily", + "checked": true, + "checkedAt": 1760000000000 + }, + { + "id": "task2", + "title": "Quête 2", + "type": "weekly", + "weeklyResetDay": 3 + } + ], + "relations": [ + { + "id": "relation1", + "fromTaskId": "task1", + "toTaskId": "task2" + }, + { + "id": "relation2", + "fromTaskId": "task2", + "toTaskId": "task1", + "dependency": true + } + ] +} +``` + +Notes : + +- `type` vaut `unique`, `daily` ou `weekly`. +- `sortMode` vaut `manual` ou `type`. Le tri par type affiche quotidiennes, hebdo, puis uniques. +- `weeklyResetDay` suit les jours JavaScript : `0` dimanche, `1` lundi, ..., `6` samedi. +- `resetTime` est global à l'outil et vaut `HH:MM`. +- `weeklyResetDay` sur une tâche hebdo surcharge le jour global. +- `relations` stocke les parents directionnels. `fromTaskId` désigne la tâche enfant, `toTaskId` son parent. +- une tâche ne conserve qu'un seul parent. +- `dependency: true` marque aussi ce parent comme pré requis. +- Les pré requis sont non bloquants : ils affichent un avertissement si le parent requis n'est pas effectué. + +Stockage compact : + +- `sortMode` est omis si la valeur vaut `manual`. +- `weeklyResetDay` global est omis si la valeur vaut `1`. +- `resetTime` est omis si la valeur vaut `00:00`. +- `lastResetAt` est omis si la valeur vaut `0`. +- `description` est omis si vide. +- `checked` et `checkedAt` sont omis si la tâche n'est pas effectuée. +- `weeklyResetDay` unitaire est omis si non défini. +- `relations` est omis si vide. +- `relations[].dependency` est omis si `false`. + ## Outil Liens Type : `links` diff --git a/tests/helpers/data-validation.mjs b/tests/helpers/data-validation.mjs index f91a400..2acdfd5 100644 --- a/tests/helpers/data-validation.mjs +++ b/tests/helpers/data-validation.mjs @@ -189,6 +189,39 @@ export function validateSiteContent(site) { "toolboxes.modules.timer.emptyCountdowns", "toolboxes.modules.timer.renameTitle", "toolboxes.modules.timer.deleteTitle", + "toolboxes.modules.taskPlanner.settingsTitle", + "toolboxes.modules.taskPlanner.settingsButtonLabel", + "toolboxes.modules.taskPlanner.sortModeLabel", + "toolboxes.modules.taskPlanner.globalWeeklyResetDayLabel", + "toolboxes.modules.taskPlanner.resetTimeLabel", + "toolboxes.modules.taskPlanner.titlePlaceholder", + "toolboxes.modules.taskPlanner.titleLabel", + "toolboxes.modules.taskPlanner.descriptionPlaceholder", + "toolboxes.modules.taskPlanner.descriptionLabel", + "toolboxes.modules.taskPlanner.typeLabel", + "toolboxes.modules.taskPlanner.uniqueType", + "toolboxes.modules.taskPlanner.dailyType", + "toolboxes.modules.taskPlanner.weeklyType", + "toolboxes.modules.taskPlanner.addButton", + "toolboxes.modules.taskPlanner.reorderTitle", + "toolboxes.modules.taskPlanner.reorderDisabledTitle", + "toolboxes.modules.taskPlanner.checkTitle", + "toolboxes.modules.taskPlanner.uncheckTitle", + "toolboxes.modules.taskPlanner.deleteTitle", + "toolboxes.modules.taskPlanner.showDescriptionTitle", + "toolboxes.modules.taskPlanner.hideDescriptionTitle", + "toolboxes.modules.taskPlanner.taskSettingsTitle", + "toolboxes.modules.taskPlanner.missingDependencyTitle", + "toolboxes.modules.taskPlanner.missingDependencyBadge", + "toolboxes.modules.taskPlanner.missingDependencyNotice", + "toolboxes.modules.taskPlanner.taskWeeklyResetDayLabel", + "toolboxes.modules.taskPlanner.inheritWeeklyResetDay", + "toolboxes.modules.taskPlanner.linksLabel", + "toolboxes.modules.taskPlanner.emptyLinks", + "toolboxes.modules.taskPlanner.addLinkLabel", + "toolboxes.modules.taskPlanner.dependencyCheckboxLabel", + "toolboxes.modules.taskPlanner.removeRelationTitle", + "toolboxes.modules.taskPlanner.emptyTasks", "toolboxes.modules.imageAnnotation.addImage", "toolboxes.modules.imageAnnotation.replaceImage", "toolboxes.modules.imageAnnotation.pastePlaceholder", diff --git a/tests/static-toolboxes.test.mjs b/tests/static-toolboxes.test.mjs index 1604d0b..9ea371f 100644 --- a/tests/static-toolboxes.test.mjs +++ b/tests/static-toolboxes.test.mjs @@ -37,6 +37,7 @@ test("toolbox storage, cards and pages are wired", async () => { assert.match(toolboxStorage, /export function normalizeCountersData/); assert.match(toolboxStorage, /export function normalizeCalculatorData/); assert.match(toolboxStorage, /export function normalizeTimerData/); + assert.match(toolboxStorage, /export function normalizeTaskPlannerData/); assert.match(toolboxStorage, /export function normalizeImageAnnotationData/); assert.match(toolboxStorage, /export function compactModuleDataForStorage\(type, value\)/); assert.match(toolboxStorage, /scrollResults/); @@ -77,6 +78,7 @@ test("toolbox module registry and modules expose expected behavior", async () => const notepadModule = await readFile("website/src/features/toolboxes/modules/NotepadModule.jsx", "utf8"); const calculatorModule = await readFile("website/src/features/toolboxes/modules/CalculatorModule.jsx", "utf8"); const timerModule = await readFile("website/src/features/toolboxes/modules/TimerModule.jsx", "utf8"); + const taskPlannerModule = await readFile("website/src/features/toolboxes/modules/TaskPlannerModule.jsx", "utf8"); const checklistModule = await readFile("website/src/features/toolboxes/modules/ChecklistModule.jsx", "utf8"); const imagesModule = await readFile("website/src/features/toolboxes/modules/ImagesModule.jsx", "utf8"); const imageAnnotationModule = await readFile("website/src/features/toolboxes/modules/ImageAnnotationModule.jsx", "utf8"); @@ -97,6 +99,7 @@ test("toolbox module registry and modules expose expected behavior", async () => assert.match(moduleRegistry, /counters:/); assert.match(moduleRegistry, /calculator:/); assert.match(moduleRegistry, /timer:/); + assert.match(moduleRegistry, /taskPlanner:/); assert.match(moduleRegistry, /imageAnnotation:/); assert.match(moduleRegistry, /Annotation d'images/); assert.match(moduleRegistry, /editable: false/); @@ -134,6 +137,13 @@ test("toolbox module registry and modules expose expected behavior", async () => assert.match(timerModule, /stopwatch/); assert.match(timerModule, /countdown/); assert.match(timerModule, /normalizeTimerData/); + assert.match(taskPlannerModule, /export function TaskPlannerModule/); + assert.match(taskPlannerModule, /normalizeTaskPlannerData/); + assert.match(taskPlannerModule, /applyDueResets/); + assert.match(taskPlannerModule, /relations/); + assert.match(taskPlannerModule, /dependency/); + assert.match(taskPlannerModule, /task-planner-warning/); + assert.match(taskPlannerModule, /usePointerReorder/); assert.match(checklistModule, /export function ChecklistModule/); assert.match(checklistModule, /TextImportModal/); assert.match(checklistModule, /Icon name="import"/); diff --git a/tests/toolbox-modules.test.mjs b/tests/toolbox-modules.test.mjs index 7437cec..c7cef72 100644 --- a/tests/toolbox-modules.test.mjs +++ b/tests/toolbox-modules.test.mjs @@ -3,6 +3,7 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { parseColonImportLines } from "../website/src/features/toolboxes/modules/textImport.js"; import { getTimePatternRecurrenceMs, getTimePatternTargetMs } from "../website/src/features/toolboxes/modules/timerUtils.js"; +import { compactModuleDataForStorage, createToolboxExportPayload, normalizeTaskPlannerData } from "../website/src/features/toolboxes/storage/toolboxStorage.js"; test("colon text import keeps urls intact after the first separator", () => { assert.deepEqual(parseColonImportLines("Build:https://example.com/build?class=rogue\nPotion:10"), [ @@ -26,3 +27,82 @@ test("time pattern recurrence uses configured frequency instead of next remainin assert.equal(getTimePatternRecurrenceMs("X:24:X"), 60 * 60 * 1000); assert.equal(getTimePatternRecurrenceMs("X:X:30"), 60 * 1000); }); + +test("task planner data normalizes invalid settings and relations", () => { + const normalized = normalizeTaskPlannerData({ + sortMode: "unknown", + weeklyResetDay: 9, + resetTime: "27:80", + tasks: [ + { id: "task1", title: "Daily", type: "daily", checked: true, checkedAt: 1000 }, + { id: "task2", title: "Weekly", type: "weekly", weeklyResetDay: 4 }, + { id: "task3", title: "Invalid type", type: "later" } + ], + relations: [ + { id: "link1", fromTaskId: "task1", toTaskId: "task2" }, + { id: "dep1", fromTaskId: "task2", toTaskId: "task1", dependency: true }, + { id: "bad", fromTaskId: "task1", toTaskId: "missing" }, + { id: "self", fromTaskId: "task2", toTaskId: "task2" } + ] + }); + + assert.equal(normalized.sortMode, "manual"); + assert.equal(normalized.weeklyResetDay, 1); + assert.equal(normalized.resetTime, "00:00"); + assert.equal(normalized.tasks[1].weeklyResetDay, 4); + assert.equal(normalized.tasks[2].type, "unique"); + assert.deepEqual(normalized.relations.map((relation) => relation.id), ["link1", "dep1"]); + assert.equal(normalized.relations[0].dependency, false); + assert.equal(normalized.relations[1].dependency, true); +}); + +test("task planner accepts temporary split links data as combined relations", () => { + const normalized = normalizeTaskPlannerData({ + tasks: [ + { id: "task1", title: "Daily", type: "daily" }, + { id: "task2", title: "Weekly", type: "weekly" } + ], + links: [{ id: "link1", fromTaskId: "task1", toTaskId: "task2" }], + dependencies: [{ id: "dep1", fromTaskId: "task2", toTaskId: "task1" }] + }); + + assert.deepEqual(normalized.relations.map((relation) => relation.toTaskId), ["task2", "task1"]); + assert.equal(normalized.relations[0].dependency, false); + assert.equal(normalized.relations[1].dependency, true); +}); + + +test("task planner storage compacts defaults and export remaps task relations", () => { + const compact = compactModuleDataForStorage("taskPlanner", { + sortMode: "type", + weeklyResetDay: 1, + resetTime: "00:00", + tasks: [ + { id: "task1", title: "Daily", description: "", type: "daily", checked: false }, + { id: "task2", title: "Weekly", description: "Run", type: "weekly", checked: true, checkedAt: 2000, weeklyResetDay: 5 } + ], + relations: [ + { id: "link1", fromTaskId: "task1", toTaskId: "task2", dependency: false }, + { id: "dep1", fromTaskId: "task2", toTaskId: "task1", dependency: true } + ] + }); + + assert.equal(compact.sortMode, "type"); + assert.equal(compact.weeklyResetDay, undefined); + assert.equal(compact.resetTime, undefined); + assert.deepEqual(compact.tasks[0], { id: "task1", title: "Daily", type: "daily" }); + assert.equal(compact.tasks[1].weeklyResetDay, 5); + + const exported = createToolboxExportPayload( + { id: "toolbox1", name: "Planner", modules: [{ id: "module1", type: "taskPlanner" }], updatedAt: "2026-01-01T00:00:00.000Z" }, + { "toolbox1:module1": compact } + ); + const exportedPlanner = exported.modules.m1; + assert.deepEqual(exportedPlanner.tasks.map((task) => task.id), ["a1", "a2"]); + assert.equal(exportedPlanner.relations[0].fromTaskId, "a1"); + assert.equal(exportedPlanner.relations[0].toTaskId, "a2"); + assert.equal(exportedPlanner.relations[0].dependency, undefined); + assert.equal(exportedPlanner.relations[1].fromTaskId, "a2"); + assert.equal(exportedPlanner.relations[1].toTaskId, "a1"); + assert.equal(exportedPlanner.relations[1].dependency, true); +}); diff --git a/website/public/data/site.json b/website/public/data/site.json index 6063a45..458edc0 100644 --- a/website/public/data/site.json +++ b/website/public/data/site.json @@ -322,6 +322,41 @@ "renameTitle": "Renommer", "deleteTitle": "Supprimer" }, + "taskPlanner": { + "settingsTitle": "Réglages du planificateur", + "settingsButtonLabel": "Paramètres des réinitialisations", + "sortModeLabel": "Tri automatique par type", + "globalWeeklyResetDayLabel": "Reset hebdo", + "resetTimeLabel": "Heure de reset", + "titlePlaceholder": "Nouvelle tâche", + "titleLabel": "Titre", + "descriptionPlaceholder": "Description", + "descriptionLabel": "Description", + "typeLabel": "Type", + "uniqueType": "Ponctuelle", + "dailyType": "Quotidienne", + "weeklyType": "Hebdomadaire", + "addButton": "Ajouter", + "reorderTitle": "Déplacer", + "reorderDisabledTitle": "Tri manuel désactivé", + "checkTitle": "Marquer effectué", + "uncheckTitle": "Marquer non effectué", + "deleteTitle": "Supprimer", + "showDescriptionTitle": "Afficher la description", + "hideDescriptionTitle": "Masquer la description", + "taskSettingsTitle": "Réglages de la tâche", + "missingDependencyTitle": "Pré requis non effectué", + "missingDependencyBadge": "Pré requis", + "missingDependencyNotice": "Pré requis parent non effectué", + "taskWeeklyResetDayLabel": "Jour hebdo", + "inheritWeeklyResetDay": "Global", + "linksLabel": "Parent", + "emptyLinks": "Aucun parent", + "addLinkLabel": "Définir un parent", + "dependencyCheckboxLabel": "Pré requis", + "removeRelationTitle": "Retirer", + "emptyTasks": "Aucune tâche planifiée." + }, "imageAnnotation": { "addImage": "Ajouter une image", "replaceImage": "Remplacer l'image", diff --git a/website/public/static/icons/settings.svg b/website/public/static/icons/settings.svg new file mode 100644 index 0000000..ec442de --- /dev/null +++ b/website/public/static/icons/settings.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/website/public/static/icons/tasklist.svg b/website/public/static/icons/tasklist.svg new file mode 100644 index 0000000..9a59d72 --- /dev/null +++ b/website/public/static/icons/tasklist.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/website/src/components/AppOverlays.jsx b/website/src/components/AppOverlays.jsx index 06f25a2..3094bec 100644 --- a/website/src/components/AppOverlays.jsx +++ b/website/src/components/AppOverlays.jsx @@ -1,10 +1,22 @@ // Rôle : regroupe les modales, notifications et viewers rendus au-dessus de l'application. import React from "react"; +import { Icon } from "./Icon.jsx"; import { ImageViewer } from "./ImageViewer.jsx"; import { ConfirmModal, CreateToolboxModal, LinkToolboxModal, NotificationToast } from "./ToolboxModals.jsx"; import { navigate } from "../router/hashRouter.js"; import { uid } from "../features/toolboxes/storage/toolboxStorage.js"; +export function InlineNotice({ className = "", iconName = "link", message, tone = "warning" }) { + if (!message) return null; + const classes = ["inline-notice", `is-${tone}`, className].filter(Boolean).join(" "); + return ( + + + {message} + + ); +} + export function AppOverlays({ actions, confirmModal, diff --git a/website/src/features/toolboxes/ToolboxPages.jsx b/website/src/features/toolboxes/ToolboxPages.jsx index 5133967..3ce807f 100644 --- a/website/src/features/toolboxes/ToolboxPages.jsx +++ b/website/src/features/toolboxes/ToolboxPages.jsx @@ -17,6 +17,7 @@ import { normalizeCountersData, normalizeImageAnnotationData, normalizeLinksData, + normalizeTaskPlannerData, normalizeTimerData, normalizeUrl, uid @@ -146,6 +147,7 @@ function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, sto normalizeCalculatorData, normalizeImageAnnotationData, normalizeTimerData, + normalizeTaskPlannerData, normalizeUrl, hostnameFromUrl, copyText, diff --git a/website/src/features/toolboxes/modules/TaskPlannerModule.jsx b/website/src/features/toolboxes/modules/TaskPlannerModule.jsx new file mode 100644 index 0000000..dd66d8c --- /dev/null +++ b/website/src/features/toolboxes/modules/TaskPlannerModule.jsx @@ -0,0 +1,623 @@ +// Rôle : fournit l'outil task planner avec tâches récurrentes, parents et pré requis. +import { useEffect, useMemo, useRef, useState } from "react"; +import { Icon } from "../../../components/Icon.jsx"; +import { InlineNotice } from "../../../components/AppOverlays.jsx"; +import { usePointerReorder } from "../../../hooks/usePointerReorder.js"; + +const TASK_TYPES = ["daily", "weekly", "unique"]; +const WEEK_DAYS = [ + { value: 1, label: "Lundi" }, + { value: 2, label: "Mardi" }, + { value: 3, label: "Mercredi" }, + { value: 4, label: "Jeudi" }, + { value: 5, label: "Vendredi" }, + { value: 6, label: "Samedi" }, + { value: 0, label: "Dimanche" } +]; + +function getTaskTypeLabel(type, textContent) { + if (type === "daily") return textContent.dailyType || "Quotidienne"; + if (type === "weekly") return textContent.weeklyType || "Hebdomadaire"; + return textContent.uniqueType || "Ponctuelle"; +} + +function parseResetTime(value) { + const [hours, minutes] = String(value || "00:00").split(":").map((part) => Number.parseInt(part, 10)); + return { + hours: Number.isInteger(hours) ? Math.min(23, Math.max(0, hours)) : 0, + minutes: Number.isInteger(minutes) ? Math.min(59, Math.max(0, minutes)) : 0 + }; +} + +function getLatestDailyReset(now, resetTime) { + const { hours, minutes } = parseResetTime(resetTime); + const reset = new Date(now); + reset.setHours(hours, minutes, 0, 0); + if (reset.getTime() > now) reset.setDate(reset.getDate() - 1); + return reset.getTime(); +} + +function getLatestWeeklyReset(now, resetTime, weekDay) { + const { hours, minutes } = parseResetTime(resetTime); + const reset = new Date(now); + reset.setHours(hours, minutes, 0, 0); + const dayDiff = (reset.getDay() - weekDay + 7) % 7; + reset.setDate(reset.getDate() - dayDiff); + if (reset.getTime() > now) reset.setDate(reset.getDate() - 7); + return reset.getTime(); +} + +function applyDueResets(data, now = Date.now()) { + let latestResetAt = data.lastResetAt; + let changed = false; + const tasks = data.tasks.map((task) => { + if (task.type === "unique") return task; + const resetAt = task.type === "daily" + ? getLatestDailyReset(now, data.resetTime) + : getLatestWeeklyReset(now, data.resetTime, task.weeklyResetDay ?? data.weeklyResetDay); + latestResetAt = Math.max(latestResetAt, resetAt); + if (!task.checked || resetAt <= (task.checkedAt || data.lastResetAt)) return task; + changed = true; + return { ...task, checked: false, checkedAt: 0 }; + }); + + if (latestResetAt > data.lastResetAt) changed = true; + return changed ? { ...data, lastResetAt: latestResetAt, tasks } : data; +} + +function moveTask(tasks, fromTaskId, toTaskId, placement = "before") { + const nextTasks = [...tasks]; + const fromIndex = nextTasks.findIndex((task) => task.id === fromTaskId); + const toIndex = nextTasks.findIndex((task) => task.id === toTaskId); + if (fromIndex < 0 || toIndex < 0) return tasks; + const [moved] = nextTasks.splice(fromIndex, 1); + const targetIndex = nextTasks.findIndex((task) => task.id === toTaskId); + nextTasks.splice(placement === "after" ? targetIndex + 1 : targetIndex, 0, moved); + return nextTasks; +} + +function sortTasks(tasks, sortMode) { + if (sortMode !== "type") return tasks; + const order = new Map(TASK_TYPES.map((type, index) => [type, index])); + return [...tasks].sort((a, b) => (order.get(a.type) ?? 99) - (order.get(b.type) ?? 99)); +} + +function getTaskTitle(tasks, taskId) { + return tasks.find((task) => task.id === taskId)?.title || "Tâche supprimée"; +} + +function getTaskParentId(taskId, relations, taskIds) { + return relations.find((relation) => relation.fromTaskId === taskId && taskIds.has(relation.toTaskId))?.toTaskId || ""; +} + +function getTaskParentMap(tasks, relations) { + const taskIds = new Set(tasks.map((task) => task.id)); + const parentMap = new Map(tasks.map((task) => [task.id, getTaskParentId(task.id, relations, taskIds)])); + tasks.forEach((task) => { + const visited = new Set([task.id]); + let parentId = parentMap.get(task.id); + while (parentId) { + if (visited.has(parentId)) { + parentMap.set(task.id, ""); + return; + } + visited.add(parentId); + parentId = parentMap.get(parentId); + } + }); + return parentMap; +} + +function getTasksForParent(tasks, parentMap, parentId, sortMode) { + return sortTasks(tasks.filter((task) => (parentMap.get(task.id) || "") === parentId), sortMode); +} + +export function TaskPlannerModule({ toolboxId, moduleId, context, editing }) { + const data = context.normalizeTaskPlannerData(context.getModuleData(toolboxId, moduleId, { tasks: [] })); + const textContent = context.moduleText?.taskPlanner || {}; + const [title, setTitle] = useState(""); + const [type, setType] = useState("daily"); + const [nowMs, setNowMs] = useState(() => Date.now()); + const [settingsOpen, setSettingsOpen] = useState(false); + const [openDescriptions, setOpenDescriptions] = useState(() => new Set()); + const [openTaskSettings, setOpenTaskSettings] = useState(() => new Set()); + const [dependencyNoticeTaskIds, setDependencyNoticeTaskIds] = useState(() => new Set()); + const dependencyNoticeTimeouts = useRef(new Map()); + const parentMap = useMemo(() => getTaskParentMap(data.tasks, data.relations), [data.tasks, data.relations]); + const { + draggingId, + dropTarget, + startDrag + } = usePointerReorder({ + targetSelector: ".task-planner-item", + getTargetId: (target) => target.dataset.taskId, + canDropOn: (target, draggingTaskId) => data.sortMode === "manual" && target.dataset.parentId === (parentMap.get(draggingTaskId) || ""), + onMove: (fromTaskId, toTaskId, placement) => save({ ...data, tasks: moveTask(data.tasks, fromTaskId, toTaskId, placement) }) + }); + + function save(nextData) { + context.setModuleData(toolboxId, moduleId, nextData, "taskPlanner"); + } + + useEffect(() => { + const intervalId = window.setInterval(() => setNowMs(Date.now()), 60000); + return () => window.clearInterval(intervalId); + }, []); + + useEffect(() => () => { + dependencyNoticeTimeouts.current.forEach((timeoutId) => window.clearTimeout(timeoutId)); + }, []); + + useEffect(() => { + const resetData = applyDueResets(data, nowMs); + if (resetData !== data) save(resetData); + }); + + function addTask(event) { + event.preventDefault(); + const cleanTitle = title.trim(); + if (!cleanTitle) return; + save({ + ...data, + tasks: [ + ...data.tasks, + { + id: context.uid("task"), + title: cleanTitle, + description: "", + type, + checked: false, + checkedAt: 0 + } + ] + }); + setTitle(""); + setType("daily"); + } + + function updateTask(taskId, updater) { + save({ ...data, tasks: data.tasks.map((task) => task.id === taskId ? updater(task) : task) }); + } + + function deleteTask(taskId) { + save({ + ...data, + tasks: data.tasks.filter((task) => task.id !== taskId), + relations: data.relations.filter((relation) => relation.fromTaskId !== taskId && relation.toTaskId !== taskId) + }); + } + + function addLinkTarget(fromTaskId, toTaskId) { + if (!toTaskId || fromTaskId === toTaskId || data.relations.some((relation) => relation.fromTaskId === fromTaskId && relation.toTaskId === toTaskId)) return; + save({ + ...data, + relations: [ + ...data.relations.filter((relation) => relation.fromTaskId !== fromTaskId), + { id: context.uid("relation"), fromTaskId, toTaskId, dependency: false } + ] + }); + } + + function removeLinkTarget(fromTaskId, toTaskId) { + save({ + ...data, + relations: data.relations.filter((relation) => relation.fromTaskId !== fromTaskId || relation.toTaskId !== toTaskId) + }); + } + + function setDependency(fromTaskId, toTaskId, dependency) { + const hasRelation = data.relations.some((relation) => relation.fromTaskId === fromTaskId && relation.toTaskId === toTaskId); + save({ + ...data, + relations: hasRelation + ? data.relations.map((relation) => relation.fromTaskId === fromTaskId && relation.toTaskId === toTaskId ? { ...relation, dependency } : relation) + : [...data.relations, { id: context.uid("relation"), fromTaskId, toTaskId, dependency }] + }); + } + + function toggleSet(setter, taskId) { + setter((current) => { + const next = new Set(current); + if (next.has(taskId)) next.delete(taskId); + else next.add(taskId); + return next; + }); + } + + function showDependencyNotice(taskId) { + const currentTimeoutId = dependencyNoticeTimeouts.current.get(taskId); + if (currentTimeoutId) window.clearTimeout(currentTimeoutId); + setDependencyNoticeTaskIds((current) => new Set(current).add(taskId)); + const timeoutId = window.setTimeout(() => { + dependencyNoticeTimeouts.current.delete(taskId); + setDependencyNoticeTaskIds((current) => { + const next = new Set(current); + next.delete(taskId); + return next; + }); + }, 2600); + dependencyNoticeTimeouts.current.set(taskId, timeoutId); + } + + return ( +
+
+
+