From 2814b4073cfb4a8935e7e1baab83fae4b5ca607a Mon Sep 17 00:00:00 2001 From: Paul Trowbridge Date: Fri, 18 Sep 2026 12:42:27 -0400 Subject: [PATCH] Tell the bridge which bucket is the forecast It was the literal 'Forecast'. The moment the buckets were renamed to carry their sort prefix -- '04 - Forecast' -- nothing matched: every row counted as a comparison rather than a step, so the walk had no middle, the loads came to nothing, and the bridge showed the basis cancelling itself exactly to zero with a Forecast anchor of 0.00 over 0 rows. The version's adjustment_bucket is the right source, being the same value an unbucketed adjustment is labelled with, so the bridge and the pivot agree by construction rather than by both hardcoding the same string. If that value names no bucket in the data -- renamed since, or never configured -- it falls back to whichever bucket actually holds the adjustments. A bridge that is merely mislabelled beats one that is silently empty. Co-Authored-By: Claude Opus 5 (1M context) --- ui/src/components/BridgeView.jsx | 33 +++++++++++++++++++++++++------- ui/src/views/Forecast.jsx | 5 +++++ 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/ui/src/components/BridgeView.jsx b/ui/src/components/BridgeView.jsx index f61b71d..14f0820 100644 --- a/ui/src/components/BridgeView.jsx +++ b/ui/src/components/BridgeView.jsx @@ -71,11 +71,28 @@ 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', + forecastBucket, }) { const hasBuckets = rows.some(r => r.pf_bucket != null) const excl = new Set(excludeIters) + // Which bucket is the forecast. It used to be the literal 'Forecast', and the + // moment the buckets were renamed to carry their sort prefix -- '04 - Forecast' + // -- no row matched it: every row counted as a comparison, the walk had no + // steps, and the bridge showed the basis cancelling itself to zero. + // + // The caller passes the version's adjustment_bucket, which is the same value + // an unbucketed adjustment is labelled with, so the two agree by construction. + // Falling back to whichever bucket actually holds the adjustments keeps a + // renamed or unconfigured version working rather than silently empty. + const fcBucket = (() => { + if (forecastBucket && rows.some(r => r.pf_bucket === forecastBucket)) return forecastBucket + const adjusted = rows.find(r => r.pf_bucket && !['baseline', 'reference'].includes(r.pf_iter)) + if (adjusted) return adjusted.pf_bucket + const baseline = rows.find(r => r.pf_bucket && r.pf_iter === 'baseline') + return baseline ? baseline.pf_bucket : (forecastBucket || 'Forecast') + })() + const num = (r, col) => (col ? (parseFloat(r[col]) || 0) : 0) const blank = () => ({ value: 0, units: 0, rows: 0 }) @@ -96,7 +113,7 @@ export function buildSteps(rows, { } // Anything outside the forecast is a comparison, never a step. - if (hasBuckets && bucket !== forecastBucket) { + if (hasBuckets && bucket !== fcBucket) { if (basis && bucket === basis) { basisT.value += v; basisT.units += u; basisT.rows += 1 } continue } @@ -137,7 +154,7 @@ export function buildSteps(rows, { }) } else { out.push({ - key: 'baseline', label: hasBuckets ? forecastBucket + ' loads' : 'Baseline', kind: 'anchor', + key: 'baseline', label: hasBuckets ? fcBucket + ' loads' : 'Baseline', kind: 'anchor', delta: loads.value, start: 0, end: loads.value, units: loads.units, rows: loads.rows, entries: 1, }) @@ -151,7 +168,7 @@ export function buildSteps(rows, { } out.push({ - key: 'current', label: hasBuckets ? forecastBucket : 'Current', kind: 'anchor', + key: 'current', label: hasBuckets ? fcBucket : 'Current', kind: 'anchor', delta: running, start: 0, end: running, units: loads.units + mid.reduce((a, g) => a + (g.units || 0), 0), rows: loads.rows + mid.reduce((a, g) => a + g.rows, 0), @@ -194,7 +211,7 @@ export function layoutSteps(steps, width, H = 340, PAD = { t: 24, r: 16, b: 64, export default function BridgeView({ open, onClose, tableRef, viewerRef, logMeta = {}, valueCol, unitsCol, colMeta = [], slices = [], - excludeIters = ['reference'], versionName, + excludeIters = ['reference'], versionName, forecastBucket, }) { const hasSelection = slices.length > 0 // 'selection' | 'filtered' | 'all' @@ -260,14 +277,16 @@ export default function BridgeView({ await view.delete() } - setSteps(buildSteps(rows, { valueCol, unitsCol, logMeta, excludeIters, basis: basis || null })) + setSteps(buildSteps(rows, { + valueCol, unitsCol, logMeta, excludeIters, basis: basis || null, forecastBucket, + })) } catch (err) { setError(err.message || String(err)) setSteps(null) } finally { setLoading(false) } - }, [tableRef, viewerRef, scope, logMeta, valueCol, unitsCol, excludeIters, slices, colMeta, basis]) + }, [tableRef, viewerRef, scope, logMeta, valueCol, unitsCol, excludeIters, slices, colMeta, basis, forecastBucket]) useEffect(() => { if (!hasSelection && scope === 'selection') setScope('filtered') diff --git a/ui/src/views/Forecast.jsx b/ui/src/views/Forecast.jsx index 4de19c6..8f9fa72 100644 --- a/ui/src/views/Forecast.jsx +++ b/ui/src/views/Forecast.jsx @@ -2006,6 +2006,11 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio slices={slices} excludeIters={versions.find(v => String(v.id) === String(versionId))?.exclude_iters || ['reference']} versionName={versions.find(v => String(v.id) === String(versionId))?.name} + // Which bucket *is* the forecast. Not a constant: it is the version's + // adjustment_bucket, which is how an adjustment with no bucket of its + // own gets labelled, so the two always agree by construction. + forecastBucket={versions.find(v => String(v.id) === String(versionId))?.adjustment_bucket + || '04 - Forecast'} /> {/* Main area — the panel lives in one of three shells, chosen by `dock` */}