diff --git a/ui/src/views/Forecast.jsx b/ui/src/views/Forecast.jsx index f6a8e54..fdafd76 100644 --- a/ui/src/views/Forecast.jsx +++ b/ui/src/views/Forecast.jsx @@ -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 }) { )} + {hasSlice && currentTotals?.byIter?.length > 0 && ( +
+
Current
+ + + + + {currentTotals.valueCol && } + {currentTotals.unitsCol && } + {currentTotals.valueCol && currentTotals.unitsCol && } + + + + {currentTotals.byIter.map(r => ( + + + {currentTotals.valueCol && } + {currentTotals.unitsCol && } + {currentTotals.valueCol && currentTotals.unitsCol && } + + ))} + {currentTotals.byIter.length > 1 && ( + + + {currentTotals.valueCol && } + {currentTotals.unitsCol && } + {currentTotals.valueCol && currentTotals.unitsCol && } + + )} + +
{currentTotals.valueCol}{currentTotals.unitsCol}price
{r.iter}{fmtNum(r.value)}{fmtNum(r.units)}{fmtNum(r.units ? r.value / r.units : null, 4)}
total{fmtNum(currentTotals.total.value)}{fmtNum(currentTotals.total.units)}{fmtNum(currentTotals.total.units ? currentTotals.total.value / currentTotals.total.units : null, 4)}
+
+ )} + {hasSlice && ( <>
@@ -653,39 +709,38 @@ export default function Forecast({ sourceId, versionId }) { {/* Mode toggle */}
{[['target','= Target'],['delta','Δ Increment']].map(([m, label]) => ( - ))}
- {/* Value row */} {currentTotals?.valueCol && ( -
-
- {currentTotals.valueCol} - {currentTotals.value?.toLocaleString(undefined, {maximumFractionDigits:2})} -
+ setScaleValue(e.target.value)} placeholder={scaleMode === 'target' ? 'target total' : '+ / − amount'} className={inp} /> -
+ )} - {/* Units row */} {currentTotals?.unitsCol && ( -
-
- {currentTotals.unitsCol} - {currentTotals.units?.toLocaleString(undefined, {maximumFractionDigits:2})} -
+ setScaleUnits(e.target.value)} placeholder={scaleMode === 'target' ? 'target total' : '+ / − amount'} className={inp} /> -
+ + )} + + {scaleMode === 'target' && currentTotals?.valueCol && currentTotals?.unitsCol && ( + + setScalePrice(e.target.value)} + placeholder="target price (holds units)" + className={inp} /> + )} {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' }) }