From bc5ce496b72332b6bb44a090b8f397a9b5ad05d6 Mon Sep 17 00:00:00 2001 From: Paul Trowbridge Date: Tue, 22 Sep 2026 22:34:44 -0400 Subject: [PATCH 1/3] 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 --- ui/src/components/BridgeView.jsx | 47 ++++++++++++++++++++++++++------ 1 file changed, 39 insertions(+), 8 deletions(-) diff --git a/ui/src/components/BridgeView.jsx b/ui/src/components/BridgeView.jsx index 2b286a8..c2a14b7 100644 --- a/ui/src/components/BridgeView.jsx +++ b/ui/src/components/BridgeView.jsx @@ -12,6 +12,12 @@ 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 DOWN = '#e34948' // decrease const ANCHOR = '#6b7280' // baseline / current — neutral, 4.83:1 on white @@ -257,6 +263,12 @@ export default function BridgeView({ const hasSelection = slices.length > 0 // 'selection' | 'filtered' | 'all' 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 // when building the AOP; empty means show the forecast's own composition. 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. 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 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() rows = [] for (const sl of slices) { - const f = [ - ...Object.entries(sl).filter(([c]) => dimNames.has(c)).map(([c, v]) => [c, '==', v]), - ...Object.entries(sl).filter(([c]) => dateNames.has(c)).map(([c, v]) => [c, '==', Number(v)]), - ] + const f = Object.entries(sl) + .filter(([c]) => COMPUTED_SLICE_COLS.has(c) || dimNames.has(c) || dateNames.has(c)) + // 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 - // 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 part = await view.to_json() await view.delete() @@ -329,8 +351,17 @@ export default function BridgeView({ } }, [tableRef, viewerRef, scope, logMeta, valueCol, unitsCol, excludeIters, slices, colMeta, basis, forecastBucket]) + // Follow the pivot's selection until the user chooses a scope explicitly. 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]) 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'], ['all', 'Whole version', 'Every row in the version, filters ignored'], ].map(([v, l, title]) => ( -