Fix Bridge's ending point to reflect the selected slice

BridgeView mounts once, up front, before anything has ever been
selected on the pivot -- so its scope defaulted to "Pivot's filters"
(effectively the whole version) regardless of a selection made
afterward. Scope now tracks the live selection until the user picks a
scope button themselves.

Even when scope was "Selection", the filter it built dropped
pf_segment/pf_bucket-keyed slice entries entirely and matched a blank
dimension value with `== null`, which this engine treats as a dropped
predicate rather than IS NULL -- both widen the bridged rows past what
was actually selected, the same failure mode the ledger's totalsFor in
Forecast.jsx was hardened against. Bridge's selection filter now
mirrors that logic.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Paul Trowbridge 2026-09-22 22:34:44 -04:00
parent 748e301464
commit bc5ce496b7

View File

@ -12,6 +12,12 @@
import { useState, useEffect, useRef, useCallback } from 'react' import { useState, useEffect, useRef, useCallback } from 'react'
// pf_segment / pf_bucket are computed server-side but are ordinary columns in
// the loaded table, so a slice keyed on one of them still filters directly.
// Mirrors the copy in Forecast.jsx -- dropping them here is what let a
// bucket-keyed slice bridge more rows than the operation it was drawn from.
const COMPUTED_SLICE_COLS = new Set(['pf_segment', 'pf_bucket'])
const UP = '#2a78d6' // increase const UP = '#2a78d6' // increase
const DOWN = '#e34948' // decrease const DOWN = '#e34948' // decrease
const ANCHOR = '#6b7280' // baseline / current — neutral, 4.83:1 on white const ANCHOR = '#6b7280' // baseline / current — neutral, 4.83:1 on white
@ -257,6 +263,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') || '')
@ -280,16 +292,26 @@ export default function BridgeView({
// view; rows matching more than one slice are counted once. // view; rows matching more than one slice are counted once.
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)
}
}
const seen = new Set() const seen = new Set()
rows = [] rows = []
for (const sl of slices) { for (const sl of slices) {
const f = [ const f = Object.entries(sl)
...Object.entries(sl).filter(([c]) => dimNames.has(c)).map(([c, v]) => [c, '==', v]), .filter(([c]) => COMPUTED_SLICE_COLS.has(c) || dimNames.has(c) || dateNames.has(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
// the same dropped predicate that widens a slice back toward its
// parent group — see totalsFor in Forecast.jsx for the same fix.
.map(([c, v]) => (v === null ? [c, 'is null'] : [c, '==', typed(c, v)]))
if (!f.length) continue if (!f.length) continue
// No expressions needed here: these filters are built from col_meta
// names, so they only ever reference real columns.
const view = await tableRef.current.view({ filter: f }) const view = await tableRef.current.view({ filter: f })
const part = await view.to_json() const part = await view.to_json()
await view.delete() await view.delete()
@ -329,8 +351,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 +411,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'}`}>