diff --git a/ui/src/components/BridgeView.jsx b/ui/src/components/BridgeView.jsx index fa06ea0..ae6f1b8 100644 --- a/ui/src/components/BridgeView.jsx +++ b/ui/src/components/BridgeView.jsx @@ -49,25 +49,66 @@ function niceTicks(min, max, count = 5) { // Turn raw forecast rows into the walk: baseline anchor, one floating step per // initiative tag, current anchor. Pure and exported so the arithmetic can be // checked against real data without a browser. -export function buildSteps(rows, { valueCol, unitsCol, logMeta = {}, excludeIters = ['reference'] }) { +// The walk from a basis to the current forecast. +// +// Without a basis this is the forecast's own composition: its loads as the +// opening anchor, then one step per adjustment tag. +// +// With one -- Plan when building a forecast, Prior Year when building the AOP -- +// it starts there instead, and the identity that makes it exact is +// +// Forecast - Basis = (Forecast loads - Basis) + adjustments +// +// so the opening step is the difference between the forecast's own loads and the +// basis, and every tagged adjustment explains the rest. No residual, no plug: the +// bars sum to the endpoint by construction rather than by hoping the tags cover +// everything. +// +// Membership comes from pf_bucket, not pf_iter. Those answer different questions +// -- Open Orders is loaded as reference so nothing adjusts it, and is still part +// of the forecast -- so a bridge keyed on iter silently dropped it. +export function buildSteps(rows, { + valueCol, unitsCol, logMeta = {}, + excludeIters = ['reference'], // kept for callers with no bucket data + basis = null, // a pf_bucket name, or null for composition + forecastBucket = 'Forecast', +}) { + const hasBuckets = rows.some(r => r.pf_bucket != null) const excl = new Set(excludeIters) - const baseline = { value: 0, units: 0, rows: 0 } - const byTag = new Map() + + const num = (r, col) => (col ? (parseFloat(r[col]) || 0) : 0) + const blank = () => ({ value: 0, units: 0, rows: 0 }) + + const loads = blank() // the forecast's own segments + const basisT = blank() // the comparison bucket + const byTag = new Map() + const buckets = new Map() // every bucket, for the picker and the markers for (const r of rows) { - const iter = r.pf_iter - if (excl.has(iter)) continue - const v = parseFloat(r[valueCol]) || 0 - const u = unitsCol ? (parseFloat(r[unitsCol]) || 0) : 0 + const v = num(r, valueCol) + const u = num(r, unitsCol) + const bucket = hasBuckets ? (r.pf_bucket || '') : null - if (iter === 'baseline') { - baseline.value += v; baseline.units += u; baseline.rows += 1 + if (bucket != null) { + const b = buckets.get(bucket) || blank() + b.value += v; b.units += u; b.rows += 1 + buckets.set(bucket, b) + } + + // Anything outside the forecast is a comparison, never a step. + if (hasBuckets && bucket !== forecastBucket) { + if (basis && bucket === basis) { basisT.value += v; basisT.units += u; basisT.rows += 1 } continue } + if (!hasBuckets && excl.has(r.pf_iter)) continue + + const isLoad = r.pf_iter === 'baseline' || r.pf_iter === 'reference' + if (isLoad) { loads.value += v; loads.units += u; loads.rows += 1; continue } + const meta = logMeta[r.pf_logid] || {} const tag = (meta.tag || '').trim() const label = tag || (meta.note || '').trim() || - `${(meta.operation || iter || 'adj')}${r.pf_logid != null ? ` #${r.pf_logid}` : ''}` + `${(meta.operation || r.pf_iter || 'adj')}${r.pf_logid != null ? ` #${r.pf_logid}` : ''}` const key = tag ? `tag:${tag}` : `log:${r.pf_logid}` const g = byTag.get(key) || { key, label, tagged: !!tag, value: 0, units: 0, rows: 0, logIds: new Set(), first: r.pf_logid } @@ -77,25 +118,49 @@ export function buildSteps(rows, { valueCol, unitsCol, logMeta = {}, excludeIter } const mid = [...byTag.values()].sort((a, b) => (a.first ?? 0) - (b.first ?? 0)) + const useBasis = !!basis && basisT.rows > 0 - let running = baseline.value - const out = [{ - key: 'baseline', label: 'Baseline', kind: 'anchor', - delta: baseline.value, start: 0, end: baseline.value, - units: baseline.units, rows: baseline.rows, entries: 1, - }] + const out = [] + if (useBasis) { + out.push({ + key: 'basis', label: basis, kind: 'anchor', + delta: basisT.value, start: 0, end: basisT.value, + units: basisT.units, rows: basisT.rows, entries: 1, + }) + const gap = loads.value - basisT.value + out.push({ + key: 'loads-vs-basis', label: `Loads vs ${basis}`, kind: 'step', + delta: gap, start: basisT.value, end: loads.value, + units: loads.units - basisT.units, rows: loads.rows, entries: 1, + }) + } else { + out.push({ + key: 'baseline', label: hasBuckets ? forecastBucket + ' loads' : 'Baseline', kind: 'anchor', + delta: loads.value, start: 0, end: loads.value, + units: loads.units, rows: loads.rows, entries: 1, + }) + } + + let running = loads.value for (const g of mid) { const start = running running += g.value out.push({ ...g, kind: 'step', delta: g.value, start, end: running, entries: g.logIds.size }) } + out.push({ - key: 'current', label: 'Current', kind: 'anchor', + key: 'current', label: hasBuckets ? forecastBucket : 'Current', kind: 'anchor', delta: running, start: 0, end: running, - units: out.reduce((a, s) => a + (s.kind === 'anchor' ? 0 : s.units || 0), baseline.units), - rows: rows.filter(r => !excl.has(r.pf_iter)).length, + units: loads.units + mid.reduce((a, g) => a + (g.units || 0), 0), + rows: loads.rows + mid.reduce((a, g) => a + g.rows, 0), entries: mid.reduce((a, g) => a + g.logIds.size, 0) + 1, }) + + // Every bucket present, so the view can offer them as bases and show the ones + // that are not the basis as comparison markers. + out.buckets = [...buckets.entries()] + .map(([name, t]) => ({ name, ...t })) + .sort((a, b) => b.value - a.value) return out } @@ -132,6 +197,9 @@ export default function BridgeView({ const hasSelection = slices.length > 0 // 'selection' | 'filtered' | 'all' const [scope, setScope] = useState(hasSelection ? 'selection' : 'filtered') + // Which bucket the walk starts from. Plan when building a forecast, Prior Year + // when building the AOP; empty means show the forecast's own composition. + const [basis, setBasis] = useState(() => localStorage.getItem('pf_bridge_basis') || '') const [asTable, setAsTable] = useState(false) const [steps, setSteps] = useState(null) const [loading, setLoading] = useState(false) @@ -180,19 +248,20 @@ export default function BridgeView({ await view.delete() } - setSteps(buildSteps(rows, { valueCol, unitsCol, logMeta, excludeIters })) + setSteps(buildSteps(rows, { valueCol, unitsCol, logMeta, excludeIters, basis: basis || null })) } catch (err) { setError(err.message || String(err)) setSteps(null) } finally { setLoading(false) } - }, [tableRef, viewerRef, scope, logMeta, valueCol, unitsCol, excludeIters, slices, colMeta]) + }, [tableRef, viewerRef, scope, logMeta, valueCol, unitsCol, excludeIters, slices, colMeta, basis]) useEffect(() => { if (!hasSelection && scope === 'selection') setScope('filtered') }, [hasSelection, scope]) + useEffect(() => { try { localStorage.setItem('pf_bridge_basis', basis) } catch {} }, [basis]) useEffect(() => { if (open) compute() }, [open, compute]) useEffect(() => { @@ -247,6 +316,23 @@ export default function BridgeView({ ))} + {/* Offered only once something carries a bucket, and only listing buckets + other than the forecast itself -- a walk from Forecast to Forecast is + the composition view, which is what the empty option gives. */} + {(steps?.buckets || []).some(b => b.name && b.name !== 'Forecast') && ( + <> +
+ Compare to + + + )} +