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 && ( +
+
+ Absorbed by + +
+
+ {scalePlug === 'price' + ? `${unitsCol} holds; price moves to reach the number.` + : `Price holds; ${unitsCol} scales with the dollars.`} +
+
+ )} + {hasExcl && (
@@ -547,6 +579,7 @@ export default function OperationPanel({ currentTotals, activeOp, setActiveOp, scaleInputs, setScaleInputs, + scalePlug, setScalePlug, targetBasis, setTargetBasis, opTag, setOpTag, knownTags = [], logMeta = {}, scaleNote, setScaleNote, @@ -608,6 +641,7 @@ export default function OperationPanel({ localStorage.getItem('pf_scale_plug') || 'price') // what a target/percentage is measured against: the rows this operation can // write, or everything the pivot shows for the slice (excluded rows included) const [targetBasis, setTargetBasis] = useState('selected') @@ -81,6 +85,7 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio const [panelWidth, setPanelWidth] = useState(() => Number(localStorage.getItem('pf_panel_w')) || 360) const [panelHeight, setPanelHeight] = useState(() => Number(localStorage.getItem('pf_panel_h')) || 260) useEffect(() => { localStorage.setItem('pf_dock', dock) }, [dock]) + useEffect(() => { localStorage.setItem('pf_scale_plug', scalePlug) }, [scalePlug]) useEffect(() => { localStorage.setItem('pf_panel_open', panelOpen ? 'open' : 'closed') }, [panelOpen]) // Esc closes the panel — the usual way out of the floating window @@ -1132,6 +1137,11 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio : p.field === 'change' ? curPrice + pn : curPrice * (1 + pn / 100) } + // A dollar change on its own is ambiguous -- price x volume, and the number + // says nothing about which moved. plug names the one that absorbs it. Only + // sent when dollars are the sole input; naming units or price has already + // answered the question. + if (vn != null && un == null && pn == null) body.plug = scalePlug } else if (op === 'recode') { const set = Object.fromEntries(Object.entries(recodeSet).filter(([, v]) => v.trim())) body = { ...body, note: recodeNote || undefined, set } @@ -1230,6 +1240,7 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio currentTotals, activeOp, setActiveOp, scaleInputs, setScaleInputs, + scalePlug, setScalePlug, targetBasis, setTargetBasis, opTag, setOpTag, knownTags, logMeta, scaleNote, setScaleNote,