Add goal tree toolbox module
All checks were successful
Deploy Sokko G / deploy (push) Successful in 15s
All checks were successful
Deploy Sokko G / deploy (push) Successful in 15s
This commit is contained in:
parent
e4cbe3e087
commit
59fdcb0927
63 changed files with 4062 additions and 252 deletions
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
1020
website/src/features/toolboxes/modules/GoalTreeModule.jsx
Normal file
1020
website/src/features/toolboxes/modules/GoalTreeModule.jsx
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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: [] });
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
269
website/src/features/toolboxes/modules/goalTreeUtils.js
Normal file
269
website/src/features/toolboxes/modules/goalTreeUtils.js
Normal 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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
|
|
|
|||
163
website/src/features/toolboxes/storage/modules/goalTree.js
Normal file
163
website/src/features/toolboxes/storage/modules/goalTree.js
Normal 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;
|
||||
}
|
||||
|
|
@ -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
|
||||
};
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ export const ID_PREFIXES = {
|
|||
marker: "k",
|
||||
timer: "z",
|
||||
task: "a",
|
||||
goal: "g",
|
||||
relation: "e",
|
||||
stroke: "d",
|
||||
equipmentType: "y",
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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"] }
|
||||
];
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue