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:
parent
748e301464
commit
bc5ce496b7
@ -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'}`}>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user