Keep the price/volume choice on screen when it isn't the one choosing

"Absorbed by" was rendered only while the edit was dollars-only, which made it
absent in two quite different states: before anything had been typed, and after
a measure had been pinned. Pinning includes a *click* -- clicking a cell pins
whatever it reads -- so an accidental click in the qty column had no visible
effect except that the control disappeared, and the way back was a small x in a
table header.

It now stays put and greys out, still showing which measure absorbs the change,
because the pinned inputs answer the same question the toggle does:

    qty pinned   -> price is what moves
    price pinned -> qty is what moves

so the inert control reads as the truth rather than a stale choice. The hint
says which case it is in, and a pinned measure gets an "unpin" next to the
toggle rather than only the x in the header.

Nothing about what gets written changes: `plug` is still sent only when the edit
is dollars-only, which is still the only case where price and volume are both
unknown.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Paul Trowbridge 2026-09-22 13:36:44 -04:00
parent 7006014cc3
commit 9e880a65be

View File

@ -61,11 +61,13 @@ function Block({ title, hint, horizontal, grow, children }) {
) )
} }
function Button({ onClick, active, children, title }) { function Button({ onClick, active, children, title, disabled }) {
return ( return (
<button onClick={onClick} title={title} <button onClick={onClick} title={title} disabled={disabled}
className={`px-3 py-1 rounded text-xs whitespace-nowrap transition-colors ${ className={`px-3 py-1 rounded text-xs whitespace-nowrap transition-colors ${
active ? 'bg-blue-600 text-white' : 'bg-white text-gray-700 hover:bg-gray-50'}`}> 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-50')}`}>
{children} {children}
</button> </button>
) )
@ -99,11 +101,16 @@ function SectionLabel({ children }) {
) )
} }
function Segmented({ options, value, onChange }) { // `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 }) {
return ( return (
<div className="inline-flex rounded border border-gray-200 overflow-hidden w-auto self-start"> <div className={`inline-flex rounded border overflow-hidden w-auto self-start ${
disabled ? 'border-gray-200 bg-gray-50' : 'border-gray-200'}`}>
{options.map(([val, label, title]) => ( {options.map(([val, label, title]) => (
<Button key={val} onClick={() => onChange(val)} active={value === val} title={title}>{label}</Button> <Button key={val} onClick={() => onChange(val)} active={value === val}
title={title} disabled={disabled}>{label}</Button>
))} ))}
</div> </div>
) )
@ -731,6 +738,34 @@ function ScaleLedger({ currentTotals, scaleInputs, setScaleInputs, scalePlug, se
? (scalePlug === 'price' ? 'units' : 'price') ? (scalePlug === 'price' ? 'units' : 'price')
: null : null
// Which measure is absorbing the change, whether or not the toggle chose it.
//
// The toggle used to be rendered only while the edit was dollars-only, so it
// was absent before anything was typed and disappeared the moment a measure
// was pinned -- including by a click, since clicking a cell pins whatever it
// reads. Two different states both looked like the control was missing, and
// an accidental click into the qty column had no visible effect except that
// the control went away. It now stays put in every state and greys out when
// the inputs have already answered it, which they genuinely have:
// qty pinned -> price is what moves
// price pinned -> qty is what moves
// so the inert control still shows the truth rather than a stale choice.
const plugState = (() => {
if (!(valueCol && unitsCol)) return null
const u = filled('units'), p = filled('price'), v = filled('value')
if (u && p) return { value: null, live: false, why: 'Both qty and price are named, so the dollars follow from them.' }
if (u) return { value: 'price', live: false, why: `${unitsCol} is pinned, so price absorbs the change.`, unpin: 'units' }
if (p) return { value: 'volume', live: false, why: `Price is pinned, so ${unitsCol} absorbs the change.`, unpin: 'price' }
if (!v) return { value: null, live: false, why: 'Applies to a dollar figure entered on its own — nothing else names price or qty.' }
return {
value: scalePlug,
live: true,
why: scalePlug === 'price'
? `${unitsCol} holds; price moves to reach the number.`
: `Price holds; ${unitsCol} scales with the dollars.`,
}
})()
const numCell = 'text-right font-mono tabular-nums whitespace-nowrap px-2' const numCell = 'text-right font-mono tabular-nums whitespace-nowrap px-2'
const rule = <td className="p-0"><div className="border-t border-gray-300 my-1" /></td> const rule = <td className="p-0"><div className="border-t border-gray-300 my-1" /></td>
@ -909,27 +944,37 @@ function ScaleLedger({ currentTotals, scaleInputs, setScaleInputs, scalePlug, se
</tbody> </tbody>
</table> </table>
{/* A dollar figure on its own does not say whether price or volume moved. {/* 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 so the toggle answers for it. Kept visible even when it is not the one
has already answered it, and the control would just be noise. */} answering -- see plugState -- because a control that comes and goes
reads as a fault, and its absence was the only sign that a stray click
had pinned a measure. */}
{/* Both of these are settings on the edit, so they use Field like every {/* Both of these are settings on the edit, so they use Field like every
other one -- an aligned label column and a hint below the control. other one -- an aligned label column and a hint below the control.
They used to hand-roll their own label row and hint, with two They used to hand-roll their own label row and hint, with two
different type sizes and no shared label width, so the two stacked different type sizes and no shared label width, so the two stacked
into a paragraph of loose words rather than reading as two settings. */} into a paragraph of loose words rather than reading as two settings. */}
{(valueCol && unitsCol && dollarsOnly || hasExcl || perSlice) && ( {(plugState || hasExcl || perSlice) && (
<div className="flex flex-col gap-2 mt-3 pt-3 border-t border-gray-100"> <div className="flex flex-col gap-2 mt-3 pt-3 border-t border-gray-100">
{valueCol && unitsCol && dollarsOnly && ( {plugState && (
<Field label="Absorbed by"> <Field label="Absorbed by" hint={plugState.why}>
<Segmented <Segmented
value={scalePlug} value={plugState.value}
onChange={setScalePlug} onChange={setScalePlug}
disabled={!plugState.live}
options={[ options={[
['price', 'Price', `${unitsCol} holds; price moves to reach the number`], ['price', 'Price', `${unitsCol} holds; price moves to reach the number`],
['volume', 'Volume', `Price holds; ${unitsCol} scales with the dollars`], ['volume', 'Volume', `Price holds; ${unitsCol} scales with the dollars`],
]} ]}
/> />
{plugState.unpin && (
<button onClick={() => clearEdit(plugState.unpin)}
title={`Stop pinning ${plugState.unpin === 'units' ? unitsCol : 'price'} and choose here instead`}
className="text-blue-600 hover:underline">
unpin {plugState.unpin === 'units' ? unitsCol : 'price'}
</button>
)}
</Field> </Field>
)} )}