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) <noreply@anthropic.com>
This commit is contained in:
Paul Trowbridge 2026-09-18 12:42:27 -04:00
parent 50f4c50b3a
commit 2814b4073c
2 changed files with 31 additions and 7 deletions

View File

@ -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')

View File

@ -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` */}