diff --git a/DESIGN_SYSTEM.md b/DESIGN_SYSTEM.md index c4067d4..42d4dd6 100644 --- a/DESIGN_SYSTEM.md +++ b/DESIGN_SYSTEM.md @@ -210,6 +210,7 @@ Exemples actuels : Regles : - ratio de base 35 / 65 entre controles et resultats ; +- lorsque son outil mesure au plus 820 px de large, le calculateur empile la saisie au-dessus de la liste, avec Calcul/Actuel à gauche et Résultat à droite dans la carte ; sous 560 px, cette carte revient à une seule colonne ; - panneau gauche compact, sans hauteur artificielle inutile ; - panneau droit scrollable uniquement quand l'utilisateur active le switch dedie ; - hauteur scrollable cible : environ 450 px ; diff --git a/docs/STORAGE_SCHEMA.md b/docs/STORAGE_SCHEMA.md index c83298a..e3d21b6 100644 --- a/docs/STORAGE_SCHEMA.md +++ b/docs/STORAGE_SCHEMA.md @@ -1088,8 +1088,9 @@ Type : `calculator` "id": "calc2", "parentId": "calc1", "label": "Minerais de fer", - "value": 500, - "formula": "base*5" + "value": 480, + "formula": "base*5", + "current": 20 } ] } @@ -1101,7 +1102,8 @@ Stockage compact : - `parentId` est omis si vide. - `label` peut être vide. - `value` est numérique. -- `formula` est omis si vide ; dans un calcul enfant, le mot `base` référence la valeur recalculée du parent. +- `formula` est omis si vide ; dans un calcul enfant, le mot `base` référence la valeur restante recalculée du parent. +- `current` contient la quantité déjà acquise et est omis si elle vaut `0` ; `value` correspond au total produit par `formula`, moins `current`. - Si un `parentId` ne pointe vers aucune entrée existante, il est réinitialisé à vide. ## Outil Tableau @@ -1194,7 +1196,7 @@ Formats : - `checklist` : sections `# Catégorie`, lignes `Item: quantité cible`. - `links` : sections `# Catégorie`, lignes `Titre: https://...` ou URL seule; le suffixe optionnel `| category=Catégorie` reste accepté à l'import. - `counters` : lignes `Libellé: valeur`, valeurs négatives acceptées. -- `calculator` : lignes `Libellé: valeur | formula=base*5`, indentation de deux espaces pour les enfants, meta `@scrollResults: true`. Le suffixe `formula=...` est optionnel et les anciens exports `Libellé: valeur` restent acceptés. +- `calculator` : lignes `Libellé: valeur | formula=base*5 | current=20`, indentation de deux espaces pour les enfants, meta `@scrollResults: true`. Les suffixes `formula=...` et `current=...` sont optionnels, et les exports `Libellé: valeur` restent acceptés. - `table` : TSV pur accepté ; l'export peut ajouter `@size`, `@columns`, `@rows` puis `@tsv` pour préserver dimensions et intitulés. - `combos` : sections `# Catégorie`, lignes `Nom | device=... | kind:value > kind:value+kind:value`, avec suffixes `[hold]` ou `[2s]`. - `taskPlanner` : sections `# Catégorie`, lignes indentées `- daily Titre`, `- weekly Titre` ou `- unique Titre`, metas `@description`, `@dailyResetTime`, `@weeklyResetDay`, `@prerequisite`. diff --git a/e2e/calculator.spec.js b/e2e/calculator.spec.js index 3f52129..4bbe59f 100644 --- a/e2e/calculator.spec.js +++ b/e2e/calculator.spec.js @@ -54,6 +54,49 @@ test("parent edits recalculate descendants and survive reload; deleting a parent await expect(tool.getByText("Aucun résultat enregistré.", { exact: true })).toBeVisible(); }); +test("current values are subtracted from totals and kept during recalculation", async ({ page }) => { + const tool = await setup(page); + const expression = tool.getByRole("textbox", { name: "Calcul", exact: true }); + const current = tool.getByLabel("Actuel", { exact: true }); + const totalLabel = tool.getByText("Total", { exact: true }); + await expect(current).toHaveCount(0); + await expect(totalLabel).toHaveCount(0); + const subtractCurrent = tool.getByRole("button", { name: "Soustraire l'actuel", exact: true }); + await expect(subtractCurrent).toHaveAttribute("aria-pressed", "false"); + await subtractCurrent.click(); + await expect(subtractCurrent).toHaveAttribute("aria-pressed", "true"); + await expect(current).toBeFocused(); + await expect(totalLabel).toBeVisible(); + const expressionBounds = await expression.boundingBox(); + const currentBounds = await current.boundingBox(); + expect(currentBounds.x).toBeCloseTo(expressionBounds.x, 0); + expect(currentBounds.width).toBeCloseTo(expressionBounds.width, 0); + await expression.fill("10"); + await current.fill("2"); + await tool.getByLabel("Libellé", { exact: true }).fill("Parent"); + await expect(tool.locator(".calculator-result strong")).toHaveAttribute("aria-label", "8"); + await tool.getByRole("button", { name: "Enregistrer", exact: true }).click(); + await tool.getByRole("button", { name: "Ajouter un calcul enfant Parent", exact: true }).click(); + await expression.fill("base * 10"); + await subtractCurrent.click(); + await current.fill("20"); + await tool.getByLabel("Libellé", { exact: true }).fill("Child"); + await expect(tool.locator(".calculator-result strong")).toHaveAttribute("aria-label", "60"); + await tool.getByRole("button", { name: "Mettre à jour", exact: true }).click(); + await entry(tool, "Parent").getByTitle("Modifier", { exact: true }).click(); + await expect(current).toHaveValue("2"); + await expression.fill("20"); + await tool.getByRole("button", { name: "Mettre à jour", exact: true }).click(); + await expect(entry(tool, "Child").locator("strong")).toHaveAttribute("aria-label", "160"); + await page.reload(); + await entry(tool, "Child").getByTitle("Modifier", { exact: true }).click(); + await expect(current).toHaveValue("20"); + await expect(tool.locator(".calculator-result strong")).toHaveAttribute("aria-label", "160"); + await subtractCurrent.click(); + await expect(current).toHaveCount(0); + await expect(tool.locator(".calculator-result strong")).toHaveAttribute("aria-label", "180"); +}); + test("reset clears only the draft, new calculation returns to root, and clipboard and scroll preferences persist", async ({ page, context }) => { await context.grantPermissions(["clipboard-read", "clipboard-write"]); const tool = await setup(page); diff --git a/e2e/style-layouts.spec.js b/e2e/style-layouts.spec.js index 7f8fa75..7dc8ffe 100644 --- a/e2e/style-layouts.spec.js +++ b/e2e/style-layouts.spec.js @@ -7,6 +7,24 @@ async function columns(locator, count) { await expect.poll(async () => (await computed(locator, "grid-template-columns")).split(" ").length).toBe(count); } +test("calculator layout follows the tool width rather than its location", async ({ page }) => { + await page.setViewportSize({ width: 1280, height: 900 }); + await mountStyles(page, `FormulaCurrentResultLabelSaveEntries`); + const tool = page.locator('[data-module-type="calculator"]'); + const calculator = tool.locator(".calculator-module"); + const card = tool.locator(".calculator-card"); + await expect(tool).toHaveCSS("container-type", "inline-size"); + await columns(calculator, 2); + await columns(card, 1); + await tool.evaluate((element) => { element.style.width = "700px"; }); + await columns(calculator, 1); + await columns(card, 2); + await expect(tool.locator(".calculator-field")).toHaveCSS("grid-column-start", "1"); + await expect(tool.locator(".calculator-result")).toHaveCSS("grid-column-start", "2"); + await tool.evaluate((element) => { element.style.width = "480px"; }); + await columns(card, 1); +}); + for (const width of [1280, 900, 390]) { test(`home, about and library retain their responsive layout at ${width}px`, async ({ page }) => { await page.setViewportSize({ width, height: 900 }); @@ -66,7 +84,7 @@ for (const width of [1280, 390]) { await mountStyles(page, ` GameGame AB - Formula12 + Formula12 ResetTime EquipmentMaterials ActionsPreferences @@ -75,7 +93,7 @@ for (const width of [1280, 390]) { await expect(page.locator(".game-grid")).toHaveCSS("display", "grid"); await columns(page.locator(".modules"), width <= 760 ? 1 : 2); await expect(page.locator(".module-column").first()).toHaveCSS("display", width <= 760 ? "contents" : "grid"); - await columns(page.locator(".calculator-module"), width <= 760 ? 1 : 2); + await columns(page.locator(".layout-calculator .calculator-module"), width <= 820 ? 1 : 2); await expect(page.locator(".calculator-result")).toHaveCSS("min-height", "72px"); await expect(page.locator(".task-planner-module")).toHaveCSS("display", "grid"); await columns(page.locator(".task-planner-settings"), width <= 760 ? 1 : 2); diff --git a/tests/helpers/data-validation.mjs b/tests/helpers/data-validation.mjs index 2fe84fe..b678e26 100644 --- a/tests/helpers/data-validation.mjs +++ b/tests/helpers/data-validation.mjs @@ -574,6 +574,10 @@ export function validateSiteContent(site) { "toolboxes.modules.combos.deleteComboTitle", "toolboxes.modules.calculator.expressionLabel", "toolboxes.modules.calculator.expressionPlaceholder", + "toolboxes.modules.calculator.totalLabel", + "toolboxes.modules.calculator.currentLabel", + "toolboxes.modules.calculator.currentPlaceholder", + "toolboxes.modules.calculator.subtractCurrentTitle", "toolboxes.modules.calculator.resultLabel", "toolboxes.modules.calculator.labelLabel", "toolboxes.modules.calculator.labelPlaceholder", diff --git a/tests/toolbox-modules.test.mjs b/tests/toolbox-modules.test.mjs index 2d0d29a..2b156ed 100644 --- a/tests/toolbox-modules.test.mjs +++ b/tests/toolbox-modules.test.mjs @@ -6,7 +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 { calculateExpression, calculateRemainingValue, createStoredFormula, evaluateCalculatorEntries, getEntriesInTreeOrder, parseCalculatorCurrent } 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"; @@ -479,7 +479,7 @@ test("text exchange imports table TSV and calculator hierarchy", () => { scrollResults: true, entries: [ { id: "a", label: "Ore", value: 100, formula: "100" }, - { id: "b", parentId: "a", label: "Shard", value: 500, formula: "base*5" } + { id: "b", parentId: "a", label: "Shard", value: 480, formula: "base*5", current: 20 } ] }); const calculatorImport = importModuleText("calculator", exportModuleText("calculator", calculator), context); @@ -487,21 +487,29 @@ test("text exchange imports table TSV and calculator hierarchy", () => { assert.equal(calculatorImport.data.scrollResults, true); assert.equal(calculatorImport.data.entries[1].parentId, calculatorImport.data.entries[0].id); assert.equal(calculatorImport.data.entries[1].formula, "base*5"); + assert.equal(calculatorImport.data.entries[1].current, 20); }); 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: "root", label: "Root", value: 8, formula: "10", current: 2 }, + { id: "child", parentId: "root", label: "Child", value: 0, formula: "base*10", current: 20 }, { 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(calculateRemainingValue("base * 8", "20", 10), 60); + assert.equal(calculateRemainingValue("80", "20,5"), 59.5); + assert.equal(calculateRemainingValue("80", "invalid"), null); + assert.equal(parseCalculatorCurrent(""), 0); 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(entries.map((entry) => [entry.id, entry.value]), [["root", 8], ["child", 60], ["sibling", 2]]); assert.deepEqual(getEntriesInTreeOrder(entries).map((entry) => entry.id), ["root", "child", "sibling"]); + const compact = compactModuleDataForStorage("calculator", normalizeCalculatorData({ entries })); + assert.equal(compact.entries[0].current, 2); + assert.equal(compact.entries[2].current, undefined); }); test("calendar layout keeps week compaction and overlapping lanes deterministic", () => { diff --git a/website/public/data/site.json b/website/public/data/site.json index 1d9cf13..1c8b89b 100644 --- a/website/public/data/site.json +++ b/website/public/data/site.json @@ -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", diff --git a/website/src/features/toolboxes/modules/CalculatorModule.jsx b/website/src/features/toolboxes/modules/CalculatorModule.jsx index 9c5ffff..4afb250 100644 --- a/website/src/features/toolboxes/modules/CalculatorModule.jsx +++ b/website/src/features/toolboxes/modules/CalculatorModule.jsx @@ -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 {textContent.expressionLabel || "Calcul"} - {formulaParent ? ( - { - 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} - /> - ) : ( - + {showCurrent && {textContent.totalLabel || "Total"}} + {formulaParent ? ( + { + 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} + /> + ) : ( + 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} + /> + )} + 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"} + > + − + + + {showCurrent && ( + + {textContent.currentLabel || "Actuel"} + setCurrent(event.target.value)} + placeholder={textContent.currentPlaceholder || "0"} + /> + + )} {textContent.resultLabel || "Résultat"} - + {textContent.labelLabel || "Libellé"} setLabel(event.target.value)} placeholder={textContent.labelPlaceholder || "Lingots de fer"} /> - {formMode === "edit" ? textContent.updateButton || "Mettre à jour" : textContent.saveButton || "Enregistrer"} + {formMode === "edit" ? textContent.updateButton || "Mettre à jour" : textContent.saveButton || "Enregistrer"} { 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)]; }); diff --git a/website/src/features/toolboxes/modules/textImport/basic.js b/website/src/features/toolboxes/modules/textImport/basic.js index 58536bd..5dd92d6 100644 --- a/website/src/features/toolboxes/modules/textImport/basic.js +++ b/website/src/features/toolboxes/modules/textImport/basic.js @@ -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; diff --git a/website/src/features/toolboxes/storage/modules/calculator.js b/website/src/features/toolboxes/storage/modules/calculator.js index 7143fb8..5bb861a 100644 --- a/website/src/features/toolboxes/storage/modules/calculator.js +++ b/website/src/features/toolboxes/storage/modules/calculator.js @@ -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; diff --git a/website/src/styles/_responsive.scss b/website/src/styles/_responsive.scss index 5f6e1d3..f762509 100644 --- a/website/src/styles/_responsive.scss +++ b/website/src/styles/_responsive.scss @@ -309,7 +309,6 @@ grid-template-columns: 1fr; } - .calculator-module, .timer-module { grid-template-columns: 1fr; } diff --git a/website/src/styles/toolboxes/_calculator-results.scss b/website/src/styles/toolboxes/_calculator-results.scss index 7a6d8a8..58223a9 100644 --- a/website/src/styles/toolboxes/_calculator-results.scss +++ b/website/src/styles/toolboxes/_calculator-results.scss @@ -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); diff --git a/website/src/styles/toolboxes/_calculator.scss b/website/src/styles/toolboxes/_calculator.scss index c421089..cc9d6ff 100644 --- a/website/src/styles/toolboxes/_calculator.scss +++ b/website/src/styles/toolboxes/_calculator.scss @@ -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, diff --git a/website/src/styles/toolboxes/shared-controls/_mix-tags.scss b/website/src/styles/toolboxes/shared-controls/_mix-tags.scss index 06c63b1..15483af 100644 --- a/website/src/styles/toolboxes/shared-controls/_mix-tags.scss +++ b/website/src/styles/toolboxes/shared-controls/_mix-tags.scss @@ -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;