Improve calculator progress tracking and responsive layout
All checks were successful
Deploy Sokko G / deploy (push) Successful in 18s

This commit is contained in:
Shinuwa 2026-09-29 11:19:11 +02:00
parent 3f86d863ac
commit 51853245ad
15 changed files with 329 additions and 75 deletions

View file

@ -844,6 +844,7 @@
"text": "Saisie d'une expression, calcul du résultat et sauvegarde avec un libellé.",
"details": [
"Le champ expression accepte les opérations de base et calcule le résultat avant sauvegarde.",
"Le bouton − affiche le champ Actuel, qui soustrait automatiquement la quantité déjà acquise du total calculé.",
"Le libellé sert à nommer un coût, une ressource, un total ou une conversion.",
"Un résultat enregistré peut être renommé ou supprimé."
]
@ -869,7 +870,7 @@
],
"importFormat": {
"text": "Une ligne par résultat. L'indentation de deux espaces conserve les liens parent/enfant.",
"example": "Lingot de champignite: 100\n Lingot de chlorophyte: 100 | formula=base\n Champignon luisant: 1500 | formula=base*15\nLingot de chlorophyte: 100\n Minerai de chlorophyte: 500 | formula=base*5"
"example": "Lingot de champignite: 80 | formula=100 | current=20\n Lingot de chlorophyte: 80 | formula=base\n Champignon luisant: 1200 | formula=base*15\nLingot de chlorophyte: 100\n Minerai de chlorophyte: 500 | formula=base*5"
}
},
"goalTree": {
@ -2305,7 +2306,7 @@
"deleteComboTitle": "Supprimer le combo"
},
"calculator": {
"importPlaceholder": "@scrollResults: true\nLingots de fer: 100 | formula=100\n Minerais de fer: 500 | formula=base*5",
"importPlaceholder": "@scrollResults: true\nLingots de fer: 80 | formula=100 | current=20\n Minerais de fer: 400 | formula=base*5",
"importOpenButton": "Importer du texte",
"exportOpenButton": "Exporter en format texte",
"importModalTitle": "Importer des résultats",
@ -2317,6 +2318,10 @@
"importError": "Format de calculateur invalide.",
"expressionLabel": "Calcul",
"expressionPlaceholder": "10*10",
"totalLabel": "Total",
"currentLabel": "Actuel",
"currentPlaceholder": "0",
"subtractCurrentTitle": "Soustraire l'actuel",
"resultLabel": "Résultat",
"labelLabel": "Libellé",
"labelPlaceholder": "ex: Lingots de fer",

View file

@ -7,7 +7,7 @@ import { CalculatorEntries, FormattedCalculatorNumber } from "./CalculatorEntrie
import { TextExchangeActions } from "./TextExchangeActions.jsx";
import {
BASE_TOKEN,
calculateExpression,
calculateRemainingValue,
createStoredFormula,
evaluateCalculatorEntries,
formatDisplayResult,
@ -15,7 +15,8 @@ import {
getDescendantIds,
getEditableFormula,
getEntriesInTreeOrder,
getResultLengthClass
getResultLengthClass,
parseCalculatorCurrent
} from "./calculatorUtils.js";
function focusExpressionInputAtEnd(element) {
@ -41,6 +42,8 @@ export function CalculatorModule({ toolboxId, moduleId, context, textExchangeOpe
const calculatedData = useMemo(() => ({ ...data, entries: calculatedEntries }), [data, calculatedEntries]);
const textContent = context.moduleText?.calculator || {};
const [expression, setExpression] = useState("");
const [current, setCurrent] = useState("");
const [showCurrent, setShowCurrent] = useState(false);
const [label, setLabel] = useState("");
const [activeParentId, setActiveParentId] = useState("");
const [editingEntryId, setEditingEntryId] = useState("");
@ -48,12 +51,13 @@ export function CalculatorModule({ toolboxId, moduleId, context, textExchangeOpe
const [copied, setCopied] = useState(false);
const calculatorCardRef = useRef(null);
const expressionInputRef = useRef(null);
const currentInputRef = useRef(null);
const pendingExpressionFocusRef = useRef(false);
const editingEntry = calculatedEntries.find((entry) => entry.id === editingEntryId);
const formulaParentId = editingEntry ? editingEntry.parentId || "" : activeParentId;
const formulaParent = calculatedEntries.find((entry) => entry.id === formulaParentId);
const formMode = editingEntry ? "edit" : formulaParent ? "child" : "root";
const result = useMemo(() => calculateExpression(expression, formulaParent?.value), [formulaParent?.value, expression]);
const result = useMemo(() => calculateRemainingValue(expression, current, formulaParent?.value), [current, formulaParent?.value, expression]);
const scopeParentId = (parentId = "") => `${moduleId}:${parentId}`;
const reorder = useGroupedReorder({
namespace: "calculator",
@ -99,13 +103,16 @@ export function CalculatorModule({ toolboxId, moduleId, context, textExchangeOpe
if (result == null) return;
const cleanLabel = label.trim();
const storedFormula = createStoredFormula(expression, formulaParent?.value);
const storedCurrent = parseCalculatorCurrent(current);
if (storedCurrent == null) return;
if (editingEntry) {
save(calculatedEntries.map((entry) => entry.id === editingEntry.id ? {
...entry,
label: cleanLabel,
value: result,
formula: storedFormula
formula: storedFormula,
current: storedCurrent
} : entry));
setLabel(cleanLabel);
return;
@ -118,10 +125,13 @@ export function CalculatorModule({ toolboxId, moduleId, context, textExchangeOpe
parentId: formulaParentId,
label: cleanLabel,
value: result,
formula: storedFormula
formula: storedFormula,
current: storedCurrent
}
]);
setExpression(formulaParent ? BASE_TOKEN : "");
setCurrent("");
setShowCurrent(false);
setLabel("");
}
@ -129,6 +139,8 @@ export function CalculatorModule({ toolboxId, moduleId, context, textExchangeOpe
setEditingEntryId(entry.id);
setActiveParentId("");
setExpression(getEditableFormula(entry));
setCurrent(entry.current ? formatResult(entry.current) : "");
setShowCurrent(Boolean(entry.current));
setLabel(entry.label || "");
}
@ -141,7 +153,8 @@ export function CalculatorModule({ toolboxId, moduleId, context, textExchangeOpe
parentId: entry.id,
label: "",
value: Number.isFinite(entry.value) ? entry.value : 0,
formula: BASE_TOKEN
formula: BASE_TOKEN,
current: 0
}
]);
setEditingEntryId("");
@ -149,6 +162,8 @@ export function CalculatorModule({ toolboxId, moduleId, context, textExchangeOpe
setEditingEntryId(childId);
setActiveParentId("");
setExpression(BASE_TOKEN);
setCurrent("");
setShowCurrent(false);
setLabel("");
}
@ -162,12 +177,16 @@ export function CalculatorModule({ toolboxId, moduleId, context, textExchangeOpe
setEditingEntryId("");
setLabel("");
setExpression("");
setCurrent("");
setShowCurrent(false);
}
save(calculatedEntries.filter((entry) => !deletedIds.has(entry.id)));
}
function resetCalculator() {
setExpression("");
setCurrent("");
setShowCurrent(false);
setLabel("");
setActiveParentId("");
setEditingEntryId("");
@ -177,9 +196,21 @@ export function CalculatorModule({ toolboxId, moduleId, context, textExchangeOpe
setActiveParentId("");
setEditingEntryId("");
setExpression("");
setCurrent("");
setShowCurrent(false);
setLabel("");
}
function toggleCurrent() {
if (showCurrent) {
setCurrent("");
setShowCurrent(false);
return;
}
setShowCurrent(true);
window.requestAnimationFrame(() => currentInputRef.current?.focus());
}
async function copyChecklistImport() {
const text = getEntriesInTreeOrder(calculatedEntries)
.map((entry) => `${entry.label || formatResult(entry.value)}:${formatResult(entry.value)}`)
@ -201,61 +232,91 @@ export function CalculatorModule({ toolboxId, moduleId, context, textExchangeOpe
<div className="calculator-card" ref={calculatorCardRef}>
<div className="calculator-field">
<span>{textContent.expressionLabel || "Calcul"}</span>
{formulaParent ? (
<MixTagsInput
aria-label={textContent.expressionLabel || "Calcul"}
className="calculator-expression-input"
inputRef={expressionInputRef}
onChange={setExpression}
onKeyDown={(event) => {
if (event.key !== "Enter") return;
event.preventDefault();
event.currentTarget.closest("form")?.requestSubmit();
}}
formulaSpacing
insertToken={{
value: "base",
label: textContent.baseTokenLabel || "Valeur du parent",
icon: "parent",
title: textContent.insertBaseTitle || "Ajouter la valeur du parent"
}}
placeholder={textContent.formulaPlaceholder || "base * 3"}
tokens={[{
value: "base",
label: textContent.baseTokenLabel || "Valeur du parent"
}]}
value={expression}
/>
) : (
<input
type="text"
aria-label={textContent.expressionLabel || "Calcul"}
className="calculator-expression-input nds-input"
<div className={`calculator-expression-row ${showCurrent ? "has-current" : ""}`}>
{showCurrent && <span className="calculator-total-label">{textContent.totalLabel || "Total"}</span>}
{formulaParent ? (
<MixTagsInput
aria-label={textContent.expressionLabel || "Calcul"}
className="calculator-expression-input"
inputRef={expressionInputRef}
onChange={setExpression}
onKeyDown={(event) => {
if (event.key !== "Enter") return;
event.preventDefault();
event.currentTarget.closest("form")?.requestSubmit();
}}
formulaSpacing
insertToken={{
value: "base",
label: textContent.baseTokenLabel || "Valeur du parent",
icon: "parent",
title: textContent.insertBaseTitle || "Ajouter la valeur du parent"
}}
placeholder={textContent.formulaPlaceholder || "base * 3"}
tokens={[{
value: "base",
label: textContent.baseTokenLabel || "Valeur du parent"
}]}
value={expression}
/>
) : (
<input
type="text"
aria-label={textContent.expressionLabel || "Calcul"}
className="calculator-expression-input nds-input"
data-size="sm"
ref={expressionInputRef}
onChange={(event) => setExpression(event.target.value)}
onKeyDown={(event) => {
if (event.key !== "Enter") return;
event.preventDefault();
event.currentTarget.closest("form")?.requestSubmit();
}}
inputMode="decimal"
placeholder={textContent.expressionPlaceholder || "10*10"}
value={expression}
/>
)}
<button
className="calculator-current-toggle nds-icon-button"
data-size="sm"
ref={expressionInputRef}
onChange={(event) => setExpression(event.target.value)}
onKeyDown={(event) => {
if (event.key !== "Enter") return;
event.preventDefault();
event.currentTarget.closest("form")?.requestSubmit();
}}
inputMode="decimal"
placeholder={textContent.expressionPlaceholder || "10*10"}
value={expression}
/>
)}
type="button"
onClick={toggleCurrent}
aria-label={textContent.subtractCurrentTitle || "Soustraire l'actuel"}
aria-pressed={showCurrent}
title={textContent.subtractCurrentTitle || "Soustraire l'actuel"}
>
<span aria-hidden="true">−</span>
</button>
</div>
</div>
{showCurrent && (
<div className="calculator-current-field">
<span>{textContent.currentLabel || "Actuel"}</span>
<input
aria-label={textContent.currentLabel || "Actuel"}
className="nds-input"
data-size="sm"
ref={currentInputRef}
type="text"
inputMode="decimal"
value={current}
onChange={(event) => setCurrent(event.target.value)}
placeholder={textContent.currentPlaceholder || "0"}
/>
</div>
)}
<div className={`calculator-result ${result == null ? "" : getResultLengthClass(result)}`} aria-live="polite">
<span>{textContent.resultLabel || "Résultat"}</span>
<strong aria-label={result == null ? "-" : formatDisplayResult(result)}>
<FormattedCalculatorNumber value={result} />
</strong>
</div>
<label>
<label className="calculator-label-field">
<span>{textContent.labelLabel || "Libellé"}</span>
<input className="nds-input" data-size="sm" type="text" value={label} onChange={(event) => setLabel(event.target.value)} placeholder={textContent.labelPlaceholder || "Lingots de fer"} />
</label>
<button className="nds-button" data-size="sm" data-variant="primary" disabled={result == null}>{formMode === "edit" ? textContent.updateButton || "Mettre à jour" : textContent.saveButton || "Enregistrer"}</button>
<button className="calculator-save-button nds-button" data-size="sm" data-variant="primary" disabled={result == null}>{formMode === "edit" ? textContent.updateButton || "Mettre à jour" : textContent.saveButton || "Enregistrer"}</button>
</div>
</form>
<div

View file

@ -16,6 +16,20 @@ export function calculateExpression(expression, baseValue = null) {
}
}
export function parseCalculatorCurrent(value) {
const normalized = String(value ?? "").replaceAll(",", ".").trim();
if (!normalized) return 0;
const parsed = Number(normalized);
return Number.isFinite(parsed) ? parsed : null;
}
export function calculateRemainingValue(expression, currentValue = 0, baseValue = null) {
const total = calculateExpression(expression, baseValue);
const current = parseCalculatorCurrent(currentValue);
if (total == null || current == null) return null;
return total - current;
}
export function formatResult(value) {
if (!Number.isFinite(value)) return "";
return Number.parseFloat(value.toFixed(6)).toString();
@ -58,7 +72,8 @@ export function getEntryFormula(entry) {
}
export function getEditableFormula(entry) {
return getEntryFormula(entry) || formatResult(entry.value);
const current = Number(entry?.current);
return getEntryFormula(entry) || formatResult(entry.value + (Number.isFinite(current) ? current : 0));
}
export function createStoredFormula(expression, parentValue) {
@ -82,7 +97,7 @@ export function evaluateCalculatorEntries(entries) {
function visit(parentId = "", parentValue = null) {
return (byParent.get(parentId) || []).flatMap((entry) => {
const formula = getEntryFormula(entry);
const calculatedValue = formula ? calculateExpression(formula, parentValue) : entry.value;
const calculatedValue = formula ? calculateRemainingValue(formula, entry.current, parentValue) : entry.value;
const nextEntry = { ...entry, value: calculatedValue ?? entry.value };
return [nextEntry, ...visit(entry.id, nextEntry.value)];
});

View file

@ -244,7 +244,8 @@ export function exportCalculatorText(data) {
function visit(parentId = "", depth = 0) {
entries.filter((entry) => (entry.parentId || "") === parentId).forEach((entry) => {
const formula = String(entry.formula || "").trim();
lines.push(`${" ".repeat(depth)}${entry.label || ""}: ${entry.value}${formula ? ` | formula=${formula}` : ""}`);
const current = Number(entry.current) || 0;
lines.push(`${" ".repeat(depth)}${entry.label || ""}: ${entry.value}${formula ? ` | formula=${formula}` : ""}${current ? ` | current=${current}` : ""}`);
visit(entry.id, depth + 1);
});
}
@ -268,10 +269,23 @@ export function importCalculatorText(text, context) {
const separatorIndex = line.indexOf(":");
if (separatorIndex < 0) throw lineError(index, `ligne calculateur invalide: ${line.trim()}`);
const label = line.slice(0, separatorIndex).trim();
const [rawValue, rawFormula = ""] = line.slice(separatorIndex + 1).split(/\s+\|\s+formula=/);
const [rawValue, ...rawMetadata] = line.slice(separatorIndex + 1).split(/\s+\|\s+/);
const value = parseNumber(rawValue, Number.NaN);
if (!Number.isFinite(value)) throw lineError(index, `valeur calculateur invalide: ${line.trim()}`);
const entry = { id: context.uid("calc"), parentId: parentStack[depth - 1] || "", label, value, formula: rawFormula.trim() };
let formula = "";
let current = 0;
rawMetadata.forEach((metadata) => {
if (metadata.startsWith("formula=")) {
formula = metadata.slice("formula=".length).trim();
return;
}
if (metadata.startsWith("current=")) {
current = parseNumber(metadata.slice("current=".length), Number.NaN);
if (Number.isFinite(current)) return;
}
throw lineError(index, `option calculateur invalide: ${metadata.trim()}`);
});
const entry = { id: context.uid("calc"), parentId: parentStack[depth - 1] || "", label, value, formula, current };
entries.push(entry);
parentStack[depth] = entry.id;
parentStack.length = depth + 1;

View file

@ -17,7 +17,8 @@ export function normalizeCalculatorData(data) {
parentId: String(entry?.parentId || ""),
label: String(entry?.label || "").trim(),
value: normalizeCalculatorValue(entry?.value),
formula: normalizeCalculatorFormula(entry?.formula)
formula: normalizeCalculatorFormula(entry?.formula),
current: normalizeCalculatorValue(entry?.current)
}));
const entryIds = new Set(entries.map((entry) => entry.id));
return {
@ -32,6 +33,7 @@ export function compactCalculatorDataForStorage(value) {
const compact = { id: entry.id, label: entry.label, value: entry.value };
if (entry.parentId) compact.parentId = entry.parentId;
if (entry.formula) compact.formula = entry.formula;
if (entry.current) compact.current = entry.current;
return compact;
});
if (!entries.length && !normalized.scrollResults) return null;

View file

@ -309,7 +309,6 @@
grid-template-columns: 1fr;
}
.calculator-module,
.timer-module {
grid-template-columns: 1fr;
}

View file

@ -7,6 +7,7 @@
.calculator-form label,
.calculator-field,
.calculator-current-field,
.calculator-result {
display: grid;
width: 100%;
@ -21,6 +22,29 @@
max-width: 100%;
}
.calculator-expression-row {
display: grid;
grid-template-columns: minmax(0, 1fr) 34px;
min-width: 0;
align-items: center;
gap: 7px;
}
.calculator-expression-row.has-current,
.calculator-current-field {
grid-template-columns: 48px minmax(0, 1fr) 34px;
}
.calculator-current-toggle > span {
font-size: 1.25rem;
font-weight: 700;
line-height: 1;
}
.calculator-current-field {
align-items: center;
}
.calculator-entry.has-drag-handle {
grid-template-columns: 30px minmax(0, 1fr) 34px 34px;
}
@ -87,6 +111,8 @@
.calculator-form label span,
.calculator-field > span,
.calculator-total-label,
.calculator-current-field > span,
.calculator-result span,
.tool-split-root-button {
color: var(--color-text-secondary);

View file

@ -19,6 +19,62 @@
min-height: 0;
}
.module[data-module-type="calculator"] {
container-name: calculator-tool;
container-type: inline-size;
}
@container calculator-tool (max-width: 820px) {
.calculator-module {
grid-template-columns: minmax(0, 1fr);
}
.calculator-card {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.calculator-field {
grid-column: 1;
grid-row: 1;
}
.calculator-current-field {
grid-column: 1;
grid-row: 2;
}
.calculator-result {
grid-column: 2;
grid-row: 1 / 3;
}
.calculator-label-field,
.calculator-save-button {
grid-column: 1 / -1;
}
}
@container calculator-tool (max-width: 560px) {
.calculator-card {
grid-template-columns: minmax(0, 1fr);
}
.calculator-field,
.calculator-current-field,
.calculator-result,
.calculator-label-field,
.calculator-save-button {
grid-column: 1;
grid-row: auto;
}
}
@supports not (container-type: inline-size) {
.calculator-module {
grid-template-columns: minmax(0, 1fr);
}
}
.calculator-card,
.tool-split-results,
.timer-control-card,

View file

@ -6,7 +6,7 @@
box-sizing: border-box;
width: 100%;
min-width: 0;
min-height: 44px;
min-height: 36px;
overflow: hidden;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
@ -29,18 +29,18 @@
width: 100%;
min-width: 0;
max-width: 100%;
height: 42px;
min-height: 42px;
height: 36px;
min-height: 36px;
overflow-x: auto;
overflow-y: hidden;
padding: 10px 4px 4px 7px;
padding: 7px 4px 4px 7px;
border: 0;
background: transparent;
color: inherit;
caret-color: var(--color-text-primary);
font: inherit;
font-variant-numeric: tabular-nums;
line-height: 20px;
line-height: 21px;
outline: none;
scrollbar-width: none;
white-space: nowrap;