diff --git a/tests/static-toolboxes.test.mjs b/tests/static-toolboxes.test.mjs
index 71c8154..d4f374b 100644
--- a/tests/static-toolboxes.test.mjs
+++ b/tests/static-toolboxes.test.mjs
@@ -146,6 +146,9 @@ test("toolbox module registry and modules expose expected behavior", async () =>
const combosBundle = `${combosModule}\n${combosEditor}\n${combosParts}\n${combosList}`;
const combosUtils = await readFile("website/src/features/toolboxes/modules/combosUtils.js", "utf8");
const calculatorModule = await readFile("website/src/features/toolboxes/modules/CalculatorModule.jsx", "utf8");
+ const calculatorEntries = await readFile("website/src/features/toolboxes/modules/CalculatorEntries.jsx", "utf8");
+ const calculatorUtils = await readFile("website/src/features/toolboxes/modules/calculatorUtils.js", "utf8");
+ const calculatorBundle = `${calculatorModule}\n${calculatorEntries}\n${calculatorUtils}`;
const tableModule = await readFile("website/src/features/toolboxes/modules/TableModule.jsx", "utf8");
const tableGrid = await readFile("website/src/features/toolboxes/modules/TableGrid.jsx", "utf8");
const tableGridParts = await readFile("website/src/features/toolboxes/modules/TableGridParts.jsx", "utf8");
@@ -274,12 +277,12 @@ test("toolbox module registry and modules expose expected behavior", async () =>
assert.doesNotMatch(combosModule, /canMoveItem:/);
assert.doesNotMatch(combosModule, /canMoveGroup:/);
assert.match(calculatorModule, /export function CalculatorModule/);
- assert.match(calculatorModule, /calculateExpression/);
+ assert.match(calculatorUtils, /export function calculateExpression/);
assert.match(calculatorModule, /activeParentId/);
assert.match(calculatorModule, /parentId/);
assert.match(calculatorModule, /MixTagsInput/);
assert.match(calculatorModule, /useGroupedReorder/);
- assert.match(calculatorModule, /calculator-drag-handle/);
+ assert.match(calculatorEntries, /calculator-drag-handle/);
assert.match(calculatorModule, /scrollResults/);
assert.match(calculatorModule, /tool-split-scroll-toggle/);
assert.match(calculatorModule, /copyChecklistImport/);
@@ -293,11 +296,11 @@ test("toolbox module registry and modules expose expected behavior", async () =>
assert.match(calculatorModule, /setExpression\(BASE_TOKEN\)/);
assert.match(calculatorModule, /editingEntryId/);
assert.match(calculatorModule, /updateButton/);
- assert.match(calculatorModule, /onAddChild/);
- assert.match(calculatorModule, /entry\.label \|\| formatResult\(entry\.value\)/);
+ assert.match(calculatorEntries, /onAddChild/);
+ assert.match(calculatorEntries, /entry\.label \|\| formatResult\(entry\.value\)/);
assert.match(calculatorModule, /ResizeObserver/);
assert.match(calculatorModule, /--calculator-scroll-height/);
- assert.match(calculatorModule, /CalculatorEntries/);
+ assert.match(calculatorBundle, /CalculatorEntries/);
assert.match(tableModule, /export function TableModule/);
assert.match(tableBundle, /Icon name="add-row"/);
assert.match(tableBundle, /Icon name="add-column"/);
diff --git a/tests/toolbox-modules.test.mjs b/tests/toolbox-modules.test.mjs
index 3d02de2..0706068 100644
--- a/tests/toolbox-modules.test.mjs
+++ b/tests/toolbox-modules.test.mjs
@@ -6,6 +6,7 @@ import { exportModuleText, importModuleText, parseColonImportLines } from "../we
import { buildInsertedMixTagValue, normalizeMixTagSpacing, normalizeMixTagText, removeMixTagAtOffset, tokenizeMixTagValue } from "../website/src/utils/mixTagsInputUtils.js";
import { getEffectiveGoalTreeProgressMode, getVisibleGoalTreeEdges } from "../website/src/features/toolboxes/modules/goalTreeDisplay.js";
import { calculateGoalTreeFormula, evaluateGoalTreeNodes } from "../website/src/features/toolboxes/modules/goalTreeUtils.js";
+import { calculateExpression, createStoredFormula, evaluateCalculatorEntries, getEntriesInTreeOrder } from "../website/src/features/toolboxes/modules/calculatorUtils.js";
import { createDefaultDraft, getDateSpanDays, getDraftSelection, normalizeDraftAlertOffsets, normalizeDraftMonthlyRepeatBy } from "../website/src/features/toolboxes/modules/calendarDraft.js";
import { getMonthOccurrenceSegments, getWeekGridLayout, getWeekMinutePosition, layoutConcurrentOccurrences } from "../website/src/features/toolboxes/modules/calendarLayout.js";
import { appendChecklistItems, moveChecklistItem, moveChecklistItemToCategory, renameChecklistSection } from "../website/src/features/toolboxes/modules/checklistUtils.js";
@@ -154,6 +155,21 @@ test("text exchange imports table TSV and calculator hierarchy", () => {
assert.equal(calculatorImport.data.entries[1].formula, "base*5");
});
+test("calculator helpers evaluate formulas and preserve tree order", () => {
+ const entries = evaluateCalculatorEntries([
+ { id: "root", label: "Root", value: 10, formula: "10" },
+ { id: "child", parentId: "root", label: "Child", value: 0, formula: "base*3" },
+ { id: "sibling", label: "Sibling", value: 2, formula: "1+1" }
+ ]);
+ assert.equal(calculateExpression("2 + 3 * 4"), 14);
+ assert.equal(calculateExpression("base / 2", 10), 5);
+ assert.equal(calculateExpression("window.alert(1)"), null);
+ assert.equal(createStoredFormula("+5", 10), "base+5");
+ assert.equal(createStoredFormula("10*2", 10), "base*2");
+ assert.deepEqual(entries.map((entry) => [entry.id, entry.value]), [["root", 10], ["child", 30], ["sibling", 2]]);
+ assert.deepEqual(getEntriesInTreeOrder(entries).map((entry) => entry.id), ["root", "child", "sibling"]);
+});
+
test("calendar layout keeps week compaction and overlapping lanes deterministic", () => {
const base = new Date(2026, 0, 5, 8, 0, 0, 0).getTime();
const occurrences = [
diff --git a/website/src/features/toolboxes/modules/CalculatorEntries.jsx b/website/src/features/toolboxes/modules/CalculatorEntries.jsx
new file mode 100644
index 0000000..0c139ea
--- /dev/null
+++ b/website/src/features/toolboxes/modules/CalculatorEntries.jsx
@@ -0,0 +1,78 @@
+// Rôle : rend l'arbre des résultats enregistrés du calculateur.
+import { Icon } from "../../../components/Icon.jsx";
+import {
+ DISPLAY_NUMBER_FORMATTER,
+ formatDisplayResult,
+ formatResult,
+ getChildren,
+ getEntryResultLengthClass
+} from "./calculatorUtils.js";
+
+export function FormattedCalculatorNumber({ value }) {
+ if (!Number.isFinite(value)) return "-";
+ let integerGroupIndex = 0;
+ const normalized = Number.parseFloat(value.toFixed(6));
+ return DISPLAY_NUMBER_FORMATTER.formatToParts(normalized).map((part, index) => {
+ if (part.type === "integer") {
+ const className = `calculator-number-group ${integerGroupIndex % 2 ? "is-muted" : "is-strong"}`;
+ integerGroupIndex += 1;
+ return {part.value};
+ }
+ if (part.type === "group") return {part.value};
+ return {part.value};
+ });
+}
+
+export function CalculatorEntries({ entries, parentId, getScopedParentId, activeEntryId, textContent, reorder, onEdit, onAddChild, onDelete }) {
+ const children = getChildren(entries, parentId);
+ if (!children.length) return null;
+
+ return (
+
+ {children.map((entry) => {
+ const className = [
+ entry.id === activeEntryId ? "active" : "",
+ reorder.isItemDragging(entry.id) ? "is-dragging" : "",
+ reorder.isItemDropTarget(entry.id) ? "is-drop-target" : "",
+ reorder.getDropPlacement("item", entry.id, parentId) === "after" ? "drop-after" : ""
+ ].filter(Boolean).join(" ");
+
+ return (
+ -
+
+
+
+
+
+
+
+
+ );
+ })}
+
+ );
+}
diff --git a/website/src/features/toolboxes/modules/CalculatorModule.jsx b/website/src/features/toolboxes/modules/CalculatorModule.jsx
index f910c16..3efa3a1 100644
--- a/website/src/features/toolboxes/modules/CalculatorModule.jsx
+++ b/website/src/features/toolboxes/modules/CalculatorModule.jsx
@@ -3,118 +3,20 @@ import { useLayoutEffect, useMemo, useRef, useState } from "react";
import { Icon } from "../../../components/Icon.jsx";
import { MixTagsInput } from "../../../components/MixTagsInput.jsx";
import { moveItem, useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
+import { CalculatorEntries, FormattedCalculatorNumber } from "./CalculatorEntries.jsx";
import { TextExchangeActions } from "./TextExchangeActions.jsx";
-
-const DISPLAY_NUMBER_FORMATTER = new Intl.NumberFormat("fr-FR", { maximumFractionDigits: 6 });
-const BASE_TOKEN = "base";
-
-function calculateExpression(expression, baseValue = null) {
- 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})`)();
- return Number.isFinite(value) ? value : null;
- } catch {
- return null;
- }
-}
-
-function formatResult(value) {
- if (!Number.isFinite(value)) return "";
- return Number.parseFloat(value.toFixed(6)).toString();
-}
-
-function formatDisplayResult(value) {
- if (!Number.isFinite(value)) return "";
- return DISPLAY_NUMBER_FORMATTER.format(Number.parseFloat(value.toFixed(6)));
-}
-
-function getResultLengthClass(value) {
- const length = formatResult(value).replace(/[^\d]/g, "").length;
- if (length >= 13) return "is-compact";
- if (length >= 9) return "is-long";
- return "";
-}
-
-function getEntryResultLengthClass(value) {
- const length = formatResult(value).replace(/[^\d]/g, "").length;
- if (length >= 21) return "is-compact";
- if (length >= 18) return "is-long";
- return "";
-}
-
-function getChildren(entries, parentId) {
- return entries.filter((entry) => (entry.parentId || "") === parentId);
-}
-
-function getDescendantIds(entries, parentId) {
- const children = getChildren(entries, parentId);
- return children.flatMap((child) => [child.id, ...getDescendantIds(entries, child.id)]);
-}
-
-function getEntriesInTreeOrder(entries, parentId = "") {
- return getChildren(entries, parentId).flatMap((entry) => [entry, ...getEntriesInTreeOrder(entries, entry.id)]);
-}
-
-function getEntryFormula(entry) {
- return String(entry?.formula || "").trim();
-}
-
-function getEditableFormula(entry) {
- return getEntryFormula(entry) || formatResult(entry.value);
-}
-
-function escapeRegExp(value) {
- return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
-}
-
-function createStoredFormula(expression, parentValue) {
- const normalized = String(expression || "").replaceAll(",", ".").trim();
- if (!Number.isFinite(parentValue)) return normalized;
- const parentText = formatResult(parentValue);
- if (/\bbase\b/i.test(normalized)) return normalized;
- const withBase = normalized.replace(new RegExp(`(^|[^\\d.])${escapeRegExp(parentText)}(?=$|[^\\d.])`, "g"), `$1${BASE_TOKEN}`);
- if (withBase !== normalized) return withBase;
- if (/^[+\-*/]/.test(normalized)) return `${BASE_TOKEN}${normalized}`;
- return normalized;
-}
-
-function evaluateCalculatorEntries(entries) {
- const byParent = new Map();
- entries.forEach((entry) => {
- const parentId = entry.parentId || "";
- byParent.set(parentId, [...(byParent.get(parentId) || []), entry]);
- });
-
- function visit(parentId = "", parentValue = null) {
- return (byParent.get(parentId) || []).flatMap((entry) => {
- const formula = getEntryFormula(entry);
- const calculatedValue = formula ? calculateExpression(formula, parentValue) : entry.value;
- const nextEntry = { ...entry, value: calculatedValue ?? entry.value };
- return [nextEntry, ...visit(entry.id, nextEntry.value)];
- });
- }
-
- return visit();
-}
-
-function FormattedCalculatorNumber({ value }) {
- if (!Number.isFinite(value)) return "-";
- let integerGroupIndex = 0;
- const normalized = Number.parseFloat(value.toFixed(6));
- return DISPLAY_NUMBER_FORMATTER.formatToParts(normalized).map((part, index) => {
- if (part.type === "integer") {
- const className = `calculator-number-group ${integerGroupIndex % 2 ? "is-muted" : "is-strong"}`;
- integerGroupIndex += 1;
- return {part.value};
- }
- if (part.type === "group") return {part.value};
- return {part.value};
- });
-}
+import {
+ BASE_TOKEN,
+ calculateExpression,
+ createStoredFormula,
+ evaluateCalculatorEntries,
+ formatDisplayResult,
+ formatResult,
+ getDescendantIds,
+ getEditableFormula,
+ getEntriesInTreeOrder,
+ getResultLengthClass
+} from "./calculatorUtils.js";
function focusExpressionInputAtEnd(element) {
if (!element) return;
@@ -393,57 +295,3 @@ export function CalculatorModule({ toolboxId, moduleId, context, textExchangeOpe
);
}
-
-function CalculatorEntries({ entries, parentId, getScopedParentId, activeEntryId, textContent, reorder, onEdit, onAddChild, onDelete }) {
- const children = getChildren(entries, parentId);
- if (!children.length) return null;
-
- return (
-
- {children.map((entry) => {
- const className = [
- entry.id === activeEntryId ? "active" : "",
- reorder.isItemDragging(entry.id) ? "is-dragging" : "",
- reorder.isItemDropTarget(entry.id) ? "is-drop-target" : "",
- reorder.getDropPlacement("item", entry.id, parentId) === "after" ? "drop-after" : ""
- ].filter(Boolean).join(" ");
-
- return (
- -
-
-
-
-
-
-
-
-
- );
- })}
-
- );
-}
diff --git a/website/src/features/toolboxes/modules/calculatorUtils.js b/website/src/features/toolboxes/modules/calculatorUtils.js
new file mode 100644
index 0000000..a610dee
--- /dev/null
+++ b/website/src/features/toolboxes/modules/calculatorUtils.js
@@ -0,0 +1,96 @@
+// Rôle : regroupe les helpers purs du calculateur et de son arbre de résultats.
+export const DISPLAY_NUMBER_FORMATTER = new Intl.NumberFormat("fr-FR", { maximumFractionDigits: 6 });
+export const BASE_TOKEN = "base";
+
+export function calculateExpression(expression, baseValue = null) {
+ 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})`)();
+ return Number.isFinite(value) ? value : null;
+ } catch {
+ return null;
+ }
+}
+
+export function formatResult(value) {
+ if (!Number.isFinite(value)) return "";
+ return Number.parseFloat(value.toFixed(6)).toString();
+}
+
+export function formatDisplayResult(value) {
+ if (!Number.isFinite(value)) return "";
+ return DISPLAY_NUMBER_FORMATTER.format(Number.parseFloat(value.toFixed(6)));
+}
+
+export function getResultLengthClass(value) {
+ const length = formatResult(value).replace(/[^\d]/g, "").length;
+ if (length >= 13) return "is-compact";
+ if (length >= 9) return "is-long";
+ return "";
+}
+
+export function getEntryResultLengthClass(value) {
+ const length = formatResult(value).replace(/[^\d]/g, "").length;
+ if (length >= 21) return "is-compact";
+ if (length >= 18) return "is-long";
+ return "";
+}
+
+export function getChildren(entries, parentId) {
+ return entries.filter((entry) => (entry.parentId || "") === parentId);
+}
+
+export function getDescendantIds(entries, parentId) {
+ const children = getChildren(entries, parentId);
+ return children.flatMap((child) => [child.id, ...getDescendantIds(entries, child.id)]);
+}
+
+export function getEntriesInTreeOrder(entries, parentId = "") {
+ return getChildren(entries, parentId).flatMap((entry) => [entry, ...getEntriesInTreeOrder(entries, entry.id)]);
+}
+
+export function getEntryFormula(entry) {
+ return String(entry?.formula || "").trim();
+}
+
+export function getEditableFormula(entry) {
+ return getEntryFormula(entry) || formatResult(entry.value);
+}
+
+export function createStoredFormula(expression, parentValue) {
+ const normalized = String(expression || "").replaceAll(",", ".").trim();
+ if (!Number.isFinite(parentValue)) return normalized;
+ const parentText = formatResult(parentValue);
+ if (/\bbase\b/i.test(normalized)) return normalized;
+ const withBase = normalized.replace(new RegExp(`(^|[^\\d.])${escapeRegExp(parentText)}(?=$|[^\\d.])`, "g"), `$1${BASE_TOKEN}`);
+ if (withBase !== normalized) return withBase;
+ if (/^[+\-*/]/.test(normalized)) return `${BASE_TOKEN}${normalized}`;
+ return normalized;
+}
+
+export function evaluateCalculatorEntries(entries) {
+ const byParent = new Map();
+ entries.forEach((entry) => {
+ const parentId = entry.parentId || "";
+ byParent.set(parentId, [...(byParent.get(parentId) || []), entry]);
+ });
+
+ function visit(parentId = "", parentValue = null) {
+ return (byParent.get(parentId) || []).flatMap((entry) => {
+ const formula = getEntryFormula(entry);
+ const calculatedValue = formula ? calculateExpression(formula, parentValue) : entry.value;
+ const nextEntry = { ...entry, value: calculatedValue ?? entry.value };
+ return [nextEntry, ...visit(entry.id, nextEntry.value)];
+ });
+ }
+
+ return visit();
+}
+
+function escapeRegExp(value) {
+ return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+}