From c5993d5f88c7db26ef16f3bc9a2bc9628d7ab458 Mon Sep 17 00:00:00 2001 From: Paul Trowbridge Date: Tue, 22 Sep 2026 22:50:09 -0400 Subject: [PATCH] Bridge selection: apply the pivot's scope, drop bucket from the filter Two corrections to the previous commit, both found from a real example: a slice with pending_rep + pf_bucket, and a pivot scoped to sseas_e <= 2027. The ledger and Bridge's ending point disagreed by a constant 1,495,062 across every step -- exactly the rows sseas_e <= 2027 excludes. Bridge's "Selection" scope built filters from the slice alone and never picked up the pivot's own view-level filter (what CLAUDE.md calls scope), the same gap totalsFor in Forecast.jsx was written to close for the ledger. It's now read off viewerRef.current.save() and applied alongside every per-slice view. Including pf_bucket in the per-slice filter (previous commit) was wrong for Bridge specifically, unlike for the ledger: it fetched only the one bucket the slice landed on, so "Compare to" had no other bucket's rows left to compare against and always collapsed to one option. Which bucket the walk starts and ends on is buildSteps's job, from forecastBucket -- not something to pre-filter out of the fetch. Co-Authored-By: Claude Sonnet 5 --- ui/src/components/BridgeView.jsx | 37 +++++++++++++++++++++++--------- 1 file changed, 27 insertions(+), 10 deletions(-) diff --git a/ui/src/components/BridgeView.jsx b/ui/src/components/BridgeView.jsx index c2a14b7..c959db8 100644 --- a/ui/src/components/BridgeView.jsx +++ b/ui/src/components/BridgeView.jsx @@ -12,12 +12,6 @@ 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 @@ -290,6 +284,14 @@ 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() @@ -301,18 +303,33 @@ export default function BridgeView({ 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]) => COMPUTED_SLICE_COLS.has(c) || dimNames.has(c) || dateNames.has(c)) + 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 (!f.length) continue - 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() await view.delete() for (const r of part) {