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

@ -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}`);