From 546af676392aab95a999e3566edadc165e6ef638 Mon Sep 17 00:00:00 2001 From: Paul Trowbridge Date: Wed, 16 Sep 2026 23:11:05 -0400 Subject: [PATCH] Ask whether a dollar change is price or volume A sales figure on its own does not say which of price or volume moved, and scale was answering silently: it wrote the value increment and left units alone, so volume stayed flat and price absorbed everything. That is one of the three behaviours the Excel form offered, picked by default rather than by the user. /opt/forecast_api/VBA/fpvt.frm has the semantics. Edit Sales, then plug one side: plug volume: pchange = fVal/(pVal+bVal); fVol = (pVol+bVol)*pchange plug price: fVol = pVol + bVol So 'volume' moves units in the same proportion as the dollars, holding price; 'price' leaves units alone, as now. Checked against those formulas: +100 on 1000/500 gives units 500 -> 550 with price held at 2.0000, and -400 on 2000/800 gives 800 -> 640 at 2.5000. Price still defaults, so nothing changes for an existing caller that does not send `plug`. The control only appears once the edit is dollars-only -- naming units or price has already settled the question. A price target now also honours a units target alongside it, which is the form's Edit Price mode where both are inputs and dollars fall out. Holding price when the selection has no value is refused rather than divided by zero, the same case the form guarded with "Zero times any number is zero". Co-Authored-By: Claude Opus 5 (1M context) --- routes/operations.js | 40 +++++++++++++++++++++++++--- ui/src/components/OperationPanel.jsx | 36 ++++++++++++++++++++++++- ui/src/views/Forecast.jsx | 11 ++++++++ 3 files changed, 82 insertions(+), 5 deletions(-) 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,