Add goal tree toolbox module
All checks were successful
Deploy Sokko G / deploy (push) Successful in 15s

This commit is contained in:
Shinuwa 2026-08-22 18:15:39 +02:00
parent e4cbe3e087
commit 59fdcb0927
63 changed files with 4062 additions and 252 deletions

View file

@ -0,0 +1,163 @@
// Rôle : normalise et compacte les données persistées de l'arbre d'objectifs.
import { normalizeTimestamp, uid } from "./shared.js";
export const DEFAULT_GOAL_TREE_WEEKLY_RESET_DAY = 1;
export const DEFAULT_GOAL_TREE_RESET_TIME = "00:00";
const NODE_KINDS = new Set(["step", "text", "checklist"]);
const NODE_ICONS = new Set(["cube", "fire", "flask", "grass", "leaf", "meat", "powder", "tear", "ball"]);
const NODE_COLORS = new Set(["gold", "cyan", "violet", "pink", "green", "gray", "red", "orange", "yellow", "blue", "indigo"]);
const RESET_TYPES = new Set(["none", "daily", "weekly"]);
const ORIENTATIONS = new Set(["horizontal", "vertical"]);
const PROGRESS_MODES = new Set(["auto", "manual"]);
function isClockTimeString(value) {
if (!/^\d{2}:\d{2}$/.test(String(value || ""))) return false;
const [hours, minutes] = String(value).split(":").map(Number);
return Number.isInteger(hours) && Number.isInteger(minutes) && hours >= 0 && hours <= 23 && minutes >= 0 && minutes <= 59;
}
function normalizeWeekDay(value, fallback = DEFAULT_GOAL_TREE_WEEKLY_RESET_DAY) {
const parsed = Number.parseInt(value, 10);
return Number.isInteger(parsed) && parsed >= 0 && parsed <= 6 ? parsed : fallback;
}
function normalizeQuantity(value, fallback = 0) {
const parsed = Number(String(value ?? "").replace(",", "."));
return Number.isFinite(parsed) ? Math.max(0, parsed) : fallback;
}
function normalizeFormula(value) {
return String(value || "").replaceAll(",", ".").trim();
}
function normalizeNodeItems(kind, items) {
if (!["text", "checklist"].includes(kind)) return [];
return (Array.isArray(items) ? items : [])
.map((item) => {
const label = String(item?.label || item?.text || "");
const normalized = { id: item?.id || uid("goalItem"), label };
if (kind === "checklist") {
normalized.formula = normalizeFormula(item?.formula || item?.qty || item?.qtyTarget || "1") || "1";
normalized.qtyTarget = normalizeQuantity(item?.qtyTarget, item?.checked === true ? 1 : 1) || 1;
normalized.qtyCurrent = normalizeQuantity(item?.qtyCurrent, item?.checked === true ? 1 : 0);
}
return normalized;
});
}
function normalizeParentIds(value) {
return [...new Set((Array.isArray(value) ? value : [])
.map((id) => String(id || "").trim())
.filter(Boolean))];
}
function normalizeGoalTreeNode(node) {
const title = String(node?.title || "") || "Objectif";
const kind = NODE_KINDS.has(node?.kind) ? node.kind : "step";
const resetType = RESET_TYPES.has(node?.resetType) ? node.resetType : "none";
const resetTime = isClockTimeString(node?.resetTime) ? node.resetTime : DEFAULT_GOAL_TREE_RESET_TIME;
const isStep = kind === "step";
return {
id: node?.id || uid("goal"),
parentId: String(node?.parentId || ""),
parentIds: normalizeParentIds(node?.parentIds),
title,
kind,
icon: NODE_ICONS.has(node?.icon) ? node.icon : "cube",
color: NODE_COLORS.has(node?.color) ? node.color : "gold",
qtyTarget: isStep ? normalizeQuantity(node?.qtyTarget, 1) || 1 : 1,
qtyCurrent: isStep ? normalizeQuantity(node?.qtyCurrent, 0) : 0,
formula: isStep ? normalizeFormula(node?.formula) : "",
progressMode: isStep && PROGRESS_MODES.has(node?.progressMode) ? node.progressMode : "",
items: normalizeNodeItems(kind, node?.items),
resetType,
resetTime,
weeklyResetDay: normalizeWeekDay(node?.weeklyResetDay),
checkedAt: normalizeTimestamp(node?.checkedAt),
collapsed: node?.collapsed === true
};
}
function clearInvalidParents(nodes) {
const nodeIds = new Set(nodes.map((node) => node.id));
const parentById = new Map(nodes.map((node) => [node.id, nodeIds.has(node.parentId) ? node.parentId : ""]));
nodes.forEach((node) => {
const visited = new Set([node.id]);
let parentId = parentById.get(node.id);
while (parentId) {
if (visited.has(parentId)) {
parentById.set(node.id, "");
return;
}
visited.add(parentId);
parentId = parentById.get(parentId);
}
});
const normalizedPrimary = nodes.map((node) => ({ ...node, parentId: parentById.get(node.id) || "" }));
const rawParentIdsById = new Map(normalizedPrimary.map((node) => [
node.id,
[...new Set([node.parentId, ...node.parentIds].filter((parentId) => nodeIds.has(parentId) && parentId !== node.id))]
]));
function wouldCreateCycle(nodeId, parentId) {
const visited = new Set([nodeId]);
const stack = [parentId];
while (stack.length) {
const currentId = stack.pop();
if (currentId === nodeId) return true;
if (!currentId || visited.has(currentId)) continue;
visited.add(currentId);
stack.push(...(rawParentIdsById.get(currentId) || []));
}
return false;
}
return normalizedPrimary.map((node) => ({
...node,
parentIds: (rawParentIdsById.get(node.id) || []).filter((parentId) => !wouldCreateCycle(node.id, parentId))
}));
}
export function normalizeGoalTreeData(data) {
const nodes = clearInvalidParents((Array.isArray(data?.nodes) ? data.nodes : []).map(normalizeGoalTreeNode));
return {
lastResetAt: normalizeTimestamp(data?.lastResetAt),
orientation: ORIENTATIONS.has(data?.orientation) ? data.orientation : "horizontal",
nodes
};
}
export function compactGoalTreeDataForStorage(value) {
const normalized = normalizeGoalTreeData(value);
const compact = {};
if (normalized.lastResetAt) compact.lastResetAt = normalized.lastResetAt;
if (normalized.orientation !== "horizontal") compact.orientation = normalized.orientation;
if (normalized.nodes.length) {
compact.nodes = normalized.nodes.map((node) => {
const item = { id: node.id, title: node.title };
if (node.parentId) item.parentId = node.parentId;
if (node.parentIds.some((parentId) => parentId !== node.parentId)) item.parentIds = node.parentIds;
if (node.kind !== "step") item.kind = node.kind;
if (node.icon !== "cube") item.icon = node.icon;
if (node.color !== "gold") item.color = node.color;
if (node.kind === "step" && node.qtyTarget !== 1) item.qtyTarget = node.qtyTarget;
if (node.kind === "step" && node.qtyCurrent) item.qtyCurrent = node.qtyCurrent;
if (node.kind === "step" && node.formula) item.formula = node.formula;
if (node.kind === "step" && node.progressMode) item.progressMode = node.progressMode;
if (node.kind !== "step" && node.items.length) item.items = node.items.map((entry) => {
const compactEntry = { id: entry.id, label: entry.label };
if (node.kind === "checklist") {
if (entry.formula !== "1") compactEntry.formula = entry.formula;
if (entry.qtyTarget !== 1) compactEntry.qtyTarget = entry.qtyTarget;
if (entry.qtyCurrent) compactEntry.qtyCurrent = entry.qtyCurrent;
}
return compactEntry;
});
if (node.resetType !== "none") item.resetType = node.resetType;
if (node.resetTime !== DEFAULT_GOAL_TREE_RESET_TIME) item.resetTime = node.resetTime;
if (node.weeklyResetDay !== DEFAULT_GOAL_TREE_WEEKLY_RESET_DAY) item.weeklyResetDay = node.weeklyResetDay;
if (node.checkedAt) item.checkedAt = node.checkedAt;
if (node.collapsed) item.collapsed = true;
return item;
});
}
return Object.keys(compact).length ? compact : null;
}

View file

@ -5,6 +5,7 @@ import { compactChecklistDataForStorage } from "./checklist.js";
import { compactCombosDataForStorage } from "./combos.js";
import { compactCountersDataForStorage } from "./counters.js";
import { compactEquipmentPlannerDataForStorage } from "./equipmentPlanner.js";
import { compactGoalTreeDataForStorage } from "./goalTree.js";
import { compactImageAnnotationDataForStorage, compactImagesDataForStorage } from "./imageAnnotation.js";
import { compactLinksDataForStorage } from "./links.js";
import { compactNotepadDataForStorage } from "./notepad.js";
@ -25,5 +26,6 @@ export const MODULE_DATA_COMPACTORS = {
table: compactTableDataForStorage,
timer: compactTimerDataForStorage,
taskPlanner: compactTaskPlannerDataForStorage,
goalTree: compactGoalTreeDataForStorage,
equipmentPlanner: compactEquipmentPlannerDataForStorage
};

View file

@ -13,6 +13,7 @@ export const ID_PREFIXES = {
marker: "k",
timer: "z",
task: "a",
goal: "g",
relation: "e",
stroke: "d",
equipmentType: "y",

View file

@ -10,6 +10,7 @@ export { normalizeCombosData } from "./modules/combos.js";
export { normalizeCountersData } from "./modules/counters.js";
export { normalizeDrawingData } from "./modules/drawing.js";
export { normalizeEquipmentPlannerData, summarizeEquipmentPlannerData } from "./modules/equipmentPlanner.js";
export { normalizeGoalTreeData } from "./modules/goalTree.js";
export { labelFromFileName, normalizeImageAnnotationData } from "./modules/imageAnnotation.js";
export { hostnameFromUrl, normalizeLinksData, normalizeUrl } from "./modules/links.js";
export { normalizeNotepadData, sanitizeNotepadHtml } from "./modules/notepad.js";
@ -275,6 +276,22 @@ function remapModuleDataForExport(type, data, nextId) {
return remapped;
}
if (type === "goalTree") {
const nodeIdMap = new Map();
compact.nodes?.forEach((node) => nodeIdMap.set(node.id, nextId("goal")));
const remapped = { ...compact };
if (compact.nodes) {
remapped.nodes = compact.nodes.map((node) => {
const nextNode = { ...node, id: nodeIdMap.get(node.id) };
if (node.parentId) nextNode.parentId = nodeIdMap.get(node.parentId) || "";
if (node.parentIds) nextNode.parentIds = node.parentIds.map((parentId) => nodeIdMap.get(parentId)).filter(Boolean);
if (node.items) nextNode.items = node.items.map((item) => ({ ...item, id: nextId("goal") }));
return nextNode;
});
}
return remapped;
}
if (type === "equipmentPlanner") {
const typeIdMap = new Map();
const equipmentIdMap = new Map();