Merge branch 'fix-bridge-selection-scope'
Fixes Bridge's ending point not matching the pivot's current selection (missing view-level scope, over-narrow bucket filtering) and brings its untagged-adjustment grouping in line with the ledger's. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
commit
bf791cbc14
@ -122,21 +122,36 @@ export function buildSteps(rows, {
|
|||||||
const isLoad = r.pf_iter === 'baseline' || r.pf_iter === 'reference'
|
const isLoad = r.pf_iter === 'baseline' || r.pf_iter === 'reference'
|
||||||
if (isLoad) { loads.value += v; loads.units += u; loads.rows += 1; continue }
|
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
|
// label first, the same precedence pf_segment uses, so the bridge and the
|
||||||
// pivot call a step by the same name
|
// pivot call a step by the same name
|
||||||
const tag = (meta.label || meta.tag || '').trim()
|
const tag = (meta.label || meta.tag || '').trim()
|
||||||
const label = tag || (meta.note || '').trim() ||
|
// Untagged entries collapse into one step, the same as the ledger's
|
||||||
`${(meta.operation || r.pf_iter || 'adj')}${r.pf_logid != null ? ` #${r.pf_logid}` : ''}`
|
// "N untagged adjustments" line in OperationPanel.jsx -- one bar per
|
||||||
const key = tag ? `tag:${tag}` : `log:${r.pf_logid}`
|
// untagged scale/recode would otherwise turn a routine cleanup pass into
|
||||||
const g = byTag.get(key) ||
|
// a wall of identical-looking bars. Tagged entries group case-
|
||||||
{ key, label, tagged: !!tag, value: 0, units: 0, rows: 0, logIds: new Set(), first: r.pf_logid }
|
// 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
|
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 (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)
|
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 useBasis = !!basis && basisT.rows > 0
|
||||||
|
|
||||||
const out = []
|
const out = []
|
||||||
@ -257,6 +272,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') || '')
|
||||||
@ -278,19 +299,52 @@ export default function BridgeView({
|
|||||||
// The union of the selected slices — the same reach an operation would
|
// 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
|
// have. Perspective view filters are AND-only, so each slice needs its own
|
||||||
// view; rows matching more than one slice are counted once.
|
// 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 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 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()
|
const seen = new Set()
|
||||||
rows = []
|
rows = []
|
||||||
for (const sl of slices) {
|
for (const sl of slices) {
|
||||||
const f = [
|
const sliceFilters = Object.entries(sl)
|
||||||
...Object.entries(sl).filter(([c]) => dimNames.has(c)).map(([c, v]) => [c, '==', v]),
|
.filter(([c]) => (dimNames.has(c) || dateNames.has(c)) && !viewFilter.some(vf => vf[0] === 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
|
||||||
if (!f.length) continue
|
// the same dropped predicate that widens a slice back toward its
|
||||||
// No expressions needed here: these filters are built from col_meta
|
// parent group — see totalsFor in Forecast.jsx for the same fix.
|
||||||
// names, so they only ever reference real columns.
|
.map(([c, v]) => (v === null ? [c, 'is null'] : [c, '==', typed(c, v)]))
|
||||||
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()
|
const part = await view.to_json()
|
||||||
await view.delete()
|
await view.delete()
|
||||||
for (const r of part) {
|
for (const r of part) {
|
||||||
@ -329,8 +383,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 +443,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'}`}>
|
||||||
@ -486,17 +549,14 @@ export default function BridgeView({
|
|||||||
<tspan key={li} x={x + barW / 2} dy={li === 0 ? 0 : 11}>{ln}</tspan>
|
<tspan key={li} x={x + barW / 2} dy={li === 0 ? 0 : 11}>{ln}</tspan>
|
||||||
))}
|
))}
|
||||||
</text>
|
</text>
|
||||||
{/* below the label, however many lines it took */}
|
{/* below the label, however many lines it took. Skipped for the
|
||||||
{!isAnchor && s.entries > 1 && (
|
untagged bucket -- its label already reads "N untagged
|
||||||
|
adjustments", so a ×N here would just repeat the count. */}
|
||||||
|
{!isAnchor && s.tagged && s.entries > 1 && (
|
||||||
<text x={x + barW / 2} y={subY} textAnchor="middle" fontSize="9" fill={INK_DIM}>
|
<text x={x + barW / 2} y={subY} textAnchor="middle" fontSize="9" fill={INK_DIM}>
|
||||||
×{s.entries}
|
×{s.entries}
|
||||||
</text>
|
</text>
|
||||||
)}
|
)}
|
||||||
{!s.tagged && !isAnchor && (
|
|
||||||
<text x={x + barW / 2} y={subY} textAnchor="middle" fontSize="9" fill={INK_DIM}>
|
|
||||||
untagged
|
|
||||||
</text>
|
|
||||||
)}
|
|
||||||
</g>
|
</g>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
@ -539,9 +599,7 @@ export default function BridgeView({
|
|||||||
<tbody>
|
<tbody>
|
||||||
{steps.map(s => (
|
{steps.map(s => (
|
||||||
<tr key={s.key} className="border-b border-gray-100">
|
<tr key={s.key} className="border-b border-gray-100">
|
||||||
<td className="py-1.5 pr-3 text-gray-800">
|
<td className="py-1.5 pr-3 text-gray-800">{s.label}</td>
|
||||||
{s.label}{!s.tagged && s.kind === 'step' && <span className="text-gray-600"> · untagged</span>}
|
|
||||||
</td>
|
|
||||||
<td className="py-1.5 px-2 text-right font-mono tabular-nums text-gray-800">
|
<td className="py-1.5 px-2 text-right font-mono tabular-nums text-gray-800">
|
||||||
{s.kind === 'anchor' ? fmt(s.end) : fmtSigned(s.delta)}
|
{s.kind === 'anchor' ? fmt(s.end) : fmtSigned(s.delta)}
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user