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

@ -20,6 +20,7 @@ import {
normalizeCombosData,
normalizeCountersData,
normalizeEquipmentPlannerData,
normalizeGoalTreeData,
normalizeImageAnnotationData,
normalizeLinksData,
normalizeModuleOrder,
@ -175,7 +176,7 @@ function shouldShowTutorial(toolbox, pendingTutorialToolboxId) {
return toolbox?.tutorial === true || pendingTutorialToolboxId === toolbox?.id;
}
function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, storageUsage, getModuleData, updateToolbox, updateModuleData, addImageFiles, pendingTutorialToolboxId = "", setPendingTutorialToolboxId }) {
function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, storageUsage, storageReady = true, getModuleData, updateToolbox, updateModuleData, addImageFiles, pendingTutorialToolboxId = "", setPendingTutorialToolboxId }) {
const toolboxId = toolbox.id;
const toolboxTutorial = toolbox.tutorial;
const moduleColumns = embedded || Number(toolbox.moduleColumns) === 1 ? 1 : 2;
@ -185,12 +186,14 @@ function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, sto
const currentToolboxIdRef = useRef(toolboxId);
const moduleContext = {
getModuleData,
storageReady,
moduleText,
normalizeChecklistData,
normalizeCombosData,
normalizeLinksData,
normalizeCountersData,
normalizeEquipmentPlannerData,
normalizeGoalTreeData,
normalizeCalculatorData,
normalizeCalendarData,
normalizeImageAnnotationData,
@ -329,7 +332,7 @@ function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, sto
);
}
export function ToolboxDrawer({ gameId, game, siteContent, toolboxes, links, actions, storageUsage, getModuleData, updateToolbox, updateModuleData, addImageFiles }) {
export function ToolboxDrawer({ gameId, game, siteContent, toolboxes, links, actions, storageUsage, storageReady = true, getModuleData, updateToolbox, updateModuleData, addImageFiles }) {
const selectedId = links[gameId] || "";
const toolbox = toolboxes.find((item) => item.id === selectedId);
const [width, setWidth] = useState("");
@ -389,7 +392,7 @@ export function ToolboxDrawer({ gameId, game, siteContent, toolboxes, links, act
</div>
<button className="drawer-close-button" onClick={() => actions.setDrawerGameId("")} aria-label="Fermer" title="Fermer"><Icon name="close" /></button>
</header>
{toolbox ? <ToolboxView siteContent={siteContent} toolbox={toolbox} toolboxGame={game} embedded actions={actions} storageUsage={storageUsage} getModuleData={getModuleData} updateToolbox={updateToolbox} updateModuleData={updateModuleData} addImageFiles={addImageFiles} /> : (
{toolbox ? <ToolboxView siteContent={siteContent} toolbox={toolbox} toolboxGame={game} embedded actions={actions} storageUsage={storageUsage} storageReady={storageReady} getModuleData={getModuleData} updateToolbox={updateToolbox} updateModuleData={updateModuleData} addImageFiles={addImageFiles} /> : (
<div className="empty"><p>Aucune toolbox associée à cette page jeu.</p><button className="primary" onClick={() => actions.setCreateModal({ gameId })}>Créer et associer</button></div>
)}
<StorageQuota usage={storageUsage} />

View file

@ -116,7 +116,7 @@ function FormattedCalculatorNumber({ value }) {
});
}
export function CalculatorModule({ toolboxId, moduleId, context, editing }) {
export function CalculatorModule({ toolboxId, moduleId, context, textExchangeOpen }) {
const data = context.normalizeCalculatorData(context.getModuleData(toolboxId, moduleId, { entries: [] }));
const calculatedEntries = useMemo(() => evaluateCalculatorEntries(data.entries), [data.entries]);
const calculatedData = useMemo(() => ({ ...data, entries: calculatedEntries }), [data, calculatedEntries]);
@ -235,8 +235,8 @@ export function CalculatorModule({ toolboxId, moduleId, context, editing }) {
return (
<div className="calculator-module">
{editing && (
<div className="module-add-panel calculator-exchange-panel">
{textExchangeOpen && (
<div className="module-text-exchange-panel">
<TextExchangeActions type="calculator" data={calculatedData} context={context} textContent={textContent} onImport={(nextData) => context.setModuleData(toolboxId, moduleId, nextData, "calculator")} />
</div>
)}
@ -289,7 +289,7 @@ export function CalculatorModule({ toolboxId, moduleId, context, editing }) {
</div>
<div className={`tool-split-tree calculator-tree ${data.scrollResults ? "is-scrollable legacy-scrollbar" : ""}`}>
{calculatedEntries.length ? (
<CalculatorEntries entries={calculatedEntries} parentId="" getScopedParentId={scopeParentId} activeParentId={activeParentId} textContent={textContent} editing={editing} reorder={reorder} onUse={useEntry} onRename={renameEntry} onUpdateFormula={updateEntryFormula} onDelete={deleteEntry} />
<CalculatorEntries entries={calculatedEntries} parentId="" getScopedParentId={scopeParentId} activeParentId={activeParentId} textContent={textContent} reorder={reorder} onUse={useEntry} onRename={renameEntry} onUpdateFormula={updateEntryFormula} onDelete={deleteEntry} />
) : (
<p className="muted">{textContent.emptyResults || "Aucun résultat enregistré."}</p>
)}
@ -299,7 +299,7 @@ export function CalculatorModule({ toolboxId, moduleId, context, editing }) {
);
}
function CalculatorEntries({ entries, parentId, getScopedParentId, activeParentId, textContent, editing, reorder, onUse, onRename, onUpdateFormula, onDelete }) {
function CalculatorEntries({ entries, parentId, getScopedParentId, activeParentId, textContent, reorder, onUse, onRename, onUpdateFormula, onDelete }) {
const children = getChildren(entries, parentId);
const [editingId, setEditingId] = useState("");
useEffect(() => {
@ -342,8 +342,8 @@ function CalculatorEntries({ entries, parentId, getScopedParentId, activeParentI
</button>
{entry.id === editingId ? (
<span className="calculator-entry-edit-fields" data-calculator-edit-fields={entry.id}>
<EditableCalculatorFormula entry={entry} entries={entries} textContent={textContent} onUpdateFormula={onUpdateFormula} autoFocus={editing} />
<EditableCalculatorLabel entry={entry} textContent={textContent} onRename={onRename} autoFocus={!editing} />
<EditableCalculatorFormula entry={entry} entries={entries} textContent={textContent} onUpdateFormula={onUpdateFormula} />
<EditableCalculatorLabel entry={entry} textContent={textContent} onRename={onRename} />
</span>
) : (
<button className="tool-split-entry-summary" type="button" onClick={() => onUse(entry)} title={textContent.useEntryTitle || "Utiliser comme base"}>
@ -362,7 +362,7 @@ function CalculatorEntries({ entries, parentId, getScopedParentId, activeParentI
<Icon name="trash" />
</button>
</div>
<CalculatorEntries entries={entries} parentId={entry.id} getScopedParentId={getScopedParentId} activeParentId={activeParentId} textContent={textContent} editing={editing} reorder={reorder} onUse={onUse} onRename={onRename} onUpdateFormula={onUpdateFormula} onDelete={onDelete} />
<CalculatorEntries entries={entries} parentId={entry.id} getScopedParentId={getScopedParentId} activeParentId={activeParentId} textContent={textContent} reorder={reorder} onUse={onUse} onRename={onRename} onUpdateFormula={onUpdateFormula} onDelete={onDelete} />
</li>
);
})}

View file

@ -306,7 +306,7 @@ function getMonthOccurrenceSegments(days, occurrences) {
});
}
export function CalendarModule({ toolboxId, moduleId, context, editing = false, setEditing }) {
export function CalendarModule({ toolboxId, moduleId, context, editing = false, setEditing, textExchangeOpen }) {
const data = context.normalizeCalendarData(context.getModuleData(toolboxId, moduleId, { initialized: false, viewMode: "week", events: [] }));
const textContent = context.moduleText?.calendar || {};
const [now, setNow] = useState(() => new Date());
@ -780,8 +780,8 @@ export function CalendarModule({ toolboxId, moduleId, context, editing = false,
</div>
{error && <p className="timer-form-error calendar-form-error" role="alert"><Icon name="warning" /><span>{error}</span></p>}
</form>}
{editing && (
<div className="calendar-exchange-panel">
{textExchangeOpen && (
<div className="module-text-exchange-panel">
<TextExchangeActions type="calendar" data={data} context={context} textContent={textContent} onImport={(nextData) => save(nextData)} />
</div>
)}

View file

@ -5,7 +5,7 @@ import { useGroupedReorder, moveItem } from "../../../hooks/useGroupedReorder.js
import { useInlineEdit } from "../../../hooks/useInlineEdit.js";
import { TextExchangeActions } from "./TextExchangeActions.jsx";
export function ChecklistModule({ toolboxId, moduleId, context, editing }) {
export function ChecklistModule({ toolboxId, moduleId, context, editing, textExchangeOpen }) {
const data = context.normalizeChecklistData(context.getModuleData(toolboxId, moduleId, { items: [] }));
const textContent = context.moduleText?.checklist || {};
const [label, setLabel] = useState("");
@ -117,6 +117,10 @@ export function ChecklistModule({ toolboxId, moduleId, context, editing }) {
<input className="checklist-qty-input" name="qty" type="number" min="1" value={qty} onChange={(event) => setQty(event.target.value)} aria-label={textContent.quantityLabel || "Quantité cible"} />
<button className="primary">{textContent.addButton || "Ajouter"}</button>
</form>
</div>
)}
{textExchangeOpen && (
<div className="module-text-exchange-panel">
<TextExchangeActions type="checklist" data={data} context={context} textContent={textContent} onImport={(nextData) => context.setModuleData(toolboxId, moduleId, nextData, "checklist")} />
</div>
)}

View file

@ -21,7 +21,7 @@ import {
setComboItemCategory
} from "./combosUtils.js";
export function CombosModule({ toolboxId, moduleId, context, editing }) {
export function CombosModule({ toolboxId, moduleId, context, editing, textExchangeOpen }) {
const data = context.normalizeCombosData(context.getModuleData(toolboxId, moduleId, { combos: [] }));
const textContent = context.moduleText?.combos || {};
const [comboCategory, setComboCategory] = useState("");
@ -589,9 +589,11 @@ export function CombosModule({ toolboxId, moduleId, context, editing }) {
</div>
</div>
</form>
{editing && !editingComboId && (
<TextExchangeActions type="combos" data={data} context={context} textContent={textContent} onImport={(nextData) => save(nextData)} />
)}
</div>
)}
{textExchangeOpen && (
<div className="module-text-exchange-panel">
<TextExchangeActions type="combos" data={data} context={context} textContent={textContent} onImport={(nextData) => save(nextData)} />
</div>
)}

View file

@ -4,7 +4,7 @@ import { useDraftForm } from "../../../hooks/useDraftForm.js";
import { moveItem, useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
import { TextExchangeActions } from "./TextExchangeActions.jsx";
export function CountersModule({ toolboxId, moduleId, context, editing }) {
export function CountersModule({ toolboxId, moduleId, context, editing, textExchangeOpen }) {
const data = context.normalizeCountersData(context.getModuleData(toolboxId, moduleId, { counters: [] }));
const textContent = context.moduleText?.counters || {};
const counterDraft = useDraftForm({ label: "" });
@ -50,6 +50,10 @@ export function CountersModule({ toolboxId, moduleId, context, editing }) {
<input {...counterDraft.getFieldProps("label", { placeholder: textContent.labelPlaceholder || "Nom du compteur" })} />
<button className="primary">{textContent.addButton || "Ajouter"}</button>
</form>
</div>
)}
{textExchangeOpen && (
<div className="module-text-exchange-panel">
<TextExchangeActions type="counters" data={data} context={context} textContent={textContent} onImport={(nextData) => context.setModuleData(toolboxId, moduleId, nextData, "counters")} />
</div>
)}

View file

@ -7,7 +7,7 @@ import { EquipmentItem } from "./EquipmentPlannerItem.jsx";
import { IconChoiceDropdown } from "./EquipmentPlannerPickers.jsx";
import { EquipmentPlannerSummary } from "./EquipmentPlannerSummary.jsx";
export function EquipmentPlannerModule({ toolboxId, moduleId, context, editing }) {
export function EquipmentPlannerModule({ toolboxId, moduleId, context, editing, textExchangeOpen }) {
const data = context.normalizeEquipmentPlannerData(context.getModuleData(toolboxId, moduleId, { types: [], equipments: [] }));
const summary = useMemo(() => context.summarizeEquipmentPlannerData(data), [context, data]);
const textContent = context.moduleText?.equipmentPlanner || {};
@ -112,6 +112,10 @@ export function EquipmentPlannerModule({ toolboxId, moduleId, context, editing }
<input value={equipmentName} onChange={(event) => setEquipmentName(event.target.value)} placeholder={textContent.equipmentPlaceholder || "Nouvel équipement"} />
<button className="primary">{textContent.addEquipmentButton || "Ajouter"}</button>
</form>
</div>
)}
{textExchangeOpen && (
<div className="module-text-exchange-panel">
<TextExchangeActions type="equipmentPlanner" data={data} context={context} textContent={textContent} onImport={(nextData) => save(nextData)} />
</div>
)}

File diff suppressed because it is too large Load diff

View file

@ -9,7 +9,7 @@ function markerLabel(index, marker, textContent) {
return marker.label || `${textContent.markerPrefix || "Marqueur"} ${index + 1}`;
}
export function ImageAnnotationModule({ toolboxId, moduleId, context, editing }) {
export function ImageAnnotationModule({ toolboxId, moduleId, context, editing, textExchangeOpen }) {
const data = context.normalizeImageAnnotationData(context.getModuleData(toolboxId, moduleId, { image: "", markers: [] }));
const textContent = context.moduleText?.imageAnnotation || {};
const previewMediaRef = useRef(null);
@ -105,6 +105,10 @@ export function ImageAnnotationModule({ toolboxId, moduleId, context, editing })
>
{pastePlaceholder}
</div>
</div>
)}
{textExchangeOpen && (
<div className="module-text-exchange-panel">
<TextExchangeActions type="imageAnnotation" data={data} context={context} textContent={textContent} onImport={(nextData) => {
save(nextData);
setTemporaryDrawings({ strokes: [] });

View file

@ -4,7 +4,7 @@ import { Icon } from "../../../components/Icon.jsx";
import { moveItem, useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
import { TextExchangeActions } from "./TextExchangeActions.jsx";
export function ImagesModule({ toolboxId, moduleId, context, editing }) {
export function ImagesModule({ toolboxId, moduleId, context, editing, textExchangeOpen }) {
const data = context.getModuleData(toolboxId, moduleId, { images: [] });
const textContent = context.moduleText?.images || {};
const [dragOver, setDragOver] = useState(false);
@ -86,6 +86,10 @@ export function ImagesModule({ toolboxId, moduleId, context, editing }) {
>
{pastePlaceholder}
</div>
</div>
)}
{textExchangeOpen && (
<div className="module-text-exchange-panel">
<TextExchangeActions type="images" data={data} context={context} textContent={textContent} onImport={(nextData) => context.setModuleData(toolboxId, moduleId, nextData, "images")} />
</div>
)}

View file

@ -5,7 +5,7 @@ import { useDraftForm } from "../../../hooks/useDraftForm.js";
import { moveItem, useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
import { TextExchangeActions } from "./TextExchangeActions.jsx";
export function LinksModule({ toolboxId, moduleId, context, editing }) {
export function LinksModule({ toolboxId, moduleId, context, editing, textExchangeOpen }) {
const data = context.normalizeLinksData(context.getModuleData(toolboxId, moduleId, { links: [] }));
const textContent = context.moduleText?.links || {};
const linkDraft = useDraftForm({ title: "", url: "" });
@ -63,6 +63,10 @@ export function LinksModule({ toolboxId, moduleId, context, editing }) {
<input {...linkDraft.getFieldProps("url", { placeholder: textContent.urlPlaceholder || "https://..." })} />
<button className="primary">{textContent.addButton || "Ajouter"}</button>
</form>
</div>
)}
{textExchangeOpen && (
<div className="module-text-exchange-panel">
<TextExchangeActions type="links" data={data} context={context} textContent={textContent} onImport={(nextData) => context.setModuleData(toolboxId, moduleId, nextData, "links")} />
</div>
)}

View file

@ -145,7 +145,7 @@ function ListTypeDropdown({ textContent, activeFormats, onCommand }) {
);
}
export function NotepadModule({ toolboxId, moduleId, context, editing = false }) {
export function NotepadModule({ toolboxId, moduleId, context, textExchangeOpen }) {
const storedData = context.getModuleData(toolboxId, moduleId, { text: "" });
const normalizedData = useMemo(() => context.normalizeNotepadData(storedData), [context, storedData]);
const textContent = context.moduleText?.notepad || {};
@ -360,8 +360,8 @@ export function NotepadModule({ toolboxId, moduleId, context, editing = false })
return (
<div className="notepad-module">
{editing && (
<div className="module-add-panel notepad-exchange-panel">
{textExchangeOpen && (
<div className="module-text-exchange-panel">
<TextExchangeActions type="notepad" data={normalizedData} context={context} textContent={textContent} onImport={(nextData) => context.setModuleData(toolboxId, moduleId, nextData, "notepad")} />
</div>
)}

View file

@ -26,7 +26,7 @@ import {
pasteCellBlock
} from "./tableUtils.js";
export function TableModule({ toolboxId, moduleId, context, editing }) {
export function TableModule({ toolboxId, moduleId, context, textExchangeOpen }) {
const data = context.normalizeTableData(context.getModuleData(toolboxId, moduleId, { rows: DEFAULT_ROWS, columns: DEFAULT_COLUMNS, cells: {} }));
const textContent = context.moduleText?.table || {};
const [editingCell, setEditingCell] = useState("");
@ -406,8 +406,8 @@ export function TableModule({ toolboxId, moduleId, context, editing }) {
return (
<div className="table-module">
{editing && (
<div className="module-add-panel table-exchange-panel">
{textExchangeOpen && (
<div className="module-text-exchange-panel">
<TextExchangeActions type="table" data={data} context={context} textContent={textContent} exportLabel="Exporter TSV" onImport={(nextData) => context.setModuleData(toolboxId, moduleId, nextData, "table")} />
</div>
)}

View file

@ -18,7 +18,7 @@ import {
moveTask
} from "./taskPlannerUtils.js";
export function TaskPlannerModule({ toolboxId, moduleId, context, editing }) {
export function TaskPlannerModule({ toolboxId, moduleId, context, editing, textExchangeOpen }) {
const data = context.normalizeTaskPlannerData(context.getModuleData(toolboxId, moduleId, { tasks: [] }));
const textContent = context.moduleText?.taskPlanner || {};
const taskDraft = useDraftForm({ title: "", type: "daily" });
@ -314,6 +314,10 @@ export function TaskPlannerModule({ toolboxId, moduleId, context, editing }) {
</select>
<button className="primary">{textContent.addButton || "Ajouter"}</button>
</form>
</div>
)}
{textExchangeOpen && (
<div className="module-text-exchange-panel">
<TextExchangeActions type="taskPlanner" data={data} context={context} textContent={textContent} onImport={(nextData) => save(nextData)} />
</div>
)}

View file

@ -29,7 +29,7 @@ import {
isAlertGuarded
} from "./timerOptions.js";
export function TimerModule({ toolboxId, moduleId, context, editing }) {
export function TimerModule({ toolboxId, moduleId, context, textExchangeOpen }) {
const data = context.normalizeTimerData(context.getModuleData(toolboxId, moduleId, { activeTab: "stopwatch" }));
const textContent = context.moduleText?.timer || {};
const activeTab = data.activeTab;
@ -247,8 +247,8 @@ export function TimerModule({ toolboxId, moduleId, context, editing }) {
return (
<div className="timer-module">
{editing && (
<div className="module-add-panel timer-exchange-panel">
{textExchangeOpen && (
<div className="module-text-exchange-panel">
<TextExchangeActions type="timer" data={data} context={context} textContent={textContent} onImport={(nextData) => save(nextData)} />
</div>
)}

View file

@ -0,0 +1,269 @@
// Rôle : regroupe les calculs purs de l'arbre d'objectifs.
export const GOAL_TREE_KINDS = ["step", "text", "checklist"];
export const GOAL_TREE_RESET_TYPES = ["none", "daily", "weekly"];
export const GOAL_TREE_ICON_NAMES = ["cube", "fire", "flask", "grass", "leaf", "meat", "powder", "tear", "ball"];
export const GOAL_TREE_COLORS = ["gold", "cyan", "violet", "pink", "green", "gray", "red", "orange", "yellow", "blue", "indigo"];
export const GOAL_TREE_COLOR_VALUES = {
gold: "#f6c453",
cyan: "#22d3ee",
violet: "#8b5cf6",
pink: "#d946ef",
green: "#34d399",
gray: "#94a3b8",
red: "#ef4444",
orange: "#f97316",
yellow: "#eab308",
blue: "#3b82f6",
indigo: "#6366f1"
};
export const GOAL_TREE_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 formatResult(value) {
if (!Number.isFinite(value)) return "";
return Number.parseFloat(value.toFixed(6)).toString();
}
export function calculateGoalTreeFormula(expression, baseValue = null, { clamp = true } = {}) {
const normalized = String(expression || "").replaceAll(",", ".").trim();
if (!normalized) return null;
const withBase = normalized.replace(/\bbase\b/gi, () => Number.isFinite(baseValue) ? `(${formatResult(baseValue)})` : "NaN");
if (!/^[\d\s+\-*/().]+$/.test(withBase)) return null;
try {
const value = Function(`"use strict"; return (${withBase})`)();
if (!Number.isFinite(value)) return null;
return clamp ? Math.max(0, value) : value;
} catch {
return null;
}
}
export function formatGoalTreeNumber(value) {
if (!Number.isFinite(value)) return "0";
return new Intl.NumberFormat("fr-FR", { maximumFractionDigits: 2 }).format(Number.parseFloat(value.toFixed(2)));
}
export function getGoalTreeChildren(nodes, parentId) {
return nodes.filter((node) => (node.parentId || "") === parentId);
}
export function getGoalTreeParentIds(node) {
return [...new Set([node?.parentId || "", ...(Array.isArray(node?.parentIds) ? node.parentIds : [])].filter(Boolean))];
}
export function getGoalTreeDescendantIds(nodes, parentId) {
return getGoalTreeChildren(nodes, parentId).flatMap((child) => [child.id, ...getGoalTreeDescendantIds(nodes, child.id)]);
}
export function getGoalTreeParentMap(nodes) {
const ids = new Set(nodes.map((node) => node.id));
return new Map(nodes.map((node) => [node.id, ids.has(node.parentId) ? node.parentId : ""]));
}
function isBaseScaledFormula(expression) {
const normalized = String(expression || "").replaceAll(",", ".").trim();
return /\bbase\b/i.test(normalized) && /^[\d\s*/().base]+$/i.test(normalized);
}
export function evaluateGoalTreeNodes(nodes) {
const nodeMap = new Map(nodes.map((node) => [node.id, node]));
const childrenByParent = new Map();
nodes.forEach((node) => {
const parentId = node.parentId || "";
childrenByParent.set(parentId, [...(childrenByParent.get(parentId) || []), node.id]);
});
const computedTargets = new Map();
const progressMap = new Map();
function computeTarget(nodeId, seen = new Set()) {
if (computedTargets.has(nodeId)) return computedTargets.get(nodeId);
if (seen.has(nodeId)) return 1;
const node = nodeMap.get(nodeId);
if (!node) return 1;
const parentTarget = node.parentId ? computeTarget(node.parentId, new Set([...seen, nodeId])) : null;
const formulaTarget = node.formula ? calculateGoalTreeFormula(node.formula, parentTarget) : null;
const target = Math.max(0, formulaTarget ?? node.qtyTarget ?? 1);
computedTargets.set(nodeId, target);
return target;
}
nodes.forEach((node) => computeTarget(node.id));
function evaluateChecklistItems(node) {
const parentTarget = node.parentId ? computedTargets.get(node.parentId) : null;
return (node.items || []).map((item) => {
const target = calculateGoalTreeFormula(item.formula || "1", parentTarget) ?? item.qtyTarget ?? 1;
return { ...item, qtyTarget: Math.max(1, target) };
});
}
function getNodeProgressTarget(node) {
if (node?.kind === "checklist") {
return evaluateChecklistItems(node).reduce((sum, item) => sum + Math.max(1, item.qtyTarget || 1), 0) || 1;
}
if (node?.kind === "text") return Math.max(1, node.items?.length || 1);
return Math.max(1, computedTargets.get(node?.id) ?? node?.qtyTarget ?? 1);
}
function getEffectiveStepProgressMode(node, childIds) {
if (node?.kind !== "step") return "auto";
if (node?.progressMode) return node.progressMode;
if (!childIds.length) return "manual";
return childIds.every((childId) => getNodeProgressTarget(nodeMap.get(childId)) <= 1) ? "manual" : "auto";
}
function getBaseScaledRequirementCount(childNode, childProgress, parentTarget) {
if (!parentTarget) return null;
if (childNode.kind === "checklist") {
const items = evaluateChecklistItems(childNode);
if (!items.length || !items.every((item) => isBaseScaledFormula(item.formula))) return null;
return items.map((item) => {
const target = Math.max(1, item.qtyTarget || 1);
const ratio = target / parentTarget;
if (!ratio) return null;
return Math.max(0, item.qtyCurrent || 0) / ratio;
});
}
if (!isBaseScaledFormula(childNode.formula)) return null;
const target = Math.max(0, computedTargets.get(childNode.id) ?? childProgress.target ?? 0);
const ratio = target / parentTarget;
if (!ratio) return null;
return [Math.max(0, childProgress.current || 0) / ratio];
}
function computeBaseScaledChildrenProgress(node, childIds, childProgress) {
const target = Math.max(1, computedTargets.get(node.id) ?? node.qtyTarget ?? 1);
const requirementCounts = [];
for (let index = 0; index < childIds.length; index += 1) {
const childNode = nodeMap.get(childIds[index]);
const counts = childNode ? getBaseScaledRequirementCount(childNode, childProgress[index], target) : null;
if (!counts) return null;
requirementCounts.push(...counts);
}
if (!requirementCounts.length) return null;
const current = Math.min(target, Math.floor(Math.min(...requirementCounts)));
return {
current,
target,
ratio: target ? Math.min(1, current / target) : 0,
complete: current >= target
};
}
function computeProgress(nodeId, seen = new Set()) {
if (progressMap.has(nodeId)) return progressMap.get(nodeId);
if (seen.has(nodeId)) return { current: 0, target: 1, ratio: 0, complete: false };
const node = nodeMap.get(nodeId);
const childIds = childrenByParent.get(nodeId) || [];
let progress;
if (childIds.length && getEffectiveStepProgressMode(node, childIds) !== "manual") {
const childProgress = childIds.map((childId) => computeProgress(childId, new Set([...seen, nodeId])));
progress = computeBaseScaledChildrenProgress(node, childIds, childProgress);
if (!progress) {
const target = childProgress.reduce((sum, item) => sum + item.target, 0);
const current = childProgress.reduce((sum, item) => sum + Math.min(item.current, item.target), 0);
progress = {
current,
target: target || 1,
ratio: target ? Math.min(1, current / target) : 0,
complete: childProgress.length > 0 && childProgress.every((item) => item.complete)
};
}
} else {
const target = computedTargets.get(nodeId) ?? 1;
const current = Math.max(0, node?.qtyCurrent || 0);
if (node?.kind === "checklist") {
const items = evaluateChecklistItems(node);
const total = items.reduce((sum, item) => sum + Math.max(0, item.qtyTarget || 1), 0);
const currentTotal = items.reduce((sum, item) => sum + Math.min(Math.max(0, item.qtyCurrent || 0), Math.max(0, item.qtyTarget || 1)), 0);
progress = {
current: currentTotal,
target: total || 1,
ratio: total ? currentTotal / total : 0,
complete: items.length > 0 && currentTotal >= total
};
} else if (node?.kind === "text") {
const count = (node.items || []).length;
progress = {
current: count,
target: count || 1,
ratio: count ? 1 : 0,
complete: count > 0
};
} else {
progress = {
current,
target,
ratio: target ? Math.min(1, current / target) : 1,
complete: current >= target
};
}
}
progressMap.set(nodeId, progress);
return progress;
}
nodes.forEach((node) => computeProgress(node.id));
return nodes.map((node) => ({
...node,
items: node.kind === "checklist" ? evaluateChecklistItems(node) : node.items,
computedQtyTarget: computedTargets.get(node.id) ?? node.qtyTarget,
progress: progressMap.get(node.id) || { current: 0, target: 1, ratio: 0, complete: false }
}));
}
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();
}
export function applyGoalTreeDueResets(data, now = Date.now()) {
let latestResetAt = data.lastResetAt || 0;
let changed = false;
const nodes = data.nodes.map((node) => {
if (!["daily", "weekly"].includes(node.resetType)) return node;
const resetAt = node.resetType === "daily"
? getLatestDailyReset(now, node.resetTime)
: getLatestWeeklyReset(now, node.resetTime, node.weeklyResetDay);
latestResetAt = Math.max(latestResetAt, resetAt);
if (resetAt <= (node.checkedAt || data.lastResetAt || 0)) return node;
if (node.kind === "checklist" && node.items?.some((item) => item.qtyCurrent)) {
changed = true;
return { ...node, items: node.items.map((item) => ({ ...item, qtyCurrent: 0 })), checkedAt: 0 };
}
if (!node.qtyCurrent) return node;
changed = true;
return { ...node, qtyCurrent: 0, checkedAt: 0 };
});
if (latestResetAt > (data.lastResetAt || 0)) changed = true;
return changed ? { ...data, lastResetAt: latestResetAt, nodes } : data;
}

View file

@ -7,6 +7,7 @@ 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 { CalendarModule } from "./CalendarModule.jsx";
import { ChecklistModule } from "./ChecklistModule.jsx";
@ -14,6 +15,7 @@ import { CombosModule } from "./CombosModule.jsx";
import { CountersModule } from "./CountersModule.jsx";
import { ImageAnnotationModule } from "./ImageAnnotationModule.jsx";
import { EquipmentPlannerModule } from "./EquipmentPlannerModule.jsx";
import { GoalTreeModule } from "./GoalTreeModule.jsx";
import { LinksModule } from "./LinksModule.jsx";
import { NotepadModule } from "./NotepadModule.jsx";
import { ImagesModule } from "./ImagesModule.jsx";
@ -31,6 +33,7 @@ const MODULE_COMPONENTS = {
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 },
@ -286,6 +289,9 @@ function ModuleShell({ toolbox, module, context, reorder, itemGroup = "", onRena
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",
@ -298,6 +304,7 @@ function ModuleShell({ toolbox, module, context, reorder, itemGroup = "", onRena
return (
<article
className={className}
data-module-type={module.type}
{...reorder.getItemProps({ itemId: module.id, groupId: itemGroup, parentId: toolbox.id })}
>
<header>
@ -337,10 +344,14 @@ function ModuleShell({ toolbox, module, context, reorder, itemGroup = "", onRena
<i aria-hidden="true" />
</button>
)}
{definition.editable && (
{hasAddPanel && (
<button
className={`module-edit-button ${editing ? "active" : ""}`}
onClick={() => setEditing((value) => !value)}
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"}
@ -348,13 +359,39 @@ function ModuleShell({ toolbox, module, context, reorder, itemGroup = "", onRena
<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" : ""}`}>
<Component toolboxId={toolbox.id} moduleId={module.id} context={context} editing={editing} setEditing={setEditing} />
<Component
toolboxId={toolbox.id}
moduleId={module.id}
context={context}
editing={editing}
setEditing={setEditing}
openEditor={() => {
setTextExchangeOpen(false);
setEditing(true);
}}
textExchangeOpen={textExchangeOpen}
/>
</div>
</article>
);

View file

@ -30,6 +30,7 @@ const SUPPORTED_TEXT_EXCHANGE_TYPES = new Set([
"timer",
"calendar",
"taskPlanner",
"goalTree",
"equipmentPlanner",
"images",
"imageAnnotation"
@ -61,6 +62,7 @@ export function hasTextExchangeContent(type, data) {
if (type === "timer") return Boolean(data?.stopwatch?.laps?.length || data?.countdowns?.length || data?.stopwatch?.elapsedMs);
if (type === "calendar") return Boolean(data?.events?.length);
if (type === "taskPlanner") return Boolean(data?.tasks?.length);
if (type === "goalTree") return Boolean(data?.nodes?.length);
if (type === "equipmentPlanner") return Boolean(data?.equipments?.length);
if (type === "images") return Boolean(data?.images?.length);
if (type === "imageAnnotation") return Boolean(data?.image);
@ -78,6 +80,7 @@ export function exportModuleText(type, data) {
if (type === "timer") return exportTimerText(data);
if (type === "calendar") return exportCalendarText(data);
if (type === "taskPlanner") return exportTaskPlannerText(data);
if (type === "goalTree") return exportGoalTreeText(data);
if (type === "equipmentPlanner") return exportEquipmentPlannerText(data);
if (type === "images") return exportImagesText(data);
if (type === "imageAnnotation") return exportImageAnnotationText(data);
@ -99,6 +102,7 @@ export function importModuleText(type, text, context) {
else if (type === "timer") data = importTimerText(value, context);
else if (type === "calendar") data = importCalendarText(value, context);
else if (type === "taskPlanner") data = importTaskPlannerText(value, context);
else if (type === "goalTree") data = importGoalTreeText(value, context);
else if (type === "equipmentPlanner") data = importEquipmentPlannerText(value, context);
else if (type === "images") data = importImagesText(value, context);
else if (type === "imageAnnotation") data = importImageAnnotationText(value, context);
@ -586,6 +590,90 @@ function importTaskPlannerText(text, context) {
return context.normalizeTaskPlannerData(data);
}
function exportGoalTreeText(data) {
const lines = [];
if (data.lastResetAt) lines.push(`@lastResetAt: ${data.lastResetAt}`);
function visit(parentId = "", depth = 0) {
(data.nodes || []).filter((node) => (node.parentId || "") === parentId).forEach((node) => {
const prefix = " ".repeat(depth);
const formula = String(node.formula || "").trim();
const qty = Number.isFinite(node.computedQtyTarget) ? node.computedQtyTarget : node.qtyTarget;
const suffix = node.kind === "step" ? (formula ? ` = ${formula}` : ` x${qty}`) : "";
const meta = formatMeta({
kind: node.kind !== "step" ? node.kind : "",
icon: node.kind === "step" && node.icon && node.icon !== "cube" ? node.icon : "",
color: node.kind === "step" && node.color && node.color !== "gold" ? node.color : "",
items: node.kind !== "step" && node.items?.length ? encodeURIComponent(JSON.stringify(node.items)) : "",
current: node.qtyCurrent || "",
reset: node.resetType !== "none" ? node.resetType : "",
resetTime: node.resetTime && node.resetTime !== "00:00" ? node.resetTime : "",
weeklyResetDay: node.weeklyResetDay !== 1 ? node.weeklyResetDay : "",
collapsed: node.collapsed
});
lines.push(`${prefix}- ${node.title}${suffix}${meta ? ` | ${meta}` : ""}`);
visit(node.id, depth + 1);
});
}
visit();
return lines.join("\n");
}
function importGoalTreeText(text, context) {
const nodes = [];
const stack = [];
const data = { lastResetAt: 0, nodes };
splitLines(text).forEach((line, index) => {
if (!line.trim()) return;
const cleanLine = line.trim();
const rootMeta = parseMetaLine(cleanLine);
if (rootMeta && !stack.length) {
if (rootMeta.key === "lastResetAt") data.lastResetAt = parseInteger(rootMeta.value, 0);
return;
}
const depth = Math.floor((line.match(/^ */)?.[0].length || 0) / 2);
const match = cleanLine.match(/^-\s+(.+)$/);
if (!match) throw lineError(index, `ligne arbre d'objectifs invalide: ${cleanLine}`);
const { title: rawTitle, meta } = parsePipeParts(match[1]);
const formulaIndex = rawTitle.lastIndexOf("=");
const quantityMatch = rawTitle.match(/\s+x(\d+(?:[,.]\d+)?)\s*$/i);
const hasFormula = formulaIndex >= 0;
const title = (hasFormula ? rawTitle.slice(0, formulaIndex) : rawTitle.replace(/\s+x(\d+(?:[,.]\d+)?)\s*$/i, "")).trim();
if (!title) throw lineError(index, "titre manquant.");
const parent = stack[depth - 1];
const node = {
id: context.uid("goal"),
parentId: parent?.id || "",
title,
kind: meta.kind || "step",
icon: meta.icon || "cube",
color: meta.color || "gold",
qtyTarget: hasFormula ? 1 : Math.max(0, parseNumber(quantityMatch?.[1], 1)) || 1,
qtyCurrent: 0,
formula: hasFormula ? rawTitle.slice(formulaIndex + 1).trim() : "",
items: parseGoalTreeItemsMeta(meta.items),
resetType: meta.reset || "none",
resetTime: meta.resetTime || "00:00",
weeklyResetDay: parseInteger(meta.weeklyResetDay, 1),
checkedAt: 0,
collapsed: parseBoolean(meta.collapsed)
};
nodes.push(node);
stack[depth] = node;
stack.length = depth + 1;
});
return context.normalizeGoalTreeData(data);
}
function parseGoalTreeItemsMeta(value) {
if (!value) return [];
try {
const parsed = JSON.parse(decodeURIComponent(value));
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
function exportTimerText(data) {
const lines = [];
if (data.activeTab && data.activeTab !== "stopwatch") lines.push(`@activeTab: ${data.activeTab}`);

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();

View file

@ -6,6 +6,7 @@ export const TOOLBOX_MODULE_DEFINITIONS = {
mode: "Texte riche",
icon: "notepad",
editable: true,
addable: false,
libraryCategory: "notesTracking"
},
checklist: {
@ -59,6 +60,7 @@ export const TOOLBOX_MODULE_DEFINITIONS = {
mode: "Arborescence",
icon: "calculator",
editable: true,
addable: false,
libraryCategory: "calculationData"
},
table: {
@ -67,6 +69,16 @@ export const TOOLBOX_MODULE_DEFINITIONS = {
mode: "Grille + formules",
icon: "table",
editable: true,
addable: false,
scrollable: true,
libraryCategory: "calculationData"
},
goalTree: {
label: "Arbre d'objectifs",
defaultTitle: "Arbre d'objectifs",
mode: "Objectifs + quantités",
icon: "diagram",
editable: true,
scrollable: true,
libraryCategory: "calculationData"
},
@ -76,6 +88,7 @@ export const TOOLBOX_MODULE_DEFINITIONS = {
mode: "Chrono + comptes à rebours",
icon: "clock",
editable: true,
addable: false,
libraryCategory: "timeRoutines"
},
calendar: {
@ -123,7 +136,7 @@ export const TOOLBOX_MODULES = Object.fromEntries(Object.entries(TOOLBOX_MODULE_
export const LIBRARY_CATEGORY_DEFINITIONS = [
{ key: "notesTracking", types: ["notepad", "checklist", "counters"] },
{ key: "references", types: ["images", "imageAnnotation", "links"] },
{ key: "calculationData", types: ["calculator", "table"] },
{ key: "calculationData", types: ["calculator", "table", "goalTree"] },
{ key: "timeRoutines", types: ["timer", "calendar", "taskPlanner"] },
{ key: "buildsCommands", types: ["combos", "equipmentPlanner"] }
];