diff --git a/routes/operations.js b/routes/operations.js index dff4942..d78ef8f 100644 --- a/routes/operations.js +++ b/routes/operations.js @@ -303,15 +303,35 @@ module.exports = function(pool) { let value = resolve(tValue, vPct, vIncr, totals.value, fixedValue); let units = resolve(tUnits, uPct, uIncr, totals.units, fixedUnits); - // A price target is the "edit price" mode of the Excel form: price and - // volume are the inputs and dollars fall out of them. With a units target - // alongside it, both move; without one, volume holds and price alone carries - // the change. An explicit value target outranks it either way. - if (tPrice !== null && tValue === null) { - const targetUnits = tUnits !== null - ? (tUnits - fixedUnits) + 0 // the units target is already absolute - : (totals.units + fixedUnits); - value = (tPrice * targetUnits) - (totals.value + fixedValue); + // A price target is the "edit price" mode of the Excel form. The price + // named holds, and whichever other measure was named decides the rest: + // price alone qty holds, dollars = price x qty + // price + qty qty lands as named (target, change or %), + // dollars = price x that qty + // price + dollars dollars land as named, qty = dollars / price + // all three refused -- two degrees of freedom, three numbers + // All on the basis the panel measures against, so under 'selected' the + // price is that of the whole selection, fixed rows included. + // + // This used to read only a qty *target*, so a qty change or % sized the + // dollars on the old qty and missed the price; took the fixed qty off a + // qty target but not the fixed dollars off the value, so under 'selected' + // it landed short; and let a dollar figure outrank the price outright. + const unitsGiven = [tUnits, uIncr, uPct].some(v => v !== null); + const valueGiven = [tValue, vIncr, vPct].some(v => v !== null); + if (tPrice !== null) { + const bad = (msg) => { const err = new Error(msg); err.status = 400; return err; }; + if (valueGiven && unitsGiven) { + throw bad('Price, dollars and qty were all given. Any two decide the third -- clear one of them.'); + } + const curValue = totals.value + fixedValue; + const curUnits = totals.units + fixedUnits; + if (valueGiven) { + if (!(tPrice > 0)) throw bad('A price of zero or less cannot set qty from dollars.'); + units = (curValue + value) / tPrice - curUnits; + } else { + value = tPrice * (curUnits + units) - curValue; + } } // Which side of price x volume absorbs a dollar change. @@ -328,8 +348,6 @@ module.exports = function(pool) { // -- the dollars land on target either way -- so the default is now the // conservative one and moving price takes saying so. const plug = body.plug === 'price' ? 'price' : 'volume'; - const unitsGiven = [tUnits, uIncr, uPct].some(v => v !== null); - const valueGiven = [tValue, vIncr, vPct].some(v => v !== null); // The price held is the *adjustable* rows' own, never a blend with the // fixed ones: the scale SQL prorates both increments over the adjustable @@ -337,7 +355,13 @@ module.exports = function(pool) { // faster than dollars whenever the fixed rows are priced differently. // Taking a slice to its fixed total -- dollars to zero -- then drove qty // past zero, leaving grains with positive sales and negative qty. - if (plug === 'volume' && value !== 0 && !unitsGiven) { + // + // Not when a price was named. That is an edit-price, not a dollar edit: + // qty holds and the dollars were sized off the price above. The panel + // greys the plug out then and does not send it, so `plug` reads as its + // 'volume' default, and this block used to scale qty by the dollar + // change as well -- which put price straight back where it started. + if (plug === 'volume' && value !== 0 && !unitsGiven && tPrice === null) { if (totals.value === 0) { const err = new Error( 'Cannot hold price constant here: the selection currently has no value, ' + @@ -387,7 +411,7 @@ module.exports = function(pool) { // the two apart, and the only way to find a price edit afterwards was to // notice qty had not moved. const method = - tPrice !== null && tValue === null ? 'price' // price named + tPrice !== null ? 'price' // price named : unitsGiven && valueGiven ? 'both' // dollars and qty named, price falls out : unitsGiven ? 'volume' // qty named, dollars follow at price : plug; // dollars only: the plug decides diff --git a/ui/src/components/OperationPanel.jsx b/ui/src/components/OperationPanel.jsx index 277a9a4..b174325 100644 --- a/ui/src/components/OperationPanel.jsx +++ b/ui/src/components/OperationPanel.jsx @@ -75,11 +75,15 @@ function TextButton({ onClick, children, title, danger }) { ) } -function Button({ onClick, active, children, title, disabled }) { +// `locked` is not `disabled`: the button reads at full strength -- it is +// reporting something true -- but does not take a click. +function Button({ onClick, active, children, title, disabled, locked }) { return ( - + ))} ) @@ -562,7 +569,41 @@ function TagInput({ value, onChange, tags = [], className }) { ) } -function ScaleLedger({ currentTotals, scaleInputs, setScaleInputs, scalePlug, setScalePlug, targetBasis, setTargetBasis, logMeta = {}, multi, applyMode, settingsExtra }) { +// The three scale boxes foot -- price = value / qty -- so at most two of them +// can be inputs, and the third is whatever those two make it. Typing into the +// third makes it an input and one of the other two has to give way: +// +// value typed last -> the plug decides, since a dollar change is the one +// edit that says nothing about price or volume: +// 'volume' (price holds) drops the qty, 'price' (qty +// holds) drops the price +// qty or price last -> the more recently touched of the other two stays +// +// The dropped box keeps what was typed in scaleInputs -- it is only left out +// here -- so flipping the plug brings it straight back. Forecast.jsx builds +// the request from the same function, so the panel and the write agree. +// Returns the inputs that count, and whether the plug is the one choosing. +const hasNumber = (edit) => edit?.raw != null && edit.raw !== '' && isFinite(parseFloat(edit.raw)) + +export function effectiveScaleInputs(inputs = {}, plug = 'volume') { + const keys = ['value', 'units', 'price'].filter(k => hasNumber(inputs[k])) + if (keys.length < 3) return { inputs, choosing: false } + const byAge = [...keys].sort((a, b) => (inputs[a].at ?? 0) - (inputs[b].at ?? 0)) + const last = byAge[2] + const drop = last === 'value' + ? (plug === 'price' ? 'price' : 'units') + : byAge.find(k => k !== last) + const { [drop]: _drop, ...rest } = inputs + return { inputs: rest, choosing: last === 'value' } +} + +// a stamp that orders the edits, so the resolver can tell which came last +let editSeq = 0 +const stamp = () => ++editSeq + +function ScaleLedger({ currentTotals, scaleInputs: rawInputs, setScaleInputs, scalePlug, setScalePlug, targetBasis, setTargetBasis, logMeta = {}, multi, applyMode, settingsExtra }) { + // everything below reads the inputs that count; only the setters touch the raw ones + const { inputs: scaleInputs, choosing: plugChoosing } = effectiveScaleInputs(rawInputs, scalePlug) const valueCol = currentTotals?.valueCol const unitsCol = currentTotals?.unitsCol const total = currentTotals?.total || { value: 0, units: 0 } @@ -715,10 +756,7 @@ function ScaleLedger({ currentTotals, scaleInputs, setScaleInputs, scalePlug, se // an edit is dollars-only when value carries a number and neither units nor // price does -- the one case where price and volume are both still unknown - const filled = (key) => { - const raw = scaleInputs[key]?.raw - return raw != null && raw !== '' && isFinite(parseFloat(raw)) - } + const filled = (key) => hasNumber(scaleInputs[key]) const dollarsOnly = filled('value') && !filled('units') && !filled('price') // Where the edit lands, for all three measures at once. The rows above each @@ -737,7 +775,12 @@ function ScaleLedger({ currentTotals, scaleInputs, setScaleInputs, scalePlug, se let newValue, newUnits - if (filled('price') && !filled('value')) { + if (filled('price') && filled('value')) { + // price and dollars named: qty falls out of the two (resolveIncrs) + const targetPrice = numOf('price', 'new') + newValue = numOf('value', 'new') + newUnits = (targetPrice > 0 && newValue != null) ? newValue / targetPrice : null + } else if (filled('price') && !filled('value')) { // price is the input; dollars fall out of price x volume const targetPrice = numOf('price', 'new') newUnits = filled('units') ? numOf('units', 'new') : curUnits @@ -804,7 +847,7 @@ function ScaleLedger({ currentTotals, scaleInputs, setScaleInputs, scalePlug, se ])) const setEdit = (key, field, raw) => - setScaleInputs(prev => ({ ...prev, [key]: { field, raw } })) + setScaleInputs(prev => ({ ...prev, [key]: { field, raw, at: prev[key]?.field === field ? (prev[key].at ?? stamp()) : stamp() } })) // Hand a measure back to being derived. Emptying the box does the same thing // -- filled() is false either way -- but that is only obvious once you know @@ -821,14 +864,16 @@ function ScaleLedger({ currentTotals, scaleInputs, setScaleInputs, scalePlug, se // carrying across whatever it currently reads const focusRow = (key, field) => setScaleInputs(prev => { + // compared against the raw inputs: a box the resolver dropped still + // holds its text, and focusing it is how it is brought back as an input const cur = prev[key] - if (cur && cur.field === field) return prev + if (cur && cur.field === field && scaleInputs[key]) return prev // Whatever the box reads is what you get, including a back-filled // number: clicking into an implied figure is how you pin it, which is // the difference between "units may move" and "units are this". const text = shown[key]?.[field] ?? '' const raw = text === '—' ? '' : String(text).replace(/,/g, '') - return { ...prev, [key]: { field, raw } } + return { ...prev, [key]: { field, raw, at: stamp() } } }) // A running total across the walk, in the primary measure. The lines sum to @@ -878,10 +923,20 @@ function ScaleLedger({ currentTotals, scaleInputs, setScaleInputs, scalePlug, se const plugState = (() => { if (!(valueCol && unitsCol)) return null const u = filled('units'), p = filled('price'), v = filled('value') + if (plugChoosing) return { + value: scalePlug, + live: true, + why: scalePlug === 'price' + ? `${valueCol} typed last — ${unitsCol} holds, price gives way` + : `${valueCol} typed last — price holds, ${unitsCol} gives way`, + } if (u && p) return { value: null, live: false, why: `${unitsCol} and price named — ${valueCol} follows` } if (u && v) return { value: null, live: false, why: `${valueCol} and ${unitsCol} named — price follows` } if (u) return { value: null, live: false, why: `price holds — ${valueCol} follows ${unitsCol}`, unpin: 'units' } - if (p) return { value: 'volume', live: false, why: `price pinned — ${unitsCol} absorbs it`, unpin: 'price' } + // a price on its own is an edit-price: qty holds and the dollars follow it, + // which is the Result line's reading and what the server writes + if (p && !v) return { value: 'price', live: false, why: `${unitsCol} holds — ${valueCol} follows price`, unpin: 'price' } + if (p) return { value: 'volume', live: false, why: `${valueCol} and price named — ${unitsCol} follows`, unpin: 'price' } if (!v) return { value: null, live: false, why: `for a ${valueCol} figure on its own` } return { value: scalePlug, @@ -1112,11 +1167,20 @@ function ScaleLedger({ currentTotals, scaleInputs, setScaleInputs, scalePlug, se {settingsExtra} {plugState && ( - + + {/* What moves, not what was picked. Clickable only while it is + the one deciding -- a dollar figure on its own, or typed last + over the other two; otherwise the inputs have settled it and + it lights whatever they move, both at once if both do. It + used to show a single "selected" option even then, greyed, + which read as a choice somebody had made: price and dollars + named lit Volume, though price was moving too. */}