Workbench: per-iteration totals and price-driven scaling

The slice panel was a single muted line; now it shows a breakdown
table — value, units, and derived price for baseline / scale / recode /
clone, with a bold total row when more than one iteration applies.
Numbers use full text contrast so the current state is legible at a
glance during adjustments. Scale gains a price input that holds units
constant and translates to a value-target call (target value =
new_price × current_units).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Paul Trowbridge 2026-04-28 21:44:14 -04:00
parent 15ab5a3a13
commit a5e59f823a

View File

@ -49,6 +49,7 @@ export default function Forecast({ sourceId, versionId }) {
const [scaleMode, setScaleMode] = useState('target') // 'target' | 'delta'
const [scaleValue, setScaleValue] = useState('')
const [scaleUnits, setScaleUnits] = useState('')
const [scalePrice, setScalePrice] = useState('')
const [scalePct, setScalePct] = useState(false)
const [scaleNote, setScaleNote] = useState('')
const [recodeSet, setRecodeSet] = useState({})
@ -101,6 +102,7 @@ export default function Forecast({ sourceId, versionId }) {
setCloneSet(blank)
setScaleValue('')
setScaleUnits('')
setScalePrice('')
if (Object.keys(slice).length > 0) fetchCurrentTotals(slice)
else setCurrentTotals(null)
}, [slice])
@ -121,8 +123,22 @@ export default function Forecast({ sourceId, versionId }) {
const view = await tableRef.current.view({ filter: filters })
const rows = await view.to_json()
await view.delete()
const sum = (col) => col ? rows.reduce((s, r) => s + (parseFloat(r[col]) || 0), 0) : null
setCurrentTotals({ value: sum(valueCol), units: sum(unitsCol), valueCol, unitsCol })
const buckets = new Map()
for (const r of rows) {
const k = r.pf_iter || '?'
const t = buckets.get(k) || { value: 0, units: 0 }
if (valueCol) t.value += parseFloat(r[valueCol]) || 0
if (unitsCol) t.units += parseFloat(r[unitsCol]) || 0
buckets.set(k, t)
}
const ITER_ORDER = ['baseline', 'scale', 'recode', 'clone']
const byIter = Array.from(buckets, ([iter, t]) => ({ iter, ...t }))
.sort((a, b) => {
const ai = ITER_ORDER.indexOf(a.iter), bi = ITER_ORDER.indexOf(b.iter)
return (ai === -1 ? 99 : ai) - (bi === -1 ? 99 : bi)
})
const total = byIter.reduce((s, r) => ({ value: s.value + (r.value || 0), units: s.units + (r.units || 0) }), { value: 0, units: 0 })
setCurrentTotals({ byIter, total, valueCol, unitsCol })
} catch {
setCurrentTotals(null)
}
@ -344,10 +360,16 @@ export default function Forecast({ sourceId, versionId }) {
if (op === 'scale') {
let vi = null, ui = null
if (scaleMode === 'target') {
if (scaleValue !== '' && currentTotals?.value != null)
vi = parseFloat(scaleValue) - currentTotals.value
if (scaleUnits !== '' && currentTotals?.units != null)
ui = parseFloat(scaleUnits) - currentTotals.units
const curValue = currentTotals?.total?.value
const curUnits = currentTotals?.total?.units
if (scalePrice !== '' && curUnits != null && curValue != null) {
// hold units constant; new value = price × current units
vi = (parseFloat(scalePrice) * curUnits) - curValue
}
if (scaleValue !== '' && curValue != null)
vi = parseFloat(scaleValue) - curValue
if (scaleUnits !== '' && curUnits != null)
ui = parseFloat(scaleUnits) - curUnits
} else {
if (scaleValue !== '') vi = scalePct ? parseFloat(scaleValue) : parseFloat(scaleValue)
if (scaleUnits !== '') ui = scalePct ? parseFloat(scaleUnits) : parseFloat(scaleUnits)
@ -372,7 +394,7 @@ export default function Forecast({ sourceId, versionId }) {
if (!res.ok) { flash(data.error, 'error'); return }
if (data.rows?.length && tableRef.current) await tableRef.current.update(data.rows)
flash(`${op}: ${data.rows_affected ?? data.rows?.length ?? ''} rows`)
if (op === 'scale') { setScaleValue(''); setScaleUnits(''); setScaleNote(''); fetchCurrentTotals(slice) }
if (op === 'scale') { setScaleValue(''); setScaleUnits(''); setScalePrice(''); setScaleNote(''); fetchCurrentTotals(slice) }
if (op === 'recode') { setRecodeNote('') }
if (op === 'clone') { setCloneNote(''); setCloneScale('1') }
} catch (err) { flash(err.message, 'error') }
@ -637,6 +659,40 @@ export default function Forecast({ sourceId, versionId }) {
)}
</div>
{hasSlice && currentTotals?.byIter?.length > 0 && (
<div className="px-3 py-2 border-b border-gray-100">
<div className="font-medium text-gray-400 uppercase tracking-wide mb-1.5" style={{fontSize:'10px'}}>Current</div>
<table className="w-full text-xs">
<thead>
<tr className="text-gray-400" style={{fontSize:'10px'}}>
<th className="text-left font-normal pb-1"></th>
{currentTotals.valueCol && <th className="text-right font-normal pb-1 pl-1">{currentTotals.valueCol}</th>}
{currentTotals.unitsCol && <th className="text-right font-normal pb-1 pl-1">{currentTotals.unitsCol}</th>}
{currentTotals.valueCol && currentTotals.unitsCol && <th className="text-right font-normal pb-1 pl-1">price</th>}
</tr>
</thead>
<tbody>
{currentTotals.byIter.map(r => (
<tr key={r.iter}>
<td className="text-gray-500 capitalize pr-1">{r.iter}</td>
{currentTotals.valueCol && <td className="text-right font-mono text-gray-700 pl-1">{fmtNum(r.value)}</td>}
{currentTotals.unitsCol && <td className="text-right font-mono text-gray-700 pl-1">{fmtNum(r.units)}</td>}
{currentTotals.valueCol && currentTotals.unitsCol && <td className="text-right font-mono text-gray-700 pl-1">{fmtNum(r.units ? r.value / r.units : null, 4)}</td>}
</tr>
))}
{currentTotals.byIter.length > 1 && (
<tr className="border-t border-gray-100">
<td className="text-gray-600 font-medium pt-1 pr-1">total</td>
{currentTotals.valueCol && <td className="text-right font-mono font-semibold text-gray-800 pt-1 pl-1">{fmtNum(currentTotals.total.value)}</td>}
{currentTotals.unitsCol && <td className="text-right font-mono font-semibold text-gray-800 pt-1 pl-1">{fmtNum(currentTotals.total.units)}</td>}
{currentTotals.valueCol && currentTotals.unitsCol && <td className="text-right font-mono font-semibold text-gray-800 pt-1 pl-1">{fmtNum(currentTotals.total.units ? currentTotals.total.value / currentTotals.total.units : null, 4)}</td>}
</tr>
)}
</tbody>
</table>
</div>
)}
{hasSlice && (
<>
<div className="flex border-b border-gray-100">
@ -653,39 +709,38 @@ export default function Forecast({ sourceId, versionId }) {
{/* Mode toggle */}
<div className="flex rounded border border-gray-200 overflow-hidden">
{[['target','= Target'],['delta','Δ Increment']].map(([m, label]) => (
<button key={m} onClick={() => { setScaleMode(m); setScaleValue(''); setScaleUnits('') }}
<button key={m} onClick={() => { setScaleMode(m); setScaleValue(''); setScaleUnits(''); setScalePrice('') }}
className={`flex-1 py-1 text-xs ${scaleMode === m ? 'bg-blue-600 text-white' : 'bg-white text-gray-500 hover:bg-gray-50'}`}>
{label}
</button>
))}
</div>
{/* Value row */}
{currentTotals?.valueCol && (
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between text-gray-400">
<span>{currentTotals.valueCol}</span>
<span className="font-mono">{currentTotals.value?.toLocaleString(undefined, {maximumFractionDigits:2})}</span>
</div>
<Row label={currentTotals.valueCol}>
<input type="number" step="any" value={scaleValue}
onChange={e => setScaleValue(e.target.value)}
placeholder={scaleMode === 'target' ? 'target total' : '+ / amount'}
className={inp} />
</div>
</Row>
)}
{/* Units row */}
{currentTotals?.unitsCol && (
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between text-gray-400">
<span>{currentTotals.unitsCol}</span>
<span className="font-mono">{currentTotals.units?.toLocaleString(undefined, {maximumFractionDigits:2})}</span>
</div>
<Row label={currentTotals.unitsCol}>
<input type="number" step="any" value={scaleUnits}
onChange={e => setScaleUnits(e.target.value)}
placeholder={scaleMode === 'target' ? 'target total' : '+ / amount'}
className={inp} />
</div>
</Row>
)}
{scaleMode === 'target' && currentTotals?.valueCol && currentTotals?.unitsCol && (
<Row label="price">
<input type="number" step="any" value={scalePrice}
onChange={e => setScalePrice(e.target.value)}
placeholder="target price (holds units)"
className={inp} />
</Row>
)}
{scaleMode === 'delta' && (
@ -739,6 +794,11 @@ function fmtBytes(n) {
return `${(n / 1048576).toFixed(1)} MB`
}
function fmtNum(n, decimals = 2) {
if (n == null || !isFinite(n)) return '—'
return n.toLocaleString(undefined, { maximumFractionDigits: decimals })
}
function fmtStamp(stamp) {
return new Date(stamp).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })
}