Back-fill the measures an edit forces

Scaling dollars with price holding moves qty, but the qty column stayed
blank until the Result row -- so the number you had just implied was the
one thing not shown beside the number you typed. A measure with no edit of
its own now reads all three ways from where the edit actually lands it.

derived stays the edit-driven map, since it is what outcome is computed
from and back-filling into it would feed the result into its own inputs; a
separate shown map is what the rows render.

Clicking into a back-filled figure pins it, which is the difference between
"units may move" and "units are this". The write is the same either way
when the pinned number is the implied one, so the cost of pinning by
accident is nothing.

Reverts the muted styling for the derived rows -- all three boxes look like
boxes again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Paul Trowbridge 2026-09-21 22:53:03 -04:00
parent 1035dae76b
commit 042c85d6c3

View File

@ -237,15 +237,6 @@ const FIELDS = [
['pct', '% change'], ['pct', '% change'],
] ]
// Exactly one box per measure is live -- scaleInputs is keyed by measure, so
// focusing another row hands that measure's edit over rather than adding a
// second one. The other two are the same number said differently, so they are
// drawn as output: no border, no ground, muted. They are still editable, and a
// border on hover is what says so.
//
// Both states used to be full input boxes differing by a border colour, which
// made three typed figures out of one typed figure and two calculations.
//
// given the active edit for a measure, what do the three rows read? // given the active edit for a measure, what do the three rows read?
// dp is the measure's own precision, so the editable rows read the same way as // dp is the measure's own precision, so the editable rows read the same way as
// the lines above them -- whole dollars against whole dollars, five places // the lines above them -- whole dollars against whole dollars, five places
@ -337,16 +328,11 @@ function LedgerInput({ value, active, onChange, onFocus, suffix }) {
onChange(el.value.replace(/,/g, '')) onChange(el.value.replace(/,/g, ''))
}} }}
onFocus={onFocus} placeholder="—" onFocus={onFocus} placeholder="—"
title={active ? undefined : 'Derived — click to enter the number this way instead'}
className={`border rounded py-0.5 text-xs w-24 text-right font-mono tabular-nums className={`border rounded py-0.5 text-xs w-24 text-right font-mono tabular-nums
transition-colors
${suffix ? 'pl-2 pr-5' : 'px-2'} ${suffix ? 'pl-2 pr-5' : 'px-2'}
${active ${active ? 'border-blue-400 bg-blue-50/40 text-gray-800' : 'border-gray-200 bg-white text-gray-700'}`} />
? 'border-blue-400 bg-blue-50/40 text-gray-900 font-medium'
: 'border-transparent bg-transparent text-gray-400 hover:border-gray-300 hover:bg-white'}`} />
{suffix && ( {suffix && (
<span className={`absolute right-1.5 pointer-events-none <span className="absolute right-1.5 text-gray-400 pointer-events-none"
${active ? 'text-gray-500' : 'text-gray-300'}`}
style={{ fontSize: '10px' }}>{suffix}</span> style={{ fontSize: '10px' }}>{suffix}</span>
)} )}
</span> </span>
@ -501,6 +487,30 @@ function ScaleLedger({ currentTotals, scaleInputs, setScaleInputs, scalePlug, se
} }
})() })()
// The same three readings for a measure nobody typed into, taken from where
// the edit actually lands it. Scaling dollars with price holding moves qty,
// and leaving that column blank until the Result row meant the number you had
// just implied was the one thing not shown next to the one you typed.
const backfill = (current, next, dp) => {
if (next == null || !isFinite(next) || current == null) return { new: '', change: '', pct: '' }
const change = next - current
const pct = current === 0 ? null : (change / Math.abs(current)) * 100
return {
new: fmtNum(next, dp),
change: fmtNum(change, dp),
pct: pct == null ? '—' : fmtNum(pct, 1),
n: { new: next, change, pct },
}
}
// What the rows render. derived stays the edit-driven map -- it is what
// outcome is computed from, so back-filling into it would feed the result
// back into its own inputs.
const shown = Object.fromEntries(measures.map(m => [
m.key,
(filled(m.key) || !outcome) ? derived[m.key] : backfill(basisOf(m.key), outcome[m.key], m.dp),
]))
const setEdit = (key, field, raw) => const setEdit = (key, field, raw) =>
setScaleInputs(prev => ({ ...prev, [key]: { field, raw } })) setScaleInputs(prev => ({ ...prev, [key]: { field, raw } }))
@ -510,9 +520,12 @@ function ScaleLedger({ currentTotals, scaleInputs, setScaleInputs, scalePlug, se
setScaleInputs(prev => { setScaleInputs(prev => {
const cur = prev[key] const cur = prev[key]
if (cur && cur.field === field) return prev if (cur && cur.field === field) return prev
const shown = derived[key]?.[field] ?? '' // Whatever the box reads is what you get, including a back-filled
const raw = shown === '—' ? '' : String(shown).replace(/,/g, '') // number: clicking into an implied figure is how you pin it, which is
return { ...prev, [key]: { field, raw: cur ? raw : '' } } // 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 } }
}) })
// A running total across the walk, in the primary measure. The lines sum to // A running total across the walk, in the primary measure. The lines sum to
@ -661,7 +674,7 @@ function ScaleLedger({ currentTotals, scaleInputs, setScaleInputs, scalePlug, se
return ( return (
<td key={m.key} className="px-2 py-0.5 text-right"> <td key={m.key} className="px-2 py-0.5 text-right">
<LedgerInput <LedgerInput
value={derived[m.key]?.[field] ?? ''} value={shown[m.key]?.[field] ?? ''}
active={active} active={active}
onChange={(raw) => setEdit(m.key, field, raw)} onChange={(raw) => setEdit(m.key, field, raw)}
onFocus={() => focusRow(m.key, field)} onFocus={() => focusRow(m.key, field)}