diff --git a/routes/operations.js b/routes/operations.js index 0a73c9a..1b4385c 100644 --- a/routes/operations.js +++ b/routes/operations.js @@ -137,12 +137,44 @@ module.exports = function(pool) { }; let value = resolve(tValue, vPct, vIncr, totals.value, fixedValue); - const units = resolve(tUnits, uPct, uIncr, totals.units, fixedUnits); + let units = resolve(tUnits, uPct, uIncr, totals.units, fixedUnits); - // a price target holds units constant: new value = price x current units. - // An explicit value target outranks it. + // 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) { - value = (tPrice * (totals.units + fixedUnits)) - (totals.value + fixedValue); + const targetUnits = tUnits !== null + ? (tUnits - fixedUnits) + 0 // the units target is already absolute + : (totals.units + fixedUnits); + value = (tPrice * targetUnits) - (totals.value + fixedValue); + } + + // Which side of price x volume absorbs a dollar change. + // + // 'price' — volume holds, so price moves. This is what the API has always + // done, and stays the default so existing callers are unaffected. + // 'volume' — price holds, so volume scales with the dollars. + // + // Only meaningful when dollars were the input and units were not given + // explicitly; naming both means the caller has already decided. + const plug = body.plug === 'volume' ? 'volume' : 'price'; + const unitsGiven = [tUnits, uIncr, uPct].some(v => v !== null); + + if (plug === 'volume' && value !== 0 && !unitsGiven) { + const curValue = totals.value + fixedValue; + const curUnits = totals.units + fixedUnits; + if (curValue === 0) { + const err = new Error( + 'Cannot hold price constant here: the selection currently has no value, ' + + 'so there is no price to hold. Scale units directly, or let price absorb ' + + 'the change.' + ); + err.status = 400; throw err; + } + // price constant means value and units move by the same proportion: + // fVol = curVol * (fVal / curVal), so the units delta is curVol * value/curVal + units = curUnits * (value / curValue); } // the scale SQL divides by the slice total; with no rows there is diff --git a/ui/src/components/OperationPanel.jsx b/ui/src/components/OperationPanel.jsx index 528d5d1..d5373fd 100644 --- a/ui/src/components/OperationPanel.jsx +++ b/ui/src/components/OperationPanel.jsx @@ -224,7 +224,7 @@ function LedgerInput({ value, active, onChange, onFocus, suffix }) { ) } -function ScaleLedger({ currentTotals, scaleInputs, setScaleInputs, targetBasis, setTargetBasis, logMeta = {}, multi, applyMode }) { +function ScaleLedger({ currentTotals, scaleInputs, setScaleInputs, scalePlug, setScalePlug, targetBasis, setTargetBasis, logMeta = {}, multi, applyMode }) { const valueCol = currentTotals?.valueCol const unitsCol = currentTotals?.unitsCol const total = currentTotals?.total || { value: 0, units: 0 } @@ -288,6 +288,14 @@ function ScaleLedger({ currentTotals, scaleInputs, setScaleInputs, targetBasis, measures.map(m => [m.key, derive(basisOf(m.key) ?? 0, scaleInputs[m.key])]) ) + // 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 dollarsOnly = filled('value') && !filled('units') && !filled('price') + const setEdit = (key, field, raw) => setScaleInputs(prev => ({ ...prev, [key]: { field, raw } })) @@ -402,6 +410,30 @@ function ScaleLedger({ currentTotals, scaleInputs, setScaleInputs, targetBasis, + {/* A dollar figure on its own does not say whether price or volume moved. + Only ask once the edit is actually dollars-only -- naming units or price + has already answered it, and the control would just be noise. */} + {valueCol && unitsCol && dollarsOnly && ( +