Merge branch 'fix-bridge-selection-scope'

Fixes Bridge's ending point not matching the pivot's current selection
(missing view-level scope, over-narrow bucket filtering) and brings its
untagged-adjustment grouping in line with the ledger's.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Paul Trowbridge 2026-09-22 23:03:16 -04:00
commit bf791cbc14

View File

@ -126,17 +126,32 @@ export function buildSteps(rows, {
// label first, the same precedence pf_segment uses, so the bridge and the // label first, the same precedence pf_segment uses, so the bridge and the
// pivot call a step by the same name // pivot call a step by the same name
const tag = (meta.label || meta.tag || '').trim() const tag = (meta.label || meta.tag || '').trim()
const label = tag || (meta.note || '').trim() || // Untagged entries collapse into one step, the same as the ledger's
`${(meta.operation || r.pf_iter || 'adj')}${r.pf_logid != null ? ` #${r.pf_logid}` : ''}` // "N untagged adjustments" line in OperationPanel.jsx -- one bar per
const key = tag ? `tag:${tag}` : `log:${r.pf_logid}` // untagged scale/recode would otherwise turn a routine cleanup pass into
const g = byTag.get(key) || // a wall of identical-looking bars. Tagged entries group case-
{ key, label, tagged: !!tag, value: 0, units: 0, rows: 0, logIds: new Set(), first: r.pf_logid } // insensitively for the same reason the ledger does: "Pull Forward" and
// "pull forward" are one initiative, not two, and the most-used spelling
// is what gets shown.
const key = tag ? `tag:${tag.toLowerCase()}` : 'untagged'
const g = byTag.get(key) || {
key, tagged: !!tag, value: 0, units: 0, rows: 0, logIds: new Set(), first: r.pf_logid,
spellings: tag ? new Map() : null,
}
g.value += v; g.units += u; g.rows += 1 g.value += v; g.units += u; g.rows += 1
if (r.pf_logid != null) { g.logIds.add(r.pf_logid); g.first = Math.min(g.first ?? r.pf_logid, r.pf_logid) } if (r.pf_logid != null) { g.logIds.add(r.pf_logid); g.first = Math.min(g.first ?? r.pf_logid, r.pf_logid) }
if (tag) g.spellings.set(tag, (g.spellings.get(tag) || 0) + 1)
byTag.set(key, g) byTag.set(key, g)
} }
const mid = [...byTag.values()].sort((a, b) => (a.first ?? 0) - (b.first ?? 0)) const mid = [...byTag.values()]
.sort((a, b) => (a.first ?? 0) - (b.first ?? 0))
.map(g => ({
...g,
label: g.tagged
? [...g.spellings.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] || ''
: `${g.logIds.size} untagged adjustment${g.logIds.size === 1 ? '' : 's'}`,
}))
const useBasis = !!basis && basisT.rows > 0 const useBasis = !!basis && basisT.rows > 0
const out = [] const out = []
@ -257,6 +272,12 @@ export default function BridgeView({
const hasSelection = slices.length > 0 const hasSelection = slices.length > 0
// 'selection' | 'filtered' | 'all' // 'selection' | 'filtered' | 'all'
const [scope, setScope] = useState(hasSelection ? 'selection' : 'filtered') const [scope, setScope] = useState(hasSelection ? 'selection' : 'filtered')
// Whether the user has picked a scope button themselves. Bridge mounts once,
// up front, before anything has ever been selected on the pivot — so the
// useState initializer above always seeds 'filtered' and nothing follows a
// selection made afterward. Until the user overrides it, scope tracks the
// live selection instead.
const [scopeTouched, setScopeTouched] = useState(false)
// Which bucket the walk starts from. Plan when building a forecast, Prior Year // 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. // when building the AOP; empty means show the forecast's own composition.
const [basis, setBasis] = useState(() => localStorage.getItem('pf_bridge_basis') || '') const [basis, setBasis] = useState(() => localStorage.getItem('pf_bridge_basis') || '')
@ -278,19 +299,52 @@ export default function BridgeView({
// The union of the selected slices — the same reach an operation would // The union of the selected slices — the same reach an operation would
// have. Perspective view filters are AND-only, so each slice needs its own // have. Perspective view filters are AND-only, so each slice needs its own
// view; rows matching more than one slice are counted once. // view; rows matching more than one slice are counted once.
//
// pf_bucket/pf_segment are deliberately left out here, even though a
// click can carry one (e.g. the column axis split by bucket): the walk
// needs rows from every bucket, not just the one the slice landed on,
// or there is nothing left for "Compare to" to compare against and
// every basis collapses to the one bucket fetched. Which bucket the
// walk starts and ends on is decided inside buildSteps, from
// forecastBucket, not by filtering it out of the fetch.
const dimNames = new Set(colMeta.filter(c => c.role === 'dimension').map(c => c.cname)) const dimNames = new Set(colMeta.filter(c => c.role === 'dimension').map(c => c.cname))
const dateNames = new Set(colMeta.filter(c => c.role === 'date').map(c => c.cname)) const dateNames = new Set(colMeta.filter(c => c.role === 'date').map(c => c.cname))
const schema = await tableRef.current.schema()
const typed = (col, val) => {
switch (schema[col]) {
case 'integer': case 'float': return Number(val)
case 'boolean': return val === true || val === 'true'
case 'date': case 'datetime': return Number(val)
default: return String(val)
}
}
// The pivot's own filter (e.g. sseas_e <= 2027) is not part of a
// clicked slice — perspective-click reports only the cell's own
// dimensions — so it has to be read off the viewer and applied
// alongside every per-slice view, the same reason Forecast.jsx's
// ledger totals do this. Without it, a grid scoped narrower than the
// whole version bridges rows the grid is hiding.
let viewFilter = []
let expressions = {}
if (viewerRef?.current) {
const cfg = await viewerRef.current.save()
viewFilter = (cfg.filter || []).filter(f => Array.isArray(f) && f.length >= 2)
expressions = cfg.expressions || {}
}
const seen = new Set() const seen = new Set()
rows = [] rows = []
for (const sl of slices) { for (const sl of slices) {
const f = [ const sliceFilters = Object.entries(sl)
...Object.entries(sl).filter(([c]) => dimNames.has(c)).map(([c, v]) => [c, '==', v]), .filter(([c]) => (dimNames.has(c) || dateNames.has(c)) && !viewFilter.some(vf => vf[0] === c))
...Object.entries(sl).filter(([c]) => dateNames.has(c)).map(([c, v]) => [c, '==', Number(v)]), // A null is filtered with `is null`, not `== null`: the engine
] // matches on type, and a null threshold against a string column is
if (!f.length) continue // the same dropped predicate that widens a slice back toward its
// No expressions needed here: these filters are built from col_meta // parent group — see totalsFor in Forecast.jsx for the same fix.
// names, so they only ever reference real columns. .map(([c, v]) => (v === null ? [c, 'is null'] : [c, '==', typed(c, v)]))
const view = await tableRef.current.view({ filter: f }) if (!sliceFilters.length) continue
const viewCfg = { filter: [...viewFilter, ...sliceFilters] }
if (Object.keys(expressions).length) viewCfg.expressions = expressions
const view = await tableRef.current.view(viewCfg)
const part = await view.to_json() const part = await view.to_json()
await view.delete() await view.delete()
for (const r of part) { for (const r of part) {
@ -329,8 +383,17 @@ export default function BridgeView({
} }
}, [tableRef, viewerRef, scope, logMeta, valueCol, unitsCol, excludeIters, slices, colMeta, basis, forecastBucket]) }, [tableRef, viewerRef, scope, logMeta, valueCol, unitsCol, excludeIters, slices, colMeta, basis, forecastBucket])
// Follow the pivot's selection until the user chooses a scope explicitly.
useEffect(() => { useEffect(() => {
if (!hasSelection && scope === 'selection') setScope('filtered') if (scopeTouched) return
setScope(hasSelection ? 'selection' : 'filtered')
}, [hasSelection, scopeTouched])
// Once the user has picked "Selection" -- themselves or via the effect above
// -- and the selection then clears, there is nothing left to show under it;
// fall back regardless of whether the choice was manual.
useEffect(() => {
if (!hasSelection && scope === 'selection') { setScope('filtered'); setScopeTouched(false) }
}, [hasSelection, scope]) }, [hasSelection, scope])
useEffect(() => { try { localStorage.setItem('pf_bridge_basis', basis) } catch {} }, [basis]) useEffect(() => { try { localStorage.setItem('pf_bridge_basis', basis) } catch {} }, [basis])
@ -380,7 +443,7 @@ export default function BridgeView({
['filtered', "Pivot's filters", 'Everything the pivot currently shows'], ['filtered', "Pivot's filters", 'Everything the pivot currently shows'],
['all', 'Whole version', 'Every row in the version, filters ignored'], ['all', 'Whole version', 'Every row in the version, filters ignored'],
].map(([v, l, title]) => ( ].map(([v, l, title]) => (
<button key={v} onClick={() => setScope(v)} title={title} <button key={v} onClick={() => { setScope(v); setScopeTouched(true) }} title={title}
disabled={v === 'selection' && !hasSelection} disabled={v === 'selection' && !hasSelection}
className={`px-3 py-1 disabled:opacity-40 disabled:cursor-not-allowed ${ className={`px-3 py-1 disabled:opacity-40 disabled:cursor-not-allowed ${
scope === v ? 'bg-blue-600 text-white' : 'bg-white text-gray-700 hover:bg-gray-50'}`}> scope === v ? 'bg-blue-600 text-white' : 'bg-white text-gray-700 hover:bg-gray-50'}`}>
@ -486,17 +549,14 @@ export default function BridgeView({
<tspan key={li} x={x + barW / 2} dy={li === 0 ? 0 : 11}>{ln}</tspan> <tspan key={li} x={x + barW / 2} dy={li === 0 ? 0 : 11}>{ln}</tspan>
))} ))}
</text> </text>
{/* below the label, however many lines it took */} {/* below the label, however many lines it took. Skipped for the
{!isAnchor && s.entries > 1 && ( untagged bucket -- its label already reads "N untagged
adjustments", so a ×N here would just repeat the count. */}
{!isAnchor && s.tagged && s.entries > 1 && (
<text x={x + barW / 2} y={subY} textAnchor="middle" fontSize="9" fill={INK_DIM}> <text x={x + barW / 2} y={subY} textAnchor="middle" fontSize="9" fill={INK_DIM}>
×{s.entries} ×{s.entries}
</text> </text>
)} )}
{!s.tagged && !isAnchor && (
<text x={x + barW / 2} y={subY} textAnchor="middle" fontSize="9" fill={INK_DIM}>
untagged
</text>
)}
</g> </g>
) )
})} })}
@ -539,9 +599,7 @@ export default function BridgeView({
<tbody> <tbody>
{steps.map(s => ( {steps.map(s => (
<tr key={s.key} className="border-b border-gray-100"> <tr key={s.key} className="border-b border-gray-100">
<td className="py-1.5 pr-3 text-gray-800"> <td className="py-1.5 pr-3 text-gray-800">{s.label}</td>
{s.label}{!s.tagged && s.kind === 'step' && <span className="text-gray-600"> · untagged</span>}
</td>
<td className="py-1.5 px-2 text-right font-mono tabular-nums text-gray-800"> <td className="py-1.5 px-2 text-right font-mono tabular-nums text-gray-800">
{s.kind === 'anchor' ? fmt(s.end) : fmtSigned(s.delta)} {s.kind === 'anchor' ? fmt(s.end) : fmtSigned(s.delta)}
</td> </td>