From 042c85d6c3acf6a94bae992a6737bdbba9d8469a Mon Sep 17 00:00:00 2001 From: Paul Trowbridge Date: Mon, 21 Sep 2026 22:53:03 -0400 Subject: [PATCH] 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) --- ui/src/components/OperationPanel.jsx | 53 +++++++++++++++++----------- 1 file changed, 33 insertions(+), 20 deletions(-) diff --git a/ui/src/components/OperationPanel.jsx b/ui/src/components/OperationPanel.jsx index f616267..4ae9ce6 100644 --- a/ui/src/components/OperationPanel.jsx +++ b/ui/src/components/OperationPanel.jsx @@ -237,15 +237,6 @@ const FIELDS = [ ['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? // 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 @@ -337,16 +328,11 @@ function LedgerInput({ value, active, onChange, onFocus, suffix }) { onChange(el.value.replace(/,/g, '')) }} 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 - transition-colors ${suffix ? 'pl-2 pr-5' : 'px-2'} - ${active - ? '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'}`} /> + ${active ? 'border-blue-400 bg-blue-50/40 text-gray-800' : 'border-gray-200 bg-white text-gray-700'}`} /> {suffix && ( - {suffix} )} @@ -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) => setScaleInputs(prev => ({ ...prev, [key]: { field, raw } })) @@ -510,9 +520,12 @@ function ScaleLedger({ currentTotals, scaleInputs, setScaleInputs, scalePlug, se setScaleInputs(prev => { const cur = prev[key] if (cur && cur.field === field) return prev - const shown = derived[key]?.[field] ?? '' - const raw = shown === '—' ? '' : String(shown).replace(/,/g, '') - return { ...prev, [key]: { field, raw: cur ? raw : '' } } + // 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 } } }) // 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 ( setEdit(m.key, field, raw)} onFocus={() => focusRow(m.key, field)}