diff --git a/ui/src/components/BridgeView.jsx b/ui/src/components/BridgeView.jsx index 2b286a8..c8d5c26 100644 --- a/ui/src/components/BridgeView.jsx +++ b/ui/src/components/BridgeView.jsx @@ -122,21 +122,36 @@ export function buildSteps(rows, { const isLoad = r.pf_iter === 'baseline' || r.pf_iter === 'reference' if (isLoad) { loads.value += v; loads.units += u; loads.rows += 1; continue } - const meta = logMeta[r.pf_logid] || {} + const meta = logMeta[r.pf_logid] || {} // label first, the same precedence pf_segment uses, so the bridge and the // pivot call a step by the same name - const tag = (meta.label || meta.tag || '').trim() - const label = tag || (meta.note || '').trim() || - `${(meta.operation || r.pf_iter || 'adj')}${r.pf_logid != null ? ` #${r.pf_logid}` : ''}` - const key = tag ? `tag:${tag}` : `log:${r.pf_logid}` - const g = byTag.get(key) || - { key, label, tagged: !!tag, value: 0, units: 0, rows: 0, logIds: new Set(), first: r.pf_logid } + const tag = (meta.label || meta.tag || '').trim() + // Untagged entries collapse into one step, the same as the ledger's + // "N untagged adjustments" line in OperationPanel.jsx -- one bar per + // untagged scale/recode would otherwise turn a routine cleanup pass into + // a wall of identical-looking bars. Tagged entries group case- + // 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 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) } - 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 out = [] @@ -257,6 +272,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') || '') @@ -278,19 +299,52 @@ export default function BridgeView({ // 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 // 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 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() 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)]), - ] - 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 sliceFilters = Object.entries(sl) + .filter(([c]) => (dimNames.has(c) || dateNames.has(c)) && !viewFilter.some(vf => vf[0] === 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 (!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() await view.delete() for (const r of part) { @@ -329,8 +383,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 +443,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]) => ( -