Make a named price hold, and keep the three scale boxes footing
A price target on its own was a no-op on price. The panel greys the plug out when a price is named and does not send it, so the server read it as its 'volume' default and scaled qty by the dollar change too, putting price straight back where it started. The price branch also read only a qty target, so a qty change or % sized the dollars on the old qty; took the fixed qty off a qty target but not the fixed dollars off the value; and let a dollar figure outrank the price outright. Now the named price holds and the other named measure decides the rest -- qty holds, or lands as named, or falls out of the dollars -- on the basis the panel measures against. All three at once is refused. The panel's boxes foot: at most two are inputs and the third is derived. Typing a third drops one -- the plug decides when dollars came last, else the older of the other two -- and the request is built from the same resolver, so what is posted is what is shown. The plug control is now "Moves": it lights whatever the edit moves, both if both do, and takes a click only while it is the one deciding. Known rough edge: with all three typed and dollars last, the choice is really which earlier number to keep, and "Volume" there moves price too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
870f08f7de
commit
770e22d865
@ -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
|
||||
|
||||
@ -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 (
|
||||
<button onClick={onClick} title={title} disabled={disabled}
|
||||
<button onClick={onClick} title={title} disabled={disabled || locked}
|
||||
className={`px-3 py-1 text-xs font-medium whitespace-nowrap transition-colors ${
|
||||
disabled
|
||||
locked
|
||||
? (active ? 'bg-blue-600 text-white cursor-default' : 'bg-white text-gray-700 cursor-default')
|
||||
: disabled
|
||||
? (active ? 'bg-gray-300 text-white cursor-default' : 'bg-white text-gray-400 cursor-default')
|
||||
: (active ? 'bg-blue-600 text-white' : 'bg-white text-gray-700 hover:bg-gray-100')}`}>
|
||||
{children}
|
||||
@ -192,13 +196,16 @@ function SectionLabel({ children, className = '' }) {
|
||||
// `disabled` keeps the control in place and shows which way it is set rather
|
||||
// than removing it. A control that vanishes reads as broken; one that greys out
|
||||
// says the choice has been made elsewhere.
|
||||
function Segmented({ options, value, onChange, disabled }) {
|
||||
// `lit`, when given, is the list of options to show active -- for a control
|
||||
// that reports state, where more than one can be true at once -- in place of
|
||||
// the single `value`.
|
||||
function Segmented({ options, value, onChange, disabled, lit, locked }) {
|
||||
return (
|
||||
<div className={`inline-flex rounded border divide-x shadow-sm overflow-hidden w-auto self-start ${
|
||||
disabled ? 'border-gray-200 divide-gray-200 bg-gray-50' : 'border-gray-300 divide-gray-300'}`}>
|
||||
{options.map(([val, label, title]) => (
|
||||
<Button key={val} onClick={() => onChange(val)} active={value === val}
|
||||
title={title} disabled={disabled}>{label}</Button>
|
||||
<Button key={val} onClick={() => onChange(val)} active={lit ? lit.includes(val) : value === val}
|
||||
title={title} disabled={disabled} locked={locked}>{label}</Button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
@ -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 && (
|
||||
<Field label="Absorbs" hint={plugState.why}>
|
||||
<Field label="Moves" hint={plugState.why}>
|
||||
{/* 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. */}
|
||||
<Segmented
|
||||
value={plugState.value}
|
||||
lit={plugState.live
|
||||
? [scalePlug]
|
||||
: [outcome?.moved?.price && 'price', outcome?.moved?.units && 'volume'].filter(Boolean)}
|
||||
onChange={setScalePlug}
|
||||
disabled={!plugState.live}
|
||||
locked={!plugState.live}
|
||||
options={[
|
||||
['price', 'Price', `${unitsCol} holds; price moves to reach the number`],
|
||||
['volume', 'Volume', `Price holds; ${unitsCol} scales with the dollars`],
|
||||
|
||||
@ -3,7 +3,7 @@ import useTheme from '../theme.jsx'
|
||||
import LayoutMenu from '../components/LayoutMenu.jsx'
|
||||
import DepthButtons, { ToggleChip } from '../components/DepthButtons.jsx'
|
||||
import useAuth from '../auth.jsx'
|
||||
import OperationPanel from '../components/OperationPanel.jsx'
|
||||
import OperationPanel, { effectiveScaleInputs } from '../components/OperationPanel.jsx'
|
||||
import BridgeView from '../components/BridgeView.jsx'
|
||||
|
||||
// Perspective is bundled, not fetched at runtime. The /inline entrypoints embed the
|
||||
@ -2001,7 +2001,10 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
|
||||
return (raw !== '' && raw != null && isFinite(n)) ? n : null
|
||||
}
|
||||
|
||||
const v = scaleInputs.value, u = scaleInputs.units, p = scaleInputs.price
|
||||
// the same two inputs the panel shows -- with all three boxes typed, the
|
||||
// one the resolver dropped is derived there and must not be sent here
|
||||
const eff = effectiveScaleInputs(scaleInputs, scalePlug).inputs
|
||||
const v = eff.value, u = eff.units, p = eff.price
|
||||
const vn = v && num(v.raw), un = u && num(u.raw), pn = p && num(p.raw)
|
||||
|
||||
if (vn != null) {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user